Des pointeurs et des hommes (2)
Et nous voilà repartis pour un nouveau chapitre dans notre découverte des pointeurs ! Maintenant que vous avez compris ce qu’est au final un pointeur, j’espère que tout cela vous fait beaucoup moins peur sur le côté théorique de la chose … Et oui, tout ça pour ça !
Pourtant le pointeur, malgré sa simplicité, est un concept fondamental du C. C’est vraiment l’un des points du langage qui le rendent, à mon humble avis, si proche de la machine et si passionnant ! Je vous propose donc de continuer notre parcours en regardant comment on les utilise sur un petit exemple de fonction.
Une fonction pourtant si simple …
Imaginons que nous écrivions un petit programme appelant une fonction d’affichage d’entier, que nous appellerons dis_moi_des_mots_doux:
On peut vérifier que notre fonction marche parfaitement :
La valeur 69 est parfaitement transmise à la fonction dis_moi_des_mots_doux, qui l’affiche. Jusque là tout va bien !
Fort de ce succès, nous décidons d’ajouter la fonction remise_a_zero qui, comme vous l’aurez sûrement deviné, devra remettre à zéro la variable transmise. La plupart des débutants vont écrire une fonction ressemblant à celle-ci :
Malheureusement, lorsque l’on teste cette fonction, notre déception est immense :
A n’a pas changé de valeur.
Et c’est tout à fait normal !!! Car A elle-même n’est jamais transmis lors de l’appel de la fonction. C’est une copie de sa valeur qui est envoyée à la fonction.
Pour expliquer ce qui se passe, je vous propose deux manières de voir la chose : graphiquement, et en assembleur. Vous allez voir que c’est très intuitif.
Lorsqu’on appelle une fonction, les opérations suivantes sont effectuées, dans l’ordre :
- On évalue l’expression entre parenthèses ( C’est à dire on cherche sa valeur numérique )
- On saute à l’emplacement de la fonction
- Une variable locale est créée ( ici n dans la fonction dis_moi_des_mots_doux )
- La variable est initialisée avec notre valeur numérique.
Graphiquement, pour le premier appel de fonction, cela donne l’exécution suivante :
Le problème, vous vous en doutez, est à l’appel de la fonction remise_a_zero, qui essaie de modifier A.
Voici les étapes suivantes de l’exécution de notre programme :
Et oui, étant donné que seule une copie de la valeur contenue dans A est transmise à remise_a_zero, la variable A reste inchangée. Cela ne peut pas fonctionner de cette façon ! (trop dur pour nous !)
Voyons maintenant “the real thing”, et plongeons nous dans l’assembleur. Si cette étape vous fait peur, vous pouvez la sauter sans aucun problème : rendez-vous au chapitre suivant, un peu plus loin !
Compilons, désassemblons, et visualisons le code de notre programme :
( Si vous n’avez pas les outils nécessaires, vous pouvez récupérer ce code assembleur ici
A étant une variable locale, elle est créée dans la pile. Ceci est effectué en agrandissant ladite pile grâce à la l’instruction suivante :
La pile est augmentée de 16 octets en changeant la valeur de %rsp ( le stack pointer ). Ceci est largement suffisant pour stocker un int (4 octets).
La valeur 69 en (0x45 en hexadécimal) est ensuite stockée dans A, qui est à l’adresse rbp-4 (donc dans la pile, et plus spécifiquement dans le frame de main():
Voyons maintenant comment se fait l’appel de la fonction remise_a_zero dans la fonction main :
La valeur de A est tout d’abord chargée dans le registre %eax depuis la pile. Elle est ensuite copiée dans le registre %edi puis l’exécution “saute” à l’adresse 4017a5, où se trouve la fonction remise_a_zero.
Ce passage dans %eax puis dans %edi peut surprendre : pourquoi ne pas mettre directement la valeur de A dans %edi ? Et bien tout simplement parce que l’expression entre parenthèses est d’abord évaluée, et ensuite seulement l’appel se fait.
Ainsi, on pourrait avoir des choses comme :
Dans ce cas, il faudrait d’abord évaluer l’expression (2*A+3), puis appeler remise_a_zero. Le compilateur a donc généré un code en deux étapes :
- Evaluation de l’expression avec %eax (ici elle est triviale)
- Appel de la fonction ( La norme nous dit que le premier argument doit être mise dans %edi )
Quoi qu’il en soit, ni A ni sa position en mémoire ne sont transmises à la fonction : seulement une copie de sa valeur !
Pointeurs et fonctions
Les choses sont un peu plus claires maintenant : la fonction remise_a_zero ne fonctionne pas parce qu’aucune variable n’est “transmise” : seulement une copie de sa valeur.
C’est là que les pointeurs entrent en jeu : au lieu de transmettre la valeur de A, nous allons transmettre son adresse, afin que la fonction appelée puisse savoir où écrire. Et les adresses, nous les manipulons avec ce nouveau type de variable que nous avons vu la dernière fois : le pointeur !
Allons-y et modifions le main(), afin de transmettre l’adresse de A à notre fonction. On utilisera tout simplement l’opérateur & que nous avions vu la dernière fois :
Puisque nous ne transmettons plus un entier mais une adresse d’entier, il va nous falloir adapter remise_a_zero :
La variable locale k est désormais un pointeur, qui contiendra donc une adresse.
Pour écrire un zéro à cette adresse, on utilise l’opérateur * comme vu la dernière fois. Encore une fois, ceci veut dire :
- Je lis k
- k contient une adresse
- J’écris 0 à cette adresse
Le code complet, ça donne ça :
Quoi ? Vous voulez un autre petit dessin ? Vraiment ?
Mais heuuuuuuu … Ça prend trois plombes à faireeeeeeuuuuu !!!!!!!!!!!!!!
Bon, comme je vous aime bien, le voici :
Et ça devrait fonctionner ! On teste ça de suite :
Je crois que nous pouvons appeler ceci une victoire !
Encore une fois, allons voir le code assembleur. Ceux qui veulent peuvent encore une fois sauter au prochain chapitre :)
Si la création de A n’a pas beaucoup changée (A est toujours en pile), La pile contient maintenant également un canari. Ce n’est pas l’objet du cours d’aujourd’hui, on en fera donc abstration.
Par contre, l’affectation de la valeur 69 à A est toujours réalisée de la même façon :
L’appel de la fonction remise_a_zero a cependant bien changé :
On utilise non plus l’instruction mov, mais l’instruction lea. Et celle-ci va stocker l’adresse de A (%rbp-0xc) dans %rax.
Vous le voyez, plus de mensonge : tout se passe bien comme prévu !
Le mot de la fin
Ce chapitre est déjà bien long, alors nous allons repousser quelques-uns des points que je voulais traiter ici à une prochaine fois. Pour conclure, je voudrais juste souligner combien le C peut être clair quand il s’agit des fonctions.
Si vous voyez une fonction déclarée de cette façon-ci :
Vous savez qu’après un appel à cette fonction, le premier paramètre transmis n’a pas pû être modifié par celle-ci, alors que les deux suivants oui ! Cela permet de “compartimenter” ses variables, et de garder un certain control même si vous utilisez des fonctions en provenance de librairies obscures.
Par contre, il va falloir faire très attention à ne pas utiliser des adresses invalides et on a très vite fait de se tirer une balle dans le pied ! Les typedefs, notamment, peuvent cacher des pointeurs à votre regard aiguisé.
Je vous dis à bientôt, et n’hésitez pas à m’envoyer vos questions !
Rancune.