Les entrées/sorties Linux : open(), read(), write() et file descriptors

Ce forum est dédié à apprendre le développement de programmes user mode sur Linux

Moderator: Rick

Post Reply
Hydraxx
Site Admin
Posts: 46
Joined: Mon Jan 12, 2026 4:04 pm
Location: France
Contact:

Les entrées/sorties Linux : open(), read(), write() et file descriptors

Post by Hydraxx »

Cours Linux — Concepts système et modèle universel d’I/O

Introduction

En programmation système Linux, deux idées reviennent partout :
  • le programme utilisateur ne parle pas directement au matériel ;
  • il demande des services au noyau à travers des appels système ;
  • les ressources d’entrée/sortie sont manipulées via des descripteurs de fichiers ;
  • un même modèle permet de manipuler fichiers, terminaux, pipes, sockets et périphériques.
Ces deux chapitres posent donc les bases de tout le développement système Linux en user mode.

On va voir :
  • ce qu’est un appel système ;
  • le rôle de la libc/glibc ;
  • la différence entre fonction de bibliothèque et syscall ;
  • la gestion des erreurs avec errno ;
  • les macros de portabilité POSIX/GNU ;
  • les types système Linux ;
  • le modèle universel d’I/O ;
  • les file descriptors ;
  • open(), read(), write(), close() ;
  • lseek() et le file offset ;
  • les fichiers creux ;
  • ioctl() et les opérations spécifiques aux périphériques.

1. Programme utilisateur, libc et noyau

1.1. Séparation user mode / kernel mode

Sous Linux, un programme classique tourne en espace utilisateur.

Cela signifie qu’il ne peut pas directement :
  • accéder au disque ;
  • manipuler librement la mémoire physique ;
  • créer des processus au niveau noyau ;
  • piloter les périphériques ;
  • modifier les structures internes du système.
Pour toutes ces opérations, le programme doit demander au noyau de faire le travail.

Cette demande passe par un appel système.

Schéma simplifié :

Code: Select all

Programme utilisateur
        |
        v
Fonction libc / wrapper
        |
        v
Instruction de transition vers le noyau
        |
        v
Noyau Linux
        |
        v
Routine système réelle
Le programme ne traverse donc pas directement toutes les couches internes du noyau. Il utilise une interface contrôlée.

1.2. Qu’est-ce qu’un appel système ?

Un appel système, ou system call, est une entrée contrôlée dans le noyau.

Exemples d’appels système Linux :

Code: Select all

open()
read()
write()
close()
fork()
execve()
mmap()
ioctl()
lseek()
Un appel système permet au programme de demander au noyau :
  • d’ouvrir un fichier ;
  • de lire des données ;
  • d’écrire des données ;
  • de créer un processus ;
  • de manipuler la mémoire virtuelle ;
  • de communiquer avec un périphérique.
Le noyau vérifie la validité de la demande avant d’exécuter l’action.

1.3. Ce qui se passe pendant un syscall

Lorsqu’un programme appelle un syscall, plusieurs choses se produisent :
  • les arguments sont préparés ;
  • le numéro de l’appel système est placé dans un registre ;
  • le processeur passe de l’espace utilisateur à l’espace noyau ;
  • le noyau vérifie les arguments ;
  • le noyau exécute la routine demandée ;
  • le résultat est renvoyé au programme ;
  • le processeur revient en espace utilisateur.
Schéma conceptuel :

Code: Select all

open("test.txt", O_RDONLY)
        |
        v
Wrapper libc open()
        |
        v
syscall
        |
        v
Table des appels système
        |
        v
Routine noyau chargée d’ouvrir le fichier
        |
        v
Retour vers le programme
1.4. Le syscall a un coût

Un appel système n’est pas une simple fonction C classique.

Il implique :
  • un changement de privilège CPU ;
  • une transition user mode vers kernel mode ;
  • des validations de pointeurs ;
  • des vérifications de permissions ;
  • une sauvegarde/restauration de contexte ;
  • un retour vers l’espace utilisateur.
C’est pour cela qu’on évite de faire inutilement des milliers de petits syscalls lorsque l’on peut regrouper le travail.

Mauvais exemple :

Code: Select all

for (int i = 0; i < 1000000; i++)
{
    write(fd, "A", 1);
}
Meilleure idée :

Code: Select all

char buffer[4096];

/* remplir buffer */

write(fd, buffer, sizeof(buffer));
L’idée générale est de réduire le nombre d’allers-retours entre user mode et kernel mode.


2. Fonctions de bibliothèque et appels système

2.1. Toutes les fonctions C ne sont pas des syscalls

Il ne faut pas confondre :
  • les appels système ;
  • les fonctions de bibliothèque.
Exemples de fonctions de bibliothèque :

Code: Select all

