12 Les fichiers¶
12.1 Système de fichiers¶
Dans un environnement POSIX tout est fichier. stdin
est un fichier, une souris USB est un fichier, un clavier est un fichier, un terminal est un fichier, un programme est un fichier.
Les fichiers sont organisés dans une arborescence gérée par un système de fichiers. Sous Windows l'arborescence classique est :
C :
├── Program Files Programmes installés
├── Users Comptes utilisateur
│ └── John
│ ├── Desktop
│ ├── Documents
│ └── Music
└── Windows
├── Fonts Polices de caractères
├── System32 Système d'exploitation 64-bits (oui, oui)
└── Temp Fichiers temporaires
Il y a une arborescence par disque physique C:
, D:
, une arborescence par chemin réseau \\eistore2
, etc. Sous POSIX, la stratégie est différente, car il n'existe qu'un seul système de fichier dont la racine est /
.
/
├── bin Programmes exécutables cruciaux
├── dev Périphériques (clavier, souris ...)
├── usr
│ └── bin Programmes installés
├── mnt Points de montage (disques réseaux, CD, clé USB)
│ └── eistore2
├── tmp Fichiers temporaires
├── home Comptes utilisateurs
│ └── john
│ └── documents
└── var Fichiers variables comme les logs ou les database
Chaque élément qui contient d'autres éléments est appelé un répertoire ou dossier, en anglais directory. Chaque répertoire contient toujours au minimum deux fichiers spéciaux :
.
Un fichier qui symbolise le répertoire courant, celui dans lequel je me trouve
..
Un fichier qui symbolise le répertoire parent, c'est à dire
home
lorsque je suis dansjohn
.
La localisation d'un fichier au sein d'un système de fichier peut être soit absolue soit relative. Cette localisation s'appelle un chemin ou path. La convention est d'utiliser le symbole :
Slash
/
sous POSIXAntislash
\
sous Windows
Le chemin /usr/bin/.././bin/../../home/john/documents
est correct, mais il n'est pas canonique. La forme canonique est /home/john/documents
. Un chemin peut être relatif s'il ne commence pas par un /
: ../bin
. Sous Windows c'est pareil, mais la racine différemment selon le type de média C:\
, \\network
, ...
Lorsqu'un programme s'exécute, son contexte d'exécution est toujours par rapport à son emplacement dans le système de fichier donc le chemin peut être soit relatif, soit absolu.
12.2 Format d'un fichier¶
Un fichier peut avoir un contenu arbitraire; une suite de zéros et d’un binaire. Selon l'interprétation, un fichier pourrait contenir une image, un texte ou un programme. Le cas particulier ou le contenu est lisible par un éditeur de texte, on appelle ce fichier un fichier texte. C'est-à-dire que chaque caractère est encodé sur 8-bit et que la table ASCII est utilisée pour traduire le contenu en un texte intelligible. Lorsque le contenu n'est pas du texte, on l'appelle un fichier binaire.
La frontière est parfois assez mince, car parfois le fichier binaire peut contenir du texte intelligible, la preuve avec ce programme :
#include <stdio.h>
#include <string.h>
int main(char* argc, char** argv)
{
static const char password[] = "un mot de passe secret";
return strcmp(argv[1], password);
}
Si nous le compilons et cherchons dans son code binaire :
$ gcc example.c
| $ hexdump -C a.out | grep -C3 sec |
| 000006f0 f3 c3 00 00 48 83 ec 08 48 83 c4 08 c3 00 00 00 | ....H...H....... |
| 00000700 01 00 02 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ |
| 00000710 75 6e 20 6d 6f 74 20 64 65 20 70 61 73 73 65 20 | un mot de passe |
| 00000720 73 65 63 72 65 74 00 00 01 1b 03 3b 3c 00 00 00 | secret.....;<... |
| 00000730 06 00 00 00 e8 fd ff ff 88 00 00 00 08 fe ff ff | ................ |
| 00000740 b0 00 00 00 18 fe ff ff 58 00 00 00 22 ff ff ff | ........X..."... |
| 00000750 c8 00 00 00 58 ff ff ff e8 00 00 00 c8 ff ff ff | ....X........... |
Sous un système POSIX, il n'existe aucune distinction formelle entre un fichier binaire et un fichier texte. En revanche sous Windows il existe une subtile différence concernant surtout le caractère de fin de ligne. La commande copy a.txt + b.txt c.txt
considère des fichiers textes et ajoutera automatiquement une fin de ligne entre chaque partie concaténée, mais celle-ci copy /b a.bin + b.bin c.bin
ne le fera pas.
12.3 Ouverture d'un fichier¶
Sous POSIX, un programme doit demander au système d'exploitation l'accès à un fichier soit en lecture, soit en écriture soit les deux. Le système d'exploitation retourne un descripteur de fichier qui est simplement un entier unique pour le programme.
#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
int main(void)
{
int fd = open("toto", O_RDONLY);
printf("%d\n", fd);
getchar();
}
Lorsque le programme ci-dessus est exécuté, il va demander l'ouverture du fichier toto
en lecture et recevoir un descripteur de fichier fd
(file descriptor) positif en cas de succès ou négatif en cas d'erreur.
Dans l'exemple suivant, on compile, puis exécute en arrière-plan le programme qui ne se terminera pas puisqu'il attend un caractère d'entrée. L'appel au programme ps
permet de lister la liste des processus en cours et la recherche de test
permet de noter le numéro du processus, ici 6690
. Dans l'arborescence de fichiers, il est possible d'aller consulter les descripteurs de fichiers ouverts pour le processus concerné.
$ gcc test.c -o test && ./test &
$ ps -u | grep test
ycr 6690 0.0 0.0 10540 556 pts/4 T 11:19 0:00 test
$ ls /proc/6690/fd
0 1 2 3
On observe que trois descripteurs de fichiers sont ouverts.
0
pourSTDIN
1
pourSTDOUT
2
pourSTDERR
3
pour le fichiertoto
ouvert en lecture seule
La fonction open
est en réalité un appel système qui n'est standardisé que sous POSIX, c'est-à-dire que son utilisation n'est pas portable. L'exemple cité est principalement évoqué pour mieux comprendre le mécanisme de fond pour l'accès aux fichiers.
En réalité la bibliothèque standard, respectueuse de C99, dispose d'une fonction fopen
pour file open qui offre plus de fonctionnalités. Ouvrir un fichier se résume donc à
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("toto", "r");
if (fp == NULL) {
return -1; // Error the file cannot be accessed
}
// ...
}
Le mode d'ouverture du fichier peut être :
r
Ouverture en lecture seule depuis le début du fichier.
r+
Ouverture pour lecture et écriture depuis le début du fichier.
w
Ouverture en écriture. Le fichier est créé s'il n'existe pas déjà, sinon le contenu est effacé. Le pointeur de fichier est positionné au début de ce dernier.
w+
Ouverture en écriture et lecture. Le fichier est créé s'il n'existe pas déjà. Le pointeur de fichier est positionné au début de ce dernier.
a
Ouverture du fichier pour insertion. Le fichier est créé s'il n'existe pas déjà. Le pointeur est positionné à la fin du fichier.
a+
Ouverture du fichier pour lecture et écriture. Le fichier est créé s'il n'existe pas déjà et le pointeur du fichier est positionné à la fin.
Sous Windows et pour soucis de compatibilité, selon la norme C99, le flag b
pour binary existe. Pour ouvrir un fichier en mode binaire on peut alors écrire rb+
.
L'ouverture d'un fichier cause, selon le mode, un accès exclusif au fichier. C'est-à-dire que d'autres programmes ne pourront pas accéder à ce fichier. Il est donc essentiel de toujours refermer l'accès à un fichier dès lors que l'opération de lecture ou d'écriture est terminée :
flose(fp);
On peut noter que sous POSIX, écrire sur stdout
ou stderr
est exactement la même chose qu'écrire sur un fichier, il n'y a aucune distinction.
Exercice 12.1¶
Écrire un programme qui saisit le nom d'un fichier texte, ainsi qu'un texte à rechercher. Le programme affiche ensuite le numéro de toutes les lignes du fichier contenant le texte recherché.
$ ./search
Fichier: foo.txt
Recherche: bulbe
4
5
19
132
981
Question subsidiaire: que fait le programme suivant :
$ grep foo.txt bulbe
12.5 Lecture / Écriture¶
La lecture, écriture dans un fichier s'effectue de manière analogue aux fonctions que nous avons déjà vues printf
et scanf
pour les flux standards (stdout, stderr), mais en utilisant les pendants fichiers :
int fscanf(FILE *stream, const char *format, ...)
Équivalent à
scanf
mais pour les fichiers
int fprintf(FILE *stream, const char *format, ...)
Équivalent à
printf
mais pour les fichiers
int fgetc(FILE *stream)
Équivalent à
getchar
(ISO/IEC 9899 §7.19.7.6-2)
int fputc(FILE *stream, char char)
Équivalent à
putchar
(ISO/IEC 9899 §7.19.7.9-2)
char *fgets(char * restrict s, int n, FILE * restrict stream)
Équivalent à
gets
int fputs(const char * restrict s, FILE * restrict stream)
Équivalent à
puts
Bref... Vous avez compris.
Les nouvelles fonctions à connaître sont les suivantes :
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream)
Lecture arbitraire de
nmemb * size
bytes depuis le fluxstream
dans le bufferptr
:int32_t buffer[12] = {0}; fread(buffer, 2, sizeof(int32_t), stdin); printf("%x\n%x\n", buffer[0], buffer[1]);$ echo -e "0123abcdefgh" | ./a.out 33323130 64636261On notera au passage la nature little-endian du système.
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream)
La fonction est similaire à
fread
mais pour écrire sur un flux.
12.6 Buffer de fichier¶
Pour améliorer les performances, C99 prévoit (§7.19.3-3), un espace tampon pour les descripteurs de fichiers qui peuvent être :
unbuffered
(_IONBF
)Pas de buffer, les caractères lus ou écrits sont acheminés le plus vite possible de la source à la destination.
fully buffered
(_IOFBF
)
line buffered
(_IO_LBF
)
Il faut comprendre qu'à chaque instant un programme souhaite écrire dans un fichier, il doit générer un appel système et donc interrompre le noyau. Un programme qui écrirait caractère par caractère sur la sortie standard agirait de la même manière qu'un employé des postes qui irait distribuer son courrier en ne prenant qu'une enveloppe à la fois, de la centrale de distribution au destinataire.
Par défaut, un pointeur de fichier est fully buffered. C'est-à-dire que dans le cas du programme suivant devrait exécuter 10x l'appel système write
, une fois par caractère.
#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[])
{
if (argc > 1 && strcmp("--no-buffering", argv[1]) == 0)
setvbuf(stdout, NULL, _IONBF, 0);
for (int i = 0; i < 10; i++)
putchar('c');
}
Cependant le comportement réel est différent. Seulement si le buffer est désactivé que le programme interrompt le noyau pour chaque caractère :
$ gcc buftest.c -o buftest
$ strace ./buftest 2>&1 | grep write
write(1, "cccccccccc", 10cccccccccc) = 10
$ strace ./buftest --no-buffering 2>&1 | grep write
write(1, "c", 1c) = 1
write(1, "c", 1c) = 1
write(1, "c", 1c) = 1
write(1, "c", 1c) = 1
write(1, "c", 1c) = 1
write(1, "c", 1c) = 1
write(1, "c", 1c) = 1
write(1, "c", 1c) = 1
write(1, "c", 1c) = 1
write(1, "c", 1c) = 1
Le changement de mode peut être effectué avec la fonction setbuf
ou setvbuf
:
#include <stdio.h>
int main(void) {
char buf[1024];
setbuf(stdout, buf);
fputs("Allo ?");
fflush(stdout);
}
La fonction fflush
force l'écriture malgré l'utilisation d'un buffer.
12.7 Fichiers et Flux¶
Historiquement les descripteurs de fichiers sont appelés FILE
alors qu'ils sont préférablement appelés streams
en C++. Un fichier au même titre que stdin
, stdout
et stderr
sont des flux de données. La norme POSIX, décrit que par défaut les flux :
0
.STDIN
,1
.STDOUT
,2
.STDERR
,
sont ouverts au début du programme. Le premier fichier ouvert par exemple avec fopen
sera très probablement assigné à l'identifiant 3
.
Pour se convaincre de cela, on peut exécuter l'exemple suivant avec le programme strace
:
#include <stdio.h>
int main(void) {
char c = fgetc(stdin);
FILE *fd = fopen("file", "w");
fputc(c, fd);
fputc(c + 1, stdout);
fputc(c + 2, stderr);
}
Pour mémoire strace
permet de capturer les appels systèmes du programme passé en argument et de les afficher. Deux particularités de la commande exécutée sont 2>&1
qui redirige stderr
vers stdout
afin de pouvoir rediriger le flux vers grep
. Ensuite grep
permet de filtrer la sortie pour n'afficher que les lignes contenant open
, read
, write
ou close
:
$ echo k | strace ./a.out 2>&1 | grep -P 'open|read|write|close'
read(0, "k\n", 4096) = 2
openat(AT_FDCWD, "file", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3
write(2, "m", 1m) = 1
write(3, "k", 1) = 1
write(1, "l", 1l) = 1
On peut voir qu’on lit k\n
sur le flux 0
, soit stdin
, puis que le fichier file
est ouvert, il porte l'identifiant 3
, enfin on écrit sur 1
, 2
et 3
.
12.8 Formats de sérialisation¶
Souvent les fichiers sont utilisés pour stocker de l'information organisée en grille, par exemple, la liste des températures maximales par ville et par mois :
Pays |
Ville |
01 |
02 |
03 |
04 |
05 |
06 |
07 |
08 |
09 |
10 |
11 |
12 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Suisse |
Zürich |
0.3 |
1.3 |
5.3 |
8.8 |
13.3 |
16.4 |
18.6 |
18.0 |
14.1 |
9.9 |
4.4 |
1.4 |
Italie |
Rome |
7.5 |
8.2 |
10.2 |
12.6 |
17.2 |
21.1 |
24.1 |
24.5 |
20.8 |
16.4 |
11.4 |
8.4 |
Allemagne |
Berlin |
0.6 |
2.3 |
5.1 |
10.2 |
14.8 |
17.9 |
20.3 |
19.7 |
15.3 |
10.5 |
6.0 |
1.33 |
Yémen |
Aden |
25.7 |
26.0 |
27.2 |
28.9 |
31.0 |
32.7 |
32.7 |
31.5 |
31.6 |
28.9 |
27.1 |
26.01 |
Russie |
Yakutsk |
-38.6 |
-33.8 |
-20.1 |
-4.8 |
7.5 |
16.4 |
19.5 |
15.2 |
6.1 |
-7.8 |
-27.0 |
-37.6 |
Il existe plusieurs manières d'écrire ces informations dans un fichier :
Écriture tabulée
Écriture avec remplissage
Utiliser un langage de sérialisation de haut niveau comme JSON, YAML ou XML
12.8.1 Format tabulé¶
Un fichier dit tabulé, utilise une sentinelle, souvent le caractère de tabulation \t
pour séparer les données. Chaque ligne du tableau est physiquement séparée de la suivante avec un \n
:
Pays\tVille\t01\t02\t03\t04\t05\t06\t07\t08\t09\t10\t11\t12\n
Suisse\tZürich\t0.3\t1.3\t5.3\t8.8\t13.3\t16.4\t18.6\t18.0\t14.1\t9.9\t4.4\t1.4\n
Italie\tRome\t7.5\t8.2\t10.2\t12.6\t17.2\t21.1\t24.1\t24.5\t20.8\t16.4\t11.4\t8.4\n
Allemagne\tBerlin\t0.6\t2.3\t5.1\t10.2\t14.8\t17.9\t20.3\t19.7\t15.3\t10.5\t6.0\t1.33\n
Yémen\tAden\t25.7\t26.0\t27.2\t28.9\t31.0\t32.7\t32.7\t31.5\t31.6\t28.9\t27.1\t26.01\n
Russie\tYakutsk\t-38.6\t-33.8\t-20.1\t-4.8\t7.5\t16.4\t19.5\t15.2\t6.1\t-7.8\t-27.0\t-37.6\n
Ce fichier peut être observé avec un lecteur hexadécimal :
$ hexdump -C data.dat
00000000 50 61 79 73 09 56 69 6c 6c 65 09 30 31 09 30 32 |Pays.Ville.01.02|
00000010 09 30 33 09 30 34 09 30 35 09 30 36 09 30 37 09 |.03.04.05.06.07.|
00000020 30 38 09 30 39 09 31 30 09 31 31 09 31 32 0a 53 |08.09.10.11.12.S|
00000030 75 69 73 73 65 09 5a c3 bc 72 69 63 68 09 30 2e |uisse.Z..rich.0.|
00000040 33 09 31 2e 33 09 35 2e 33 09 38 2e 38 09 31 33 |3.1.3.5.3.8.8.13|
00000050 2e 33 09 31 36 2e 34 09 31 38 2e 36 09 31 38 2e |.3.16.4.18.6.18.|
00000060 30 09 31 34 2e 31 09 39 2e 39 09 34 2e 34 09 31 |0.14.1.9.9.4.4.1|
00000070 2e 34 0a 49 74 61 6c 69 65 09 52 6f 6d 65 09 37 |.4.Italie.Rome.7|
00000080 2e 35 09 38 2e 32 09 31 30 2e 32 09 31 32 2e 36 |.5.8.2.10.2.12.6|
00000090 09 31 37 2e 32 09 32 31 2e 31 09 32 34 2e 31 09 |.17.2.21.1.24.1.|
000000a0 32 34 2e 35 09 32 30 2e 38 09 31 36 2e 34 09 31 |24.5.20.8.16.4.1|
000000b0 31 2e 34 09 38 2e 34 0a 41 6c 6c 65 6d 61 67 6e |1.4.8.4.Allemagn|
000000c0 65 09 42 65 72 6c 69 6e 09 30 2e 36 09 32 2e 33 |e.Berlin.0.6.2.3|
000000d0 09 35 2e 31 09 31 30 2e 32 09 31 34 2e 38 09 31 |.5.1.10.2.14.8.1|
000000e0 37 2e 39 09 32 30 2e 33 09 31 39 2e 37 09 31 35 |7.9.20.3.19.7.15|
000000f0 2e 33 09 31 30 2e 35 09 36 2e 30 09 31 2e 33 33 |.3.10.5.6.0.1.33|
00000100 0a 59 c3 a9 6d 65 6e 09 41 64 65 6e 09 32 35 2e |.Y..men.Aden.25.|
00000110 37 09 32 36 2e 30 09 32 37 2e 32 09 32 38 2e 39 |7.26.0.27.2.28.9|
00000120 09 33 31 2e 30 09 33 32 2e 37 09 33 32 2e 37 09 |.31.0.32.7.32.7.|
00000130 33 31 2e 35 09 33 31 2e 36 09 32 38 2e 39 09 32 |31.5.31.6.28.9.2|
00000140 37 2e 31 09 32 36 2e 30 31 0a 52 75 73 73 69 65 |7.1.26.01.Russie|
00000150 09 59 61 6b 75 74 73 6b 09 2d 33 38 2e 36 09 2d |.Yakutsk.-38.6.-|
00000160 33 33 2e 38 09 2d 32 30 2e 31 09 2d 34 2e 38 09 |33.8.-20.1.-4.8.|
00000170 37 2e 35 09 31 36 2e 34 09 31 39 2e 35 09 31 35 |7.5.16.4.19.5.15|
00000180 2e 32 09 36 2e 31 09 2d 37 2e 38 09 2d 32 37 2e |.2.6.1.-7.8.-27.|
00000190 30 09 2d 33 37 2e 36 0a |0.-37.6.|
00000198
L'inconvénient de ce format est que pour obtenir directement la température du mois de mars à Berlin, sachant que Berlin est la quatrième ligne du fichier, il est nécessaire de parcourir le fichier depuis le début, car la longueur des lignes n'est à priori pas connue. On dit que la lecture séquentielle est facilitée, mais la lecture aléatoire est plus lente.
12.8.2 Format avec remplissage¶
Pour pallier au défaut du format tabulé, il est possible d'écrire le fichier en utilisant un caractère de remplissage. Dans le fichier suivant, les mois de mai sont toujours alignés avec la 48e colonne :
0000000000111111111122222222223333333333444444444455555555556666666666777777777788
0123456789012345678901234567890123456789012345678901234567890123456789012345678901
+---------+-------+-----+-----+-----+----+----+----+----+----+----+----+-----+--->
Pays Ville 01 02 03 04 05 06 07 08 09 10 11 12
Suisse Zürich 0.3 1.3 5.3 8.8 13.3 16.4 18.6 18.0 14.1 9.9 4.4 1.4
Italie Rome 7.5 8.2 10.2 12.6 17.2 21.1 24.1 24.5 20.8 16.4 11.4 8.4
Allemagne Berlin 0.6 2.3 5.1 10.2 14.8 17.9 20.3 19.7 15.3 10.5 6.0 1.33
Yémen Aden 25.7 26.0 27.2 28.9 31.0 32.7 32.7 31.5 31.6 28.9 27.1 26.01
Russie Yakutsk -38.6 -33.8 -20.1 -4.8 7.5 16.4 19.5 15.2 6.1 -7.8 -27.0 -37.6
Idéalement on utilise comme caractère de remplissage le caractère nulle \0
, mais le caractère espace peut aussi convenir à condition que les données ne contiennent pas d'espace.
La lecture aléatoire de ce type de fichier est facilitée, car la position de chaque entrée est connue à l'avance, on sait par exemple que le pays est stocké sur 11 caractères, la ville sur 9 caractères et chaque température sur 7 caractères.
L'utilisation de fseek
est par conséquent utile :
int line = 2;
int month = 3;
double temperature;
fseek(fd, line * (11 + 9 + 12 * 7 + 1), SEEK_SET);
fseek(fd, 11 + 9 + month * 7 SEEK_CUR);
fscanf(fd, "%lf", &temperature);
L'inconvénient de ce format de fichier est la place qu'il prend en mémoire. L'autre problème est que si le nom d'une ville dépasse les 9 caractères alloués, il faut réécrire tout le fichier. Généralement ce problème est contourné en allouant des champs d'une taille suffisante, par exemple 256 caractères pour le nom des villes.
12.8.3 Format sérialisé¶
Des langages de sérialisation permettent de structurer de l'information en utilisant un format spécifique. Ici JSON :
[
{
"pays": "Suisse",
"ville": "Zürich",
"mois": {
"janvier": 0.3,
"février": 1.3,
"mars": 5.3,
"avril": 8.8,
"mai": 13.3,
"juin": 16.4,
"juillet": 18.6,
"août": 18.0,
"septembre": 14.1,
"octobre": 9.9,
"novembre": 4.4,
"décembre": 1.4
}
},
{
"pays": "Italie",
"ville": "Rome",
"mois": {
"janvier": 7.5,
"février": 8.2,
"mars": 10.2,
"avril": 12.6,
"mai": 17.2,
"juin": 21.1,
"juillet": 24.1,
"août": 24.5,
"septembre": 20.8,
"octobre": 16.4,
"novembre": 11.4,
"décembre": 8.4
}
}
]
L'avantage de ce type de format est qu'il est facilement modifiable avec un éditeur de texte et qu'il est très interopérable. C'est-à-dire qu'il est facilement lisible depuis différents langages de programmation.
En C, on pourra utiliser la bibliothèque logicielle json-c.
Exercice 12.2¶
Considérez les deux programmes ci-dessous très similaires.
#include <stdio.h>
int main(void)
{
char texte[80];
printf("Saisir un texte:");
gets(texte);
printf("Texte: %s\n", texte);
}
#include <stdio.h>
int main(void)
{
char texte[80];
printf("Saisir un texte:");
fgets(texte, 80, stdin);
printf("Texte: %s\n", texte);
}
Quelle est la différence entre ces 2 programmes ?
Dans quel cas est-ce que ces programmes auront un comportement différent ?
Quelle serait la meilleure solution ?