Des pointeurs et des hommes (3)

Previous Next

Il fait beau aujourd’hui : le soleil est là, les oiseaux gazouillent … Je pense qu’il est temps d’aborder dans un petit article les sources fréquentes d’usage des pointeurs. Cela va être difficile d’être exhaustif, mais je pense que ce ne sera pas trop long : nous devrions pouvoir couvrir quelques exemples typiques et retourner bien vite jouer au soleil. On y va ?

La MMU, le garde-fou des processus

Comme nous l’avons vu dans les épisodes précédents, un pointeur n’est qu’une variable contenant un numéro de case mémoire, ou une “adresse” si vous préférez. Encore faut-il que cette adresse soit valide !

Que ce passe-t-il si ce n’est pas le cas ? Pour comprendre la réponse à cette question, il faut revenir à la gestion de la mémoire virtuelle et au rôle de la MMU (Memory Management Unit).

Sur un PC, tout processus est persuadé d’être seul au monde, et de disposer de l’intégralité de la mémoire pour lui. Mais on lui ment. Les adresses qu’il utilise ne sont que des adresses factices (on parle d’adresses virtuelles) qui sont traduites par la MMU en adresses physiques.

image

Ainsi, si le processus n’utilise pas certaines parties de la mémoire, La MMU n’a pas encore établi de correspondance avec de la véritable RAM, et on gagne de la place ! Dans l’exemple ci-dessus, le processus n’utilise que trois zones mémoires, respectivement notées 1,2 et 3. La MMU leur a fait correspondre trois zones de la RAM qu’elle a réservée à cette usage.

Si un deuxième processus a lui aussi besoin de RAM, elle fera de même :

image

Deux choses font que ce système fonctionne. La première, c’est qu’un processus n’utilise en général pas toute la RAM mise à sa disposition. Les zones non utilisées sont simplement non mappées (sans correspondance en RAM) et n’occupent donc aucune place en mémoire physique.

La seconde chose qui nous arrange bien, c’est que les processus vont parfois demander le chargement en mémoire d’une même ressource : librairie, fichier ou autre. Dans ce cas, la MMU ne charge qu’une seule fois la ressource en mémoire, et la remappe indépendamment pour chaque processus qui en fait la demande. Dans le graphique ci-dessus, c’est le cas de la zone notée “2”. Mais la MMU n’est pas seulement en charge de “traduire” les adresses : elle gère aussi des droits d’accès. Et de façon similaire aux fichiers, On peut avoir sur une zone mémoire des droits de lecture, écriture et exécution.

Une dernière chose : La granularité de travail de la MMU n’est pas l’octet. Pour simplifier son travail, elle travaille par “page mémoire”, c’est à dire par quantité de 4096 Octets (Cela peut varier selon les machines, en général entre 512 et 8192 Octets.) Si vous ne souhaitez que 4 Octets, la MMU en mappera de toute façon 4096 (1 page).

Revenons un peu à nos pointeurs. Dans ce contexte de protection par la MMU, quand peuvent-ils poser problème ?

Le dangling pointer, ou comment se tirer une balle dans le pied.

Le pire ennemi du développeur C, c’est le pointeur qui contient une adresse non valide. Parce que ça va avoir des conséquences dramatiques sur le fonctionnement du programme. Prenons un exemple simple :

#include <stdio.h>

int 
main() 
{

    int variable = 17 ;
    int* pointeur = &variable;

    printf("Le pointeur contient l'adresse %p \n", pointeur) ;
    
    printf("Je modifie cette adresse \n") ;
    *pointeur = 42 ;

    printf("still alive !!!! \n") ;

    return 0 ;
}

Le programme fonctionne parfaitement :

$ ./test
Le pointeur contient l'adresse 0x7ffcfb309ffc
Je modifie cette adresse
still alive !!!!

Mais si nous oublions d’initialiser notre pointeur, que se passerait-il ?

int 
main() 
{

    int variable = 17 ;
    int* pointeur ;         // Le pointeur contient une adresse inconnue !!!! 

    printf("Le pointeur contient l'adresse %p \n", pointeur) ;
    
    printf("Je modifie cette adresse \n") ;
    *pointeur = 42 ;

    printf("still alive !!!! \n") ;

    return 0 ;
}
+$ ./test
Le pointeur contient l'adresse (nil)
Je modifie cette adresse
Erreur de segmentation

Cette fois-ci, le programme plante, et nous lâche un magnifique SEGFAULT. C’est en fait la MMU qui déclenche l’alerte, parce que nous essayons d’accéder à l’adresse zéro (nil) et que cette adresse ne nous est pas autorisée !