printf()
malloc()
strcpy()
strlen()
fopen()
fprintf()
perror()
strerror()
Certaines fonctions de bibliothèque utilisent des syscalls en interne, mais elles ne sont pas forcément elles-mêmes des syscalls.

Exemple :

Code: Select all

printf("Hello\n");
Cette fonction peut finir par utiliser write() en interne pour écrire sur stdout, mais printf() est une fonction de la bibliothèque C.

2.2. La libc / glibc

Sous Linux, la bibliothèque C la plus courante est la glibc.

Elle fournit :
  • les wrappers autour des appels système ;
  • les fonctions standards C ;
  • les fonctions POSIX ;
  • des extensions GNU ;
  • des outils de conversion, formatage, chaînes, mémoire, etc.
Schéma :

Code: Select all

Programme C
    |
    v
glibc
    |
    v
syscall si nécessaire
    |
    v
noyau Linux
2.3. Exemple avec fopen() et open()

La fonction fopen() appartient à la bibliothèque standard C.

Elle retourne un FILE* :

Code: Select all

FILE *f = fopen("test.txt", "r");
La fonction open(), elle, est plus bas niveau et retourne un file descriptor :

Code: Select all

int fd = open("test.txt", O_RDONLY);
Différence :
  • fopen() donne une interface C standard avec buffering de stdio ;
  • open() donne accès au modèle UNIX bas niveau avec file descriptor.
On peut voir fopen() comme une couche plus haut niveau.

Code: Select all

fopen()
  |
  v
stdio / buffering
  |
  v
open(), read(), write(), close()
  |
  v
noyau

3. Gestion des erreurs : errno

3.1. Les appels système peuvent échouer

Un programme système doit toujours vérifier les valeurs de retour.

Exemple :

Code: Select all

int fd = open("test.txt", O_RDONLY);

if (fd == -1)
{
    perror("open");
    return 1;
}
Ici, si open() échoue, il retourne -1.

3.2. errno

Quand un appel système ou une fonction de bibliothèque échoue, une variable nommée errno indique souvent la raison de l’erreur.

Exemples d’erreurs possibles :

Code: Select all

EACCES   Permission refusée
ENOENT   Fichier introuvable
EEXIST   Le fichier existe déjà
EBADF    Mauvais file descriptor
EINVAL   Argument invalide
ENOMEM   Mémoire insuffisante
EINTR    Appel interrompu par un signal
Exemple :

Code: Select all

#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>

int main(void)
{
    int fd = open("inexistant.txt", O_RDONLY);

    if (fd == -1)
    {
        perror("open");
        return 1;
    }

    close(fd);
    return 0;
}
perror() affiche un message basé sur errno.

3.3. strerror()

strerror() transforme un code errno en texte.

Code: Select all

#include <stdio.h>
#include <string.h>
#include <errno.h>

int main(void)
{
    printf("Erreur : %s\n", strerror(errno));
    return 0;
}
3.4. Attention : errno n’est significatif qu’après une erreur

Il ne faut pas lire errno au hasard.

Mauvais raisonnement :

Code: Select all

printf("errno = %d\n", errno);
errno peut contenir une ancienne erreur.

La bonne logique est :

Code: Select all

if (fonction() == -1)
{
    perror("fonction");
}
On consulte errno seulement après avoir détecté une erreur.


4. Macros de portabilité et standards UNIX

4.1. Linux, POSIX et GNU

Linux n’est pas seulement un noyau. Pour programmer dessus, on utilise aussi des bibliothèques et des standards.

Les standards importants :
  • POSIX ;
  • SUS / Single UNIX Specification ;
  • X/Open ;
  • extensions GNU ;
  • extensions spécifiques Linux.
Un programme peut être :
  • portable POSIX ;
  • spécifique Linux ;
  • spécifique GNU/glibc ;
  • spécifique à un UNIX particulier.
4.2. Les feature test macros

Certaines macros contrôlent quelles fonctions et définitions sont visibles dans les headers.

Exemple :

Code: Select all

#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
Ou :

Code: Select all

#define _POSIX_C_SOURCE 200809L
#include <unistd.h>
Ces macros doivent être définies avant les includes.

Elles peuvent rendre visibles :
  • certaines fonctions POSIX ;
  • certaines structures ;
  • certaines constantes ;
  • certaines extensions GNU.
4.3. _GNU_SOURCE

_GNU_SOURCE active souvent beaucoup d’extensions GNU et Linux.

Exemple :

Code: Select all

#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
Cela peut être utile si l’on écrit un programme destiné spécifiquement à Linux/glibc.

4.4. _POSIX_C_SOURCE

_POSIX_C_SOURCE permet de demander une certaine version de POSIX.

Exemple :

Code: Select all

#define _POSIX_C_SOURCE 200809L
#include <unistd.h>
Cela est utile si l’on veut écrire du code plus portable.

