Séance 3 — Organisation de la mémoire

Adresses, pointeurs, tableaux, pile et tas

Dr. El Hadji Bassirou TOURÉ

Département de Mathématiques et Informatique, Faculté des Sciences et Techniques, Université Cheikh Anta Diop de Dakar

Objectifs de la séance

Ce que la séance construit

La mémoire vue par un programme C : une suite d’octets numérotés, des objets rangés à des adresses, des pointeurs qui contiennent ces adresses, des tableaux et des chaînes posés côte à côte, les zones où vivent les objets et l’allocation à la demande.

  • Lire un dump mémoire et retrouver un int, un short, un char, une chaîne ou un pointeur, en little-endian comme en big-endian.
  • Dire ce que valent p, *p et &p, et suivre une double indirection **pp.
  • Calculer l’adresse de a[i], de p + i et de m[i][j] à partir de l’adresse de base et de sizeof.
  • Situer un objet dans sa classe de stockage (pile, données statiques, tas) et en déduire sa durée de vie.
  • Utiliser malloc, calloc, realloc et free, et reconnaître une fuite, un double free et un accès après free.
  • Afficher les octets d’un objet avec show_bytes.

Mot machine, adresses et endianness

Mot machine, adresses et tailles

Définitions

Vue par le programme, la mémoire d’un processus est un très grand tableau d’octets, la mémoire virtuelle. Le mot machine est l’unité que traite le processeur : 32 ou 64 bits. Il fixe la largeur des registres et des adresses : \(2^{32}\) octets (4 Gio) en 32 bits ; en 64 bits, 48 bits d’adresse utilisés aujourd’hui (\(2^{48}\) octets, 256 Tio). Une adresse est le numéro d’un octet ; un objet de plusieurs octets a pour adresse celle de son premier octet, et l’atteindre demande son adresse et sa taille.

printf("char %zu, short %zu, int %zu, long %zu\n",
       sizeof(char), sizeof(short),
       sizeof(int), sizeof(long));
printf("float %zu, double %zu\n",
       sizeof(float), sizeof(double));
printf("void * %zu, int * %zu\n",
       sizeof(void *), sizeof(int *));

Résultat \(\rightarrow\)

char 1, short 2, int 4, long 8
float 4, double 8
void * 8, int * 8

Lecture. La norme ne fixe que des minimums et l’ordre char \(\leq\) short \(\leq\) int \(\leq\) long. long : 8 octets ici (modèle LP64, Linux), 4 sous Windows 64 bits (LLP64) ; d’où <stdint.h> (int32_t, uint8_t).

Un int en mémoire, octet par octet

Adresses simplifiées ; sur la machine, x est en 0x7fffffffecbc et o en 0x7fffffffecc0, avec la même disposition.

Sortie \(\rightarrow\)

67 45
23 01

Lecture. Ligne 5 : x occupe 4 octets, de 0x14 à 0x17 ; l’octet de poids faible, 67, est à l’adresse la plus basse : little-endian.

Lecture. Ligne 6 : o reçoit l’adresse de x ; un pointeur occupe un mot de 8 octets, lui aussi rangé en little-endian.

Lecture. Ligne 7 : o[0] lit l’octet en 0x14, o[1] celui en 0x15 : 67 et 45.

Lecture. Ligne 8 : o[2] et o[3] : 23 et 01, le poids fort en dernier.

Little-endian et big-endian

Deux conventions

Little-endian : l’octet de poids faible à l’adresse la plus basse (x86-64, la plupart des ARM).

Big-endian : l’octet de poids fort à l’adresse la plus basse (protocoles réseau, certains formats de fichiers).

0x01234567 en 0x100 : 67 45 23 01 en little-endian, 01 23 45 67 en big-endian.

L’ordre compte dès que des octets changent de machine ou de type : réseau, fichier binaire, lecture avec un autre type.

int is_little_endian(void)
{
    uint32_t x = 1;        /* LE : 01 00 00 00 */
    uint8_t *p = (uint8_t *)&x;
    return p[0] == 1;      /* 1 : little-endian */
}
int main(void)
{
    if (is_little_endian())
        printf("Cette machine est little-endian\n");
    else
        printf("Cette machine est big-endian\n");
    return 0;
}

Résultat \(\rightarrow\)

Cette machine est little-endian

Lecture. x = 1 n’a qu’un octet non nul : son premier octet vaut 1 en little-endian, 0 en big-endian.

Exercice : endianness

0x12345678 est rangé sur 4 octets en 0x100 ; en 0x200, un dump relevé sur une machine little-endian.

Addr +0 +1 +2 +3
0x100 (big) 12 34 56 78
0x100 (little) 7878 5656 34 12
0x200 7878 5656 3434 1212

Énoncé

  1. Remplir la ligne 0x100 en big-endian ;
  2. puis en little-endian ;
  3. little-endian : l’octet lu en 0x100 ;
  4. little-endian : un short lu en 0x100 ;
  5. l’int rangé en 0x200 ;
  6. le même dump lu en big-endian.

