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 · supplément
Le tableau de la mémoire
Suivre des pointeurs à la main sans dessiner de flèches - une ligne par variable, avec son adresse, son contenu, son nom, son type et sa taille. Un pointeur de pointeur, un tableau, le passage par adresse et une chaîne, lus dans le tableau. Puis pourquoi seule la taille compte, le type n'étant qu'une illusion, et les tailles à connaître selon les machines.
Pourquoi ce supplément
Ce tableau est ma façon d'expliquer les variables et les pointeurs à ceux qui n'ont pas compris le C. Plutôt que de réexpliquer * et &, on écrit la mémoire, ligne par ligne, et on lit chaque expression dans le tableau.
Je me suis rendu compte que c'était un manque dans tous les cours de C, y compris ceux dont celui-ci est tiré. Ils disent ce qu'est un pointeur, et le dessinent avec des flèches. Mais une flèche cache ce qu'il faut comprendre : un pointeur est une variable comme une autre, rangée à une adresse, et son contenu est un nombre, l'adresse d'une autre variable. Comme ce cours est le mien, je peux l'ajouter.
Si le chapitre 2 vous a paru clair, ce supplément se lit vite. S'il vous a laissé perplexe, commencez par ici, puis relisez-le.
Écrire la mémoire
Pour suivre un programme qui manipule des pointeurs, les flèches dessinées au tableau deviennent vite illisibles. On peut plutôt écrire la mémoire dans un tableau, une ligne par variable, avec quatre colonnes :
& | * | nom | type (taille) |
|---|---|---|---|
| l'adresse | le contenu | le nom de la variable | le type de la variable, et sa taille en octets |
Pour simplifier, on compte les adresses à partir de la première variable, qui est à l'adresse 0, et on range les suivantes à la suite, dans l'ordre de leur déclaration. Les vraies adresses sont grandes et changent à chaque exécution, et le compilateur ne range pas toujours les variables dans cet ordre, mais cela ne change rien au raisonnement : seuls comptent les écarts entre les adresses. On prend les tailles d'un PC 64 bits sous Linux : char 1 octet, int 4, pointeur 8. Un pointeur commence à une adresse multiple de 8, ce qui peut laisser des octets inutilisés avant lui (l'alignement, chapitre 3).
Ensuite, trois règles suffisent :
x: on cherche la ligne de nomx, et on lit sa colonne*;&x: on cherche la ligne de nomx, et on lit sa colonne&;*p: on lit le contenu dep, qui est une adresse, puis on cherche la ligne qui a cette adresse dans la colonne&.*pest cette ligne : on lit son contenu, ou on y écrit.
Un pointeur, et un pointeur de pointeur
int a = 42;
int b = 7;
int *p = &a;
int **pp = &p;
& | * | nom | type (taille) |
|---|---|---|---|
| 0 | 42 | a | int (4) |
| 4 | 7 | b | int (4) |
| 8 | 0 | p | int * (8) |
| 16 | 8 | pp | int ** (8) |
p contient 0, l'adresse de a ; pp contient 8, l'adresse de p. Les expressions se lisent alors dans le tableau :
| Expression | Lecture | Valeur |
|---|---|---|
a | contenu de a | 42 |
&a | adresse de a | 0 |
p | contenu de p | 0 |
*p | contenu de la ligne d'adresse 0 (a) | 42 |
&p | adresse de p | 8 |
*pp | contenu de la ligne d'adresse 8 (p) | 0 |
**pp | contenu de la ligne d'adresse *pp, c'est-à-dire 0 (a) | 42 |
Une affectation modifie une seule case de la colonne * : celle que désigne ce qui est à gauche du =.
*p = 5;:pcontient 0, on écrit 5 dans la ligne d'adresse 0.avaut 5.p = &b;: on écrit 4, l'adresse deb, dans la lignep.aetbne changent pas.**pp = 9;:ppcontient 8, la lignep;pcontient maintenant 4, la ligneb. On écrit 9 dansb.
& | * | nom | type (taille) |
|---|---|---|---|
| 0 | 5 | a | int (4) |
| 4 | 9 | b | int (4) |
| 8 | 4 | p | int * (8) |
| 16 | 8 | pp | int ** (8) |
Un tableau
int t[3] = {10, 20, 30};
int *q = &t[1];
& | * | nom | type (taille) |
|---|---|---|---|
| 0 | 10 | t[0], et t | int (4) |
| 4 | 20 | t[1] | int (4) |
| 8 | 30 | t[2] | int (4) |
| 12 | (inutilisé) | (4) | |
| 16 | 4 | q | int * (8) |
Le tableau occupe trois lignes consécutives, une par case. Et t, c'est t[0] : le nom du tableau est posé sur la ligne de sa première case, à la même adresse. t vaut donc l'adresse de cette ligne, &t[0], et *t est t[0] lui-même. t n'a pas de ligne à lui : ce n'est pas une variable qui contiendrait une adresse, c'est un autre nom pour le début du bloc. C'est ce qui distingue un tableau d'un pointeur : q a sa propre ligne, et on peut y écrire ; t n'en a pas, et t = q; est refusé.
Une seule chose sépare encore t de t[0] : la taille. sizeof t[0] vaut 4, la taille d'une case ; sizeof t vaut 12, la taille du bloc entier.
L'arithmétique des pointeurs se lit sur la colonne & : ajouter 1 à un int *, c'est avancer d'une ligne int, donc de 4 octets.
| Expression | Lecture | Valeur |
|---|---|---|
t | adresse de la ligne t[0] | 0 |
*t | contenu de la ligne d'adresse 0, t[0] | 10 |
q | contenu de q | 4 |
*q | contenu de la ligne d'adresse 4 | 20 |
q + 1 | 4 + 1 × 4 | 8 |
*(q + 1), ou q[1] | contenu de la ligne d'adresse 8 | 30 |
q[-1] | contenu de la ligne d'adresse 4 − 4 = 0 | 10 |
&t[2] | adresse de la ligne t[2] | 8 |
Une blague, pour finir, qui est aussi une bonne vérification. Pour t un tableau d'entiers et i un entier, t[i] n'est qu'une autre écriture de *(t + i) : la ligne d'adresse t + i × 4. Or une addition se lit dans les deux sens : t + i est i + t. Donc :
t[i] == *(t + i) == *(i + t) == i[t]
Et i[t] compile, sans un avertissement, même avec -pedantic :
int t[3] = {10, 20, 30};
int i = 2;
printf("%d %d %d %d\n", t[i], *(t + i), *(i + t), i[t]);
30 30 30 30
Pour le compilateur, 2[t] est la case 2 de t. N'écrivez jamais cela dans un vrai programme ; mais si vous comprenez pourquoi cela marche, vous avez compris les crochets.
Le passage par adresse
Le tableau montre pourquoi echanger(&x, &y) fonctionne et echanger_faux(x, y) non (chapitre 2). À l'appel, les paramètres reçoivent de nouvelles lignes, qui contiennent des copies de ce qu'on a passé. On les ajoute à la suite, pour la durée de l'appel :
int x = 0, y = 42;
echanger(&x, &y); /* void echanger(int *a, int *b) { int c = *a; *a = *b; *b = c; } */
& | * | nom | type (taille) |
|---|---|---|---|
| 0 | 0 | x | int (4) |
| 4 | 42 | y | int (4) |
| 8 | 0 | a | int * (8) |
| 16 | 4 | b | int * (8) |
| 24 | c | int (4) |
int c = *a;:acontient 0, la lignex;creçoit 0.*a = *b;:bcontient 4, la ligney, qui vaut 42 ; on écrit 42 dans la ligne d'adresse 0,x.*b = c;: on écrit 0 dans la ligne d'adresse 4,y.
Au retour, les lignes a, b et c disparaissent, mais c'est dans x et y qu'on a écrit. Avec echanger_faux(int a, int b), les lignes a et b auraient contenu 0 et 42, des copies des valeurs : la fonction les aurait échangées entre elles, sans jamais toucher aux lignes 0 et 4.
Une chaîne
char s[6] = "salut";
char *c = s + 2;
Un char tient sur un octet : chaque ligne avance de 1.
& | * | nom | type (taille) |
|---|---|---|---|
| 0 | 's' | s[0] | char (1) |
| 1 | 'a' | s[1] | char (1) |
| 2 | 'l' | s[2] | char (1) |
| 3 | 'u' | s[3] | char (1) |
| 4 | 't' | s[4] | char (1) |
| 5 | '\0' | s[5] | char (1) |
| 6 | (inutilisé) | (2) | |
| 8 | 2 | c | char * (8) |
*c vaut 'l', c[1] vaut 'u', et c + 1 vaut 3 : pour un char *, avancer d'une case, c'est avancer d'un octet. printf("%s", c) lit à partir de la ligne 2 jusqu'au '\0', et affiche lut.
La taille, pas le type
Regardez la dernière colonne du tableau : « type (taille) ». Des deux, c'est la taille qui fait tout. C'est elle qui donne l'adresse de la ligne suivante, elle qui dit de combien avance p + 1, elle qui dit combien d'octets on lit derrière *p. Le type, lui, est une illusion : il n'existe que dans le fichier source, pour le compilateur. En mémoire, aucune ligne ne porte d'étiquette « int » ou « float ». Il n'y a que des octets, et le programme ne sait pas ce qu'ils représentent.
Les mêmes octets
Ce programme recopie les octets d'une variable dans une autre, avec memcpy, sans aucune conversion, puis les lit avec un autre type :
#include <stdio.h>
#include <string.h>
/* afficher(p, n) affiche les n octets qui commencent à l'adresse p */
void afficher(const void *p, size_t n)
{
const unsigned char *o = p;
for (size_t i = 0; i < n; i++)
printf("%02x ", o[i]);
printf("\n");
}
int main(void)
{
int i = 1078530011;
float f;
memcpy(&f, &i, sizeof f); /* on recopie les 4 octets, sans conversion */
afficher(&i, sizeof i);
afficher(&f, sizeof f);
printf("en int : %d\n", i);
printf("en float : %f\n", f);
int moins_un = -1;
unsigned int u;
memcpy(&u, &moins_un, sizeof u);
afficher(&moins_un, sizeof moins_un);
printf("en int : %d, en unsigned : %u\n", moins_un, u);
char mot[4] = {'C', 'a', 'f', 'e'};
int n;
memcpy(&n, mot, sizeof n);
afficher(mot, sizeof mot);
printf("en int : %d\n", n);
return 0;
}
db 0f 49 40
db 0f 49 40
en int : 1078530011
en float : 3.141593
ff ff ff ff
en int : -1, en unsigned : 4294967295
43 61 66 65
en int : 1701208387
Les quatre mêmes octets db 0f 49 40 valent 1078530011 si on les lit comme un int, et π si on les lit comme un float. ff ff ff ff vaut −1 ou 4 294 967 295. Et le mot « Cafe » est aussi un entier. Aucune de ces lectures n'est la « vraie » : ce sont quatre octets, et c'est le type qu'on choisit au moment de les lire qui décide de ce qu'ils veulent dire.
Dans le tableau de la mémoire, on pourrait donc écrire la colonne * en octets, et elle serait juste pour toutes les lectures à la fois. On y écrit 42 ou 's' par commodité : c'est déjà une lecture.
Ce que le compilateur fait du type
Le type sert au compilateur, pendant la compilation, et à rien d'autre. Il en tire :
- la taille : combien d'octets réserver pour la variable, et de combien avancer pour
p + 1out[i]; - la lecture : quelle instruction du processeur employer, une addition entière ou flottante, une comparaison signée ou non signée ;
- des vérifications : il refuse certaines affectations entre types différents.
Une fois le programme compilé, il ne reste rien du type. Le code machine dit « lire 4 octets à telle adresse, les additionner comme des entiers ». Le processeur ne vérifie jamais que ces octets ont été rangés comme des entiers.
Les tailles à connaître
Si seule la taille compte, encore faut-il la connaître. Or la norme du C ne la fixe pas : elle ne donne que des minimums, et chaque machine choisit. Ce choix, qui s'appelle le modèle de données, se résume par les types qui font 32 ou 64 bits : ILP32 (int, long, pointer sur 32 bits), LP64 (long, pointer sur 64 bits), LLP64 (long long, pointer sur 64 bits).
| Type | 32 bits (ILP32) | 64 bits Linux, macOS (LP64) | 64 bits Windows (LLP64) | Arduino Uno (AVR 8 bits) |
|---|---|---|---|---|
char | 1 | 1 | 1 | 1 |
short | 2 | 2 | 2 | 2 |
int | 4 | 4 | 4 | 2 |
long | 4 | 8 | 4 | 4 |
long long | 8 | 8 | 8 | 8 |
pointeur, size_t | 4 | 8 | 8 | 2 |
float | 4 | 4 | 4 | 4 |
double | 8 | 8 | 8 | 4 |
- 32 bits : les PC x86 32 bits, et les processeurs ARM 32 bits (Raspberry Pi sous un système 32 bits, la plupart des microcontrôleurs ARM Cortex-M, les anciens téléphones).
- 64 bits Linux, macOS : les PC x86-64 sous Linux ou macOS, et l'ARM 64 bits (AArch64) : les Mac à puce Apple, les Raspberry Pi récents sous un système 64 bits, les téléphones actuels, les serveurs ARM. C'est la machine de ce cours.
- 64 bits Windows : Windows sur x86-64 comme sur ARM64.
longy reste sur 4 octets, pour ne pas casser les vieux programmes : c'est la seule différence avec Linux, et elle suffit à faire planter un programme qui range un pointeur dans unlong. - Arduino Uno : un microcontrôleur 8 bits.
intn'y fait que 2 octets, et ne dépasse pas 32 767 ;doublen'y est qu'un autre nom defloat(avec avr-gcc, le compilateur de l'Arduino, par défaut).
Ce qui ne change nulle part : sizeof(char) vaut 1 par définition, et c'est l'unité de sizeof. Ce que la norme garantit : un short et un int ont au moins 16 bits, un long au moins 32, un long long au moins 64, et char ≤ short ≤ int ≤ long ≤ long long. Rien de plus.
Un dernier piège : un char seul peut être signé ou non, selon la machine. Il l'est sur x86 (de −128 à 127), il ne l'est pas sur ARM sous Linux (de 0 à 255), sauf chez Apple où il l'est. Un programme qui range −1 dans un char et le compare à −1 marche sur un PC, et échoue sur un Raspberry Pi. Pour un octet, on écrit donc unsigned char (ou signed char), jamais char seul.
Tout cela se vérifie d'une ligne. Sur la machine de ce cours (x86-64, Linux) :
#include <stdio.h>
#include <stddef.h>
int main(void)
{
printf("char %zu short %zu int %zu long %zu long long %zu pointeur %zu size_t %zu float %zu double %zu long double %zu\n",
sizeof(char), sizeof(short), sizeof(int), sizeof(long), sizeof(long long),
sizeof(void *), sizeof(size_t), sizeof(float), sizeof(double), sizeof(long double));
printf("char signe : %d\n", (char)-1 < 0);
return 0;
}
char 1 short 2 int 4 long 8 long long 8 pointeur 8 size_t 8 float 4 double 8 long double 16
char signe : 1
(long double varie encore plus que les autres : 16 octets ici, 12 en 32 bits x86, 8 sous Windows et sur les Mac à puce Apple. On s'en sert rarement.)
Deux règles en découlent :
- dans le code, on n'écrit jamais une taille en chiffres : on écrit
sizeof.malloc(n * sizeof *p)est juste partout ;malloc(n * 4)ne l'est que là où unintfait 4 octets ; - quand la taille compte vraiment, pour un format de fichier, un protocole réseau, un registre de matériel, on prend les types de
stdint.h, dont la taille est dans le nom :int8_t,uint16_t,int32_t,uint64_t… Unuint32_tfait 32 bits sur toutes les machines. Et pour ranger une adresse dans un entier, il y auintptr_t, paslong.
Rigide et permissif à la fois
On dit du C qu'il est très permissif. On dit aussi qu'il est rigide sur les types. Les deux sont vrais, et c'est ce qui surprend :
#include <stdio.h>
int main(void)
{
double d = 3.99;
int i = d; /* permissif : tronqué en 3, sans un mot */
int *p = &i;
double *q = p; /* rigide : types de pointeurs incompatibles */
printf("%d %p\n", i, (void *)q);
return 0;
}
$ gcc -std=c11 -Wall -Wextra -Werror rigide.c
rigide.c:8:17: error: initialization of 'double *' from incompatible pointer type 'int *' [-Werror=incompatible-pointer-types]
Transformer 3,99 en 3, et perdre une information, ne dérange personne : même -Wall -Wextra ne dit rien, il faut ajouter -Wconversion. Mais ranger une adresse d'int dans un pointeur de double, qui ne perd rien du tout, est refusé.
Et cette rigidité s'arrête à void *, le pointeur « sans type » : on y range n'importe quelle adresse, et on la ressort dans n'importe quel pointeur, sans conversion ni avertissement, même avec -Wconversion -Werror :
int i = 3;
void *v = &i;
double *q = v; /* accepté sans un mot */
La vérification porte sur les étiquettes que le programmeur a écrites, pas sur les octets. Dès qu'on retire l'étiquette, il n'y a plus rien à vérifier.
On s'en sort très bien sans
Au chapitre 2, echanger n'échangeait que des int. Il faudrait donc une fonction par type ? Non : pour échanger deux variables, il suffit de savoir où elles sont et combien d'octets elles occupent.
#include <stdio.h>
#include <string.h>
/* echanger(a, b, taille) échange les deux blocs de taille octets pointés par a et b */
void echanger(void *a, void *b, size_t taille)
{
unsigned char c[taille];
memcpy(c, a, taille);
memcpy(a, b, taille);
memcpy(b, c, taille);
}
struct point { double x, y; };
int main(void)
{
int i = 1, j = 2;
double x = 1.5, y = 2.5;
struct point p = {0, 0}, q = {3, 4};
char s[8] = "bonjour", t[8] = "au revo";
echanger(&i, &j, sizeof i);
echanger(&x, &y, sizeof x);
echanger(&p, &q, sizeof p);
echanger(s, t, sizeof s);
printf("%d %d | %g %g | (%g, %g) (%g, %g) | %s %s\n",
i, j, x, y, p.x, p.y, q.x, q.y, s, t);
return 0;
}
2 1 | 2.5 1.5 | (3, 4) (0, 0) | au revo bonjour
Une seule fonction échange des entiers, des flottants, des structures et des chaînes. Elle ne connaît aucun de ces types : elle reçoit deux adresses et une taille, et déplace des octets. (Le tableau c est un tableau de taille variable, sur la pile : il convient pour des variables ordinaires, pas pour des blocs de plusieurs mégaoctets.)
La bibliothèque standard est écrite ainsi. Regardez les signatures de ses fonctions les plus utilisées : pas un type, seulement des adresses void * et des tailles size_t.
void *malloc(size_t taille);
void *memcpy(void *dest, const void *src, size_t n);
void qsort(void *base, size_t nombre, size_t taille,
int (*comparer)(const void *, const void *));
size_t fwrite(const void *p, size_t taille, size_t nombre, FILE *f);
malloc ne sait pas ce qu'on va ranger dans le bloc, seulement sa taille. qsort trie un tableau de n'importe quoi : il lui suffit de savoir où il commence, combien il a de cases, et la taille d'une case ; la seule chose qu'il ne sait pas faire, comparer deux cases, on la lui passe sous forme de fonction. fwrite écrit des octets dans un fichier, quels qu'ils soient.
La limite de l'illusion
Il y a une façon de relire des octets avec un autre type qui semble plus directe que memcpy :
int i = 1078530011;
float f = *(float *)&i; /* à ne pas faire */
Elle compile, et affiche souvent 3.141593. Mais la norme l'interdit : c'est un comportement indéfini. Le compilateur a le droit de supposer qu'un int * et un float * ne désignent jamais les mêmes octets (c'est la règle d'aliasing strict), et d'optimiser en conséquence : avec l'optimisation, le programme obtenu peut ne plus faire ce qu'on a écrit. C'est le seul endroit où le type, qui n'existe plus à l'exécution, se venge pendant la compilation.
Deux façons de faire sont sûres, et elles suffisent :
- recopier les octets avec
memcpy, comme plus haut. Avec l'optimisation (-O2), le compilateur la reconnaît et n'appelle aucune fonction : la copie devient une seule instruction ; - lire les octets un par un avec un
unsigned char *, commeafficher. Un pointeur de caractère a le droit de désigner n'importe quoi : c'est la façon officielle de voir la mémoire comme une suite d'octets.