4.5. Analogie avec Windows

Sous Windows, on trouve une logique proche avec :

Code: Select all

#define _WIN32_WINNT 0x0A00
#include <Windows.h>
Cela permet de rendre visibles certaines APIs selon la version Windows ciblée.

Sous Linux/UNIX, les feature test macros jouent un rôle similaire pour les standards POSIX/GNU.


5. Les types système

5.1. Pourquoi utiliser des types spécialisés ?

En programmation système, il ne faut pas remplacer tous les types par int ou long.

Linux définit des types spécialisés :

Code: Select all

pid_t
uid_t
gid_t
off_t
mode_t
size_t
ssize_t
time_t
socklen_t
dev_t
ino_t
nlink_t
Ces types existent pour être portables et représenter correctement les concepts système.

5.2. Exemple avec pid_t

Mauvais style :

Code: Select all

int pid;
Meilleur style :

Code: Select all

pid_t pid;
Exemple :

Code: Select all

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main(void)
{
    pid_t pid = getpid();

    printf("PID = %ld\n", (long) pid);

    return 0;
}
Même si pid_t est souvent un int sur certaines plateformes, le code propre utilise le type officiel.

5.3. size_t et ssize_t

size_t est un type non signé utilisé pour représenter une taille.

Exemple :

Code: Select all

size_t taille = 1024;
ssize_t est une version signée, utile quand une fonction doit retourner soit une taille, soit -1 en cas d’erreur.

Exemple :

Code: Select all

ssize_t n = read(fd, buffer, sizeof(buffer));

if (n == -1)
{
    perror("read");
}
read() retourne ssize_t car elle doit pouvoir retourner -1.

5.4. off_t

off_t représente un offset dans un fichier.

Exemple :

Code: Select all

off_t pos = lseek(fd, 0, SEEK_CUR);
Il ne faut pas supposer qu’un offset est simplement un int.

5.5. Attention à printf()

Certains types système n’ont pas toujours la même taille selon les systèmes.

Exemple prudent :

Code: Select all

pid_t pid = getpid();

printf("PID = %ld\n", (long) pid);
L’idée est d’éviter les erreurs de formatage lorsque les tailles changent selon l’architecture.


6. Le modèle universel d’I/O UNIX

6.1. Le principe : presque tout est fichier

UNIX utilise un modèle très simple :

Code: Select all

open()
read()
write()
close()
Ces fonctions peuvent manipuler différents types d’objets :
  • fichiers réguliers ;
  • terminaux ;
  • pipes ;
  • sockets ;
  • FIFO ;
  • périphériques ;
  • pseudo-fichiers.
C’est ce qu’on appelle le modèle universel d’I/O.

6.2. Exemple

Le même appel peut lire des données depuis plusieurs types de ressources :

Code: Select all

read(fd, buffer, sizeof(buffer));
fd peut représenter :
  • un fichier texte ;
  • un socket réseau ;
  • un pipe ;
  • un terminal ;
  • un périphérique dans /dev.
Le programme n’a pas besoin de connaître tous les détails internes.

6.3. Le kernel dispatch selon le type réel

Derrière le file descriptor, le noyau sait quel objet est réellement ouvert.

Il peut donc rediriger l’opération vers :
  • le système de fichiers ;
  • la couche socket ;
  • le pilote de terminal ;
  • le pilote de périphérique ;
  • une implémentation spéciale.
L’interface côté programme reste simple :

Code: Select all

read(fd, ...)
write(fd, ...)
close(fd)

7. Les file descriptors

7.1. Définition

Un file descriptor est un petit entier utilisé par le processus pour référencer une ressource ouverte.

Déclaration :

Code: Select all

int fd;
Exemple :

Code: Select all

int fd = open("test.txt", O_RDONLY);
Le file descriptor n’est pas le fichier lui-même. C’est un identifiant utilisé pour retrouver l’entrée correspondante dans les structures du noyau.

7.2. Table des descripteurs

Chaque processus possède sa propre table de descripteurs.

Exemple conceptuel :

Code: Select all

Processus
  fd 0  -> stdin
  fd 1  -> stdout
  fd 2  -> stderr
  fd 3  -> fichier ouvert
  fd 4  -> socket
  fd 5  -> pipe
Le numéro dépend de l’ordre d’ouverture et des numéros disponibles.

7.3. stdin, stdout, stderr

Par convention, au démarrage d’un programme :

Code: Select all

0 = stdin
1 = stdout
2 = stderr
En C/POSIX, on peut utiliser aussi :

Code: Select all

STDIN_FILENO
STDOUT_FILENO
STDERR_FILENO
Exemple :

Code: Select all

#include <unistd.h>

int main(void)
{
    write(STDOUT_FILENO, "Hello\n", 6);
    return 0;
}
7.4. Pourquoi le premier fd est souvent 3 ?

