Le langage C

Dixième partie

par Guilhem de Wailly

Résumé

Dans cette série d'articles, nous présentons le langage C. Il ne s'agit pas de réécrire les ouvrages de référence donnés en annexe, mais de donner, au travers du C, une méthode de programmation basée sur la notion d'interface.

Au fur et à mesure de notre progression, nous décrirons les éléments indispensables du langage et conduirons le lecteur à consulter les ouvrages de références. Le but poursuivi est clairement de parvenir à construire des programmes de manière segmentée.

Dans cet article, nous commençons à étudier les pointeurs du langage C. Véritable bête noire des débutants, nous espérons montrer que, manipulés avec précaution et attention, les pointeurs ne sont pas si difficiles à appréhender.

Cette étude sommaire est nécessaire pour comprendre les mécanismes mis en Å“oeuvre pour utiliser l'allocation dynamique que nous décrirons dans un prochain article.

Introduction

L'article précédent a décrit la manière avec laquelle le compilateur C génère du code en langage machine correspondant aux appels de fonctions. Il a insisté sur la structure de la pile, et le code en pseudo-assembleur qui réalise les mécanismes de l'appel d'une fonction. Nous avons constaté à l'issu de cette première approche que la manière avec laquelle nous avons réalisé la bibliothèque des nombres rationnels, bien que correcte dans le fonctionnement, soufrait de performances médiocres. Cependant, l'avantage de cette première mise en oeuvre est sa grande simplicité.

Dans cet article, nous allons approfondir la structure de la mémoire et la manière avec la quelle le compilateur C place les variables du langage C en mémoire. Cette approche va nous montrer l'existence des adresses mémoires et nous verrons que le langage C permet de manipuler ces adresses à l'aide des fameux pointeurs. Par la suite, nous étudierons comment une fonction peut retourner plusieurs valeurs et l'avantage d'utiliser les pointeurs dans ce cas.

Les prochains articles continueront sur la lancée des pointeurs en les utilisant directement dans la bibliothèque des nombres rationnels. Nous tenterons de mesurer le gain en performances lié à l'utilisation des pointeurs. Par la suite, nous introduirons l'allocation dynamique de la mémoire, en poussant même l'étude jusqu'à l'écriture d'un allocateur de mémoire.

Adresse en mémoire

Le coeur névralgique d'un ordinateur est principalement constitué d'un micro-processeur et de mémoire. La mémoire est un ensemble de cases numérotées dans lesquelles on peut ranger et lire des valeurs. Ces valeurs sont codées avec un certain nombre d'éléments binaires qui ne peuvent prendre que deux valeurs, 0 ou 1. On appelle chacun de ces éléments binaires un bit. Les informations de la mémoire sont codées avec un certain nombre de bits, et ce nombre est fixé matériellement. Le microprocesseur a donc un dispositif permettant de lire et d'écrire les valeurs des cases de la mémoire. Il possède aussi possède un certain nombre de registres dont le nombre de bits correspond en général à celui de la mémoire. Le travail du micro-processeur consiste à aller lire des valeurs depuis la mémoire pour les ranger dans ses registres internes, puis à effectuer une opération entre ces registres, à placer le résultat dans un autre registre, et enfin à transférer ce résultat du registre vers la mémoire. Les opérandes et les opérations sont déterminées par un programme qui est lui aussi rangé en mémoire et que le micro-processeur suit pas à pas. Un programme est constitué d'instructions qui sont codées en mots. Chaque instruction répond à un format particulier déterminé par le fabriquant du micro-processeur. L'ensemble des instructions constitue le langage d'assemblage du micro-processeur. Chaque micro-processeur a un langage d'assemblage qui lui est propre et il est impossible d'exécuter sur un micro-processeur le programme écrit en langage d'assemblage d'un autre micro-processeur, sauf s'ils sont de la même famille. Cette architecture d'ordinateur est communément appelée "l'architecture de Von Neumann".

Un programme C repose beaucoup sur le mode de fonctionnement des micro-processeurs et sur les échanges avec la mémoire. En C, les cases mémoires sont les variables et on passe son temps à lire des variables, à effectuer des calculs et à ranger les résultats dans d'autres variables. Cependant, le langage C ne donne pas directement accès aux registres du micro-processeur. On considère souvent que la langage C est "un langage d'assemblage portable" car le même programme C peut être exécuté, après compilation, sur tout type de micro-processeur.