Correction

  1. Poids fort d’abord : 12 34 56 78.
  2. Poids faible d’abord : 78 56 34 12.
  3. L’octet en 0x100 : 0x78.
  4. 2 octets, 78 56, lus de droite à gauche : 0x5678.
  5. 78 56 34 12 lus de droite à gauche : 0x12345678.
  6. Lus de gauche à droite : 0x78563412.

Les pointeurs

Quoi, pourquoi, quand ça échoue : le pointeur

Une adresse est un nombre ; une variable peut donc la contenir.

Quoi

Une variable de type \(T\) * dont la valeur est l’adresse d’un objet de type \(T\). &x : l’adresse de x ; *p : l’objet rangé à l’adresse contenue dans p. Comme toute variable, p a une valeur (une adresse) et sa propre adresse, &p.

Pourquoi

Une fonction reçoit des copies de ses arguments : lui passer une adresse lui permet de modifier une variable de l’appelant, ou d’accéder à une zone sans la copier. Le type pointé dit combien d’octets lire et comment les interpréter.

Quand ça échoue

Rien ne vérifie qu’un pointeur désigne un objet vivant : pointeur nul, non initialisé, ou dont l’objet a disparu. Le déréférencer est un comportement indéfini : arrêt par SIGSEGV, ou corruption silencieuse.

Un pointeur, pas à pas

Adresses simplifiées ; sur la machine, x est en 0x7fffffffeca8, avec la même disposition.

Sortie \(\rightarrow\)

x=10 y=20 *p=10
x=30 y=20 *p=30
x=30 y=25 *p=25

Lecture. Ligne 5 : x et y occupent 4 octets chacun, côte à côte ; 10 s’écrit 0a 00 00 00 (little-endian).

Lecture. Ligne 6 : p reçoit l’adresse de x, 0x10 ; sur x86-64, un pointeur occupe un mot de 8 octets.

Lecture. Ligne 7 : premier affichage ; *p lit les 4 octets en 0x10, donc 10.

Lecture. Ligne 8 : *p = 30 écrit à l’adresse 0x10 : x devient 30 (1e) sans être nommé.

Lecture. Ligne 9 : deuxième affichage ; p n’a pas changé.

Lecture. Ligne 10 : p = &y change le contenu de p : 0x14. x ne bouge pas.

Lecture. Ligne 11 : *p += 5 lit 20 en 0x14 et y écrit 25 (19).

Lecture. Ligne 12 : troisième affichage ; *p désigne maintenant y.

Le passage d’adresses à une fonction

Adresses simplifiées ; sur la machine, x est en 0x7fffffffecc0 et a en 0x7fffffffec68.

Sortie \(\rightarrow\)

x = 2, y = 1

Lecture. Dans main : x et y, dans le stack frame de main.

Lecture. L’appel copie &x et &y dans les paramètres a et b, dans le stack frame de echange, plus bas dans la pile ; t n’a pas encore de valeur.

Lecture. t = *a : lecture de 4 octets à l’adresse contenue dans a, donc x : t vaut 1.

Lecture. *a = *b : l’écriture passe par l’adresse ; x de main vaut 2.

Lecture. *b = t : y vaut 1.

Lecture. Au retour, le stack frame de echange est rendu ; l’affichage montre l’échange des variables de l’appelant.

Pointeur nul, pointeur non initialisé

int *p = NULL;         /* aucun objet */
printf("p = %p\n", (void *)p);
fflush(stdout);
*p = 1;                /* indefini */
printf("jamais affiche\n");

Résultat \(\rightarrow\)

p = (nil)

Fin du processus : signal SIGSEGV (Segmentation fault)

int *p;                /* indetermine */
*p = 5;
printf("*p = %d\n", *p);

Compilateur \(\rightarrow\)

s2_noninit.c:5:8: warning: 'p' is used uninitialized [-Wuninitialized]

Résultat \(\rightarrow\)

*p = 5

Lecture. NULL ne désigne aucun objet : l’écriture en *p arrête le processus. Le pointeur non initialisé contient ce qui traînait sur la pile : ici l’écriture tombe quelque part et le programme semble fonctionner. Seul l’avertissement de gcc signale l’erreur.

Exercice : double indirection

Addr +0 +1 +2 +3 +4 +5 +6 +7
0x1000 0a0a 0000 0000 0000 141414 000000 000000 000000
0x1008 1e1e 0000 0000 0000 00 00 00 00
0x1010 0404 1010 0000 0000 0000 0000 0000 0000
0x1018 1010 1010 0000 0000 0000 0000 0000 0000
int arr[3];   /* &arr[0] = 0x1000 */
int *p;       /* &p      = 0x1010 */
int **pp;     /* &pp     = 0x1018 */

Machine little-endian, 64 bits (pointeurs sur 8 octets).

Énoncé : donner

  1. arr[0], arr[1], arr[2] ;
  2. p, l’objet qu’il désigne, *p ;
  3. pp et *pp ;
  4. **pp, et le chemin suivi.

Correction

  1. Trois int de 4 octets à partir de 0x1000 : 10, 20, 30 (0a, 14, 1e).
  2. 8 octets en 0x1010 (0x100c à 0x100f : inutilisés, alignement) : p = 0x1004 \(=\) &arr[1] ; *p = 20.
  3. 8 octets en 0x1018 : pp = 0x1010 \(=\) &p ; *pp est p, donc 0x1004.
  4. **pp \(=\) *p \(=\) 20 : pp \(\rightarrow\) p (0x1010) \(\rightarrow\) arr[1] (0x1004).

