Consignes

Pour ce TP, vous devrez rendre une archive zip contenant les fichiers assembleur question_??.s (pour les questions pertinentes) ainsi qu'un fichier texte README.

Ce fichier README n'est pas un compte-rendu "classique". Vous devez produire un document synthétique du style "fiche de révision" contenant un résumé des notions et concepts que vous aurez explorés pendant le TP.

L'objectif est que vous produisiez une synthèse contenant les choses qui peuvent vous servir, par exemple pour l'examen !

Il n'est pas nécessaire d'apprendre toutes les instructions utilisées par cœur, mais les techniques utilisées pour les 2 premiers tiers du TP sont au programme de l'examen.

Tous vos fichiers contiendront obligatoirement une entête avec :

N'envoyez pas les fichiers exécutables ou les fichiers *.o produits par la compilation. Vous pouvez facilement les supprimer avec la commande

$ make clean

IMPORTANT: les bases de la programmation en assembleur x86 sont au programme de l'examen. Il n'est pas nécessaire d'apprendre les noms des instructions, mais vous devez savoir faire l'équivalent des questions 3, 4, 6, 7 et 9. Les concepts associés à la pile et aux appels de fonctions sont également au programme.

Liens utiles

Objectifs du TP

L'objectif de ce TP est de se familiariser avec les concepts de base du langage d'assemblage Intel x86.

1. Préliminaires

Si vous travaillez sur votre ordinateur, vous pourrez avoir des besoins de programmes qui ne sont pas toujours installés par défaut. Vous pouvez les installez avec

$ sudo apt install binutils gdb strace

Un fichier complet en assembleur ressemble à

.intel_syntax noprefix          # <--- pour activer la syntaxe intel, plus simple que la syntaxe par défaut (AT&T)
.global  _start                 # <--- déclaration du début du code à exécuter

##############
.section .text                  # <--- le segment "text" contient les instructions
                                #      d'autres segments sont data, rodata et bss

    # ...

_start:                         # <--- point d'entrée du programme ("fonction principale")
    # instructions
    # ...
    # ...

# fin du programme              # <--- fin du programme
fin:                            # NE PAS MODIFIER LES LIGNES SUIVANTES
    mov     rax, 60
    mov     rdi, 0
    syscall