On s'aperçoit que les transferts entre le micro-processeur et la mémoire sont nombreux. Les cases de la mémoire sont numérotées en partant de zéro. On appelle ces numéros les adresses en mémoire. Chaque adresse est unique.

L'opérateur & en C permet d'obtenir l'adresse d'une variable C. Cette adresse est un nombre entier de la taille d'un mot machine. Sur un processeur Intel de la famille du 80386, une adresse est codée sur 32 bits. On peut donc avoir 232 valeurs d'adresses différentes, ce qui donne une taille théorique de la mémoire d'un peu plus de 4 Go.

L'adresse retournée par l'opérateur & correspond à l'adresse en mémoire du premier octet de la variable. En effet, en C, il est possible de manipuler des valeurs "plus grosses" qu'une seule case mémoire, comme les nombre rationnels qui contiennent chacun deux entiers. Dans ce cas, la structure est rangée en mémoire dans plusieurs cases, et l'adresse retournée est l'adresse de la première case.

Manipulons les adresses :

        void main (void) {
          int a = 123;

          printf ("valeur de a = %d, adresse de a=%d\n",
                  a,
                  & a);
          }
      

Dans ce programme, on déclare une variable a, puis on affiche successivement sa valeur, puis son adresse. Sa valeur est 123. Son adresse dépend de l'emplacement en mémoire où est chargé le programme au démarrage. Cette adresse change à chaque fois que le programme est lancé. Par contre, elle reste constante pendant toute l'exécution du programme.

Hexadécimal

Les ordinateurs comptent en binaires, c'est à dire par une succession d'élément binaires pouvant prendre comme valeur 0 ou 1, rassemblés en mots. Sur les premiers processeurs, les mots étaient de 4 puis 8 bits.

Les processeurs modernes ont des mots codés sur 32 bits (Intel, PowerPC) ou 64 bits (Alpha, Sparc). Le nombres de bits des mots déterminent la puissance de calcul en entiers du processeur et sa capacité d'adressage. Avec 4 bits, on peut compter de 0 à 15 (c'est à dire que l'on a 24 valeurs possibles) ; avec 8, jusqu'à 255 (28-1), avec 16, jusqu'à 65535 (216-1). Pour manipuler des nombres entiers dépassant sa capacité, le processeur est donc obligé de coder les informations sur plusieurs mots, ce qui ralentit les traitements en multipliant les accès à la mémoire.

De plus la taille des mots détermine l'espace mémoire pouvant être adressé. Si les adresses sont codées sur 16 bits, il est possible d'accéder à 65536 adresses différentes, ce qui donne 64ko. C'est la taille maximale de la mémoire principale des vieux ordinateurs comme l'Apple II. Plus la taille des adresses est importante, plus le processeurs peut avoir de mémoire. Sur les processeurs 32 bits, la taille théorique de la mémoire est de plus de 4Go.