Tableaux, pointeurs et tableaux

Un tableau en mémoire

Adresses simplifiées dans la grille ; la sortie montre les vraies (0x7fffffffecd0 pour a[0]).

Sortie \(\rightarrow\)

&a[0] = 0x7fffffffecd0  a[0] = 10
&a[1] = 0x7fffffffecd4  a[1] = 20
&a[2] = 0x7fffffffecd8  a[2] = 30
&a[3] = 0x7fffffffecdc  a[3] = 40
&a[4] = 0x7fffffffece0  a[4] = 50

Lecture. Un tableau range \(n\) objets de même type côte à côte. Ligne 5 : 5 int, 20 octets de 0x10 à 0x23 ; rien ne marque la fin.

Lecture. i = 0 : &a[0] \(=\) a \(+\,0\times4\) octets (0x10 ici) ; la vraie adresse affichée avance aussi de 4.

Lecture. i = 1 : &a[1] \(=\) a \(+\,1\times4\) octets (0x14 ici) ; la vraie adresse affichée avance aussi de 4.

Lecture. i = 2 : &a[2] \(=\) a \(+\,2\times4\) octets (0x18 ici) ; la vraie adresse affichée avance aussi de 4.

Lecture. i = 3 : &a[3] \(=\) a \(+\,3\times4\) octets (0x1C ici) ; la vraie adresse affichée avance aussi de 4.

Lecture. i = 4 : &a[4] \(=\) a \(+\,4\times4\) octets (0x20 ici) ; la vraie adresse affichée avance aussi de 4.

Un tour de boucle de trop

Adresses simplifiées ; disposition réelle du stack frame de main (gcc 13, -O0, lue avec objdump -d), a en 0x7fffffffecd0 ; ·· : octet non montré.

Sortie \(\rightarrow\)

a 0x7fffffffecd0, voisin 0x7fffffffecc8
voisin = 7

Lecture. Ligne 4 : a occupe 16 octets ; gcc range le tableau au-dessus des scalaires, avant 8 octets inutilisés et le canari.

Lecture. Ligne 5 : voisin reçoit 7 ; il est avant le tableau, en 0x08.

Lecture. Les vraies adresses confirment la disposition : voisin 8 octets avant a.

Lecture. Tours i = 0 à 3 : les quatre éléments passent à 0.

Lecture. Tour i = 4 : a[4], en 0x20, est hors du tableau ; l’écriture tombe sans erreur dans un emplacement inutilisé.

Lecture. voisin, rangé avant, vaut toujours 7 ; rien ne le garantit (comportement indéfini).

Le débordement vu par AddressSanitizer

Le même programme, compilé avec -fsanitize=address : des zones interdites entourent chaque tableau, et tout accès y est signalé.

Sortie d’erreur avec AddressSanitizer \(\rightarrow\)

==28885==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffff5400050
WRITE of size 4 at 0x7ffff5400050 thread T0
    #0 0x555555555481 in main s3_debord.c:9
...
    [64, 80) 'a' (line 4) <== Memory access at offset 80 overflows this variable
...
SUMMARY: AddressSanitizer: stack-buffer-overflow s3_debord.c:9 in main

Lecture. L’écriture de 4 octets à l’offset 80 du stack frame sort de l’intervalle [64, 80) occupé par a, à la ligne 9 (a[i] = 0, tour i = 4). Sans cet outil, rien ne signale l’erreur ; la norme ne prévoit aucun contrôle des indices.

Un tableau à deux dimensions

Adresses simplifiées ; sur la machine, mat est en 0x7fffffffece0.

Sortie \(\rightarrow\)

8
12
20

Lecture. Ligne 5 : 6 int rangés ligne par ligne : la ligne 0 (bleu) puis la ligne 1 (vert).

Lecture. b désigne le premier octet ; les écarts se comptent en octets.

Lecture. &mat[0][2] : \((0\times3+2)\times4=8\) octets après le début.

Lecture. &mat[1][0] : \((1\times3+0)\times4=12\) octets après le début.

Lecture. &mat[1][2] : \((1\times3+2)\times4=20\) octets après le début.

Pointeur et tableau : les équivalences

Adresses simplifiées ; sur la machine, p est en 0x7fffffffecb8 et arr en 0x7fffffffece0.

Sortie \(\rightarrow\)

30 30 30 30 30
40 30 3

Lecture. Ligne 5 : le tableau, 20 octets à partir de 0x38.

Lecture. Ligne 6 : arr se convertit en &arr[0] : p contient 0x38.

Lecture. Les cinq écritures lisent le même int, à \(\texttt{arr}+2\times4\) : a[i] est par définition *(a + i), d’où même 2[arr].

Lecture. Ligne 10 : p + 3 avance de \(3\times4=12\) octets : 0x44.

Lecture. *p vaut 40, p[-1] 30 ; p - arr compte des éléments, pas des octets : 3.

Un tableau n’est pas un pointeur

