codefordumm!ies: the cool logo

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 :

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 :

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 :

  1. 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.

  1. on exécute call :
    call repeat_write
    
IP ← SP
...
...
  1. dans la fonction, on pousse dx et ax sur la pile :

    repeat_write:
    push dx
    push a
    ...
    
  2. on effectue les instructions de la fonction repeat_write
  3. on récupère dx et ax:
    ...
    pop dx
    pop ax
    ...
    
IP ← SP
...
...
  1. 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 !