L'exécution d'un programme écrit en assembleur est plus complexe que celle d'un programme Python.

  1. Il faut transformer le langage d'assemblage en langage machine, par exemple avec la commande as (comme assemble) :

    $ as -g question_00.s -o question_00.o

    (N'oubliez pas de remplacer question_00.s par le nom du fichier que vous souhaiter utiliser !)

  2. La commande précédente produit question_00.o qui est un fichier binaire contenant des instructions machines. Le fichier n'est pas "complet" car il peut encore faire référence à des commande présentes dans d'autres fichiers / bibliothèques partagées. Pour le transformer en fichier exécutable complet, on utilise la commande ld :

    $ ld -g question_00.o -o question_00
  3. On peut ensuite lancer le fichier exécutable avec

    $ ./question_00

    Comme faire des affichages (print en Python) en assembleur est assez complexe, nous ne pourrons en général pas observer ce qui se passe.

    Pour expérimenter, nous allons exécuter le programme dans un débogueur pour pouvoir exécuter les instructions une par une et observer l'état des registres à chaque étape. Pour éviter d'avoir à lancer ces commandes à chaque fois, vous pouvez télécharger le fichier Makefile et le mettre dans le même répertoire que votre fichier question_00.s. Vous pourrez ensuite utiliser

    $ make question_00.gdb

    pour compiler votre fichier question_00.s et le lancer directement dans le débogeur.

Je ne garantie pas que ce TP fonctionne sur un Mac, qui utilise une architecture différente. Si vous voulez tester, il faudra probablement

Téléchargez le fichier question_00.s et le fichier Makefile.

  1. lancez la commande

    $ make question_00.gdb

    Vous devriez arriver sur un écran ressemblant à la capture suivante, peut-être avec des couleurs différentes.

    TP5/gdb.png
  2. Le programme a été lancé, mais l'exécution est stoppée juste avant la première instruction mov rax, 3735805103. La partie haute contient les valeurs des registres, en hexadécimal et décimal ; la partie centrale contient le code assembleur du programme, avec la prochaine instruction surlignée ; et la partie basse sert a donner des commandes au débogeur.

    Pour avancer dans l'exécutions, vous pouvez utilisez les commandes suivantes dans le débogueur:

    • step, ou simplement s pour exécuter l'instruction suivante ;

    • continue, ou simplement c pour continuer l'exécution jusqu'à la fin ;

    • quit, ou simplement q pour quitter le débogueur.

    D'autres commandes sont disponibles. Toutes les commandes utilisées dans le TP sont résumées à la fin du sujet.

    Exécutez les 2 instructions suivantes (mov et add) et cherchez la valeur du registre rax.

  3. Finissez l'exécution du programme avec la commande continue, et quittez le débogueur.

Que se passe t'il si vous supprimez les 3 dernières instructions (mov, mov, syscall, après la ligne fin:) du programme ?

Cherchez et expliquez le rôle de ces lignes.

2. Premiers programmes : registres et calculs simples

L'assembleur x86 a plusieurs registres généraux que l'on peut utiliser pour les calculs: rax, rbx, rcx, rdx, rsi et rdi. Ces registres font chacun 8 octets, c'est à dire 64 bits.

Il est possible de faire une "affectation" de registre avec l'instruction mov:

REG1 est un nom de registre, et REG2 est soit un nom de registre, soit un nombre entier.

Les opérations arithmétiques usuelles sont

Comme pour l'instruction mov, REG1 est un registre (rax, rbx, etc.) et REG2 est soit un registre, soit un nombre.

La multiplication est similaire, mais avec une difficulté supplémentaire : elle modifie 2 registres !

En partant du fichier question_03.s, écrivez quelques instructions assembleurs pour :

  1. faire l'équivalent de rcx = rax + rbx + 12345 (vous ne devez pas modifier rax ou rbx) ;

  2. puis faire l'équivalent de rax = rcx * (rbx + 12345*rax) (vous ne devez pas modifier rbx ou rcx) ;

  3. puis faire l'équivalent de rbx = rcx * (rbx + 12345*rax) (vous ne devez pas modifier rax ou rcx).

Si vous avez besoin de registre auxiliaires, vous pouvez utiliser les registres r8, r9, ..., r15. (Remarque : ces registres n'existent pas en mode 32 bits.)

Pour vérifier, vous pouvez utiliser les valeurs suivantes : Les résultats devraient être :

  1. pour rcx = rax + rbx + 12345, avec

    • mov rax, 54321

    • mov rbx, 6789

    rcx doit prendre la valeur 73455,

  2. pour rax = rcx * (rbx + 12345*rax), avec

    • mov rax, 54321

    • mov rbx, 6789

    • mov rcx, 113355

    rax doit prendre la valeur 76015810176570,

  3. rbx = rcx * (rbx + 12345*rax), avec

    • mov rax, 54321

    • mov rbx, 6789

    • mov rcx, 113355

    rbx doit prendre la valeur 76015810176570.

3. Contrôle du flot : boucle et conditionnelles

Les langage d'assemblage n'ont pas de boucles for ou while. Le programmeur doit gérer le flot d'exécution à la main. Par défaut, le processeur exécute les instructions dans l'ordre. Pour modifier ceci, il faut utiliser des instructions de saut.

Pour faire en saut en fonction d'une comparaison, on utilise :

On peut aussi utiliser

  1. une comparaison cmp REG1, REG2, où REG1 et REG2 sont des registres ou des valeurs,

  2. suivie d'un saut

    • je LABEL pour aller à l'instruction annotée par LABEL si la comparaison précédente a renvoyé "égal" (e comme equal),

    • jne LABEL ... si la comparaison précédente à renvoyé "différent" (ne comme not equal),

    • jl LABEL ... si la comparaison précédente à renvoyé "plus petit" (l comme less),

    • jle LABEL ... si la comparaison précédente à renvoyé "plus petit ou égal" (le comme less or equal).

    • jg LABEL ... si la comparaison précédente à renvoyé "plus grand" (g comme greater),

    • jge LABEL ... si la comparaison précédente à renvoyé "plus grand ou égal" (ge comme greater or equal).

    Attention, il ne faut pas mettre d'autres instructions entre le cmp et le saut !

LABEL désigne une annotation que l'on peut mettre devant n'importe quelle instruction. Par exemple, _start: annote la première instruction du fichier question_00.s. Voici un exemple de saut :

.intel_syntax noprefix
.global  _start

##############
.section .text

_start:
    # ...
    # ...
    sub     rax, 1
    cmp     rax, 0
    je      fin         # si rax devient 0, on quitte le programme
    # ...
    # ...

# fin du programme
fin:                    # NE PAS MODIFIER LES LIGNES SUIVANTES
    mov     rax, 60
    mov     rdi
    syscall

Après une instruction add ou sub, on peut faire un saut sans utiliser cmp :

Pour connaitre le nombre de chiffres utilisés pour écrire un entier, il suffit de chercher la première puissance de 10 strictement plus grande au nombre en question. En Python :

def nb_chiffre(n: int) -> int:
    r = 0
    p = 1
    while p <= n:
        p = 10 * p
        r += 1
    return r
  1. En partant du fichier squelette.s, écrivez l'équivalent en assembleur x86. Vous pouvez supposez que n>0 est contenu dans le registre rdi et vous devez calculez la valeur de r dans le registre rax.

  2. La fonction Python nb_chiffre renvoie 0 pour n=0. Est-ce le cas pour votre code en assembleur ? Si non, corrigez votre code pour que la valeur finale de rax soit bien égale à 0 si rdi vaut initialement 0.

  3. Donnez en commentaire la liste des registres auxiliaires que vous modifiez.

Une manière équivalente est de compter le nombre de division par 10 nécessaires avant de tomber sur 0. En Python :

def nb_chiffre(n: int) -> int:
    r = 0
    while n > 0:
        n = n // 10
        r += 1
    return r
  1. Écrivez l'équivalent en assembleur x86. En supposant que n>0 est contenu dans le registre rdi, calculez la valeur de r dans le registre rax.

    Vous aurez besoin pour ceci de faire une division. Cette opération est plus complexe que les précédentes :

    • idiv REG, avec un unique argument, qui est obligatoirement un registre

      1. le registre REG est le diviseur

      2. le nombre à diviser est toujours dans les 2 registres rax et rdx : rax contient les bits de poids faible et rdx les bits de poids fort. Lorsque le nombre est positif et inférieur à 263, il suffit de mettre rdx à 0. (On peut aussi utiliser l'instruction cqo, qui fonctionne pour les entiers négatifs.)

      3. le quotient sera mis dans le registre rax

      4. le reste sera mis dans le registre rdx.

  2. La fonction Python nb_chiffre renvoie 0 pour n=0. Est-ce le cas pour votre code en assembleur ? Si non, corrigez votre code pour que la valeur finale de rax soit bien égale à 0 si rdi vaut initialement 0.

  3. Donnez en commentaire la liste des registres auxiliaires que vous modifiez.

Écrivez l'équivalent du code suivant en assembleur x86.

def signe(x: int) -> int:
    if x < 0:
        s = -1
    elif x > 0:
        s = +1
    else:
        s = 0
    return s

Vous supposerez que x est contenu dans le registre rdi et calculerez la valeur de s dans le registre rax.

4. La pile

Même avec les 8 registres supplémentaires r8, ..., r15 disponibles en mode 64 bits, le nombre de registre devient vite une limite dans les programme écrits en assembleur x86.

La pile est une zone mémoire allouée pour chaque programme. On peut y sauvegarder la valeur d'un registre et la récuperer plus tard.

Votre code pour calculer le nombre de chiffres d'un nombre utilise au moins 3 registres :

Faites une copie de votre fichier question_04.s en question_07.s et ajoutez les quelques instructions pour sauvegarder et restaurer la valeur de ce(s) registre(s) auxiliaire(s) autour du bloc de code correspondant à nb_chiffres.

Vérifiez que votre code fonctionne toujours et restaure bien les bonnes valeurs dans tous les registres.

Dans le débogueur, vous pouvez afficher les 5 dernières valeurs de la pile avec la commande "ss 5". Cela affichera l'adresse de chaque valeur, la valeur elle même en héxadécimal et en décimal.

  1. Dans un programme assembleur, faites plusieurs push avec des valeurs connues. Est-ce que l'empilement des valeurs se fait en augmentant ou en diminuant les adresses mémoire ?

  2. Est-ce que le nombre d'octets utilisés pour une valeur empilée dépend de la valeur ?

  3. Un des registre spéciaux est utilisé pour stocker l'adresse de la dernière valeur de la pile. Quel est se registre ?

5. Fonctions

Pour appeler une "fonction" en assembleur, il suffit de faire un saut vers le code correspondant avec jmp. Par exemple :

    # ...

# calcul du nombre de chiffres en base 10 pour rdi
# le résultat est stocké dans rax
nb_chiffres:
    # ...
    # ...


# code principal
_start:
    # ...
    mov rdi, 12345
    jmp nb_chiffres     # pour faire les calculs avec la valeur 12345
    # ...

Il y a 2 problèmes.

  1. il faut penser à restaurer les valeurs des registres auxiliaires utilisés par les instructions intermédiaires.

  2. Il faut, à la fin de la fonction, faire un saut vers l'instruction qui suivait l'appel à la fonction, càd l'instruction qui vient juste après jmp ma_fonction.

Pour le premier problème, il suffit d'utiliser push et pop (cf question précédente). Pour le second problème, on utilise 2 instructions de saut spéciales :

En partant de votre fichier contenant le code pour nb_chiffres, ajoutez / modifiez les lignes pertinentes pour faire des appels de fonction vers nb_chiffres pour les valeurs

  1. En exécutant un programme dans le débogueur, cherchez le registre spécial contenant l'adresse de l'instruction en cours. Comment s'appelle t'il ?

  2. Vérifiez que l'adresse de l'instruction suivante est bien ajouté dans la pile par l'instruction call LABEL. Décrivez ce que vous faites pour cette vérification.

  3. Que se passe t'il si vous empilez une valeur dans la pile avant d'utiliser l'instruction ret ?

  4. En regardant l'évolution du registre contenant l'adresse de l'instruction en cours, calculez la taille (en octets) des instruction x86

    • nop,

    • add rax, rbx,

    • add rax,1,

    • add rax,123456789?

    Décrivez comment vous obtenez vos résultats.

6. Pile et adresses : registre rsp

L'assembleur x86 utilise une douzaine de registre "généraux": rax, rbx, rcx, rdx, rdi, rsi, r8, r9, r10, r11, r12, r13, r14 et r15. (Les registres r8 jusqu'à r15 n'existent pas en mode 32 bits.) Il n'est donc en général pas possible d'utiliser un registre pour chaque variable d'un programme.

La solution est de stocker les variables dans la pile: un morceau de mémoire RAM où l'on peut stocker des valeurs.

Ces 2 instructions modifient le registre spécial rsp (sp comme stack pointer) :

Visuellement, voici ce qui se passe pour l'instruction push rax:

./TP5/stack-push.svg

Comme rsp donne toujours la dernière adresse de la pile, on peut récupérer

Les adresses sont notées entre crochets "[...]":

.intel_syntax noprefix
.global  _start

##############
.section .text

_start:
    push    123                 # empile 123
    push    456                 # empile 456
    # ...
    # ...
    mov     rax, [rsp]          # on récupère 123
    mov     rbx, [rsp + 8]      # on récupère 456
    imul    rax, rbx

# fin du programme
    mov     rax, 60
    mov     rdi, 0
    syscall

L'accès général à une adresse se fait avec

[REG1 + N*REG2 + OFFSET]

Il est parfois nécessaire de spécifier combien d'octet on souhaite lire à cet adresse :

  • byte ptr [REG1 + N*REG2 + OFFSET] pour un seul octet,

  • word ptr [REG1 + N*REG2 + OFFSET] pour 2 octets,

  • dword ptr [REG1 + N*REG2 + OFFSET] pour 4 octets,

  • qword ptr [REG1 + N*REG2 + OFFSET] pour 8 octets.

Écrivez un programme en assembleur qui :

À la fin, la valeur de rax doit être 4950.

Pour éviter d'avoir à avancer de plusieurs centaines d'étapes avant d'arriver au résultat, vous pouvez ajouter un "point d'arrêt" dans le débogeur:

  • breakpoint POS, ou simplement b POS pour que l'exécution s'arrête à la position POS (en général, un label du programme, ou un numéro de ligne),

  • continue, ou simplement c pour continuer l'exécution jusqu'au prochain point d'arrêt.

7. Pour aller plus loin

Appels système

L'affichage d'une chaine de caractère nécessite l'accès à un périphérique. C'est le système d'exploitation qui doit s'en charger. En assembleur, il faut explicitement faire une demande au système avec l'instruction syscall.

Chaque fonctionnalité offerte par le noyau (on parle d'appel système) a son propre numéro. Pour l'invoquer, on utilise l'instruction syscall en mettant le numéro correspondant dans le registre rax. Par exemple, pour le noyau Linux :

appel système numéro effet
read 0 lit rdx octets depuis le fichier ou assimilé rdi vers l'adresse rsi
write 1 écrit rdx octets depuis l'adresse rsi vers le fichier ou assimilé rdi
open 2 ouvre un fichier ou assimilé ...
close 3 ferme un fichier ou assimilé ...
... etc. ...

Pour afficher une chaine de caractères on utilise la sortie standard, qui est assimilée à un fichier et qui a automatiquement le numéro 1. Il faut donc

  1. mettre l'entier 1 dans le registre rax (pour spécifier write),

  2. mettre l'entier 1 dans rdi (pour spécifier stdout),

  3. mettre des octets (ASCII) dans la mémoire et stocker l'adresse du premier octet dans rsi,

  4. mettre le nombre d'octets que l'on souhaite afficher dans rdx,

  5. utiliser l'instruction syscall.

L'étape 3 est la plus complexe. On peut

  1. Écrivez votre propre version du célèbre "hello world" en assembleur x86 64 bits. N'oubliez pas de terminer votre affichage par un saut de ligne.

  2. Pour le tester, utiliser à la fois make question_12-hello.gdb, puis faite une exécution "normale" avec

    $ ./question_12-hello

    pour voir l'affichage directement dans le terminal.

La commande strace permet d'afficher (sur stderr) tous les appels système utilisé par un processus. L'option -n permet d'afficher leur numéro :

$ strace -n COMMANDE
...
... exécution de COMMANDE, en affichant les appels systèmes
... sur stderr lorqu'ils sont exécutés
...
  1. Quels sont les appels systèmes utilisés par le programme question_12-hello ?

  2. Écrivez une version du programme "Hello world" en Python. Quels sont les appels système utilisés par ce programme ? Que constatez-vous ?

Compilez et exécutez le code contenu dans le fichier question_14-mystere.s.

  1. Que se passe t'il ?

  2. Expliquez le comportement du programme en détaillant ce que font les instructions importantes.

Un "gros" programme

Pour finir, nous allons écrire un "vrai" programme en assembleur x86. Ce programme agira comme un filtre qui lit des données sur stdin, passe tous les caractères ASCII alphabétiques en majuscule, et écrit le résultat sur stdout :

$ ./question_15 < question_00.s
.INTEL_SYNTAX NOPREFIX
.GLOBAL  _START

##############
.SECTION .TEXT

_START:
    # INSTRUCTIONS
    MOV     RAX, 3735805103
    ADD     RAX, 123456
    NOP

# FIN DU PROGRAMME
    MOV     RAX, 60
    MOV     RDI, 0
    SYSCALL

Nous avons presque tous les outils pour écrire ce programme.

  1. L'appel système read permet de lire des octets dans un fichier ou assimilé :

    • son numéro est 0, il faut donc mettre 0 dans le registre rax avant d'appeler l'instruction syscall ;

    • l'entrée standard est automatiquement ouverte, et son numéro est 0, il faut donc mettre 0 dans le registre rdi avant d'appeler l'instruction syscall ;

    • comme pour write, il faut donner une adresse dans rsi. C'est à cette adresse que les octets lus seronts copiés.

    • comme pour write, il faut donner un nombre d'octets dans rdx. Attention, la zone mémoire correspondant à rsi doit pouvoir contenir au moins ce nombre d'octets !

  2. Un registre contient 8 octets. Pour travailler sur les caractères d'une chaine, il faut utiliser un seul octet. Il est possible de n'utiliser qu'un seul octet des registres traditionnels rax, rbx, rcx et rdx. Pour ceci, on utilise al au lieu de rax, bl au lieu de rbx, cl au lieu de rcx et dl au lieu de rdx. Cela permet en particulier de ne recopier qu'un seul octet lorsqu'on écrit

        mov al, [REG]       # on récupère l'octet à l'adresse REG
    
  3. Les lettres ASCII minuscules vont de 97 (correspondant à la lettre a) à 122 (correspondant à la lettre z). La lettre A a le numéro 65. Pour passer une lettre minuscule en majuscule, il suffit donc de lui soustraire 32 (32 = 97-65).

    Vous pouvez écrire un caractère ASCII entre guillemets simples "'" pour dénoter son nombre en ASCII: cmp REG, 97 est équivalent à cmp REG, 'a'.

À la fin de l'appel système, le nombre d'octets lus et copiés à l'adresse rsi est stocké dans rax. Ce nombre peut être inférieur à rdx si le fichier (ou dans notre cas, l'entrée standard) contenait moins d'octets.

Écrivez un filtre dans un fichier question_15.s qui lit toute l'entrées standard et la réécrit sur la sortie standard en passant les lettres ASCII en majuscules.

Attention, il s'agit d'un programme conséquent. (Ma version fait 50 lignes.) Ajoutez dans votre fichier des commentaires pour expliquer votre démarche et vos essais. Ceci est particulièrement important si vous utilisez l'IA pour vous aidez à écrire votre code.

L'architecture usuelle d'un tel programme ressemble en général à :

  1. on initialise un tableau ("buffer") de 4096 octets (par exemple),

  2. on rempli ce buffer avec read

    • si read renvoie "0 octet lu", on termine le programme

    • sinon, on travaille avec les octets du buffer avant de recommencer à l'étape 2.

Résumé des instructions x86

instruction sémantique arguments notes
nop rien (nop: "no operation")
mov REG1, REG2 REG1 = REG2 REG1: registre ou adresse, REG2: registre, adresse ou nombre
add REG1, REG2 REG1 = REG1 + REG2 REG1: registre ou adresse, REG2: registre, adresse ou nombre
sub REG1, REG2 REG1 = REG1 + REG2 REG1: registre ou adresse, REG2: registre, adresse ou nombre
imul REG1, REG2 REG1 = REG1 * REG2 REG1: registre ou adresse, REG2: registre, adresse ou nombre rdx est modifié par l'instruction
idiv REG rax = rax // REG
rdx = rax % REG
REG: registre ou adresse rdx doit être mis à 0 avant l'instruction
jmp LABEL saut à l'instruction après LABEL LABEL est un label
jz LABEL saut si l'instruction précédente (add ou sub) a donné 0 LABEL est un label
jnz LABEL saut si l'instruction précédente (add ou sub) n'a pas donné 0 LABEL est un label
cmp REG1, REG2 compare les 2 valeurs entières REG1 et REG2: registres, adresses ou nombres utilisé avec les sauts conditionnels
je, jne,
jl, jle,
jg, jge
saut conditionnel après une instruction cmp LABEL est un label e pour equal, ne pour not equal,
l pour less, le pour less or equal,
g pour greater, etc.
push REG ajoute la valeur d'un registre dans la pile
pop REG enlève la dernière valeur de la pile et la stocke dans un registre
call LABEL appel de fonction LABEL est un label doit être associé à des instructions return
pour retourner après l'instruction call ...
ret retourne à l'instruction suivant le dernier call ...

Résumé des commandes GDB

commande gdb abbreviation sémantique notes
step s passe à l'instruction suivante
quit q quitte gdb
continue c continue l'exécution jusqu'à la fin ou le prochain point d'arrêt
break POS b POS ajoute un point d'arrêt POS peut être un label ou un numéro de ligne
next n passe à l'instruction suivante ne rentre pas dans les fonctions (instruction call ...
showstack N ss N affiche les N dernières valeurs de la pile fonction ad-hoc, n'existe pas dans gdb par défaut