Le rendu des TP se fera via GitHub. Une fois votre dépôt créé et avant d’ajouter le répertoire pour le premier TP, créez un fichier .gitignore à la racine de votre dépôt.
echo "*.o" >> .gitignore
echo "*.out" >> .gitignore
echo "*.exe" >> .gitignore
echo "*.a" >> .gitignore
echo "*.so" >> .gitignore
Organisation attendue
C-Unix_Nom1_Nom2/
├─ .gitignore # spécifique à ce TP
├─ image.c
├─ image.h
├─ ppm.c
├─ ppm.h
├─ first_ppm.c
├─ gradient.c
├─ darker.c
├─ main.c
├─ README.md # Si vous avez des commentaires ?
└─ Makefile
Ce qui n’est pas poussé sur votre dépôt n’est pas corrigé. Pensez donc bien à effectuer des commit et des push régulièrement. Afin de pratiquer les entrées-sorties en C, vous allez manipuler des images au format PPM, un grand classique.
Création d’une image manuelle
La première étape va consister à créer une image en écrivant dans un fichier text. Vous allez travailler avec le format PPM qui permet de stocker simplement les valeurs des différents pixels directement sous forme matricielle. Par exemple, pour encoder l’image avec 6 pixels suivante :
Figure 1 : FirstPPM.ppm
Un fichier PPM se compose de deux parties : un en-tête et les données d’image. L’en-tête se compose d’au moins trois parties, généralement délimitées par des retours chariot et/ou des sauts de ligne, mais la spécification PPM n’exige que des espaces blancs. Il faut respecter les étapes suivantes :
- “P3” pour le format ASCII, RGB. (On parle aussi d’identifiant magique)
- 3 pixels de large, 2 pixels de haut
- Valeur maximale possible pour les composants (rouge, vert ou bleu)
- Les valeurs des composantes sont ensuite mises les unes à la suite des autres (R G B R G B …)
- Chaque ligne de pixel donne une ligne dans le fichier PPM
En plus des lignes obligatoires ci-dessus, un commentaire peut être placé n’importe où à l’aide du caractère #. Le commentaire s’étend jusqu’à la fin de la ligne, comme en Python. Il devra être pris en compte plus tard afin d’omettre le commentaire lors du traitement de l’image.
Donnera donc le fichier PPM :
P3
3 2
255
255 0 0 0 255 0 0 0 255
255 255 0 255 255 255 0 0 0
Cette image peut être visualisé avec des logiciels classiques …
Un peu de théorie
Écrire dans un fichier est une opération fondamentale dans la plupart des langages de programmation, permettant de stocker des données de manière persistante ou de générer des fichiers destinés à d’autres programmes. Selon le langage et le niveau d’abstraction choisi, cette tâche peut se faire directement via des descripteurs de fichiers bas niveau, offrant un contrôle précis et rapide avec le noyau, ou via des flux/objets plus haut niveau qui simplifient la gestion des tampons et des erreurs. La compréhension des mécanismes sous-jacents, comme le concept de File Descriptor en C, est essentielle pour écrire du code robuste et éviter les fuites de ressources ou les comportements inattendus.
En C (version linux):
#include <fcntl.h> // Pour 'open'
#include <unistd.h> // Pour 'write'
#include <string.h> // Pour 'strlen'
#include <stdio.h> // Pour 'perror'
#include <stdlib.h>
#include <errno.h>
int main(int argc, char *argv[]) {
const char *en_tete = "P3\n100 100\n255\n";
// Création du fichier : Écrasement si existe, lecture/écriture, droits rw- pour l'utilisateur
int fd = open("firstPPM.ppm", O_CREAT | O_TRUNC | O_RDWR, S_IRUSR | S_IWUSR);
if (fd == -1) {
perror("Erreur à l'ouverture du fichier");
return EXIT_FAILURE;
}
ssize_t bytes_written = write(fd, en_tete, strlen(en_tete));
if (bytes_written == -1) {
perror("Erreur à l'écriture dans le fichier");
close(fd); // On ferme quand même le fichier
return EXIT_FAILURE;
}
close(fd);
return EXIT_SUCCESS;
}
Ici, fd signifie File Descriptor : c’est un entier représentant une ressource ouverte par le système (fichier, socket, etc.). Le programmeur doit gérer manuellement l’ouverture, l’écriture et la fermeture. Le système d’exploitation garde la trace de tous les descripteurs de fichiers ouverts par chaque programme. L’entier fd correspond à un index dans une table de mappage interne gérée par le noyau. On peut observer cette table depuis l’utilisateur via /proc/self/fd (self étant le processus courant) :
$ ls -id -l /proc/self/fd
total 0
267056 lrwx------. 1 ovan ovan 64 Jan 22 19:28 0 -> /dev/pts/0
267057 lrwx------. 1 ovan ovan 64 Jan 22 19:28 1 -> /dev/pts/0
267058 lrwx------. 1 ovan ovan 64 Jan 22 19:28 2 -> /dev/pts/0
270772 lr-x------. 1 ovan ovan 64 Jan 22 19:28 3 -> /proc/2645/fd
Vous pouvez compilé le programme précédent. Ajoutez simplement un
while(1); pour éviter que le processus ne ce termine trop vite. Puis avec Ctrl+Z suspendez le processus, cela devrais affichier le pid du processus en background.
Ici, ce sont les descripteurs de fichiers ouverts par ls. À chaque exécution, le PID de ls change (c’est une nouvelle instance du programme) et de nouveaux fd sont générés. Les trois premiers id sont des flux standards préexistants, souvent appelés « fichiers virtuels », utilisés pour transmettre des données entre le programme et l’environnement. Les descripteurs de fichier sont les suivants :
- (0) stdin : le flux d’entrée standard, généralement le clavier ou l’entrée d’un autre programme.
- (1) stdout : le flux de sortie standard, utilisé pour afficher des informations à l’écran ou les rediriger vers un fichier.
- (2) stderr : le flux de sortie d’erreurs, séparé de stdout pour permettre de distinguer les messages d’information des messages d’erreur.
- (3) correspond probablement à l’ouverture du dossier
/proc/self/fd, c’est donc en réalité un inode
Dans le tableau précédent, le premier chiffre correspond au numéro d’inode. Par exemple, si Firefox a le PID 6073, alors le répertoire /proc/6073 existe. Depuis ce processus, /proc/self est un lien symbolique vers /proc/6073, ce qui facilite l’accès aux informations du processus courant. Dit autrement c’est un pointeur.
Attention : /proc est un dossier virtuel créé par le noyau. Il ne contient pas de fichiers physiques sur le disque, mais expose des informations sur les processus et le système en temps réel. C’est un pseudo-fs.
ls -la /proc/6073/fd
total 0
dr-x------ 2 ovan ovan 460 Sep 4 11:29 .
dr-xr-xr-x 9 ovan ovan 0 Sep 4 11:29 ..
lr-x------ 1 ovan ovan 64 Sep 8 18:56 0 -> /dev/null
l-wx------ 1 ovan ovan 64 Sep 8 18:56 1 -> /dev/null
lrwx------ 1 ovan ovan 64 Sep 8 18:56 10 -> 'anon_inode:[eventfd]'
lrwx------ 1 ovan ovan 64 Sep 8 18:56 100 -> 'anon_inode:[eventfd]'
lrwx------ 1 ovan ovan 64 Sep 8 18:56 101 -> 'anon_inode:[eventpoll]'
lrwx------ 1 ovan ovan 64 Sep 8 18:56 102 -> 'socket:[31432]'
lrwx------ 1 ovan ovan 64 Sep 8 18:56 103 -> 'socket:[32402]'
l-wx------ 1 ovan ovan 64 Sep 8 18:56 107 -> 'pipe:[32426]'
lrwx------ 1 ovan ovan 64 Sep 8 18:56 114 -> bounce-tracking-protection.sqlite
lrwx------ 1 ovan ovan 64 Sep 8 18:56 115 -> content-prefs.sqlite
lrwx------ 1 ovan ovan 64 Sep 8 18:56 16 -> /dev/nvidiactl
lrwx------ 1 ovan ovan 64 Sep 8 18:56 263 -> '/memfd:pulseaudio'
lrwx------ 1 ovan ovan 64 Sep 8 18:56 319 -> /dev/nvidia-modeset
lrwx------ 1 ovan ovan 64 Sep 8 18:56 45 -> /dev/nvidia0
...
De cette façon, on se rend compte que chaque entier fd correspond à une ressource système unique, et que le noyau maintient la correspondance entre cet entier et l’objet réel (fichier, socket, pipe, etc.). Cela permet également de comprendre pourquoi oublier de fermer un fd peut bloquer des ressources sur le système jusqu’à la libération de cette dernière. Dans cet exemple, on retrouve :
- les entrées/sorties usuelles
- des connexions réseaux
- des communications inter-processus (IPC : pipe)
- des fichiers
- le serveur d’affichage via NVIDIA
- le service audio via PulseAudio
Supposons que le programme dialogue directement avec une imprimante (en réalité, c’est le service CUPS qui gère l’impression, sous linux). Si le programme oublie de fermer la ressource et continue de tourner, il pourrait bloquer l’accès à l’imprimante pour tout autre programme, empêchant ainsi toute impression tant que le processus n’a pas libéré le descripteur de fichier.
En résumé : le File Descriptor est le lien direct entre le programme et le noyau, et observer /proc/[pid]/fd permet de visualiser concrètement toutes les ressources ouvertes par un processus à un instant donné. C’est la table des descripteurs ouverts. Au niveau du système, il existe une table des fichiers ouverts partagée par tous les processus. Chaque entrée de cette table contient des informations sur l’état du fichier (position du curseur, indicateurs d’erreur et de fin de fichier), ainsi qu’un pointeur vers un i-node (c’est-à-dire sa localisation physique ou virtuelle).
Pour en savoir plus :
* Understanding Linux’s File Descriptors: A Deep Dive Into ‘2>&1’ and Redirection.
* File Descriptor Exploits: Lessons from BlackHat USA 2022 Presentation.
Modes d’ouvertures :
- Lecture seule (O_RDONLY)
- Écriture seule (O_WRONLY)
- Lecture-Écriture (O_RDWR)
-O_APPEND
-O_CLOEXEC
- …
-O_CREAT: création du fichier s’il n’existe pas
-O_DIRECTORY: erreur si le nom n’est pas un répertoire
-O_EXCL: assure la création du fichier (erreur sur fichier existant)
-O_TRUNC: efface le contenu du fichier après ouverture
- …
-S_IRWXU,S_IRUSR,S_IWUSR,S_IXUSR→ 0700, 0400, 0200, 0100, → rwx – –, r– – –, -w- – –, –x – –
-S_IRWXG,S_IRGRP,S_IWGRP,S_IXGRP→ 0070, 0040, 0020, 0010, → – rwx –, – r– –, – -w- –, – –x –
-S_IRWXO,S_IROTH,S_IWOTH,S_IXOTH→ 0007, 0004, 0002, 0001, → – – rwx, – – r–, – – -w-, – – –x
En C (version lib standard):
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
FILE *fp = fopen("firstPPM.ppm", "w"); // ouverture en écriture (création/écrasement)
if (fp == NULL) {
perror("Erreur d'ouverture du fichier");
return EXIT_FAILURE;
}
const char *en_tete = "P3\n100 100\n255\n";
fprintf(fp, "%s", en_tete); // écriture du texte dans le fichier
fclose(fp); // fermeture explicite du fichier
return EXIT_SUCCESS;
}
Ici, on utilise un flux FILE* plutôt qu’un descripteur de fichier brut. La bibliothèque standard C ajoute une couche d’abstraction. Un tampon mémoire est utilisé pour optimiser les écritures, les fonctions (fprintf, fscanf, etc.) facilitent la manipulation de données textuelles.[18][19][20][21][22][23][1]
rOuvre le fichier en lecture, curseur positionné au débutr+Ouvre le fichier en lecture et écriture, curseur positionné au débutwEfface ou le crée le fichier, l’ouvre en écriture seule, curseur positionné au débutw+Idem mais ouverture en lecture-écritureaOuvre ou crée le fichier en écriture seule, curseur à la fin du fichier.a+Ouvre ou crée le fichier en lecture-écriture, curseur d’écriture à la fin du fichier, curseur de lecture au début.
Exemples supplémentaires en C:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
const char *en_tete = "P3\n100 100\n255\n";
// Utilisation de fopen/fwrite (écriture binaire)
FILE *fp = fopen("firstPPM.ppm", "w");
if (fp == NULL) {
perror("Erreur d'ouverture");
return EXIT_FAILURE;
}
fwrite(en_tete, sizeof(char), strlen(en_tete), fp);
fclose(fp);
return EXIT_SUCCESS;
}
En C : Les flux standards stdin, stdout et stderr sont des variables globales de type FILE* déclarées dans <stdio.h> et correspondent aux descripteurs de fichiers 0, 1 et 2 respectivement.ctivement.
Première image
Créez et ajoutez à Git un fichier C nommé first_ppm.c. Puis, en partant du code ci-dessous, créez et écrivez dans un fichier nommé FirstPPM.ppm pour reproduire la Figure 1. Une fois le code compilé et exécuté, vous pouvez ouvrir le fichier produit, FirstPPM.ppm, avec un éditeur de texte pour en examiner le contenu et vérifier que ce qui est obtenu correspond à ce qui est attendu.
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
FILE *fp = fopen("FirstPPM.ppm", "w");
if (fp == NULL) {
perror("Erreur d'ouverture du fichier");
return EXIT_FAILURE;
}
fprintf(fp, "P3\n");
// Écriture des dimensions
// Écriture de la valeur maximale
// Écriture des pixels
// Première ligne : rouge, vert, bleu
// Deuxième ligne : jaune, blanc, noir
fclose(fp); // Fermeture du fichier
printf("Image PPM créée avec succès !\n");
return EXIT_SUCCESS;
}
Compilez et exécutez ce programme avec :
gcc -o first_ppm first_ppm.c
./first_ppm
Ne pas oublié first_ppm dans le .gitignore
Création d’un dégradé
L’objectif est de produire un dégradé de bleu d’au moins 200x100 pixels :
Pour ce faire nous allons créer une abstraction de la notion d’image. Créez un fichier image.h qui déclare la structure et les fonctions. Ce module sera utile pour la suite du TP/TD.
#ifndef IMAGE_H
#define IMAGE_H
#include <stdio.h>
typedef struct {
int width;
int height;
// pixels[y][x][0=R,1=G,2=B]
unsigned char *pixels;
} Image;
Image* image_create(int width, int height);
void image_free(Image *img);
void image_set_pixel(Image *img, int x, int y, unsigned char r, unsigned char g, unsigned char b);
unsigned char image_get_red(Image *img, int x, int y);
unsigned char image_get_green(Image *img, int x, int y);
unsigned char image_get_blue(Image *img, int x, int y);
void image_save_txt(Image *img, const char *filename);
void image_save_bin(Image *img, const char *filename);
Image* image_read_txt(const char *filename);
Image* image_read_bin(const char *filename);
#endif
Créez maintenant le fichier image.c qui implémente ces fonctions :
#include "image.h"
#include <stdlib.h>
#include <string.h>
Image* image_create(int width, int height) {
Image *img = (Image *)malloc(sizeof(Image));
// TODO
return img;
}
void image_free(Image *img) {
// TODO
}
void image_set_pixel(Image *img, int x, int y, unsigned char r, unsigned char g, unsigned char b) {
if (img != NULL && x >= 0 && x < img->width && y >= 0 && y < img->height) {
int index = 0; // TODO trouvé le calcule
img->pixels[index + 0] = r;
img->pixels[index + 1] = g;
img->pixels[index + 2] = b;
}
}
void image_save_txt(Image *img, const char *filename) {
if (img == NULL) return;
// TODO
}
Pourquoi des unsigned char ont-ils été utilisés pour stocker les pixels ? En C, les valeurs 0-255 correspondent parfaitement au type unsigned char (8 bits). C’est une représentation classique pour les couleurs.
Enfin créez et complétez le fichier gradient.c :
#include "image.h"
#include <stdio.h>
int main(int argc, char *argv[]) {
Image *img = image_create(200, 100);
if (img == NULL) {
return 1;
}
// Génération du dégradé de bleu
image_save_txt(img, "gradient.ppm");
image_free(img);
return 0;
}
Pour compiler l’ensemble :
gcc -o gradient gradient.c image.c -lm
./gradient
Pour observer vos images, vous utiliserez un logiciel d’affichage d’image (gimp, eog, gwenview, paint …) et non plus un éditeur de texte.
Lire depuis un fichier
Lire un fichier est une opération complémentaire de l’écriture, qui permet de récupérer des données stockées sur le disque ou générées par un autre programme. Voici un exemple en C utilisant un descripteur de fichier pour lire les premiers octets d’un fichier :
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
char buffer[128];
int fd = open("kaamelott.mp4", O_RDONLY);
if (fd == -1) {
perror("Erreur à l'ouverture du fichier");
return EXIT_FAILURE;
}
ssize_t bytes_read = read(fd, buffer, sizeof(buffer) - 1);
if (bytes_read == -1) {
perror("Erreur à la lecture du fichier");
close(fd);
return EXIT_FAILURE;
}
buffer[bytes_read] = '\0';
printf("%s\n", buffer);
// ici buffer peut contenir plusieurs lignes
close(fd);
return EXIT_SUCCESS;
}
Lorsqu’on utilise la bibliothèque standard C, la lecture devient plus simple grâce aux flux. Le code suivant montre comment lire la première ligne d’un fichier avec FILE* :
#include <stdio.h>
#include <stdlib.h>
int main() {
FILE *fp = fopen("DeathNote.md", "r");
if (fp == NULL) {
perror("Erreur d'ouverture du fichier");
return EXIT_FAILURE;
}
char buffer[128];
if (fgets(buffer, sizeof(buffer), fp) != NULL) {
printf("%s", buffer);
}
fclose(fp);
return EXIT_SUCCESS;
}
Ici, FILE* représente un flux avec tampon intégré. Les fonctions comme fgets ou fread permettent de manipuler facilement le contenu d’un fichier tout en profitant de la gestion “automatique” des tampons. Il existe également la fonction getline bien pratique pour lire des lignes de longueur variable.
Modification d’une image
Ajoutez la fonction image_read_txt au fichier image.c, sans oublié de déclarer la fonction dans le header :
#include <ctype.h>
Image* image_read_txt(const char *filename) {
FILE *fp = 0;
Image *img = 0;
// TODO
return img;
}
Créez maintenant le fichier filter.c :
#include "image.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
// Vérification des arguments
if (argc < 4) {
fprintf(stderr, "Usage: %s <input_file> <output_file> <filter_type>\n", argv[0]);
fprintf(stderr, "Vous devez fournir des noms de fichiers d'entrée et de sortie et un type de filtre. Abandon\n");
return -1;
}
const char *inputFilename = argv[1];
const char *outputFilename = argv[2];
const char *filterType = argv[3];
Image *source = image_read_txt(inputFilename);
if (source == NULL) {
fprintf(stderr, "Erreur lecture fichier d'entrée\n");
return -1;
}
int h = source->height;
int w = source->width;
Image *destination = image_create(w, h);
if (destination == NULL) {
image_free(source);
return -1;
}
// Application du filtre pixel par pixel
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
unsigned char rouge = image_get_red(source, x, y);
unsigned char vert = image_get_green(source, x, y);
unsigned char bleu = image_get_blue(source, x, y);
if (strcmp(filterType, "copy") == 0) {
image_set_pixel(destination, x, y, rouge, vert, bleu);
}
else if (strcmp(filterType, "dark") == 0) {
image_set_pixel(destination, x, y, rouge / 2, vert / 2, bleu / 2);
}
else if (strcmp(filterType, "bright") == 0) {
unsigned char r = (rouge > 128) ? 255 : rouge * 2;
unsigned char g = (vert > 128) ? 255 : vert * 2;
unsigned char b = (bleu > 128) ? 255 : bleu * 2;
image_set_pixel(destination, x, y, r, g, b);
}
else if (strcmp(filterType, "grayscale") == 0) {
unsigned char gray = (unsigned char)(0.299 * rouge + 0.587 * vert + 0.114 * bleu);
image_set_pixel(destination, x, y, gray, gray, gray);
}
else {
fprintf(stderr, "Type de filtre inconnu: %s\n", filterType);
image_free(source);
image_free(destination);
return -1;
}
}
}
// Sauvegarde de l'image filtrée
image_save_txt(destination, outputFilename);
printf("Image filtrée sauvegardée dans %s\n", outputFilename);
image_free(source);
image_free(destination);
return 0;
}
Compilez avec :
gcc -o filter filter.c image.c -lm
./filter gradient.ppm output.ppm copy
Pourquoi utilise-t-on deux objets Image ? Cela permet de séparer l’image source de l’image destination, évitant les modifications accidentelles. Cependant, cela double l’utilisation mémoire. Pour optimiser, on pourrait traiter les pixels en place si le filtre le permet, ou utiliser un deuxième tampon plus petit pour les opérations de convolution impliquant les pixels voisins.
Sauvegarde au format binaire
Vous allez ajouter les fonctions image_save_bin et image_read_bin aux fichiers image.h et image.c. Vous pouvez repartir de leurs réciproque au format txt et modifier ce qu’il faut. Elles auront donc des signatures similaires aux fonctions de format texte :
void image_save_bin(Image *img, const char *filename) {
if (img == NULL) return;
// Écriture binaire directe des pixels
fclose(fp);
}
Image* image_read_bin(const char *filename) {
return 0;
}
L’utilisation devient :
Image *img = image_read_bin("input.ppm"); // lecture binaire
image_save_bin(img, "copy.ppm"); // sauvegarde binaire
La différence principale est que les pixels sont stockés directement en binaire, avec une valeur maximale de 255 (unsigned char en C), sans espaces ni retours à la ligne. L’en-tête fonctionne de manière identique :
P6
largeur hauteur
255
# Les données des pixels suivent immédiatement en binaire
# Chaque pixel est codé sur 3 octets : rouge, vert, bleu.
Le format binaire (P6) présente plusieurs avantages. Tout d’abord, il permet de réduire la taille des fichiers, car les pixels sont stockés directement en binaire. Ensuite, la lecture et l’écriture des images sont plus rapides, puisqu’il n’est pas nécessaire de parser ou de convertir des nombres en texte. Enfin, ce format est moins sensible aux erreurs de formatage, car il ne dépend pas des espaces ou des retours à la ligne pour séparer les valeurs des pixels.
Conclusion
Vous noterez que la simplicité apparente du C en ce qui concerne la gestion des fichiers doit tenir compte des coûts sous-jacents liés à la gestion manuelle de la mémoire et aux appels système. Même si la bibliothèque standard C offre une abstraction commode avec les flux et les tampons, chaque opération d’E/S entraîne des appels système pouvant être coûteux en temps d’exécution, notamment pour de grands fichiers ou des traitements intensifs. Par exemple :
De plus, la gestion manuelle de la mémoire en C, bien qu’offrant un contrôle précis, introduit la responsabilité du programmeur de libérer les ressources via free() et d’éviter les fuites mémoire. Ainsi, la performance d’un programme ne dépend pas uniquement de sa complexité algorithmique, mais également d’un certain nombre de facteurs tels que la gestion des entrées/sorties, l’utilisation de la mémoire, la taille des tampons, les appels système et la manière dont le langage ou la bibliothèque gère ces opérations.
Ainsi, comprendre ces différences permet non seulement de choisir le bon outil pour chaque tâche, mais également d’anticiper les comportements liés aux performances, à la mémoire et à la gestion des ressources, en particulier pour des applications manipulant de grandes quantités de données ou nécessitant des traitements en temps réel. En C, cette gestion explicite permet une optimisation fine, mais demande une vigilance constante pour éviter les erreurs courantes comme les fuites mémoire, les accès hors-limites, etc