void f(int t[5])           /* t : int * */
{
    printf("f : sizeof t = %zu\n", sizeof t);
}
int main(void)
{
    int a[5] = {0};
    printf("main : sizeof a = %zu\n", sizeof a);
    f(a);
    return 0;
}

Compilateur \(\rightarrow\)

s3_decroissance.c:4:43: warning: 'sizeof' on array function parameter 't' will return size of 'int *' [-Wsizeof-array-argument]

Résultat \(\rightarrow\)

main : sizeof a = 20
f : sizeof t = 8
int g(int *p)
{
    int a[5] = {0};
    a = p;            /* refuse */
    return a[0];
}

Compilateur \(\rightarrow\)

s3_affect.c:4:7: error: assignment to expression with array type

Lecture. En paramètre, int t[5] est un int * : sizeof t vaut 8. Le nom d’un tableau n’est pas une variable : a = p est refusé.

Exercice : adresses dans un tableau

Addr +0 +1 +2 +3 +4 +5 +6 +7
0x7000 0a 00 00 00 1414 0000 0000 0000
0x7008 1e1e 0000 0000 0000 28 00 00 00
0x7010 32 00 00 00 3c3c 0000 0000 0000
int tab[6] = {10, 20, 30, 40, 50, 60};  /* en 0x7000 */
int *p = tab + 2;
int *q = &tab[5];

Énoncé : donner

  1. p et *p ;
  2. q et q - p ;
  3. *(p + 3) et p[-1] ;
  4. (char *)q - (char *)p ;
  5. (T *)0x7000 + 3 pour int, short, char ; (double *)0x7000 + 2 ;
  6. &m[1][2] pour int m[3][4] rangé en 0x7000.

Correction

  1. \(\texttt{0x7000}+2\times4\) : p = 0x7008 ; *p = 30.
  2. q = 0x7014 ; q - p = 3 (des int, pas des octets).
  3. *(p + 3) \(=\) tab[5] = 60 ; p[-1] \(=\) tab[1] = 20.
  4. Entre char * : 12 octets.
  5. 0x700c (\(+12\)), 0x7006 (\(+6\)), 0x7003 (\(+3\)), 0x7010 (\(+16\)).
  6. \(\texttt{0x7000}+(1\times4+2)\times4\) \(=\) 0x7018.

Classes de stockage et allocation dynamique

Les trois classes de stockage

Au-delà des variables locales : la place d’un objet et sa durée de vie dépendent de sa déclaration.

classe déclaration zone durée de vie
automatique locale sans static pile (stack) du début à la fin du bloc {}
statique globale, ou locale static segments .data, .bss, .rodata toute l’exécution
dynamique bloc rendu par malloc, calloc, realloc tas (heap) jusqu’au free
void exemple(int n)
{
    int local[10];               /* automatique : la duree de l'appel */
    static int persistant[5];    /* statique : garde sa valeur entre appels */
    int *dyn = malloc(n * sizeof(int));    /* dynamique : jusqu'a free */
    free(dyn);
}

Lecture. La classe de stockage fixe la zone et la durée de vie ; utiliser un objet après la fin de sa durée de vie est un comportement indéfini.

Une adresse par catégorie d’objet

int global_init = 221;              /* .data */
int global_zero;                    /* .bss : a zero */
const int constante = 7;            /* .rodata */
int main(void)
{
    static int compteur = 0;        /* statique local */
    int local = 1;                  /* automatique */
    int *tas = malloc(sizeof *tas); /* allouee */
    char *gros = malloc(1 << 20);   /* allouee, 1 Mio */
    for (int k = 0; k < 9; k++)
        printf("%-12s %p\n", nom[k], adr[k]);
    free(tas);
    free(gros);
    return 0;
}

Résultat \(\rightarrow\)

main         0x5555555551a9
"Kaolack"    0x555555556053
constante    0x555555556004
global_init  0x555555558010
global_zero  0x555555558018
compteur     0x55555555801c
*tas         0x5555555592a0
*gros        0x7ffff7ea9010
local        0x7fffffffec38

Lecture. Dans l’ordre croissant : le code, les constantes et littéraux (.rodata), les statiques (.data, puis .bss pour ceux qui valent 0, dont compteur), le tas, la zone mmap, la pile.

La carte d’un processus

Les adresses affichées par le programme précédent, rangées par zone dans l’ordre croissant.

Lecture. Le code et les données statiques viennent de l’exécutable ; le tas grandit vers les adresses hautes, la pile vers les basses. La glibc place un grand bloc (ici 1 Mio, au-delà de 128 Kio par défaut) dans la zone mmap, avec les bibliothèques.

Retour de l’adresse d’un tableau local

int *creer(void)
{
    int t[3] = {1, 2, 3};     /* automatique */
    return t;                 /* adresse de t */
}
int main(void)
{
    int *p = creer();
    printf("p = %p\n", (void *)p);
    fflush(stdout);
    printf("p[0] = %d\n", p[0]);
    return 0;
}

Compilateur \(\rightarrow\)

s5_retour.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr]

Résultat \(\rightarrow\)

p = (nil)

Fin du processus : signal SIGSEGV (Segmentation fault)