Comme 0, 1 et 2 sont généralement déjà utilisés, le premier open() retourne souvent 3.

Exemple :

Code: Select all

int fd = open("test.txt", O_RDONLY);

/* fd vaut souvent 3 si aucun autre fd n'a été ouvert */
Mais ce n’est pas garanti.

Si le programme a déjà ouvert autre chose, le fd peut être 4, 5, 6, etc.

7.5. Un socket est aussi un fd

Un socket n’a pas un numéro spécial.

Exemple :

Code: Select all

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
sockfd contient simplement le prochain file descriptor libre.

On peut donc faire :

Code: Select all

close(sockfd);
Et parfois :

Code: Select all

read(sockfd, buffer, sizeof(buffer));
write(sockfd, buffer, taille);
Même si pour le réseau on utilise souvent send() et recv().

7.6. Le nom de variable n’a aucune importance

fd est une convention.

Ceci est valide :

Code: Select all

int fichier = open("test.txt", O_RDONLY);
Ceci aussi :

Code: Select all

int logFd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
Le nom de la variable ne change rien. Ce qui compte, c’est la valeur entière retournée par le noyau.


8. open()

8.1. Prototype

open() permet d’ouvrir ou de créer un fichier.

Code: Select all

#include <fcntl.h>

int open(const char *pathname, int flags, ...);
Elle retourne :
  • un file descriptor si succès ;
  • -1 si erreur.
Exemple :

Code: Select all

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main(void)
{
    int fd = open("test.txt", O_RDONLY);

    if (fd == -1)
    {
        perror("open");
        return 1;
    }

    close(fd);
    return 0;
}
8.2. Modes d’accès

Les trois modes de base sont :

Code: Select all

O_RDONLY   lecture seule
O_WRONLY   écriture seule
O_RDWR     lecture et écriture
Exemples :

Code: Select all

int fd1 = open("a.txt", O_RDONLY);
int fd2 = open("b.txt", O_WRONLY);
int fd3 = open("c.txt", O_RDWR);
8.3. O_CREAT

O_CREAT crée le fichier s’il n’existe pas.

Avec O_CREAT, il faut généralement fournir un troisième argument : le mode.

Exemple :

Code: Select all

int fd = open("log.txt", O_WRONLY | O_CREAT, 0644);
0644 signifie :

Code: Select all

propriétaire : lecture + écriture
groupe       : lecture
autres       : lecture
8.4. O_TRUNC

O_TRUNC vide le fichier à l’ouverture s’il existe déjà.

Exemple :

Code: Select all

int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
Attention : cette option détruit le contenu existant.

8.5. O_APPEND

O_APPEND force les écritures à se placer à la fin du fichier.

Exemple :

Code: Select all

int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
Très utile pour les logs.

Avec O_APPEND, le noyau garantit que l’écriture se fait à la fin au moment de l’appel.

8.6. O_EXCL

O_EXCL est souvent utilisé avec O_CREAT pour échouer si le fichier existe déjà.

Exemple :

Code: Select all

int fd = open("unique.txt", O_WRONLY | O_CREAT | O_EXCL, 0644);

if (fd == -1)
{
    perror("open");
}
C’est utile pour créer un fichier de manière exclusive.

8.7. O_NONBLOCK

O_NONBLOCK demande un mode non bloquant.

Exemple :

Code: Select all

int fd = open("fifo", O_RDONLY | O_NONBLOCK);
Avec ce mode, certaines opérations retournent immédiatement au lieu d’attendre.

Attention : un mauvais programme peut alors tourner en boucle et consommer 100% CPU.

Mauvais exemple :

Code: Select all

while (1)
{
    read(fd, buffer, sizeof(buffer));
}
Il faut souvent utiliser poll(), select() ou epoll() pour attendre intelligemment.

8.8. O_CLOEXEC

O_CLOEXEC indique que le file descriptor doit être automatiquement fermé lors d’un exec().

Exemple :

Code: Select all

int fd = open("secret.txt", O_RDONLY | O_CLOEXEC);
Cela évite que le fd soit transmis involontairement à un nouveau programme après fork()+exec().

C’est important pour :
  • la sécurité ;
  • les programmes multi-processus ;
  • les serveurs ;
  • les shells ;
  • les démons.
8.9. O_SYNC

O_SYNC demande des écritures synchrones.

Exemple :

Code: Select all

int fd = open("data.bin", O_WRONLY | O_CREAT | O_SYNC, 0644);
Cela peut réduire les performances, mais augmente les garanties de persistance.

8.10. O_DIRECT

O_DIRECT demande de limiter l’usage du cache d’I/O du noyau.

Ce mode est plus complexe et impose souvent des contraintes d’alignement.