L’erreur de segmentation peut arriver dans plusieurs cas de figure :

Par contre, si vous tapez dans une zone mémoire erronée, mais qui vous appartient : la MMU vous laisse faire ! Après tout, être débile n’a jamais été interdit tant qu’on respecte les lois ! (tiens, c’est toute l’histoire de Twitter ça …)

Ce dernier type d’erreur est le plus vicieux. Et cela, en plus, sera parfois difficile à reproduire pour débugger !

Stressons un peu la MMU …

Pour illustrer un peu les rêgles d’accès, voyons voir ce qui se passe si nous écrivons n’importe où de façon systématique :

#include <stdio.h>
#include <stdlib.h>

int A ;

int
main()
{
    int* p ;
    p = &A ;

    while(1) {

        printf("Acces à %p :\n", p ) ;
        *p= 42 ;
        printf("ok ! \n") ;

        p += 1 ;
    }

    return 0 ;
}

Comme j’imagine que vous l’avez compris, nous partons de l’adresse de A et nous avançons progressivement dans la mémoire, sans chercher à comprendre où nous écrivons. Voyons jusqu’où nous pouvons aller :

$ ./test
Acces à 0x55872c89903c :
ok !
Acces à 0x55872c899040 :
ok !
Acces à 0x55872c899044 :
ok !
Acces à 0x55872c899048 :
ok !
Acces à 0x55872c89904c :
ok !
Acces à 0x55872c899050 :
ok !
Acces à 0x55872c899054 :

[...]

Acces à 0x55872c899ff8 :
ok !
Acces à 0x55872c899ffc :
ok !
Acces à 0x55872c89a000 :
Erreur de segmentation

Mmmmh … Ca fait beaucoup d’écritures à la barbare tout ça ! Que fait Monique ?

Si on fait le calcul :

0x55872c89a000 - 0x55872c89903c = 0XFC4 = 4036

Cela ressemble à la taille d’une page mémoire !

Pour en avoir le coeur net, démarrons à nouveau le programme, dans un debugger :

$ gcc -g -o test main.c
$ gdb ./test

[...]

@gef➤ b main
Breakpoint 1 at 0x114d: file main.c, line 10.

@gef➤  run
Starting program: /home/rancune/test/test
Breakpoint 1, main () at main.c:10
[...]

@gef➤  print &A
$1 = (int *) 0x55555555803c <A>