Lecture. t disparaît au retour de creer ; gcc le signale et rend NULL à sa place : lire p[0] arrête le processus. Ailleurs, l’adresse rendue serait invalide (comportement indéfini).

Solution 1 : un tableau static

int *creer_tableau_static(void)
{
    static int tab[5] = {1, 2, 3, 4, 5};    /* .data */
    return tab;
}
int main(void)
{
    int *p = creer_tableau_static();
    printf("%d\n", p[0]);
    int *q = creer_tableau_static();
    q[0] = 99;
    printf("%d\n", p[0]);      /* meme tableau */
    printf("p == q : %d\n", p == q);
    return 0;
}

Résultat \(\rightarrow\)

1
99
p == q : 1

Lecture. tab est statique : il survit à l’appel, et l’adresse rendue reste valide. Mais il est unique : tous les appels rendent le même tableau, si bien que q[0] = 99 modifie aussi ce que désigne p.

Solutions 2 et 3 : le tas, ou la zone de l’appelant

void remplir(int *t, int n)      /* zone fournie */
{
    for (int i = 0; i < n; i++)
        t[i] = i + 1;
}
int *creer_tas(int n)            /* sur le tas */
{
    int *t = malloc(n * sizeof *t);
    if (t != NULL)
        remplir(t, n);
    return t;
}

Résultat \(\rightarrow\)

c[2] = 3
d[0] = 1, e[0] = 99
int main(void)
{
    int c[3];
    remplir(c, 3);              /* appelant */
    printf("c[2] = %d\n", c[2]);
    int *d = creer_tas(3);
    int *e = creer_tas(3);
    if (d == NULL || e == NULL)
        return 1;
    e[0] = 99;
    printf("d[0] = %d, e[0] = %d\n", d[0], e[0]);
    free(d);
    free(e);
    return 0;
}

Lecture. remplir écrit dans la zone de l’appelant. creer_tas alloue un bloc par appel : d et e sont indépendants, à libérer par l’appelant.

Quoi, pourquoi, quand ça échoue : malloc

Quoi

Des fonctions de <stdlib.h> qui réservent un bloc sur le tas et en rendent l’adresse : malloc(n) (\(n\) octets non initialisés), calloc(k, t) (\(k\times t\) octets à zéro), realloc(p, n) (redimensionne, en déplaçant si besoin) ; free(p) rend le bloc.

Pourquoi

Une taille connue seulement à l’exécution, ou un objet qui doit survivre à la fonction qui le crée.

Quand ça échoue

Le retour vaut NULL si la mémoire manque : il se teste. Le bloc reste réservé jusqu’au free : l’oublier est une fuite ; libérer deux fois, ou utiliser le bloc après free, est un comportement indéfini.

malloc, free, calloc

Sortie \(\rightarrow\) adresses simplifiées ; premier bloc en 0x5555555592a0, sur le tas.

p = 0x5555555592a0
q = 0x5555555592a0
0x5555555592a0 : 59 55 55 55 05 00 00 00
0x5555555592a8 : 00 00 00 00 00 00 00 00
0x5555555592b0 : 41 41 41 41 41 41 41 41
0x55555555a2d0 : 00 00 00 00 00 00 00 00
0x55555555a2d8 : 00 00 00 00 00 00 00 00
0x55555555a2e0 : 00 00 00 00 00 00 00 00

Lecture. Ligne 6 : 24 octets sur le tas, au contenu indéterminé ; p contient leur adresse (0x10 ici).

Lecture. memset écrit 24 fois 0x41 ('A').

Lecture. L’adresse du bloc est affichée avant sa libération.

Lecture. free rend le bloc ; le lire serait un comportement indéfini.

Lecture. Un malloc de même taille rend le même bloc : q reçoit l’adresse de l’ancien p.

Lecture. calloc(3, 8) : un autre bloc de 24 octets, mis à zéro.

Lecture. Même adresse pour l’ancien p et pour q.

Lecture. q : 16 octets réécrits par la glibc (liste des blocs libres) ; les 8 derniers gardent les 'A'.

Lecture. Le bloc de calloc est entièrement à zéro ; celui de malloc contient ce qui s’y trouvait.

Agrandissement d’un bloc : realloc

int *t = malloc(4 * sizeof *t);
for (int i = 0; i < 4; i++)
    t[i] = 10 * (i + 1);
int *bouchon = malloc(16);             /* apres t */
int *n = realloc(t, 1000 * sizeof *n); /* 4 000 o */
if (n == NULL) {                       /* t valide */
    free(t);
    return 1;
}
int *m = realloc(n, 2000 * sizeof *m); /* 8 000 o */
if (m == NULL) {
    free(n);
    return 1;
}
printf("m[3] = %d\n", m[3]);
free(m);
free(bouchon);

Résultat \(\rightarrow\)

t = 0x5555555592a0, bouchon = 0x5555555592c0
n = 0x5555555592e0, deplace : oui
m = 0x5555555592e0, deplace : non
m[3] = 40

Lecture. bouchon occupe la place qui suit t : realloc déplace le bloc et recopie les 16 octets ; au second appel, la place suivante est libre : même adresse. L’ancienne adresse ne doit plus servir.

Double free et accès après free

