Forum Security-X > Cours

Mieux comprendre les fonctions en C : Démonstration en langage d'assemblage

(1/1)

XmichouX:
Les fonctions en bas niveau

Veuillez vous reporter au fil "Pointeurs : les bases" et à la partie consacrée à la fonction SWAP.

Nous allons illustrer plus en profondeur, mais de manière simplifiée, la notion de pile pour bien comprendre. Chaque thread a sa propre zone de pile, qui est bien-sûr en réalité juste un bloc contiguë d'octets en mémoire dynamique, mais limité (d'où les Stack Overflow = dépassement de pile) à 8 Mo sous Linux par exemple. Cependant, on la conceptualise de la manière suivante pour comprendre son fonctionnement.

Contexte :
Le processeur contient des petites mémoires internes très rapide d'accès (beaucoup plus que la mémoire vive) appelés registres. Dans ses registres figurent par exemple le registre IP (Instruction Pointer) qui pointe la prochaine instruction en mémoire à exécuter, des registres de calcul (AX, BX, CX, DX), ainsi que des registres servant à référencer la zone de pile du processus en cours, notamment BP (Base Pointer) qui contient l'adresse de la base de la pile pour la fonction en cours, et SP (Stack Pointer) qui contient l'adresse du sommet de la pile pour la fonction en cours.
Dans les registres de calcul, le registre AX sert généralement à faire un calcul arithmétique, à stocker le retour d'une fonction, ou encore à contenir un vecteur d'interruption (avant de faire un appel système). Le registre BX sert aussi pour les calculs arithmétiques et calculs sur adresses. Le registre CX sert généralement de compteur dans les boucles et enfin le registre DX sert notamment à stocker des données destinées à des fonctions. De manière générale, ce sont les registres "de calcul".

Note : Au cours de vos recherches, vous pourrez voir aussi bien AX, que EAX, AL, AH, ou encore RAX. La différenciation est très simple, pas besoin de s'embrouiller l'esprit. Jetez un coup d'oeil au schéma suivant :


Les 64,32, etc. représentent la taille des registres schématisés par des rectangles. Pour une architecture 32 bits, il n'y a qu'un seul registre EAX par exemple. Cependant, l'assembleur peut choisir de manipuler aussi bien tout le registre (EAX), que des sous registres : par exemple AX qui représente les 16 bits de poids faible d'EAX.
Dans notre cas, vu que nous sommes sur une architecture 32 bits, on manipulera des registres 32 bits pour référencer des adresses (EIP, EBP, ESP), puisqu'une adresse s'exprime sur quatre octets. En revanche, pour des opérations de calcul avec des entiers de type short par exemple (2 octets), le processeur utilisera seulement AX, BX, ... que la totalité du registre en taille.

Le processeur alternant entre les différents processus démarrés (en réalité des threads) pour leur allouer un certain temps d'exécution en millisecondes (ce qu'on appelle une commutation de contexte et qui donne l'impression à l'utilisateur que tous les processus s'exécutent en même temps grâce à la vitesse incroyable du CPU), le processeur se doit de sauvegarder certaines informations concernant chaque processus afin de pérenniser leur exécution : état des registres processeur, ressources allouées au processus (fichiers, stockets), et quelques autres informations.
Cet ensemble d'informations concernant tel processus à un instant T s'appelle le contexte d'exécution du processus (à l'instant T). Ce contexte est par ailleurs sauvegardé dans la PCB (Process Control Block). Lors d'une commutation de contexte du processus A vers le processus B, le processeur sauvegarde le contexte d'exécution du processus A dans la PCB et charge de la PCB celui du processus B au dernier instant T où il s'exécutait dans le processeur.