Une valeur binaire s'écrit donc avec une succession de 0 et de 1. Par exemple, 123 s'écrit 1111011 en binaire (Linux fourni des calculatrices capables d'effectuer de telles conversions). Le binaire n'est pas très commode à manipuler car les nombres s'écrivent très vite avec beaucoup de symboles. On utilise donc d'autres bases pour coder les informations. Les nombres sont codés avec plus de caractères qu'en base 10 pour les bases en dessous de 10, et moins de caractères pour les bases au dessus de 10. L'octal est un codage en base 8. Il contient tous les chiffres de 0 à 7. 123 s'écrit 173 en octal. Un autre code très utilisé en informatique est l'hexadécimal qui code les nombres en base 16. Pour coder en base 16, on utilise les symboles de 0 à 9, puis les lettres de A à F. On à la table suivante :

Décimal
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Binaire
0000
0001
0010
0011
0100
0101
0110
0111
1000
1001
1010
1011
1100
1101
1110
1111
Hexadécimal
0
1
2
3
4
5
6
7
8
9
A
B
C
D
E
F

L'hexadécimal est très utilisé pour donner la valeur des adresses. Notamment, les débogueurs C affichent les adresses sous cette forme. Il n'est en général pas nécessaire de calculer en hexadécimal, mais si on doit le faire, le plus simple est d'utiliser une calculatrice avec la conversion hexadécimale.

Pour afficher un entier en hexadécimal avec la fonction printf du C, on utilise le format %x. Ainsi, printf ("%d->%x\n", 15, 15) affichera 15->F.

Le langage C permet d'écrire les entiers directement en hexadécimal, en plaçant le préfixe 0x devant le nombre. Ainsi, 0x7B est la représentation en hexadécimal de l'entier 123. De même, un entier dont le premier caractère est 0 est écrit en octal. ; 0173 est la représentation en octal de l'entier 123.

L'avantage de manipuler ces bases est de savoir à la seule lecture de la valeur comment le nombre est rangé en mémoire. Prenons par exemple l'entier 123. En hexadécimal, il s'écrit Ox7B. On a besoin de deux caractères pour coder ce nombre. Or en hexadécimal, chaque caractère correspond à 4 bits (voir la table). Donc l'entier 123 peux être codé avec 2x4 bits, c'est à dire 8 bits. Dans une mémoire 32 bits, les 8 bits de poids fort sont à zéro, ce qui donne l'occupation en mémoire :

Binaire 0000 0000 0111 1011
Hexadécimal 0 0 7 B

Ainsi, à la seule écriture du nombre, on sait comment sont positionnés les bits dans la mémoire (avec certaine précautions relative au mode de stockage des mots ; on connait le mode big-endian et le mode little-endian).

Pointeur

L'opérateur & retourne la valeur de l'adresse mémoire d'un objet. Cette valeur est codée en fonction de la taille des adresses de l'ordinateur. L'adresse mémoire peut être rangée dans un entier, mais cela peut poser des problèmes lorsque la taille d'un entier est inférieure à la taille d'une adresse. C'est notamment le cas avec le système d'exploitation DOS ou les entiers sont codés sur 16 bits et les adresses sont codées sur 32 bits (c'est même encore un peu plus compliqué que ça...). Avec Linux, les adresses et les entiers sont tous deux codés sur 32 bits.

Pour ranger les adresses dans une variable, le langage C offre la syntaxe suivante :

        type * nom;
      

Cette écriture déclare une variable nommée nom contenant l'adresse d'une donnée de type type. Par exemple :

        int * p_entier;
      

Cette écriture déclare une variable p_entier destinée à contenir l'adresse d'un entier. Pour ranger l'adresse d'un entier, nous écrivons simplement :

        int   entier;
        int * p_entier;

        /* étape 1 */
        entier = 123;

        /* étape 2 */
        p_entier = & entier;

        /* étape 3 */
      

Que s'est-il passé dans la mémoire de l'ordinateur ? Chaque variable occupe un ou plusieurs emplacements dans la mémoire. Ici, nous avons donc un emplacement pour entier et un emplacement pour p_entier.

A la première étape, nous avons dans la mémoire :

Variable Adresse Valeur
entier 100 44534
p_entier 102 233443

Les adresses des variables sont différente chaque fois que le programme est lancé, mais elles restent constantes durant toute sont exécution. Les valeurs des adresses sont ici données en exemple. N'étant pas initialisées, les variables contiennent des valeurs aléatoires. A la seconde étape, nous rangeons 123 dans la variable entier, ce qui donne :

Variable Adresse Valeur
entier 100 123
p_entier 102 233443

Enfin, à la dernière étape, la variable p_entier contient l'adresse de la variable entier et nous obtenons :

Variable Adresse Valeur
entier 100 123
p_entier 102 100

Nous savons maintenant obtenir l'adresse d'une variable et la ranger dans une autre variable. Voyons maintenant comment accéder à la valeur située à l'adresse contenue dans une variable (dans notre exemple, cela reviendrait à accéder à valeur de entier en utilisant la variable p_entier). Pour cette opération, le langage C propose une syntaxe assez simple : il s'agit de placer une étoile devant la variable contenant l'adresse :

        * variable
      

Dans notre exemple, on aurait :

        int   entier, autre;
        int * p_entier;

        /* étape 1 */
        entier = 123;

        /* étape 2 */
        p_entier = & entier;

        /* étape 3 */
        autre = * p_entier;
      

Pour bien comprendre ces éléments de syntaxe, on peut se dire que dans la déclaration de p_entier, il y a une étoile entre le contenu de p_entier et l'int ; c'est cette étoile que l'on retrouve dans l'affectation de la variable autre.