@gef➤  vmmap
[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
0x00555555554000 0x00555555555000 0x00000000000000 r-- /home/rancune/test/test
0x00555555555000 0x00555555556000 0x00000000001000 r-x /home/rancune/test/test
0x00555555556000 0x00555555557000 0x00000000002000 r-- /home/rancune/test/test
0x00555555557000 0x00555555558000 0x00000000002000 r-- /home/rancune/test/test
0x00555555558000 0x00555555559000 0x00000000003000 rw- /home/rancune/test/test
0x007ffff7de1000 0x007ffff7de3000 0x00000000000000 rw-
0x007ffff7de3000 0x007ffff7e05000 0x00000000000000 r-- /lib64/libc-2.33.so
0x007ffff7e05000 0x007ffff7f48000 0x00000000022000 r-x /lib64/libc-2.33.so
0x007ffff7f48000 0x007ffff7f93000 0x00000000165000 r-- /lib64/libc-2.33.so
0x007ffff7f93000 0x007ffff7f97000 0x000000001af000 r-- /lib64/libc-2.33.so
0x007ffff7f97000 0x007ffff7f99000 0x000000001b3000 rw- /lib64/libc-2.33.so
0x007ffff7f99000 0x007ffff7f9f000 0x00000000000000 rw-
0x007ffff7fc6000 0x007ffff7fca000 0x00000000000000 r-- [vvar]
0x007ffff7fca000 0x007ffff7fcc000 0x00000000000000 r-x [vdso]
0x007ffff7fcc000 0x007ffff7fcd000 0x00000000000000 r-- /lib64/ld-2.33.so
0x007ffff7fcd000 0x007ffff7ff1000 0x00000000001000 r-x /lib64/ld-2.33.so
0x007ffff7ff1000 0x007ffff7ffb000 0x00000000025000 r-- /lib64/ld-2.33.so
0x007ffff7ffb000 0x007ffff7ffd000 0x0000000002e000 r-- /lib64/ld-2.33.so
0x007ffff7ffd000 0x007ffff7fff000 0x00000000030000 rw- /lib64/ld-2.33.so
0x007ffffffdd000 0x007ffffffff000 0x00000000000000 rw- [stack]
0xffffffffff600000 0xffffffffff601000 0x00000000000000 --x [vsyscall]

@gef➤  continue
[...]
Acces à 0x555555579ffc :
ok !
Acces à 0x55555557a000 :

Program received signal SIGSEGV, Segmentation fault.

La commande vmmap nous permet de visualiser les espaces mémoire mappés actuellement par la MMU.

Notre variable A, dont l’adresse est 0x55555555803c est dans le bloc :

[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
[...]
0x00555555558000 0x00555555559000 0x00000000003000 rw- /home/rancune/test/test
[...]

Et dans ce bloc, il se trouve que nous avons les droits d’écriture (rw), ce qui est bien pratique il faut l’avouer.

Mais ensuite ?

Et bien ensuite, il n’y a rien. Pas de bloc mémoire mappé par la MMU avant l’adresse 0x007ffff7de1000. Du coup, la MMU râle quand on y accède et c’est le segfault !

Plusieurs choses à observer dans cette petite expérience. La première, c’est que nous pouvons écrire sans problème un peu partout, tant que c’est dans les zones mappées par la MMU. Une (petite) erreur d’adresse ne fera pas forcément planter le programme tout de suite et reviendra nous hanter bien plus tard ! Toute la philosophie du C, c’est que le programmeur sait ce qu’il fait, et qu’on ne va pas le contrarier !

La seconde, c’est de confirmer que ce bloc est bien de taille 4096 : la MMU nous a mappé une page mémoire entière alors que, vraisemblablement, il n’y a pas grand chose dans ce coin là. Il s’agit de la zone “bss” qui contient les variables globales non initialisées.

$ size ./test
text    data     bss     dec     hex filename
1552     592       8    2152     868 ./test

Je vous propose maintenant de voir quelques exemples d’erreurs classiques …

La petite galerie des erreurs

Je n’y reviens pas in extenso, nous venons de le faire en long, en large, et en travers :

int 
main()
{
    int* p ;
    *p = 69 ;

    return 0 ;
}

Nous sommes en train d’écrire une valeur sur 4 octets quelquepart en mémoire. Mais alors où ???? Il y a peu de chance que ce soit sans conséquences …

Une bonne habitude est de toujours mettre un NULL (zéro) dans les pointeurs que l’on vient de déclarer. Ça ne coute rien, et c’est beaucoup plus facile à débugger.

Alors celle-là, elle n’est pas trop grave parce que le compilo va vous râler dessus. Quand on veut déclarer trois pointeurs, on est tenté de faire ça :

double* A, B, C ;

Sauf que non. Dans ce que je viens d’écrire, A est bien un double*, mais B et C sont des doubles. La syntaxe correcte de déclaration est celle-ci :

double *A, *B, *C ;

Je ne range pas vraiment ça dans les erreurs de pointeur, mais il fallait que je vous en parle. Maintenant, c’est fait ! \o/

Ca, c’est un petit peu plus vicieux. Prenez la fonction suivante :

int* 
fonction_qui_pue( int A ) {
    int p ;
    p = A+1 ;
    return &p ;
}

L’adresse que vous récupérez en résultat de la fonction est une adresse de variable locale, dont la portée est réduite à la fonction. Et quand vous voulez vous servir de cette adresse, ce n’est plus p qui s’y trouve. p a disparu.

Dans ce cas précis, p est logé dans la stack, et cela veut dire que si nous utilisons cette adresse beaucoup plus loin dans le programme, il y a de fortes chances que nous écrivions en plein milieu d’autres données. Pas glop.

Une variante rigolote :

int
main() {
    int *K ;
    {
        int G ;
        K = &G ;
    }

    [...]

}

En C, la portée d’une variable est le bloc dans lequel elle est déclarée. Quand on sort de ce bloc, la variable n’existe plus. K contiendra donc une adresse problématique …

Le mot de la fin

Comme à chaque fois, je me dis “Mon dieu, déjà ? C’est beaucoup trop long !!!!” Et pourtant il y aurait tant à dire ! Les pointeurs sont un outil puissant, mais sans garde fou et il vaut mieux comprendre ce que l’on fait pour les manipuler.

Il existe encore bien d’autres façons de se tirer une balle dans le pied, notamment avec malloc et free, mais nous les verrons une prochaine fois !

A bientôt,

Rancune.