Il est utilisé dans certains logiciels bas niveau :
  • bases de données ;
  • outils de stockage ;
  • benchmarks disque ;
  • systèmes spécialisés.

9. read()

9.1. Prototype

read() lit des octets depuis un file descriptor.

Code: Select all

#include <unistd.h>

ssize_t read(int fd, void *buffer, size_t count);
Elle retourne :

Code: Select all

> 0   nombre d'octets lus
0     fin de fichier / EOF
-1    erreur
9.2. Exemple simple

Code: Select all

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>

int main(void)
{
    char buffer[128];

    int fd = open("test.txt", O_RDONLY);

    if (fd == -1)
    {
        perror("open");
        return 1;
    }

    ssize_t n = read(fd, buffer, sizeof(buffer));

    if (n == -1)
    {
        perror("read");
        close(fd);
        return 1;
    }

    write(STDOUT_FILENO, buffer, n);

    close(fd);
    return 0;
}
9.3. read() ne lit pas forcément tout

read() peut retourner moins d’octets que demandé.

Exemple :

Code: Select all

ssize_t n = read(fd, buffer, 4096);
n peut valoir :

Code: Select all

4096
2000
512
1
0
-1
Cela dépend du type de ressource, de l’état du système, du mode non bloquant, des signaux, etc.

9.4. read() ne rajoute pas de '\0'

read() lit des octets bruts.

Elle ne transforme pas les données en chaîne C.

Mauvais exemple :

Code: Select all

char buffer[100];

read(fd, buffer, sizeof(buffer));

printf("%s\n", buffer);
Ce code est dangereux car buffer n’est pas forcément terminé par '\0'.

Version correcte :

Code: Select all

char buffer[101];

ssize_t n = read(fd, buffer, 100);

if (n == -1)
{
    perror("read");
}
else
{
    buffer[n] = '\0';
    printf("%s\n", buffer);
}
9.5. Lire jusqu’à EOF

Pour lire tout un fichier :

Code: Select all

char buffer[4096];
ssize_t n;

while ((n = read(fd, buffer, sizeof(buffer))) > 0)
{
    write(STDOUT_FILENO, buffer, n);
}

if (n == -1)
{
    perror("read");
}
Quand read() retourne 0, on est à la fin du fichier.


10. write()

10.1. Prototype

write() écrit des octets vers un file descriptor.

Code: Select all

#include <unistd.h>

ssize_t write(int fd, const void *buffer, size_t count);
Elle retourne :

Code: Select all

>= 0  nombre d'octets écrits
-1    erreur
10.2. Exemple simple

Code: Select all

#include <unistd.h>

int main(void)
{
    write(STDOUT_FILENO, "Hello Linux\n", 12);
    return 0;
}
10.3. write() peut être partiel

write() n’écrit pas toujours tout.

Exemple :

Code: Select all

ssize_t n = write(fd, buffer, 4096);
n peut valoir moins que 4096.

Cela arrive notamment avec :
  • sockets ;
  • pipes ;
  • fichiers en mode non bloquant ;
  • signaux ;
  • conditions particulières d’I/O.
10.4. Fonction robuste pour tout écrire

Exemple de fonction qui boucle jusqu’à avoir écrit tout le buffer :

Code: Select all

#include <unistd.h>
#include <errno.h>

int write_all(int fd, const void *buffer, size_t count)
{
    const char *p = buffer;
    size_t total = 0;

    while (total < count)
    {
        ssize_t n = write(fd, p + total, count - total);

        if (n == -1)
        {
            if (errno == EINTR)
                continue;

            return -1;
        }

        total += n;
    }

    return 0;
}
Ce genre de logique est très courant en programmation système.


11. close()

11.1. Prototype

close() ferme un file descriptor.

Code: Select all

#include <unistd.h>

int close(int fd);
Elle retourne :

Code: Select all

0   succès
-1  erreur
11.2. Exemple

Code: Select all

if (close(fd) == -1)
{
    perror("close");
}
11.3. Pourquoi fermer ?

Fermer un fd permet :
  • de libérer l’entrée dans la table du processus ;
  • de libérer ou décrémenter les ressources noyau ;
  • de terminer proprement l’usage d’un fichier ;
  • d’éviter les fuites de descripteurs.
11.4. Double close : piège dangereux

Mauvais code :

Code: Select all

close(fd);
close(fd);
Pourquoi c’est dangereux ?

Après le premier close(), le numéro fd redevient disponible.

Le noyau peut réutiliser ce même numéro pour une autre ressource.

Donc le deuxième close() pourrait fermer autre chose.

Exemple conceptuel :

Code: Select all

int fd = open("a.txt", O_RDONLY);  /* fd = 3 */
close(fd);                         /* 3 est libre */

int sock = socket(...);             /* sock peut devenir 3 */

close(fd);                         /* ferme le socket par erreur */
Bonne pratique :

