Lire un fichier octet par octet

Cette opération est à la base de tout traitement programmé en C.
Elle présente une petite difficulté technique qu'il est intéressant d'élucider complètement.
C'est le but de cette page.

La technique expliquée ci-dessous sera mise en œuvre systématiquement dans la suite.

  1. I. Du fichier et de son nom
  2. II. Octets, entiers et caractères
    1. L'octet ou sa valeur ?
    2. Ce qu'il ne faut pas faire !
    3. Entiers négatifs, interprétation du bit de poids fort
    4. Ce qu'il ne faut pas faire (bis)
    5. Ce qu'il faut faire (enfin !)
    6. Écriture d'un octet dans un fichier
  3. III. Programme complet illustrant cette discussion

I. Du fichier et de son nom

  1. Le fichier est matérialisé par un pointeur sur fichier (de type FILE *),
    obtenu à partir du nom du fichier (chaîne de caractères) par la fonction  fopen.
    Pour un fichier dans lequel on doit lire, fopen prend comme 2ème argument la chaîne "r" (comme read).
    Par exemple, le fichier nommé "toto.txt" sera mis en œuvre à travers le pointeur fp comme suit :

    FILE *fp; /* déclaration de la variable fp, avec le type idoine */

    fp = fopen("toto.txt", "r");

  2. En général, le nom du fichier est passé comme argument à la commande d'exécution,
    et il est donc connu comme argv[1] (si c'est le premier argument sur la ligne de commande :
    par exemple dans l'exécution de la commande "./a.out toto.txt tata.txt",
    la chaine "toto.txt" se trouve dans argv[1], et la chaine "tata.txt" se trouve dans argv[2]).
    En outre, on se prémunit contre une erreur toujours possible en s'assurant que l'opération d'ouverture
    a réussi, d'où l'écriture traditionnelle :

        if ( (fp = fopen(argv[1], "r")) == NULL ) {
             printf("Je ne peux pas ouvrir %s", argv[1]);
             exit(1);
        }