int *p = malloc(10 * sizeof *p);
free(p);
printf("premier free fait\n");
fflush(stdout);
free(p);                /* double free */
printf("jamais affiche\n");

Compilateur \(\rightarrow\)

s6_double_free.c:9:5: warning: pointer 'p' used after 'free' [-Wuse-after-free]

Résultat \(\rightarrow\)

premier free fait

Sortie d’erreur \(\rightarrow\)

free(): double free detected in tcache 2

Fin du processus : signal SIGABRT (Aborted)

int *p = malloc(4 * sizeof *p);
p[0] = 221;
free(p);
printf("p[0] = %d\n", p[0]);

Compilateur \(\rightarrow\)

s6_uaf.c:8:5: warning: pointer 'p' used after 'free' [-Wuse-after-free]

Résultat \(\rightarrow\)

p[0] = 1431655769

Lecture. La glibc détecte le double free : SIGABRT. L’accès après free passe : p[0] lit le lien écrit par la glibc dans le bloc libéré (0x55555559 \(=\) 1431655769), et non plus 221.

La fuite de mémoire, vue par valgrind

char *copier(const char *s)
{
    char *c = malloc(strlen(s) + 1);  /* + '\0' */
    if (c != NULL)
        strcpy(c, s);
    return c;                         /* a liberer */
}
int main(void)
{
    char *v = copier("Saint-Louis");  /* 12 octets */
    v = copier("Tivaouane");          /* bloc perdu */
    free(v);
    return 0;
}

Sortie d’erreur sous valgrind \(\rightarrow\)

==28965== 12 bytes in 1 blocks are definitely lost in loss record 1 of 1
==28965==    at 0x4846828: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==28965==    by 0x1091D0: copier (s6_fuite.c:5)
==28965==    by 0x10920F: main (s6_fuite.c:12)

Lecture. Le second appel écrase la seule copie de l’adresse du premier bloc : ces 12 octets ne peuvent plus être libérés. valgrind les compte comme definitely lost et donne la ligne de l’allocation.

Les tableaux à longueur variable (VLA)

void pile(size_t n)
{
    int v[n];                  /* VLA, sur la pile */
    v[0] = v[n - 1] = 1;
    printf("pile %zu : %zu o\n", n, sizeof v);
    fflush(stdout);
}
int main(void)
{
    pile(10);
    pile(1000000);             /* 4 Mo */
    pile(4000000);             /* 16 Mo */
    return 0;
}

Résultat \(\rightarrow\)

pile 10 : 40 o
pile 1000000 : 4000000 o

Fin du processus : signal SIGSEGV (Segmentation fault)

size_t n = 4000000;
int *v = malloc(n * sizeof *v);    /* 16 Mo */
if (v == NULL)
    return 1;
v[0] = v[n - 1] = 1;
printf("tas %zu : %zu o\n", n, n * sizeof *v);
free(v);

Résultat \(\rightarrow\)

tas 4000000 : 16000000 o

Lecture. int v[n] (C99) reste sur la pile : 16 Mo dépassent les 8 Mio, SIGSEGV ; le tas, lui, peut rendre NULL. Un VLA n’est ni static ni global ; optionnel depuis C11 (__STDC_NO_VLA__).

Exercice : allocation dynamique

int *a = malloc(5 * sizeof(int));
int *b = calloc(5, sizeof(int));
int *p = malloc(sizeof(int));        /* cas 1 */
free(p);
*p = 42;
int *q = malloc(10 * sizeof(int));   /* cas 2 */
q = malloc(20 * sizeof(int));
int *r = malloc(sizeof(int));        /* cas 3 */
free(r);
free(r);

Énoncé

  1. Octets alloués pour a, pour b ;
  2. contenus initiaux de a et de b ;
  3. lire a[0] juste après le malloc ?
  4. libérer correctement les deux blocs ;
  5. l’erreur des cas 1, 2 et 3.

Correction

  1. \(5\times4=20\) octets chacun.
  2. a : indéterminé (par exemple les restes d’un bloc libéré) ; b : 20 octets à zéro.
  3. Valeur indéterminée : le résultat n’a pas de sens.
  4. free(a); free(b); puis a = b = NULL;.
  5. Cas 1 : accès après free, comportement indéfini ; cas 2 : fuite des 40 premiers octets ; cas 3 : double free, arrêt par la glibc (SIGABRT).

Arithmétique des pointeurs et chaînes

L’arithmétique dimensionnée par le type

Les tableaux l’ont montré : p + i désigne le \(i\)-ième objet après p. La règle générale : pour T *p, p + n ajoute \(n\times\texttt{sizeof(T)}\) octets à l’adresse, et q - p compte des objets de type T.

int x = 0x01234567;
int *pi = &x;
unsigned char *pc = (unsigned char *)&x;    /* meme adresse */
printf("pi = %p, pc = %p\n", (void *)pi, (void *)pc);
printf("*pi = 0x%08x, *pc = 0x%02x\n", *pi, *pc);
printf("pi + 1 = %p  (+ 4)\n", (void *)(pi + 1));
printf("pc + 1 = %p  (+ 1)\n", (void *)(pc + 1));

Résultat \(\rightarrow\)