Code: Select all

close(fd);
fd = -1;

12. Exemple complet : copier un fichier

Voici un exemple simple qui copie un fichier avec open(), read(), write() et close().

Code: Select all

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

#define BUFFER_SIZE 4096

int main(int argc, char *argv[])
{
    int inputFd;
    int outputFd;
    char buffer[BUFFER_SIZE];
    ssize_t nread;

    if (argc != 3)
    {
        fprintf(stderr, "Usage: %s source destination\n", argv[0]);
        return 1;
    }

    inputFd = open(argv[1], O_RDONLY);

    if (inputFd == -1)
    {
        perror("open source");
        return 1;
    }

    outputFd = open(argv[2],
                    O_WRONLY | O_CREAT | O_TRUNC,
                    0644);

    if (outputFd == -1)
    {
        perror("open destination");
        close(inputFd);
        return 1;
    }

    while ((nread = read(inputFd, buffer, sizeof(buffer))) > 0)
    {
        ssize_t total = 0;

        while (total < nread)
        {
            ssize_t nwritten = write(outputFd,
                                     buffer + total,
                                     nread - total);

            if (nwritten == -1)
            {
                if (errno == EINTR)
                    continue;

                perror("write");
                close(inputFd);
                close(outputFd);
                return 1;
            }

            total += nwritten;
        }
    }

    if (nread == -1)
    {
        perror("read");
    }

    if (close(inputFd) == -1)
    {
        perror("close source");
    }

    if (close(outputFd) == -1)
    {
        perror("close destination");
    }

    return nread == -1 ? 1 : 0;
}
Ce programme montre la structure typique :

Code: Select all

open source
open destination
boucle read/write
close source
close destination

13. File offset et lseek()

13.1. Le file offset

Chaque fichier ouvert possède une position courante appelée file offset.

C’est l’endroit où la prochaine lecture ou écriture aura lieu.

Exemple conceptuel :

Code: Select all

Fichier :  A B C D E F G H
Offset :   0 1 2 3 4 5 6 7
                    ^
                    position courante
read() et write() avancent automatiquement cette position.

13.2. Exemple

Code: Select all

read(fd, buffer, 10);
Après cette lecture, l’offset avance de 10 octets.

Un deuxième read() continuera à partir de la nouvelle position.

13.3. Prototype de lseek()

lseek() permet de déplacer l’offset.

Code: Select all

#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);
Elle retourne :

Code: Select all

nouvel offset si succès
-1 si erreur
13.4. SEEK_SET, SEEK_CUR, SEEK_END

Les trois modes principaux :

Code: Select all

SEEK_SET   depuis le début du fichier
SEEK_CUR   depuis la position courante
SEEK_END   depuis la fin du fichier
Exemples :

Code: Select all

lseek(fd, 0, SEEK_SET);   /* début du fichier */
lseek(fd, 0, SEEK_END);   /* fin du fichier */
lseek(fd, 10, SEEK_CUR);  /* avance de 10 octets */
lseek(fd, -5, SEEK_CUR);  /* recule de 5 octets */
13.5. Obtenir la position courante

Code: Select all

off_t pos = lseek(fd, 0, SEEK_CUR);

if (pos == -1)
{
    perror("lseek");
}
13.6. Exemple simple

Code: Select all

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main(void)
{
    int fd = open("test.txt", O_RDONLY);

    if (fd == -1)
    {
        perror("open");
        return 1;
    }

    if (lseek(fd, 5, SEEK_SET) == -1)
    {
        perror("lseek");
        close(fd);
        return 1;
    }

    char c;

    if (read(fd, &c, 1) == 1)
    {
        write(STDOUT_FILENO, &c, 1);
    }

    close(fd);
    return 0;
}
Ce programme lit un octet à partir de l’offset 5.

13.7. lseek() ne marche pas partout

lseek() fonctionne sur les fichiers réguliers.

Mais il ne fonctionne généralement pas sur :
  • sockets ;
  • pipes ;
  • FIFO ;
  • certains périphériques ;
  • certains flux spéciaux.
Exemple :

Code: Select all

lseek(sockfd, 0, SEEK_SET);
Cela n’a pas de sens pour un socket, car un socket est un flux, pas un fichier positionnable.


14. Fichiers creux : file holes

14.1. Principe

On peut déplacer l’offset au-delà de la fin du fichier puis écrire.

Exemple :

Code: Select all

lseek(fd, 1000000, SEEK_SET);
write(fd, "X", 1);
Cela crée un fichier dont la taille logique est grande, mais sans forcément allouer physiquement tous les octets intermédiaires.

Ces zones vides s’appellent des trous.

14.2. Lecture d’un trou

Si on lit une zone correspondant à un trou, le système retourne des octets à zéro.

Conceptuellement :

Code: Select all