Revenons à notre pile...
Comme dit plus haut, les données sont empilées puis dépilées de la pile en suivant la loi du LIFO (comme pour une pile d'assiettes). Quand on se trouve dans la fonction main du programme, le registre BP référence la base du bloc de pile et le registre SP pointe sur le sommet de la pile (dynamique, sa valeur change quand des données sont empilées/dépilées), tout cela pour la fonction main. Lorsqu'on appelle une fonction à partir du main (par exemple swap ici), des données vont être empilées sur la pile, la valeur du registre SP va être modifiée, et le registre IP va être modifié pour pointer sur la prochaine instruction à exécuter dans la fonction appelée. Pour pouvoir revenir dans la fonction principale et savoir quelle est la prochaine instruction à exécuter et quel est l'espace de pile correspondant, le processeur doit automatiquement sauvegarder ces informations.

Un appel de fonction va être réalisé de la manière suivante :
- Le processeur va empiler les paramètres de la fonction si existant (ici, une copie de a et de b),
- Le processeur va empiler la valeur du registre EIP avant de brancher sur la fonction appelée (pour savoir sur quelle instruction se brancher après le retour de la fonction),
- Le processeur va empiler la valeur du registre EBP (pour retrouver ensuite la base de la pile de la fonction principale),
- Le processeur va mettre le contenu du registre ESP dans le registre EBP : la base de pile de la fonction appelée correspond au sommet de pile de la fonction appelante. En effet, les espaces de pile de chaque fonction sont distincts.
- Le processeur alloue de la mémoire (de l'espace dans la zone de pile) pour la fonction appelée (ses variables locales, etc).

Voici un schéma qui explique un peu ces notions ...


Notez que ce schéma n'est pas exact, notamment au niveau de l'adressage. Mais c'est pour faire un exemple.

Comme vous pouvez le remarquer sur ce schéma. La pile est particulière : Quand on empile quelque chose dessus, l'adresse d'ESP diminue. En fait, la pile croît en diminuant d'adresse, et décroît en augmentant l'adresse. La pile fonctionne donc à l'envers, il ne faut pas chercher plus compliqué.
De ce fait, la base de pile du Main se trouve tout en haut du schéma (fixe jusqu'à que le processus soit terminé). La fonction appelante (Main) empile les paramètres nécessaires au bon fonctionnement de la fonction appelée (param1 et param2). Il empile donc des copies de b et de a. Ces paramètres sont empilés dans l'ordre inverse de la lecture du prototype en C, puisque la pîle croît à l'envers, enfin ce n'est pas important : b est empilé, puis a, donc la copie de a est plus proche du sommet de pile (ESP) que la copie de b. Lors de l'appel de fonction (call), le contenu d'EIP est sauvegardé automatiquement (à la fin de la fonction appelée, le processeur se branche dessus).
Enfin, dans la fonction appelée (swap), s'effectue le prologue : la fonction appelée prépare son cadre de pile. Pour cela, elle sauvegarde la base de la pile de la fonction appelante, pour la lui restituer à la fin de son exécution. Puis, elle fait du sommet de pile de la fonction appelante (en comptant dedans la sauvegarde d'EIP, EBP..) sa base de pile. Enfin, elle s'alloue de la mémoire pour stocker ses variables locales et toutes autres informations pour son bon fonctionnement.
On voit bien cette étape sur le schéma : EBP est empilé sur la pile (sauvegarde), le cadre de pile est préparé avec l'équivalence à gauche esp main = ebp swap, puis la variable locale tmp est alloué sur la pile (empilé à partir de l'adresse d'EBP pour la fonction swap).
À la fin de la fonction, on a l'étape de l'épilogue, réciproquement au prologue. On restitue le cadre de pile initial de la fonction appelante.
Si vous avez bien compris tout ceci, vous pouvez continuer, sinon n'hésitez pas à poser des questions !

Voici le code assembleur concret correspondant au programme ci-dessus obtenu avec GCC options -S (sans pointeurs) et qui correspond par ailleurs à mon schéma de pile (approximativement). Toutes les lignes sauf exceptions sont commentées.
Le code assembleur ci-dessous est à la norme AT&T, plus claire, je trouve, que la norme Intelx86.

Pour pouvoir comprendre mes commentaires, je vous fournis les indications suivantes (informations sur des instructions assembleur).
N.B : <InstructionL> signifie <Instruction Long>, Long représentant un entier de 4 octets dans notre cas !

Instruction MOVL <Source ou Valeur>, <Destination> : Copie la valeur ou le contenu de la source vers la destination.
Exemples :
MOVL %eax, -4(%ebp) : Copie le contenu du registre %eax à l'adresse pointée par %ebp -4.
MOVL $40, 24(%esp) : Copie la valeur 40 (entier de 4 octets) à l'adresse pointée par esp + 24.

Note :
- %registre != (%registre). %registre signifie le contenu du registre. (%registre) signifie l'adresse pointée par le contenu du registre.
- 24(%esp), -4(%esp). Ici, l'adressage est relatif par rapport à l'adresse pointée par le registre. Pour le premier cas, la donnée est donc copiée à l'adresse pointée par le registre ESP + 24. Pour le deuxième, à l'adresse d'ESP - 4.

Instruction PUSHL <Donnée> : Empile (sur le sommet de la pile) la donnée. Si <Donnée> n'est pas une valeur absolue ($25 par ex), c'est une copie qui est empilée.
Exemple :
PUSHL %ebp : Empile une copie du contenu du registre EBP sur le sommet de la pile. Cette instruction est équivalente au bloc suivant :

--- Code: ---MOVL %ebp, -4(%esp)
SUBL 4, %esp
--- Fin du code ---
Pourquoi -4 ? On rappelle que la pile croît à l'envers et qu'on empile une donnée de 4 octets, on copie donc la donnée à l'adresse pointée par le registre ESP -4 octets (sur le sommet de la pile donc). La donnée va occuper cet espace mémoire : [(%esp) - 4, (%esp) - 3, (%esp) - 2, (%esp) - 1]. Pas (%esp) qui contient la dernière donnée empilée. Pour bien comprendre, regardez le schéma de pile au-dessus.
Il faut raisonner à l'envers ;) (SUB = Substract = Soustraire)

Instruction POPL <Destination> : Dépile une donnée de 4 octets du sommet de la pile pour la mettre dans la destination.
Exemple :
POPL %ebp : Dépile la donnée de 4 octets du sommet de la pile (ici, une ancienne valeur du registre EBP) pour la placer dans le registre EBP.
Cette instruction est équivalente au bloc suivant :

--- Code: ---MOVL (%esp), %ebp
ADDL 4, %esp
--- Fin du code ---
Même logique qu'avant... On copie le contenu se trouvant à l'adresse pointée par ESP (de (%esp) à (%esp)+3) dans le registre ebp. On ajoute 4 à esp. (ADD = Additionner)

Ce sont les instructions asm les plus basiques que l'on retrouve partout.

Maintenant, vous êtes prêt à regarder ce fichier en langage d'assemblage (premier programme sans pointeurs). Reportez-vous d'abord à la fonction main : __main, puis remontez à swap : __swap.


--- Code: --- .file "test.c"
.text
.globl _swap
.def _swap; .scl 2; .type 32; .endef
_swap:
pushl %ebp           // On empile %ebp = met %ebp au sommet de la pile, et le registre %esp diminue automatiquement
movl %esp, %ebp     // On copie le contenu de %esp dans %ebp
subl $16, %esp      // On alloue 16 octets sur la pile
movl $0, -4(%ebp)   // On met la valeur 0 dans la variable tmp (alloué à %ebp -4)
movl 8(%ebp), %eax  // On met param1 (copie de a) dans le registre %eax. 8(%ebp), parce qu'il y a EIP et EBP empilé dessus.
movl %eax, -4(%ebp) // On met le contenu de %eax (copie de a) dans la variable tmp (%ebp - 4)
movl 12(%ebp), %eax // On met param2 (copie de b) dans le registre %eax
movl %eax, 8(%ebp)  // On met le contenu de %eax (copie de b) dans la variable a (%ebp - 8)
movl -4(%ebp), %eax // On met le contenu de tmp dans %eax
movl %eax, 12(%ebp) // Enfin, on copie le contenu de %eax (tmp) dans la variable b
leave                  // Dépile %ebp pour le mettre dans %esp, ce qui correspond à faire : movl %ebp, %esp
       //     popl %ebp
ret                    // Dépile l’adresse de retour pour la mettre dans le registre %eip.
       // Similaire à popl %eip (sauf que le développeur ne peut pas directement accéder à ce registre me semble-t-il.
.def ___main; .scl 2; .type 32; .endef
.section .rdata,"dr"
.align 4
LC0: // Chaîne à passer en premier paramète à printf.
.ascii "a vaut maintenant %i et b vaut maintenant %i\12\0"
.text
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp     // %ebp = %esp
andl $-16, %esp
subl $32, %esp      // On alloue 32 octets sur la pile pour stocker les variables locales, empiler des paramètres ... (baisse l'adresse d'esp de 32)
call ___main
movl $20, 28(%esp)  // a = 20. a est donc stocké dans l'espace alloué, à 28(%esp). Autrement dit de (%esp)+28 à (%ebp) = (%esp)+32
movl $40, 24(%esp)  // b = 40.
movl 24(%esp), %eax // On empile une copie de b
movl %eax, 4(%esp)  // sur la pile (%esp + 4)
movl 28(%esp), %eax // On empile une copie de a
movl %eax, (%esp)   // sur la pile (%esp)
call _swap          // Sauvegarde %EIP (prochaine instruction après l'appel à swap) et  branchement sur l'adresse de SWAP (%EIP juste avant)
movl 24(%esp), %eax // On copie b dans %eax
movl %eax, 8(%esp)  // On copie %eax à %esp+8
movl 28(%esp), %eax // On copie a dans %eax
movl %eax, 4(%esp)  // On copie %eax à %esp+4
movl $LC0, (%esp)   // On copie l'adresse de la chaîne LCO à %esp
call _printf        // Appel de la fonction printf !
        movl $0, %eax    // Le main retourne 0. La valeur de retour est toujours mise dans le registre EAX.
leave
ret
.def _printf; .scl 2; .type 32; .endef
--- Fin du code ---

Bien, si vous êtes arrivés jusqu'ici, vous comprenez donc que la fonction Swap modifie bien les copies des variables a et b empilées sur la pile.

Vous vous demandez peut-être pourquoi y a-t-il cette instruction au début du main : andl   $-16, %esp.
Cela veut dire : "faire un ET logique sur le contenu du registre ESP avec le nombre -16." (et logique effectué bit à bit sur deux nombres de 4 octets donc)
Je rappelle qu'un ET logique est une opération d'algèbre booléenne. 1 & 1 retourne 1, sinon & retourne toujours 0
-16 est égal (voir nombres négatifs codé en Complément à deux) à 0xFFFFFFF0.
La conséquence immédiate est que les 4 bits de poids faible du registre ESP sont mis à 0.
Autrement dit, l'adresse contenue dans %esp est alignée sur 16 octets.
Pourquoi ? Les processeurs fonctionnent plus vite avec des accès mémoire alignés. Le bus de données du processeur étant sur 128 bits, soit 16 octets, les données et instructions sont cherchées par blocs de 16 octets, d'où l'intérêt d'aligner les données de base sur une adresse multiple de 16 octets pour optimiser les accès mémoire. Donc des blocs de 2 octets (short) sont mieux lus sur des adresses paires, de 4 (int) sur des adresses multiples de 4, de 8 (double) sur des adresses multiples de 8.
Le blocs de 16, eux, ne correspondent à aucun type du langage C. Cependant, ils sont essentiels à toutes les opérations SSE du processeur Intel, etc (sachant que les processeur évoluent de plus en plus).

Le compilateur GCC garantit qu'après la fonction main, le cadre de pile reste aligné sur un multiple de 16 octets, ce qui entraine souvent l'allocation d'un cadre de pile plus gros que la normale. Cependant, la pile n'a besoin d'être arrondie que dans le prologue de la fonction main..
Vous remarquerez donc que vous aurez subl $16, %esp, ou $32 .. mais jamais un nombre qui n'est pas multiple de 16.

Si je fais par exemple cette opération et que %esp vaut 0x22FFFF07, il vaudra après 0x22FFFF00.
Il convient donc que %esp et %ebp ne sont plus égaux après cette opération, sauf si %esp avait déjà ses 4 derniers bits à 0.

Maintenant, voyons ce que cela donne avec le fichier C, comprenant les pointeurs. Je commente cette fois seulement les différences !


--- Code: --- .file "testP.c"
.text
.globl _swap
.def _swap; .scl 2; .type 32; .endef
_swap:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
movl $0, -4(%ebp)
movl 8(%ebp), %eax  // On copie le contenu de 8(%ebp) dans %eax, qui est en fait l'adresse de la variable a.
movl (%eax), %eax   // On copie le contenu de l'adresse pointée par %eax (adresse de b) dans %eax
movl %eax, -4(%ebp) // On copie ce contenu dans la variable locale tmp située à -4 (%ebp)
movl 12(%ebp), %eax // On copie le contenu de 12(%ebp) dans %eax, qui est en fait l'adresse de la variable b.
movl (%eax), %edx   // On copie le contenu de l'adresse pointée par %eax (adresse de b), autrement dit b, dans le registre EDX.
movl 8(%ebp), %eax  // On copie le contenu de 8(%ebp) (&a) dans %eax.
movl %edx, (%eax)   // On copie le contenu d'EDX (contenu de la vraie variable b) dans l'adresse pointée par EAX (l'adresse de A).
movl 12(%ebp), %eax // On copie le contenu de 12(%ebp) dans %eax (adresse de b)
movl -4(%ebp), %edx // On copie le contenu de tmp (ancien contenu de la variable a) dans le registre EDX.
movl %edx, (%eax)   // Enfin, on copie ce contenu (ancien contenu de la variable a) dans l'adresse pointée par %eax (&b), autrement dit dans b.
leave
ret
.def ___main; .scl 2; .type 32; .endef
.section .rdata,"dr"
.align 4
LC0:
.ascii "a vaut maintenant %i et b vaut maintenant %i\12\0"
.text
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp
andl $-16, %esp
subl $32, %esp
call ___main
movl $20, 28(%esp)
movl $40, 24(%esp)
leal 24(%esp), %eax // Au lieu de copier le contenu de 24(%esp) qui correspond à b, on copie l'adresse. (LEAL)
movl %eax, 4(%esp)
leal 28(%esp), %eax // Idem qu'au-dessus.
movl %eax, (%esp)
call _swap
movl 24(%esp), %edx
movl 28(%esp), %eax
movl %edx, 8(%esp)
movl %eax, 4(%esp)
movl $LC0, (%esp)
call _printf
movl $0, %eax
leave
ret
.def _printf; .scl 2; .type 32; .endef

--- Fin du code ---

Voilàà ! Pour matérialiser les pointeurs en C, l'asm se sert de deux registres (%eax et %edx par ex), un pour stocker l'adresse de la variable qu'il veut modifier, et l'autre pour stocker le contenu qu'il souhaite y mettre).
Ensuite, plus qu'à faire : MOVL %registre_contenant_la_valeur, (%registre_contenant_l'adresse_de_la_variable_a_modifier).

igor51:
Up

Navigation

[0] Index des messages

Sortir du mode mobile