pi = 0x7fffffffece4, pc = 0x7fffffffece4
*pi = 0x01234567, *pc = 0x67
pi + 1 = 0x7fffffffece8  (+ 4)
pc + 1 = 0x7fffffffece5  (+ 1)

Lecture. pi et pc contiennent la même adresse ; *pi lit 4 octets, *pc un seul, le poids faible 67. pi + 1 avance de 4 octets, pc + 1 de 1 ; un double * avancerait de 8.

La réinterprétation par cast

Adresses simplifiées ; sur la machine, x est en 0x7fffffffecb8.

Sortie \(\rightarrow\)

octet 0 : 0x44
octet 1 : 0x33
octet 2 : 0x22
octet 3 : 0x11

Lecture. Ligne 5 : 0x11223344 rangé 44 33 22 11.

Lecture. Le cast donne un unsigned char * vers le même octet : le type pointé change, pas l’adresse.

Lecture. cp[0] : l’octet en 0x10, cp \(+\,0\) (pas de 1 octet).

Lecture. cp[1] : l’octet en 0x11, cp \(+\,1\) (pas de 1 octet).

Lecture. cp[2] : l’octet en 0x12, cp \(+\,2\) (pas de 1 octet).

Lecture. cp[3] : l’octet en 0x13, cp \(+\,3\) (pas de 1 octet).

Une chaîne en mémoire

Adresses simplifiées ; sur la machine, s est en 0x7fffffffece2 (première ligne du dump).

Sortie \(\rightarrow\)

sizeof s = 6, strlen(s) = 5
0x7fffffffece2 : 44 61 6b 61 72 00
s = "Da", strlen(s) = 2
s + 3 = "ar"

Lecture. Ligne 5 : 5 lettres et le terminateur '\0' (00) : 6 octets.

Lecture. sizeof mesure le tableau (6), strlen compte les octets avant le premier 00 (5).

Lecture. Le dump montre les codes ASCII : D = 44, a = 61, k = 6b, r = 72.

Lecture. Ligne 9 : un 00 en s[2] coupe la chaîne en deux.

Lecture. s s’arrête au premier 00 : "Da", longueur 2.

Lecture. s + 3 désigne la chaîne "ar", toujours en place après la coupure.

Tableau ou pointeur sur un littéral

char t[] = "Thies";        /* copie locale */
char *p = "Thies";         /* litteral */
printf("t = %p, sizeof t = %zu\n",
       (void *)t, sizeof t);
printf("p = %p, sizeof p = %zu\n",
       (void *)p, sizeof p);
char *q = "Thies";         /* meme litteral */
printf("q = %p\n", (void *)q);
t[0] = 't';
printf("t = %s\n", t);

Résultat \(\rightarrow\)

t = 0x7fffffffece2, sizeof t = 6
p = 0x555555556004, sizeof p = 8
q = 0x555555556004
t = thies
char *p = "Thies";
printf("avant : %s\n", p);
fflush(stdout);
p[0] = 't';       /* indefini */
printf("apres : %s\n", p);

Résultat \(\rightarrow\)

avant : Thies

Fin du processus : signal SIGSEGV (Segmentation fault)

Lecture. char t[] : une copie modifiable sur la pile. char *p : l’adresse du littéral, en .rodata, partagé par q ; sizeof p est la taille d’un pointeur. Écrire dans le littéral arrête le processus.

strcpy, strcat, strlen et sizeof

Adresses simplifiées ; sur la machine, dest est en 0x7fffffffecd0.

Sortie \(\rightarrow\)

Longueur : 11
Taille   : 20

Lecture. Ligne 6 : 20 octets réservés, au contenu indéterminé (··).

Lecture. strcpy copie les 5 lettres et le terminateur ; le reste n’est pas touché.

Lecture. strcat cherche le 00, écrit " World" par-dessus, puis un nouveau terminateur.

Lecture. strlen : 11 caractères avant le 00.

Lecture. sizeof : 20 octets ; ni strcpy ni strcat ne connaissent cette taille (danger si dest est trop petit : strncpy borne la copie). strcmp compare, strchr cherche un caractère.

Exercice : chaînes de caractères

Addr +0 +1 +2 +3 +4 +5 +6 +7
0x20 4242 6f6f 6e6e 6a6a 6f6f 7575 7272 0000
0x28 5555 4343 4141 4444 0000 00 00 00
char *s1 = (char *)0x20;
char *s2 = (char *)0x28;
char arr[] = "Hello";
char *ptr  = "Hello";

Énoncé : donner

  1. la chaîne s1 ;
  2. strlen(s1) et sizeof("Bonjour") ;
  3. la chaîne s2 et strlen(s2) ;
  4. sizeof(arr) et sizeof(ptr) ;
  5. arr[0] = 'X', ptr[0] = 'X' : valides ?
  6. ptr = "World", arr = "World" : valides ?

ASCII : 41 A, 42 B, 43 C, 44 D, 55 U, 6a j, 6e n, 6f o, 72 r, 75 u.