Début du fichier
    |
    v
[ données réelles ][ trou ][ données réelles ]
Le trou ne consomme pas forcément autant d’espace disque que sa taille logique.

14.3. Utilité

Les fichiers creux sont utiles pour :
  • images disque ;
  • fichiers de machines virtuelles ;
  • certains dumps ;
  • bases de données ;
  • systèmes de stockage ;
  • tests bas niveau.
14.4. Attention

Tous les systèmes de fichiers ne gèrent pas forcément les trous de la même manière.

De plus, certains outils de copie peuvent transformer un fichier creux en fichier plein s’ils ne préservent pas les trous.


15. ioctl()

15.1. Pourquoi ioctl() existe ?

Le modèle universel d’I/O repose sur :

Code: Select all

open()
read()
write()
close()
lseek()
Mais certaines opérations ne rentrent pas bien dans ce modèle.

Exemples :
  • changer les paramètres d’un terminal ;
  • demander une information spéciale à un périphérique ;
  • envoyer une commande spécifique à un driver ;
  • configurer une interface ;
  • faire une opération qui n’est ni une lecture ni une écriture simple.
Pour cela, UNIX fournit ioctl().

15.2. Prototype

Code: Select all

#include <sys/ioctl.h>

int ioctl(int fd, unsigned long request, ...);
fd représente la ressource ouverte.

request indique l’opération demandée.

Le troisième argument dépend de la commande.

15.3. Exemple conceptuel

Code: Select all

ioctl(fd, REQUEST_CODE, &data);
Cela signifie :

Code: Select all

"Sur cette ressource ouverte, exécute cette commande spéciale avec ces données."
15.4. Lien avec les drivers

ioctl() est souvent utilisé pour parler à un périphérique ou un driver.

C’est très proche du concept Windows :

Code: Select all

DeviceIoControl()
IOCTL
IRP_MJ_DEVICE_CONTROL
Sous Linux, l’appel ressemble à :

Code: Select all

ioctl(fd, request, arg);
Sous Windows :

Code: Select all

DeviceIoControl(hDevice,
                IOCTL_CODE,
                inputBuffer,
                inputSize,
                outputBuffer,
                outputSize,
                &bytesReturned,
                NULL);
Dans les deux cas, l’idée est la même :

Code: Select all

envoyer une commande spécifique à un objet/périphérique

16. Comparaison rapide avec Windows

16.1. File descriptor vs HANDLE

Sous Linux :

Code: Select all

int fd;
Sous Windows :

Code: Select all

HANDLE h;
Les deux représentent une ressource ouverte.

Différence importante :
  • Linux expose un petit entier ;
  • Windows expose une valeur opaque typée HANDLE ;
  • dans les deux cas, le noyau possède les vraies structures internes.
16.2. open() vs CreateFile()

Linux :

Code: Select all

int fd = open("test.txt", O_RDONLY);
Windows :

Code: Select all

HANDLE h = CreateFileW(L"test.txt",
                       GENERIC_READ,
                       FILE_SHARE_READ,
                       NULL,
                       OPEN_EXISTING,
                       FILE_ATTRIBUTE_NORMAL,
                       NULL);
Même logique générale :
  • ouvrir une ressource ;
  • définir les droits ;
  • obtenir un identifiant ;
  • utiliser cet identifiant pour les opérations suivantes.
16.3. read/write vs ReadFile/WriteFile

Linux :

Code: Select all

read(fd, buffer, size);
write(fd, buffer, size);
Windows :

Code: Select all

ReadFile(h, buffer, size, &readBytes, NULL);
WriteFile(h, buffer, size, &writtenBytes, NULL);
Même idée : transférer des octets entre le programme et une ressource.

16.4. lseek() vs SetFilePointer

Linux :

Code: Select all

lseek(fd, offset, SEEK_SET);
Windows :

Code: Select all

SetFilePointer(hFile, offset, NULL, FILE_BEGIN);
Même concept : déplacer la position courante du fichier.


17. Pièges classiques

17.1. Oublier de vérifier open()

Mauvais :

Code: Select all

int fd = open("test.txt", O_RDONLY);
read(fd, buffer, sizeof(buffer));
Bon :

Code: Select all

int fd = open("test.txt", O_RDONLY);

if (fd == -1)
{
    perror("open");
    return 1;
}
17.2. Utiliser printf("%s") après read()

Mauvais :

Code: Select all

read(fd, buffer, sizeof(buffer));
printf("%s", buffer);
Bon :

Code: Select all

ssize_t n = read(fd, buffer, sizeof(buffer) - 1);

if (n > 0)
{
    buffer[n] = '\0';
    printf("%s", buffer);
}
17.3. Supposer que write() écrit tout

Mauvais :

Code: Select all

write(fd, buffer, size);
Bon : vérifier le retour et boucler si nécessaire.