II. Octets, entiers et caractères

  1. L'octet ou sa valeur ?

    Il y a ici un passage délicat auquel il faut faire très attention. Nous y retrouvons le problème fondamental que nous avons
    déja abordé au  cours n° 1 :
    Les objets qui nous intéressent, à savoir les octets, ne pourront pas être extraits du fichier de manière directe.
    et nous ne les obtiendrons que par le biais de leurs valeurs numériques (nombres entiers).

    Donc tout va bien...
    Mais cette valeur numérique renvoyée par fgetc se révèle protéiforme, en fonction du type de la variable où le programmeur la loge
    (car il faut bien la loger quelque part pour pouvoir l'exploiter, et pour cela définir une variable, à laquelle on devra donner un type !).
    Sur ce point, il est facile de se tromper, et pour raisonner à coup sûr il faut prendre un peu de recul.
  2. Ce qu'il ne faut pas faire !

    On trouve dans la documentation la forme suivante pour la lecture d'un fichier octet par octet :


        char ch; /* bien noter le type "char" pour la variable ch : hic jacet lepus ! */

        while ( (ch = fgetc(fp)) !=EOF ) {
            ...exploiter ch...
        }

    Pour comprendre l'enjeu, rien de tel qu'un essai ! Notre fichier de test sera don.txt, qui n'a pas d'autre intérêt que de contenir
    des caractères représentés en UTF-8 par 1, 2, 3 et 4 octets. Voici son contenu, en négligeant les espaces mais
    avec commentaires entre crochets :


    Aa [ASCII]
    ¡
    [le point d'exclamation inversé, lettre espagnole figurant dans le bloc Latin-1]
    éàœùçÇ
    [sans commentaire]
    ႫႫ
    [deux fois le "m" géorgien (3 octets en UTF-8)]
    ÿ
    [retour à Latin-1]
    𐌰 𐌲
    [deux caractères gothiques, hors du plan de base (4 octets en UTF-8)]
    [le "d" géorgien (3 octets en UTF-8)]
    zZ [ASCII]


    Voyons ce que donne la boucle ci-dessus en définissant "exploiter" comme "imprimer" :

        while ( (ch = fgetc(fp)) !=EOF ) {
            printf("%d ", ch); /* "%d" comme "en décimal" */
        }


    jfp% ./a.out don.txt
    65 97 32 -62 -95 32 -61 -87 -61 -96 -59 -109 -61 -71 -61 -89 -61 -121 32 -31 -126 -85 -31 -126 -85 32
    -61 -65 32 -16 -112 -116 -80 32 -16 -112 -116 -78 32 -31 -125 -109 32 122 90
    jfp%

    Où sont nos valeurs entre 0 et 255 ? On trouve bien les valeurs des octets ASCII, y compris les blancs (32),
    mais pourquoi ces nombres négatifs ?

  3. Entiers négatifs, interprétation du bit de poids fort

    Pour représenter en machine les entiers négatifs, on convient qu'un mot

    Par exemple, avec des mots de 32 bits, le mot 1111 1111 1111 1111 1111 1111 1011 1011 représente l'entier négatif -69.

    Dès lors, pour un octet dont le bit de poids fort vaut 1, c'est à dire pour un octet "non-ASCII", (par exemple 1100 0010 )
    la question se pose :

    C'est une question de point de vue ! 
    Et comment s'exprime le point de vue adopté ? En C, il est manifesté par le type de la variable qui contient le mot à interpréter.

    En C, la notion de type recouvre deux aspects :
    1. Le format de représentation (nombre d'octets utilisés) ;
      on parle de la taille (size) du type, et cette quantité est accessible par la primitive sizeof(nom du type).
    2. La manière d'interpréter l'information :
      en ce qui nous concerne, le bit de poids fort doit-il être traité comme un bit de signe ?

    Le langage C connaît quatre formats pour loger des informations destinées à être interprétées comme des entiers :
    par ordre de tailles croissantes
    char (1 octet), short (2 octets), int (2 ou 4 octets), long (4 octets) - et récemment un cinquième long long (8 octets).
    Sur les machines actuelles (janvier 2007), sizeof(int) = 4 et par conséquent int est équivalent à long.
    Ce n'était pas le cas autrefois !
    Chacun de ces formats peut être qualifié de signed ou unsigned (par défaut, c'est signed)
    le premier qualificatif signale que oui, le bit de poids fort doit être traité comme un bit de signe, le second que non.

     Voyons les conséquences du choix du type pour la variable logeant la valeur de notre octet lu, 
    en laissant de côté les types longs : Allons-y !
  4. Ce qu'il ne faut pas faire (bis)

    Sur la base de l'analyse précédente, il paraît indiqué de rectifier notre programme ainsi :

        unsigned char ch; /* je dis bien "unsigned" */

        while ( (ch = fgetc(fp)) !=EOF ) {
           
    printf("%d ", ch);
        }

    Mézalor le compilateur s'alarme ! En réponse à la demande gcc -Wall -pedantic lirOct.c
    il s'exclame :
    lirOct.c: In function 'main':
    lirOct.c:27: warning: comparison is always true due to limited range of data type


    En effet, la valeur -1 en unsigned char devient 255 !

    Si vous désirez voir de vos yeux ce phénomène, faites fonctionner le petit programme que voici.
  5. Ce qu'il faut faire (enfin !)

    Il faut loger la valeur lue dans une variable de type int (ou short si on veut vraiment raffiner).
    Et cela suffit à nos besoins.
    Notre boucle de lecture s'écrit donc :

        int val; /* bien noter le type "int" pour la variable val : hic jacet lepus */

        while ( (val = fgetc(fp)) !=EOF ) {
            ...exploiter val...
        }


    Dans ces conditions, pourquoi la documentation dit-elle char ?
    C'est que la tradition de la programmation en C est enracinée dans une culture purement ASCII !
    Pour un programmeur C, un caractère ne saurait occuper plus de sept bits.
    Nos préoccupations sont autres, nous devons donc rompre avec la tradition.
    Si la chose vous intéresse, voici quelques détails supplémentaires, mais non nécessaires à notre but principal.
  6. Écriture d'un octet dans un fichier

    Maintenant que nous savons lire un octet dans un fichier d'entrée,
    la question se pose de l'écriture d'un octet dans un fichier de sortie.

    La fonction fputc prend comme arguments un entier et un pointeur sur fichier,
    et elle envoie dans le fichier l'octet correspondant à cet entier :

    Comme nos valeurs d'octets restent dans la plage des unsigned char ([0, 255]),
    il n'y aura pas de perte d'information et nous pourrons passer ces valeurs à fputc sans inquiétude.

III. Programme complet illustrant cette discussion

Ce programme attend deux noms de fichier, l'un à lire et l'autre à écrire.
Il imprime sur la sortie standard les valeurs numériques des octets successifs du fichier d'entrée,
ainsi que ces mêmes valeurs converties en char, et il envoie ces valeurs dans le fichier de sortie.
À la fin du calcul le fichier de sortie a exactement le même contenu que le fichier d'entrée.

fichier lirOct.c ;

#include <stdio.h>

int main(int argc, char *argv[]) {
FILE *fentr, *fsort;
int val;
char ch;

if (argc!=3) {
printf("Il me faut deux noms de fichiers");
exit(1);
}
if ((fentr= fopen(argv[1], "r")) == NULL) {
printf("Je ne peux pas ouvrir %s", argv[1]);
exit(1);
}
if ((fsort= fopen(argv[2], "w")) == NULL) {
printf("Je ne peux pas ouvrir %s", argv[2]);
exit(1);
}

while ((val=fgetc(fentr)) !=EOF) {
printf("%d ", val);
fputc(val, fsort);
ch = val;
printf("%d\n", ch);
}

fclose(fentr);
fclose(fsort);
return 0;
}/* main */



En compilant ce programme par
gcc -Wall -pedantic lirOct.c
et en exécutant
./a.out don.txt dan.txt
on obtient à l'écran :
jfp% ./a.out don.txt dan.txt
65 65
97 97
32 32
194 -62
161 -95
32 32
195 -61
169 -87
195 -61
160 -96
197 -59
147 -109
195 -61
185 -71
195 -61
167 -89
195 -61
135 -121
32 32
225 -31
130 -126
171 -85
225 -31
130 -126
171 -85
32 32
195 -61
191 -65
32 32
240 -16
144 -112
140 -116
176 -80
32 32
240 -16
144 -112
140 -116
178 -78
32 32
225 -31
131 -125
147 -109
32 32
122 122
90 90
jfp%

et le fichier-résultat dan.txt se révèle identique au fichier-donnée don.txt.