Correction

  1. 42 6f 6e 6a 6f 75 72 : « Bonjour ».
  2. 7, arrêt au 00 en 0x27 ; 8, terminateur compris.
  3. 55 43 41 44 : « UCAD », longueur 4.
  4. 6 octets ; 8, la taille d’une adresse.
  5. Valide pour la copie arr ; ptr[0] : écriture dans un littéral, comportement indéfini ; ici SIGSEGV (.rodata en lecture seule).
  6. ptr change d’adresse : valide. arr = … : refusé (assignment to expression with array type).

Visualisation de la mémoire

show_bytes : les octets d’un objet

typedef unsigned char *byte_pointer;
void show_bytes(byte_pointer start, size_t len) {
    for (size_t i = 0; i < len; i++)
        printf(" %.2x", start[i]); /* 2 car. */
    printf("\n");
}
void show_int(int x) {
    printf("int %d (0x%X) :", x, x);
    show_bytes((byte_pointer)&x, sizeof(int));
}
void show_float(float x) {
    printf("float %f :", x);
    show_bytes((byte_pointer)&x, sizeof(float));
}
void show_pointer(void *x) {
    printf("pointeur %p :", x);
    show_bytes((byte_pointer)&x, sizeof(void *));
}
int main(void) {
    int val = 0x12345678;
    float f = 1.0f;
    show_int(val);
    show_float(f);
    show_pointer(&val);
    return 0;
}

Résultat \(\rightarrow\)

int 305419896 (0x12345678) : 78 56 34 12
float 1.000000 : 00 00 80 3f
pointeur 0x7fffffffece0 : e0 ec ff ff ff 7f 00 00

Lecture. Un unsigned char * parcourt l’objet octet par octet : int et pointeur en little-endian, 1.0f \(=\) 0x3f800000.

Exercice : forensique mémoire

Addr +0 +1 +2 +3 +4 +5 +6 +7
0x00 1e 00 00 00 00 00 00 00
0x08 aa bb cc dd eeee ffff 00 11
0x10 8a 7c 6f 22 9a 66 44 17
0x18 3333 777777 6f6f 6c6c6c 6666 6262 7979 7474
0x20 6565 7373 0000 44 00 00 88 c0
0x28 dede adad bebe efef caca fefe f0f0 0d0d
char  *c = (char *)0x19;
short *s = (short *)0x0C;
int   *p = (int *)0x28;

Énoncé (little-endian) : type et valeur

  1. *s ;
  2. *p ;
  3. *(c+2) ;
  4. p[1] ;
  5. *((short *)(c-1)) ;
  6. strlen(c).

Correction

  1. short, ee ff : 0xffee (\(-18\)).
  2. int, de ad be ef : 0xefbeadde.
  3. char en 0x1b : 0x6c, 'l'.
  4. int en 0x2c : 0x0df0feca.
  5. short en 0x18, 33 77 : 0x7733.
  6. size_t : 9, la chaîne « wolfbytes » s’arrête au 00 en 0x22.

Synthèse

À retenir

À retenir

  1. La mémoire est une suite d’octets numérotés ; l’adresse d’un objet est celle de son premier octet, et le mot machine fixe la taille des adresses (8 octets en x86-64).
  2. Little-endian : le poids faible à l’adresse basse (x86-64) ; big-endian : l’inverse.
  3. Un pointeur contient une adresse ; *p est l’objet désigné, de la taille du type pointé.
  4. Un tableau occupe des cases contiguës, sans contrôle des indices ; son nom devient &a[0], sauf sous sizeof et & ; et a[i] \(\equiv\) *(a + i).
  5. p + n ajoute \(n\times\texttt{sizeof(*p)}\) octets ; q - p compte des éléments.
  6. Automatique (pile), statique (données), dynamique (tas, jusqu’au free) : la classe fixe la durée de vie.
  7. Une chaîne est un tableau de char terminé par 00 ; strlen ne compte pas ce terminateur, sizeof mesure le tableau ; un littéral est en lecture seule.

Prochaine séance

Séance 4 : structures et unions

Une struct range des champs de types différents côte à côte ; une union les superpose aux mêmes adresses. La séance repart des outils de celle-ci : sizeof, adresses des champs, lecture des octets avec show_bytes, et explique l’alignement et le remplissage entre les champs.

À préparer

Le TD et le TP de la séance 3 : dumps à lire, pointeurs à suivre, programme show_bytes à étendre à un tableau et à une chaîne.

Références

  • R. E. Bryant, D. R. O’Hallaron, Computer Systems: A Programmer’s Perspective, 3e éd., Pearson, 2016 : chap. 2.1 (show_bytes, endianness), chap. 3.8 (tableaux), chap. 9.9 (allocation).
  • B. W. Kernighan, D. M. Ritchie, The C Programming Language, 2e éd., Prentice Hall, 1988 : chap. 5 (pointeurs et tableaux).
  • K. N. King, C Programming: A Modern Approach, 2e éd., Norton, 2008 : chap. 11, 12, 13 et 17.
  • ISO/IEC 9899:2018 (C17) : 6.2.4 (durées de stockage), 6.5.6 (opérateurs additifs), 7.22.3 (gestion de la mémoire).

Mesures de la séance : x86-64, Linux 6.18, gcc 13.3.0 (-std=c17 -Wall -Wextra -O0 -g), glibc 2.39, valgrind 3.22.0 ; adresses rendues déterministes par setarch -R, pile de 8192 Kio.