Version sans JavaScript, en lecture seule : les ateliers, les quiz et l’exécution du code n’y sont pas. Version complète · Catalogue · Se connecter
Support de cours · chapitre 5
Entrées-sorties
Sous Unix, tout est fichier - les descripteurs, read et write, ouvrir et fermer, se déplacer, rediriger, les tubes. Puis les tampons de stdio.h, et un fichier vu comme de la mémoire avec mmap.
Tout est fichier
Les descripteurs de fichiers
Unix obéit à une règle simple : tout est fichier. Un fichier du disque, bien sûr, mais aussi le terminal, un périphérique (/dev/null, /dev/urandom), un tube entre deux programmes, une connexion réseau. Tous se lisent et s'écrivent avec les mêmes deux fonctions, read et write.
Pour le programme, une telle ressource ouverte est désignée par un simple entier, le descripteur de fichier (file descriptor, souvent abrégé fd). C'est l'indice d'une case dans la table des ressources ouvertes que le noyau tient pour chaque processus.
Trois descripteurs sont déjà ouverts au lancement de tout programme :
| Numéro | Constante (<unistd.h>) | Rôle | Par défaut |
|---|---|---|---|
| 0 | STDIN_FILENO | entrée standard | le clavier |
| 1 | STDOUT_FILENO | sortie standard | le terminal |
| 2 | STDERR_FILENO | sortie d'erreur | le terminal |
Les fonctions de ce chapitre ne font pas partie du C, mais de la norme POSIX, celle des systèmes Unix. Avec -std=c11, gcc ne déclare que le C standard ; pour avoir aussi POSIX, on compile avec -D_POSIX_C_SOURCE=200809L. Tous les programmes du chapitre se compilent ainsi :
$ gcc -std=c11 -Wall -Wextra -Werror -D_POSIX_C_SOURCE=200809L prog.c -o prog
read et write
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, const void *buf, size_t n);
read(fd, buf, n)lit au plusnoctets depuisfd, et les range à l'adressebuf. Elle rend le nombre d'octets lus.write(fd, buf, n)écrit au plusnoctets pris à l'adressebuf. Elle rend le nombre d'octets écrits.
Ce sont des appels système : des fonctions du noyau, que le programme appelle directement. Leur page de manuel est dans la section 2 (man 2 read), celles de la bibliothèque C dans la section 3 (man 3 printf). On note souvent read(2) ou printf(3) pour dire de quelle section il s'agit.
#include <unistd.h>
int main(void)
{
write(STDOUT_FILENO, "bonjour\n", 8);
write(STDERR_FILENO, "erreur\n", 7);
return 0;
}
$ ./bonjour
bonjour
erreur
$ ./bonjour > sortie.txt
erreur
$ cat sortie.txt
bonjour
Le shell a redirigé la sortie standard dans le fichier ; la sortie d'erreur est restée sur le terminal. C'est tout l'intérêt de deux sorties distinctes : les messages d'erreur ne se mélangent pas aux résultats.
ssize_t est un entier signé de la taille de size_t : il faut pouvoir rendre -1.
Les erreurs, et errno
En cas d'échec, un appel système rend -1, et range dans la variable globale errno (déclarée par <errno.h>) un code qui dit pourquoi. perror("msg") affiche msg: suivi de l'explication en clair, et strerror(errno) rend cette explication sous forme de chaîne. Les codes les plus courants pour read et write :
EBADF:fdn'est pas un descripteur ouvert (ou pas dans le bon sens) ;EFAULT:bufpointe hors de la mémoire du programme ;EIO: erreur matérielle d'entrée-sortie.
Deux codes ne sont pas de vraies erreurs, il suffit de recommencer :
EINTR: l'appel a été interrompu par l'arrivée d'un signal (chapitre 6) avant d'avoir pu lire quoi que ce soit ;EAGAIN: le descripteur est en mode non bloquant, et il n'y a rien à lire pour l'instant. On ne le rencontre que si l'on a demandé ce mode.
Lectures partielles, fin de fichier
Trois règles gouvernent read :
- Elle est bloquante : s'il n'y a rien à lire (un terminal où l'on n'a rien tapé, un tube vide), le programme est endormi jusqu'à l'arrivée de données.
- Elle rend ce qui est disponible, même si c'est moins que ce qu'on a demandé. Sur un fichier du disque, on obtient en général tout ; sur un terminal, on obtient une ligne ; sur un tube ou le réseau, ce qui est arrivé.
- Elle rend 0 à la fin du fichier. C'est le seul moyen fiable de la détecter.
write peut, elle aussi, écrire moins que demandé (sur un tube plein, une connexion réseau, un disque plein). Le résultat de read et de write doit donc toujours être regardé. Lire exactement n octets demande une boucle :
size_t pos = 0;
while (pos < n)
{
ssize_t r = read(fd, buf + pos, n - pos);
if (r == 0)
break; /* fin de fichier avant n octets */
if (r == -1)
{
if (errno == EINTR)
continue;
/* vraie erreur : la traiter */
}
pos += r;
}
Un cat
La commande cat recopie sur la sortie standard les fichiers qu'on lui donne, ou son entrée standard si on ne lui en donne pas, ou si l'un d'eux s'appelle -. Écrivons-la.
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define TAILLE 4096
/* Écrit les n octets de buf, même si write n'en prend qu'une partie. */
static int ecrire_tout(int fd, const char *buf, size_t n)
{
while (n > 0)
{
ssize_t w = write(fd, buf, n);
if (w == -1)
{
if (errno == EINTR)
continue;
return -1;
}
buf += w;
n -= w;
}
return 0;
}
/* Recopie fd sur la sortie standard. Rend 0, ou -1 en cas d'erreur. */
static int copier(int fd)
{
char buf[TAILLE];
ssize_t r;
while ((r = read(fd, buf, TAILLE)) != 0)
{
if (r == -1)
{
if (errno == EINTR)
continue;
return -1;
}
if (ecrire_tout(STDOUT_FILENO, buf, r) == -1)
return -1;
}
return 0;
}
int main(int argc, char *argv[])
{
int statut = 0;
if (argc < 2)
return copier(STDIN_FILENO) == -1 ? 1 : 0;
for (int i = 1; i < argc; i++)
{
int fd = STDIN_FILENO;
if (strcmp(argv[i], "-") != 0)
{
fd = open(argv[i], O_RDONLY);
if (fd == -1)
{
fprintf(stderr, "moncat: %s: %s\n", argv[i], strerror(errno));
statut = 1;
continue;
}
}
if (copier(fd) == -1)
{
fprintf(stderr, "moncat: %s: %s\n", argv[i], strerror(errno));
statut = 1;
}
if (fd != STDIN_FILENO)
close(fd);
}
return statut;
}
$ ./moncat a.txt b.txt
ligne 1
ligne 2
fin
$ echo "du clavier" | ./moncat a.txt - b.txt
ligne 1
ligne 2
du clavier
fin
$ ./moncat a.txt absent.txt b.txt
ligne 1
ligne 2
moncat: absent.txt: No such file or directory
fin
$ echo $?
1
$ ./moncat /tmp
moncat: /tmp: Is a directory
Quelques choix, qui corrigent la version d'origine :
- On lit par blocs de 4096 octets, pas octet par octet : chaque appel système coûte cher (le programme passe la main au noyau), il faut en faire le moins possible.
- Chaque bloc lu est écrit en entier, grâce à
ecrire_tout. - Un fichier qu'on ne peut pas ouvrir n'arrête pas le programme : on le signale, on passe au suivant, et le code de retour dira qu'il y a eu un problème. C'est le comportement du vrai
cat. - Le message d'erreur est fabriqué juste après l'échec, avec
strerror(errno). L'original construisait le message à l'avance avecmalloc,strncpyetstrncat(pour qu'aucun appel ne modifieerrnoentre l'échec etperror) : c'était fragile, et le message n'était jamais libéré en cas d'erreur. - Chaque fichier ouvert est refermé : un processus n'a droit qu'à un nombre limité de descripteurs (souvent 1024), et un
catde deux mille fichiers échouerait.
Ouvrir, fermer, se déplacer
open et close
#include <fcntl.h>
int open(const char *chemin, int drapeaux);
int open(const char *chemin, int drapeaux, mode_t mode);
#include <unistd.h>
int close(int fd);
open rend un nouveau descripteur, toujours le plus petit numéro libre, ou -1 en cas d'échec. close le libère. Les drapeaux disent comment ouvrir. Il en faut exactement un parmi :
O_RDONLY: lecture seule ;O_WRONLY: écriture seule ;O_RDWR: lecture et écriture.
On peut y ajouter, avec l'opérateur « ou bit à bit » | :
O_CREAT: crée le fichier s'il n'existe pas. C'est le seul cas où le troisième paramètre,mode, sert ;O_TRUNC: si le fichier existe, le vide ;O_APPEND: chaque écriture se fait à la fin du fichier ;O_EXCL: avecO_CREAT, échoue si le fichier existe déjà.
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void)
{
int fd = open("essai.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd == -1)
{
perror("essai.txt");
return 1;
}
write(fd, "abc\n", 4);
close(fd);
return 0;
}
C'est ce que fait le shell pour > essai.txt.
Les droits, et le umask
Le mode donne les droits du fichier créé, en octal (le 0 initial le dit au compilateur) : 0666 est rw-rw-rw-, lecture et écriture pour tous. Mais le noyau retire les droits interdits par le umask du processus, un masque hérité du shell : les droits réels sont mode & ~umask.
$ umask
0002
$ ./creer; ls -l essai.txt
-rw-rw-r-- … essai.txt
$ umask 077; rm essai.txt; ./creer; ls -l essai.txt
-rw------- … essai.txt
Avec un umask de 002, le droit d'écriture des « autres » est retiré ; avec 077, tous les droits du groupe et des autres. On donne donc toujours 0666 pour un fichier de données (0777 pour un exécutable ou un répertoire), et on laisse l'utilisateur décider avec son umask.
lseek
Pour un fichier du disque, le noyau tient une position courante : chaque read ou write de k octets l'avance de k. Elle est commune à la lecture et à l'écriture, et partagée par tous les descripteurs qui désignent la même ouverture du fichier (après un dup, ou un fork). lseek la déplace :
off_t lseek(int fd, off_t decalage, int depart);
depart vaut :
SEEK_SET:decalagecompte depuis le début du fichier ;SEEK_CUR: depuis la position courante ;SEEK_END: depuis la fin du fichier.
lseek rend la nouvelle position, comptée depuis le début, ou -1. lseek(fd, 0, SEEK_END) rend donc la taille du fichier, et lseek(fd, 0, SEEK_CUR) la position courante. off_t est un entier signé, sur 64 bits sous Linux. Sur un tube ou un terminal, où « se déplacer » n'a pas de sens, lseek échoue avec ESPIPE.
On peut se placer après la fin du fichier. Si l'on écrit là, le trou entre l'ancienne fin et la nouvelle écriture se lit comme des octets nuls, mais n'occupe pas de place sur le disque :
int fd = open("troue.bin", O_WRONLY | O_CREAT | O_TRUNC, 0644);
write(fd, "debut", 5);
off_t pos = lseek(fd, 1000000, SEEK_CUR);
write(fd, "fin", 3);
printf("position après le saut : %lld\n", (long long)pos);
printf("taille : %lld\n", (long long)lseek(fd, 0, SEEK_END));
close(fd);
position après le saut : 1000005
taille : 1000008
$ du -h troue.bin
8,0K troue.bin
Le fichier fait un million d'octets, mais n'occupe que 8 Kio sur le disque : c'est un fichier creux. Sous Linux, lseek accepte aussi SEEK_DATA et SEEK_HOLE, pour sauter d'une zone de données à la suivante ; tous les systèmes de fichiers ne les gèrent pas.
Rediriger
dup et dup2
int dup(int ancien);
int dup2(int ancien, int nouveau);
dup(ancien)rend un nouveau descripteur (le plus petit libre) qui désigne la même ressource qu'ancien.dup2(ancien, nouveau)fait denouveauune copie d'ancien. Sinouveauétait ouvert, il est d'abord fermé.
Après un dup, les deux descripteurs partagent tout, position courante comprise. La ressource n'est vraiment fermée que lorsque tous les descripteurs qui la désignent ont été fermés.
C'est ainsi que le shell fait ses redirections. Pour prog > journal.txt, il ouvre journal.txt, en fait le descripteur 1 avec dup2, puis lance prog, qui écrit sur sa sortie standard sans rien savoir. On peut faire la même chose dans un programme, et même revenir en arrière, en gardant une copie de l'ancienne sortie :
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void)
{
int sauve = dup(STDOUT_FILENO);
int fd = open("journal.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd == -1)
{
perror("journal.txt");
return 1;
}
dup2(fd, STDOUT_FILENO);
close(fd);
write(STDOUT_FILENO, "dans le fichier\n", 16);
dup2(sauve, STDOUT_FILENO);
close(sauve);
write(STDOUT_FILENO, "à l'écran\n", 12);
return 0;
}
$ ./redir
à l'écran
$ cat journal.txt
dans le fichier
Une fois dup2 fait, fd ne sert plus : le fichier reste ouvert à travers le descripteur 1. On le ferme aussitôt. (La chaîne "à l'écran\n" fait 12 octets et non 10 : à et é prennent chacun deux octets en UTF-8.)
Les tubes
Une file entre deux processus
Un tube (pipe) est un canal de communication à sens unique : on écrit d'un côté, on lit de l'autre, dans l'ordre d'écriture. C'est ce que construit le shell pour ls | wc -l : la sortie de ls devient l'entrée de wc.
int pipe(int t[2]);
pipe crée le tube et range deux descripteurs dans le tableau : t[0] est la sortie du tube (on y lit), t[1] son entrée (on y écrit). Pour s'en souvenir : 0 est l'entrée standard, où l'on lit ; 1 la sortie standard, où l'on écrit.
- La lecture est destructrice : un octet lu est retiré du tube.
- Le tube n'a pas d'« auteur » : un processus qui lit dans un tube où il vient d'écrire lit ce qu'il a écrit lui-même. Il faut donc que chaque participant soit clairement lecteur ou rédacteur, et une communication dans les deux sens demande deux tubes.
- Un tube n'a de sens qu'entre deux processus : on le crée, puis on appelle
fork(chapitre 6), qui duplique le processus avec ses descripteurs. Père et fils ont alors accès aux deux bouts du tube.
Fermer les bouts inutiles
Deux règles rendent la fermeture indispensable :
readsur un tube vide ne rend 0 (fin de fichier) que lorsque toutes les entrées du tube sont fermées, dans tous les processus. Tant qu'il en reste une ouverte, quelqu'un pourrait encore écrire :readattend.writesur un tube dont toutes les sorties sont fermées tue le processus avec le signalSIGPIPE: personne ne lira jamais.
Chaque processus ferme donc, juste après le fork, le bout dont il ne se sert pas. Oublier de fermer son t[1] dans le processus lecteur, c'est l'endormir pour toujours : il attend une fin de fichier qu'il empêche lui-même d'arriver.
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
int t[2];
char c;
pipe(t);
if (fork() != 0)
{
close(t[0]);
write(t[1], "x", 1);
close(t[1]);
wait(NULL);
}
else
{ /* oubli : close(t[1]); */
while (read(t[0], &c, 1) > 0)
printf("lu : %c\n", c);
printf("fin du fils\n");
}
return 0;
}
$ ./tube_bloque
lu : x
^C
Le fils lit x, puis attend indéfiniment ; le père attend le fils dans wait. Il faut interrompre avec Ctrl-C. (Si la sortie est redirigée dans un fichier, on ne voit même pas lu : x : la ligne est restée dans le tampon de printf au moment où le programme a été tué, voir plus bas.)
Et dans l'autre sens :
int t[2];
pipe(t);
close(t[0]);
printf("avant\n");
fflush(stdout);
write(t[1], "x", 1);
printf("après\n");
$ ./sigpipe
avant
$ echo $?
141
après n'est jamais affiché : write a tué le processus. Le shell rend 128 + le numéro du signal qui a tué le programme, et SIGPIPE est le signal 13.
Un exemple complet
Le père envoie un message au fils par un tube, huit octets à la fois ; le fils le lit quatre octets à la fois et le recopie sur la sortie standard. Rien n'oblige les deux côtés à lire et écrire par blocs de même taille : le tube est un flot d'octets, sans découpage.
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
static void lecteur(int fd)
{
char buf[4];
ssize_t r;
while ((r = read(fd, buf, sizeof buf)) != 0)
{
if (r == -1)
{
if (errno == EINTR)
continue;
perror("lecteur");
return;
}
write(STDOUT_FILENO, buf, r);
}
}
static void redacteur(int fd, const char *texte)
{
size_t reste = strlen(texte);
while (reste > 0)
{
ssize_t w = write(fd, texte, reste < 8 ? reste : 8);
if (w == -1)
{
if (errno == EINTR)
continue;
perror("redacteur");
return;
}
texte += w;
reste -= w;
}
}
int main(void)
{
int t[2];
if (pipe(t) == -1)
{
perror("pipe");
return 1;
}
if (fork() != 0)
{ /* le père écrit */
close(t[0]);
redacteur(t[1], "Un message qui traverse le tube, huit octets par huit.\n");
close(t[1]);
wait(NULL);
}
else
{ /* le fils lit */
close(t[1]);
lecteur(t[0]);
close(t[0]);
}
return 0;
}
$ ./tube
Un message qui traverse le tube, huit octets par huit.
Le père ferme son entrée du tube avant d'attendre le fils : c'est ce qui permet au fils de voir la fin de fichier et de se terminer. Dans l'autre ordre, chacun attendrait l'autre.
Les entrées-sorties de stdio.h
Des tampons
Un appel système coûte cher : il faut passer la main au noyau, et en revenir. Écrire un fichier caractère par caractère avec write serait très lent. Gérer soi-même des blocs, comme dans moncat, est vite fastidieux. La bibliothèque C le fait pour nous : c'est <stdio.h>.
Un FILE * cache un descripteur et un tampon (buffer), une zone de mémoire. printf, fputs, fputc écrivent dans le tampon ; le contenu n'est envoyé au noyau, par un seul write, que lorsque le tampon est plein, ou dans certains cas précis. De même, fgets ou scanf lisent un gros bloc d'un coup, et puisent ensuite dedans. stdin, stdout et stderr sont les FILE * des descripteurs 0, 1 et 2 ; fopen et fclose ouvrent et ferment les autres.
Quand le tampon est-il vidé ?
stdoutvers un terminal : à chaque fin de ligne (tampon par ligne) ;stdoutvers un fichier ou un tube : quand le tampon est plein, quelques kilo-octets (tampon complet) ;stderr: jamais mis en tampon, chaque écriture part tout de suite ;- dans tous les cas : quand on appelle
fflush, quand on ferme le fichier, et à la fin normale du programme (retour demainouexit).
Les pièges
Le tampon rend l'écriture différée : un printf n'écrit pas au moment où il est appelé. Tant qu'on n'utilise que stdio.h, on ne s'en aperçoit pas. Mais si l'on mélange printf et write :
printf("un ");
write(STDOUT_FILENO, "deux ", 5);
printf("trois\n");
$ ./tampon
deux un trois
un est resté dans le tampon (pas encore de fin de ligne), tandis que deux partait directement. On ne mélange pas les deux sur un même descripteur, ou on appelle fflush(stdout) avant chaque write.
Plus surprenant, avec fork, qui duplique toute la mémoire du processus, tampons compris :
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
printf("avant le fork\n");
if (fork() == 0)
return 0;
wait(NULL);
return 0;
}
$ ./tampon_fork
avant le fork
$ ./tampon_fork > f.txt
$ cat f.txt
avant le fork
avant le fork
Sur le terminal, la ligne est partie dès le \n, avant le fork. Redirigée dans un fichier, elle attendait dans le tampon ; le fork a copié le tampon, et chacun des deux processus l'a vidé en se terminant. Le même programme n'écrit pas la même chose selon l'endroit où va sa sortie. D'où la règle : fflush(stdout) avant tout fork.
Un fichier en mémoire : mmap
Plutôt que de lire un fichier dans un tableau, on peut demander au noyau de le projeter dans la mémoire du processus : le fichier apparaît comme un tableau d'octets, qu'on parcourt avec des pointeurs. Le noyau charge les pages du fichier à mesure qu'on les touche (la mémoire virtuelle, au chapitre 3).
#include <sys/mman.h>
void *mmap(void *adresse, size_t longueur, int protection, int drapeaux,
int fd, off_t decalage);
int munmap(void *adresse, size_t longueur);
Compter les lignes d'un fichier :
#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
if (argc != 2)
{
fprintf(stderr, "usage : %s fichier\n", argv[0]);
return 1;
}
int fd = open(argv[1], O_RDONLY);
struct stat s;
if (fd == -1 || fstat(fd, &s) == -1)
{
perror(argv[1]);
return 1;
}
if (s.st_size == 0)
{
printf("0 ligne\n");
return 0;
}
char *p = mmap(NULL, s.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
if (p == MAP_FAILED)
{
perror("mmap");
return 1;
}
size_t lignes = 0;
for (off_t i = 0; i < s.st_size; i++)
if (p[i] == '\n')
lignes++;
printf("%zu lignes\n", lignes);
munmap(p, s.st_size);
return 0;
}
$ ./compter moncat.c
77 lignes
$ wc -l moncat.c
77 moncat.c
fstatdonne des informations sur un fichier ouvert, dont sa taille,st_size.PROT_READ: on ne fera que lire.MAP_PRIVATE: si l'on écrivait, les modifications resteraient propres au processus ; avecMAP_SHARED, elles iraient dans le fichier.- En cas d'échec,
mmapne rend pasNULLmaisMAP_FAILED. - Une fois la projection faite, le descripteur peut être fermé : la projection reste valable jusqu'à
munmap. - On ne peut pas projeter un fichier vide (longueur 0), d'où le test.
mmap sert aussi à allouer de la mémoire sans fichier (chapitre 3), et à partager une zone de mémoire entre processus.