Pour le CPU, la mémoire n’est qu’une suite d’octets numérotés. Il ne sait pas ce qu’est une variable. En C, une variable est un nom que le compilateur donne à une plage de ces octets, et son type lui dit deux choses : combien d’octets elle occupe, et comment les interpréter — comme un entier signé ou non signé, un caractère, une adresse, un nombre à virgule flottante. Une fois le programme compilé, les noms ont disparu ; il ne reste que les adresses et les instructions qui s’en servent.
Ce chapitre suit quelques variables du C jusqu’aux octets. La façon dont le matériel calcule avec ces octets — l’arithmétique en complément à deux, le format flottant IEEE 754 — est le sujet du chapitre du niveau jeu d’instructions sur les types que comprend le matériel.
Quelle taille fait un int ?
La norme C ne fixe que des minimums : char fait au moins 8 bits, short et int au moins 16, long au moins 32 et long long au moins 64. Les tailles réelles sont fixées par l’ABI de chaque plateforme. Mesurées avec clang pour cinq cibles, en octets :
| Type | Linux x86-64 | Linux ARM64 | macOS ARM64 | Windows x64 | Linux x86 (32 bits) |
|---|---|---|---|---|---|
char | 1 | 1 | 1 | 1 | 1 |
short | 2 | 2 | 2 | 2 | 2 |
int | 4 | 4 | 4 | 4 | 4 |
long | 8 | 8 | 8 | 4 | 4 |
long long | 8 | 8 | 8 | 8 | 8 |
pointeur, size_t | 8 | 8 | 8 | 8 | 4 |
float / double | 4 / 8 | 4 / 8 | 4 / 8 | 4 / 8 | 4 / 8 |
long double | 16 | 16 | 8 | 8 | 12 |
Deux choses ressortent. D’abord, long fait 8 octets sur les Unix 64 bits mais 4 octets sur Windows 64 bits. Ces conventions s’appellent LP64 (Long et Pointer font 64 bits) et LLP64 (seuls Long Long et Pointer les font). Un code qui range un pointeur dans un long fonctionne sous Linux et tronque silencieusement les adresses sous Windows. C’est la raison d’être de <stdint.h> : int32_t, uint64_t et uintptr_t ont le même sens partout.
Ensuite, long double est ce que la plateforme décide : l’ancien format 80 bits du x86 complété à 16 ou 12 octets, un vrai format 128 bits sur Linux ARM64, ou simplement un double sous Windows et macOS.
Signé, non signé, et les mêmes bits
Un int et un unsigned int sont les mêmes 32 bits. Seule l’interprétation change. En complément à deux, la représentation qu’utilisent tous les CPU modernes — et la seule qu’autorise C23 —, le motif 0xFFFFFFFF vaut −1 en int et 4 294 967 295 en unsigned int. Rien en mémoire n’indique lequel des deux c’est : le compilateur choisit des instructions différentes selon le type, comme jl (inférieur signé) ou jb (inférieur non signé) pour les comparaisons.
Quand le C mélange les deux, la valeur signée est convertie en non signée. C’est la source d’un bug classique :
int i = -1;
unsigned int u = 1;
if (i < u) // faux ! i devient 4294967295
…
gcc ne le signale qu’avec -Wextra (comparison of integer expressions of different signedness). Ranger une valeur dans un type trop petit n’en garde que les bits de poids faible : unsigned char c = 300; stocke 44, car 300 vaut 0x12C et seul l’octet 0x2C tient.
Dans l’autre sens, l’arithmétique sur les petits types se fait en int : dans a + b avec deux unsigned char valant 200 et 100, les deux sont d’abord promus en int, et le résultat vaut 300, pas 44.
Même le char tout court réserve une surprise : la norme ne dit pas s’il est signé. Il l’est sur x86 et sur l’ARM64 d’Apple, mais il est non signé sur Linux ARM64. Le même programme, char c = 200; printf("%d", c);, affiche −56 sur un PC x86 et 200 sur un Raspberry Pi. Quand la différence compte, écrivez signed char ou unsigned char.
L’ordre des octets
Un int de 4 octets occupe quatre adresses. Savoir quel octet vient en premier, c’est la question de l’ordre des octets, ou boutisme (endianness). Le x86, ainsi que l’ARM et le RISC-V tels qu’on les utilise en pratique, sont petit-boutistes (little-endian) : l’octet de poids faible est à l’adresse la plus basse. La valeur 0x11223344 est rangée sous la forme des octets 44 33 22 11.
Le C permet de le voir en lisant un int à travers un pointeur d’octets :
À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.
- int main() {
- int x = 0x11223344;
- unsigned char *p = (unsigned char *)&x;
- return p[0];
- }
Le programme renvoie 68, soit 0x44 : l’octet de poids faible est venu en premier. Sur une machine gros-boutiste (big-endian), il renverrait 0x11.
Les exemples de machines gros-boutistes de Tanenbaum sont le SPARC et les grands systèmes IBM. Le SPARC a depuis presque disparu, et le gros-boutisme survit aujourd’hui surtout dans les grands systèmes IBM z, certains équipements réseau, et dans l’ordre réseau : les en-têtes TCP/IP sont gros-boutistes, d’où les appels à htons et ntohl qui inversent les octets sur les machines petit-boutistes. Les formats de fichiers choisissent aussi : ELF et PE rangent leurs champs dans l’ordre de la machine, PNG et les fichiers .class de Java en gros-boutiste. Le problème décrit par le livre — un enregistrement mêlant texte et entiers copié entre machines d’ordres opposés — existe toujours ; il apparaît simplement aujourd’hui dans les analyseurs de fichiers et les protocoles réseau.
Alignement et remplissage
Les CPU lisent la mémoire le plus efficacement quand une valeur est à une adresse multiple de sa taille : un int de 4 octets à une adresse divisible par 4, un double de 8 octets à un multiple de 8. C’est l’alignement. Le x86 tolère les accès mal alignés pour un faible surcoût, mais certaines architectures les refusent, et les opérations atomiques exigent en général l’alignement. Chaque ABI fixe l’alignement de chaque type, et les compilateurs le respectent.
Pour les structures, cela veut dire que le compilateur insère des octets de remplissage (padding) invisibles. Mesuré avec clang pour Linux x86-64 :
struct A { char c; int x; char d; }; // sizeof = 12
struct B { int x; char c; char d; }; // sizeof = 8
Dans struct A, x doit commencer à un multiple de 4 : trois octets de remplissage suivent donc c, et x est au décalage 4. Après d (décalage 8), trois octets de plus complètent la structure à 12, pour que dans un tableau de struct A le x de chaque élément reste aligné. struct B contient exactement les mêmes champs dans un autre ordre, et n’a besoin que de 8 octets. Le C ne réordonne jamais les champs à votre place : l’ordre écrit est l’ordre en mémoire, ce qui permet aux structures de décrire exactement des en-têtes de fichiers et des registres matériels.
Une struct { char c; double d; short s; } fait 24 octets sur Linux x86-64, mais 16 sur Linux x86 32 bits, où l’ABI n’aligne les champs double des structures que sur 4 octets. La disposition fait partie de l’ABI, et change d’une plateforme à l’autre.
Où vit chaque variable
La durée de stockage d’une variable décide où sont ses octets. Compiler ce fichier avec clang et lister ses symboles avec nm donne la réponse, section par section :
int counter = 42; // D .data globale initialisée
int zeroed; // B .bss globale initialisée à zéro
const int limit = 100; // R .rodata en lecture seule
static int hidden = 7; // d .data locale à ce fichier
const char *msg = "hello"; // D .data (le pointeur)
// "hello" lui-même va dans .rodata.str1.1
int bump(void) {
static int calls; // b .bss conservée entre les appels
int local = counter + limit; // sur la pile : aucun symbole
…
}
- Les globales et les variables
staticvivent pendant tout le programme, à des adresses fixes, dans les sections de données de l’exécutable :.datasi leur valeur initiale n’est pas nulle,.bsssi elles commencent à zéro, et.rodatasi elles sontconst..bssne prend pas de place dans le fichier : l’exécutable n’en note que la taille, et le chargeur fournit de la mémoire remplie de zéros. staticchange deux choses différentes selon l’endroit où il est écrit. Sur une globale (hidden), il rend le nom privé au fichier :nmaffiche une minuscule, qui signale un symbole local. Sur une variable dans une fonction (calls), il lui donne une adresse fixe, pour qu’elle garde sa valeur d’un appel à l’autre. Le compilateur la renomme pour la garder unique : clang l’appellebump.calls, gcccalls.0.- Les variables locales vivent dans le cadre de pile de la fonction et n’existent que pendant l’appel. Elles n’ont pas de symbole : dans le code machine,
localn’est que[rbp-4]ou un registre. - La mémoire du tas, obtenue par
malloc, n’a aucun nom — seulement un pointeur vers elle. C’est le sujet du chapitre sur les structures, malloc et le tas.
Le simulateur montre tout cela côte à côte. Avancez pas à pas dans le programme ci-dessous au niveau du C, en observant le panneau des globales (counter, limit, zeroed et la variable statique calls) et celui des variables (la structure p sur la pile), puis zoomez pour voir les mêmes octets dans les vues de la pile et des données :
À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.
- int counter = 42;
- int zeroed;
- const int limit = 100;
- struct Point {
- char tag;
- int x;
- int y;
- };
- int bump(void) {
- static int calls;
- calls++;
- return calls;
- }
- int main() {
- struct Point p;
- p.tag = 'A';
- p.x = 0x11223344;
- p.y = -1;
- bump();
- bump();
- zeroed = counter + limit;
- return sizeof(p);
- }
Le programme renvoie 12, la taille de struct Point avec son remplissage. Dans l’assembleur produit, p.tag est écrit en [rbp-12], p.x en [rbp-8] et p.y en [rbp-4] : les trois octets de [rbp-11] à [rbp-9] sont du remplissage que rien ne touche jamais. La variable statique calls est devenue une globale nommée calls.0 dans .bss, et finit à 2. Le compilateur du simulateur reste simple et place limit dans .data ; un vrai compilateur la mettrait dans .rodata, où y écrire fait planter le programme.
À retenir
- Une variable est une plage d’octets nommée ; son type en donne la taille et l’interprétation. Les noms disparaissent à la compilation.
- Les tailles viennent de l’ABI de la plateforme :
longfait 8 octets sous Linux et macOS (LP64) mais 4 sous Windows (LLP64). Utilisez les types de<stdint.h>quand la taille compte. - Signés et non signés partagent les mêmes bits (complément à deux). Les mélanger convertit en non signé :
-1 < 1uest faux. Lechartout court est signé sur x86 mais non signé sur Linux ARM64. - x86, ARM et RISC-V fonctionnent en petit-boutiste :
0x11223344est rangé44 33 22 11. Les réseaux et de nombreux formats de fichiers utilisent le gros-boutiste. - Les compilateurs insèrent du remplissage pour garder les champs alignés ; l’ordre des champs change la taille d’une structure (12 contre 8 octets ici).
- Globales et statiques vivent dans
.data,.bssou.rodataà des adresses fixes ; les locales vivent sur la pile sans symbole ; la mémoire du tas n’est atteinte que par des pointeurs.