17.4. Double close

Mauvais :

Code: Select all

close(fd);
close(fd);
Bon :

Code: Select all

close(fd);
fd = -1;
17.5. Utiliser lseek() sur un socket

Mauvais :

Code: Select all

lseek(sockfd, 0, SEEK_SET);
Un socket n’a pas de position comme un fichier régulier.


18. Mini-programme d’entraînement

Objectif :

Créer un petit programme Linux qui :
  • ouvre un fichier donné en argument ;
  • lit son contenu ;
  • l’affiche sur stdout ;
  • gère les erreurs ;
  • ferme correctement le fd.
Code :

Code: Select all

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>

#define BUF_SIZE 1024

int main(int argc, char *argv[])
{
    int fd;
    char buffer[BUF_SIZE];
    ssize_t n;

    if (argc != 2)
    {
        fprintf(stderr, "Usage: %s fichier\n", argv[0]);
        return 1;
    }

    fd = open(argv[1], O_RDONLY);

    if (fd == -1)
    {
        perror("open");
        return 1;
    }

    while ((n = read(fd, buffer, sizeof(buffer))) > 0)
    {
        ssize_t total = 0;

        while (total < n)
        {
            ssize_t w = write(STDOUT_FILENO,
                              buffer + total,
                              n - total);

            if (w == -1)
            {
                if (errno == EINTR)
                    continue;

                perror("write");
                close(fd);
                return 1;
            }

            total += w;
        }
    }

    if (n == -1)
    {
        perror("read");
        close(fd);
        return 1;
    }

    if (close(fd) == -1)
    {
        perror("close");
        return 1;
    }

    return 0;
}
Compilation :

Code: Select all

gcc -Wall -Wextra -o catmini catmini.c
Utilisation :

Code: Select all

./catmini test.txt

19. Ce qu’il faut retenir absolument

Résumé chapitre concepts système
  • Un appel système est une entrée contrôlée dans le noyau.
  • Le programme user mode ne manipule pas directement les ressources matérielles.
  • La glibc fournit des wrappers et des fonctions de bibliothèque.
  • Fonction de bibliothèque et syscall ne sont pas toujours la même chose.
  • Les syscalls ont un coût.
  • Il faut toujours vérifier les valeurs de retour.
  • errno indique la cause d’une erreur après un échec.
  • Les macros comme _GNU_SOURCE et _POSIX_C_SOURCE contrôlent les définitions visibles.
  • Les types système comme pid_t, off_t, ssize_t existent pour écrire du code propre et portable.
Résumé chapitre modèle I/O
  • UNIX utilise un modèle universel d’I/O.
  • Un file descriptor est un int, mais il représente une ressource noyau ouverte.
  • 0, 1 et 2 correspondent généralement à stdin, stdout et stderr.
  • Un socket, un pipe, un terminal ou un fichier peuvent tous être représentés par un fd.
  • open() ouvre ou crée une ressource.
  • read() lit des octets et retourne le nombre d’octets lus.
  • write() écrit des octets et peut écrire partiellement.
  • close() libère le fd.
  • lseek() déplace le file offset.
  • read() ne rajoute jamais de '\0'.
  • EOF est indiqué par read() qui retourne 0.
  • Les fichiers creux permettent d’avoir une taille logique plus grande que l’espace réellement utilisé.
  • ioctl() sert aux opérations spécifiques qui ne rentrent pas dans read/write/lseek.

20. Conclusion

Ces deux chapitres sont une base énorme pour comprendre Linux en profondeur.

La philosophie est simple :

Code: Select all

Programme
   |
   v
file descriptor
   |
   v
open/read/write/close
   |
   v
noyau Linux
   |
   v
ressource réelle
Le programme manipule un entier, mais derrière cet entier se trouvent :
  • des structures noyau ;
  • des permissions ;
  • des offsets ;
  • des flags ;
  • des opérations spécifiques ;
  • des systèmes de fichiers ;
  • des drivers ;
  • des sockets ;
  • des pipes.
C’est exactement là que commence la vraie programmation système Linux.

Comprendre les file descriptors, c’est comprendre une grande partie de la philosophie UNIX :

Code: Select all

tout est manipulé comme un flux ou un fichier ouvert
À partir de cette base, on peut ensuite aborder beaucoup de sujets avancés :
  • dup() et dup2() ;
  • redirections shell ;
  • pipes ;
  • fork() et héritage de fd ;
  • exec() et O_CLOEXEC ;
  • sockets ;
  • epoll ;
  • mmap ;
  • VFS ;
  • drivers Linux ;
  • IPC ;
  • I/O asynchrone.
Ce socle est donc indispensable pour toute personne qui veut vraiment comprendre Linux au niveau système.

Who is online

Users browsing this forum: No registered users and 1 guest