3.2. Les fonctions

Notre découverte de l'assembleur nous montre bien qu'au bout d'un moment les programmes doivent devenir plutôt indigestes... C'est pourquoi, comme avec tout autre langage, il est possible de structurer son code en mettant en place des fonctions[1].

Une fonction utile, par exemple, pourrait prendre en charge l'affichage d'une chaîne. Voici donc notre coucou_asm.asm de la Section 1.3 un peu plus structuré[2]:

Exemple coucou_func_asm.asm


	section .text
	        global _start
               
	_start:
	        push dword msg_len
	        push dword msg
	        call write_screen
	        add esp,8
       
	        mov eax,1
	        int 80h

	write_screen:
	        push ebp
	        mov ebp,esp

	        mov eax,4
	        mov ebx,1
	        mov ecx,[ebp + 8]
	        mov edx,[ebp + 12]
	        int 80h
	
	        mov esp,ebp
	        pop ebp
	        ret
	       
	section .data
	        msg db "coucou",0x0A
	        msg_len equ $ - msg
	

Ca n'allonge notre code que de 68 petits octets, et c'est tout de même plus propre (si l'on doit faire un programme plus utile évidemment qui aura recours plus d'une fois à cette fonction).

C'est la façon la plus propre de coder une fonction en assembleur. Le prologue :


	  push ebp
	  mov ebp,esp
	
et l'épilogue:

	  mov esp,ebp
	  pop ebp
	
permettent à notre fonction de créer son propre espace sur la pile pour le cas ou nous aurions à nous en servir. C'est assez simple à comprendre. Nous avons vu dans le Tableau 2-3 qu'il existe un registre qui contient en permanence l'offset du sommet de la pile : ebp . Donc notre programme, pour savoir ou commence la pile, se fie toujours à ce registre. Ce que nous faisons à l'entrée de notre fonction permet de déplacer la base du nouvel espace de notre pile à la fin de la pile effectivement utilisée par le programme appelant, en nous fiant à esp qui pointe la dernière valeur empilée par l'appelant.

Pour récupérer chacun des arguments passés à notre fonction, on utilise l'adressage indirect, relatif à la base de notre nouvelle pile, en remontant pour aller les chercher sur le sommet de la pile du programme appelant. On voit donc que le dernier argument empilé par l'appelant sera accessible via le plus petit déplacement (ici, 8).

La question qui se pose est : pourquoi 8 ? En fait, l'instruction call à demandé un petit travail supplémentaire au processeur : l'empilement discret du contenu du registre eip, qui contient l'offset d'exécution de l'appelant au moment de son appel. call va donc empiler la valeur d'eip et ret s'en servira pour sauter vers cet offset une fois notre fonction terminée.

Après avoir poussé nos deux arguments, notre pile ressemble donc à :

Tableau 3-1. Etat de la pile après empilement des arguments

RegistresPileOffset factice (en décimal)
ebp->msg255
esp->msg_len251

Lorsque nous faisons appel à call, cette instruction modifie la pile comme suit :

Tableau 3-2. Etat de la pile après instruction call

RegistresPileOffset factice (en décimal)
ebp->msg255
 msg_len251
esp-> eip247

Ensuite, après la première ligne du prologue (push ebp) :

Tableau 3-3. Etat de la pile après la première ligne du prologue

RegistresPileOffset factice (en décimal)
ebp->msg255
 msg_len251
eip247
esp-> ebp243

et après la seconde ligne du prologue (mov ebp,esp) :

Tableau 3-4. Etat de la pile après la seconde ligne du prologue

RegistresPileOffset factice (en décimal)
 msg255
 msg_len251
eip247
ebp = esp-> ebp243

On sauvegarde donc ebp pour pouvoir retrouver la base de notre ancienne pile dans le programme appelant, et on l'initialise avec esp après lequel nous pouvons empiler tout ce que l'on voudra :-)

C'est une bonne habitude à prendre, et puis cela permet également de faire des fonctions récursives qui ne crachent pas dès le premier retour d'imbrication...

On comprend à présent pourquoi il est nécessaire d'ajouter 8 octets à ebp pour accéder à nos arguments : c'est qu'entre le début de notre nouvelle pile et la fin de celle de l'appelant, le processeur à stocké l'adresse de retour.

Il faudra bien évidemment penser à sauvegarder tous les registres que nous utiliserons au sein de notre fonction et à les restaurer avant l'épilogue. Il faut toujours avoir à l'esprit la restitution en l'état de la pile et des registres au programme appelant.

Pour les fonctions qui retournent une valeur, la convention veut que la valeur retournée le soit dans eax[3].

Avertissement

Gardez bien à l'esprit tout de même que, s'il est vrai que les fonctions permettent une meilleure lisibilité et une meilleure maintenance du code, elle le ralentissent également[4]. Si c'est donc la plus grande rapidité possible que vous recherchez et que votre code n'est après tout pas si long que ca, n'hésitez pas à écrire le même code trois ou quatre fois au lieu de l'encapsuler dans une fonction. Le programme généré sera bien sûr plus long, mais aussi plus rapide. A vous de voir.

Notes

[1]

Que les pascaliens nous excusent, nous ne ferons pas la différence entre fonction et procédure ici, et suivrons plutôt le vocabulaire C.

[2]

Compilez avec nasm -f elf coucou_asm.asm ; ld -s coucou_asm.o -o coucou_asm

[3]

C'est en tout cas ainsi que procèdent les fonctions système Linux.

[4]

Les instructions call, ret, ainsi que toutes les opérations impliquant la pile sont très coûteuses en cycles processeur.