Si on ajoute à la suite de l'exemple :

        printf ("entier=%d, & entier=%d\n",
                entier,
                & entier);

        printf ("p_entier=%d, * p_entier=%d\n",
                p_entier,
                * p_entier);
        printf ("autre=%d\n", autre);
      

On aura la ligne suivante affichée :

        entier   = 123,
        & entier = 100,
        p_entier = 100, * p_entier = 123,
        autre    = 123
      

Au passage, comme p_entier et autre sont des variables, elles possèdent elles-aussi des adresses que l'on peut obtenir avec l'opérateur &. Ce qui est intéressant, c'est de savoir comment déclarer la variable qui va recevoir l'adresse de p_entier. Eh bien en plaçant une étoile de plus !

On obtient :

        int    entier, autre, other;
        int *  p_entier;
        int ** pp_entier;

        entier    = 123;
        p_entier  = & entier;
        pp_entier = & p_entier;
        autre     = * p_entier;
        other     = ** pp_entier;
      

De même, pour obtenir la valeur de other, on utilise deux étoiles. A la fin de cet exemple, on a le schéma mémoire suivant :

Variable Adresse Valeur
entier 100 123
autre 101 123
other 102 123
p_entier 103 100
pp_entier 104 103

Eh bien, cher lecteur, vous venez de manipuler les pointeurs du langage C ! Moyennant quelques précautions et quelques prudences, vous constatez que ce n'est pas si difficile que ça.

A quoi ça sert ?

Maintenant que nous avons la mécanique de base pour manipuler les pointeurs, posons nous la question de savoir quelle en est l'utilité.

Imaginons une fonction de calcul devant retourner plus d'un résultat. L'instruction return nous permet de retourner un seul résultat, aussi, si nous voulons en retourner plus, nous devons définir une structure de donnée qui sera remplie avec les valeurs à retourner :

        /* structure de donnée de retour */
        typedef struct {
          int valeur_1; /* valeur à retourner */
          int valeur_2;
          int valeur_3;
        } _Retour_f;

        /* la foncton qui doit retourner
         * plusieurs valeurs */
        _Retour_f f (int a, int b, int c) {
          _Retour_f retour;

          retour.valeur_1 = a + b;
          retour.valeur_2 = a - b;
          retour.valeur_3 = a * b;
          return retour;
        }

        /* le programme principal */
        void main (void) {
          _Retour_f retour;

          ret = f (1, 2, 3);
          printf ("première valeur  = %d\n",
                  retour.valeur_1);
          printf ("deuxième valeur  = %d\n",
                  retour.valeur_1);
          printf ("troisième valeur = %d\n",
                  retour.valeur_1);
        }
      

Nous voyons là que nos connaissances en C nous permettent déjà de concevoir une fonction retournant plusieurs valeurs. Maintenant, nous allons utiliser les pointeurs pour obtenir le même résultat :

        /* la fonction qui doit retourner plusieurs
         * valeurs. On utilise des pointeurs */
        void f (int a, int b, int    c, 
               int * r1, int * r2, int * r3) {
          * r1 = a + b;
          /* la valeurs à retourner sont
	   * placées dans les pointeurs */
	  * r2 = a - b; 
          * r3 = a * b;
        }

        void main (void) {
          int premier, second, troisieme;

          ret = f (1, 2, 3, & premier, & second, & troisieme);

          printf ("première valeur  = %d\n", premier);

          printf ("deuxième valeur = %d\n",  second);

          printf ("troisième valeur = %d\n", troisieme);
        }
      

En utilisant les pointeurs, nous n'avons plus besoin de déclarer une structure de donnée. La fonction appelée reçoit des adresses lui indiquant à quels endroits placer les résultats.

Ce mode de fonctionnement est connu depuis très longtemps et je me souviens l'avoir rencontré pour la première fois avec ma calculatrice programmables TI57 de Texas Instrument il y a près de vingt ans !

Dès que l'on sait manipuler des adresses, on peut utiliser le passages des arguments par référence. Jusqu'à maintenant, nous n'avons vu que le passage par valeur où la valeur de l'argument est placé sur la pile. La passage par référence va nous permettre de placer non plus la valeur, mais la référence (ou adresse) de la variable. Nous verrons dans le prochain article que le fait de manipuler les adresses des nombres rationnels au lieu des nombres eux-mêmes va procurer un gain sensible en performance.

L'auteur

Guilhem de Wailly (gdw at free dot fr)

Références