J'apprends la programmation avec l'assembleur x86, partie 5
Cette partie 5 est consacrée à l'écriture d'une fonction et à l'utilisation de la pile évoquée dans le chapitre précédent.
Et pour vous parler de tout ça, quoi de mieux que de se lancer un petit challenge, écrire un programme qui va afficher ça :
999999999
88888888
7777777
666666
55555
4444
333
22
1
FFFFFF
EEEEE
DDDD
CCC
BB
A
C:\_
On affiche 9 fois le caractère '9', 8 fois le caractère '8', etc, jusqu'au caractère '0' qu'on va afficher... 0 fois.
Et puis rebelotte avec les lettres mais on se contentera d'aller de 'F' à 'A', parce que ça suffira à étayer nos propos.
Être pro, c'est dur
On va devoir mettre en place plusieurs boucles, dont une qui sera répétée de nombreuses fois : celle qui affiche X fois le même caractère.
Cette boucle sera placée dans une fonction, ce qui nous permettra :
- d'éviter de l'écrire deux fois, une pour les nombres et une pour l'alphabet
- de rendre notre code plus lisible
- de me permettre de vous expliquer les fonctions, ce qui, avouons-le, est bien pratique
Bon techniquement, notre fonction est une procédure car elle ne retourne pas de valeurs à exploiter, elle va juste afficher des caractères, mais on n'est pas là pour chippoter.
La partie de code suivante correspond à la boucle pour afficher les nombres :
mov dl, '9' ; le service 0x02 de l'int 0x21 affiche le caractère dans dl
mov al, '1' ; le dernier caractère à afficher
loop_number:
call repeat_write ; on appelle notre fonction
dec dl ; on passe au caractère précédent
cmp dl, al ; on vérifie si c'est le dernier
jge loop_number ; si ce n'est pas le cas, on relance la boucle
Une introduction ret/call
Rien de bien neuf à part l'appel à la fonction repeat_write qui se fait avec l'instruction call nom_de_fonction.
Avec l'assembleur NASM, une fonction sera définie ainsi :
nom_fonction:
instructions
instructions
instructions
instructions
...
ret ; on quitte la fonction
L'instruction importante à retenir est ret qui se situe à la fin - le derrière - de la fonction est permet de retourner dans notre programme à la ligne qui suit l'appel à cette fonction :
call repeat_write ; on appelle notre fonction
dec dl ; après l'exécution de ret, on quitte la fonction et on arrive ici
Faut respecter l'adresse code
Les instructions exécutées par le CPU se trouvent toutes à l'adresse cs:ip. Vous connaissez déj́à cs, le segment de code, et voici maintenant son comparse, ip (instruction pointer) qui correspond à l'offset de l'instruction.
Au fur et à mesure du déroulement du programme par le CPU, ip est incrémenté de la taille des instructions exécutés. Ainsi cs:ip représente toujours la prochaine instruction qui sera traitée.
Du coup, si on change la valeur de ce couple cs:ip, on peut choisir quelles instructions seront exécutée. C'est exactement ce que fait les instructions de sauts que nous avons vu précédemment - loop, jmp, etc... - mais aussi l'instruction call, avec cependant une subtile différence, l'usage de la pile pour conserver la position de l'instruction suivante.
La pile ne fait pas le moine
Quand on fait un saut, on passe à un nouvelle offset mais on ne retient pas la position du code après ce saut.
Un appel à une fonction suit ce processus :
- call nom_fonction conserve la valeur ip, position de la prochaine instruction, sur la pile puis saute vers nom_fonction.
- les instructions de la fonction sont exécutées, puis on arrive à
- ret qui récupère la valeur d'ip sur la pile et saute justement vers cet ip.
Petite cerise sur le plateau, dans notre programme la fonction repeat_write modifie les valeurs des registres ax et dx lors de son exécution. Et comme nous avons besoin de conserver ces valeurs pour notre boucle d'affichage, nous allons aussi pousser ces deux registres sur la pile, puis les retirer avant de quitte repeat_write.
Voici à quoi va ressembler notre pile lors de l'appel de la fonction :
- avant l'appel à call :
... | ← sp |
... |
Les valeurs stockées sur la pile sont inconnues. Notez qu'il existe le registre sp - Stack Pointer - qui correspond à l'offset de la position du sommet de la pile en mémoire. Le segment de la pile est ss, Stack Segment.
- on exécute call :
call repeat_write
IP | ← SP |
... | |
... |
-
dans la fonction, on pousse dx et ax sur la pile :
repeat_write: push dx push a ...
- on effectue les instructions de la fonction repeat_write
- on récupère dx et ax:
... pop dx pop ax ...
IP | ← SP |
... | |
... |
- et on quitte la fonction :
... ret
... | ← SP |
... |
Il est important de noter qu'un appel de fonction permet de changer non seulement d'offset de code, mais aussi de segment de code. Ce sont des appels long utilisant les instructions call far nom_fonction et retf qui stockent sur le pile cs et ip.
On n'utilisera pas de tels appels dans nos programme .COM qui n'utilise qu'un segment de mémoire.
Ne jamais croiser les effluves
Alors que la valeur de l'offset ip augmente au fur et à mesure de l'exécution du programme, la valeur de sp diminiue au fur et à mesure qu'on stocke des valeurs sur la pile.
Pour éviter que le contenu de la pile n'écrase des instructions ou des données, l'adresse ss:sp correspond au début du programme à la fin du segment de code cs.
Il faudra faire attention à ce que données et instructions ne croisent pas la pile en mémoire, sinon le programme risque de crasher.
Le code source de function.asm
; ========
; function
; ========
;
; nasm -fbin function.asm -o function.com
org 0x100
global main
section .text
main:
mov dl, '9' ; le service 0x02 de l'int 0x21 affiche le caractère dans dl
mov al, '1' ; le dernier caractère à afficher
loop_number:
call repeat_write ; on appelle notre fonction
dec dl ; on passe au caractère précédent
cmp dl, al ; on vérifie si c'est le dernier
jge loop_number ; si ce n'est pas le cas, on relance la boucle
mov dl, 'F' ; même processus mais en partant du caractère F
mov al, 'A'
loop_alphabet:
call repeat_write
dec dl
cmp dl, al
jge loop_alphabet
mov ax, 0x4c00 ; on termine le programme sans erreur
int 0x21
repeat_write:
push dx ; dx va être modifié dans cette fonction, on le conserve sur la pile
push ax ; idem pour ax
xor cx, cx ; on met cx à 0
mov cl, dl ; cl correspond à notre caractère : pour '9' c'est le code ASCII 57
dec al ; al est le caractère de fin -1 : pour '1' ce sera 49 - 1 = 48
sub cl, al ; cl sera donc 57 - 48 = 9 répétitions
write:
mov ah, 0x02 ; service 0x02 qui affiche le caractère dans dl
int 0x21 ; on appelle l'interruption
loop write ; on répète tant que cx > 0
mov dl, 13 ; il faut écrire deux caractères
int 0x21 ; le 13 et le 10
mov dl, 10 ; pour faire un retour à la ligne
int 0x21 ; sous MS-DOS
pop ax ; on récupère la valeur que ax avait au début de la fonction
pop dx ; idem pour dx
ret ; on quitte la fonction, et on revient dans la boucle loop_number puis loop_alphabet
On compile :
C:\nasm -fbin function.asm -o function.com
On exécute :
C:\function
Et on obtient l'incroyable résultat présenté en haut de cette page.
Pour m'entrainer
Écrivez un programme qui échange les valeurs de ax et de bx en utilisant uniquement les instructions push et pop.
(au début du programme on mettra 0x01 dans ax, et 0x02 dans bx en utilsant mov)
Solution
Et après...
Découvrez les joies du mode graphique dans notre partie 6 !