Assembler

Architecture x86_64 (AMD64)

Les processeurs basés sur l’architecture x86_64 (Intel Core2, AMD Phenom, …) sont dotés de 16 registres 64 bits : les 8 registres 32 bits hérités du x86 (eax, ebx, ecx, edx, esi, edi, ebp, esp) étendus à 64 bits (rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp) + 8 nouveaux registres (r8, r9, …, r15). Ces registres 64 bits sont accessibles dans leur totalité ou partiellement par l’utilisation de sous-registres. Ces sous-registres permettent d’accéder partiellement à un registre 64 bits pour y stocker seulement 32 bits (un entier int), 16 bits (un entier short) ou 8 bits (un entier char). Par exemple les 32 bits de poids faible du registre 64 bits rax sont définis par un sous-registre 32 bits nommé eax, et les 16 bits de poids faible de rax (et donc de eax) sont définis par un sous-registre 16 bits nommé ax, … Le schéma suivant présente le schéma de découpage des 16 registres 64 bits en sous-registres.

Programmation assembleur x86_64

La programmation assembleur est accessible par différents programmes appelés assembleurs chargés de transformer le code assembleur en code objet. Les exemples présentés ici reposent sur l’utilisation de l’assembleur NASM (The Netwide Assembler).

Assemblage

L’assemblage d’un fichier assembleur en fichier objet 64 bits (.o) s’effectue à l’aide de la commande suivante :
nasm -f elf64 fichier.asm

Cette commande produit le fichier objet fichier.o. Il faut ensuite effectuer l’édition de lien à l’aide de gcc pour obtenir un programme exécutable :
gcc -o exec fichier.o

Structure/Sections d’un programme assembleur NASM

Un programme est composé de différentes sections :

  • “text” contient le code du programme
  • “rodata” contient les définitions des constantes
  • “data” contient les définitions des variables globales initialisées (valeurs différentes de 0)
  • “bss” contient les définitions des variables globales non initialisées (pas de valeur ou dont la valeur est 0)

Dans un programme assembleur NASM, le début de chaque section est identifié par une ligne définie de la manière suivante :
section .nom de la section
La structure d’un programme NASM est généralement la suivante :

section .data
; define your global initialized variables here, if any

section .bss
; define your global uninitialized variables here, if any

section .rodata
; define your constants here, if any

section .text
; your code here

Déclaration de variables globales non initialisées : section bss

La déclaration de variables globales non initialisées se fait dans la section bss avec la syntaxe suivante :

nomvariable:  taille  nombre

La taille est définie à l’aide des pseudo-instructions de préfixe res :

  • resb : réserve un octet (8 bits, 1 byte)
  • resw : réserve un mot (16 bits, 2 bytes)
  • resd : réserve un double mot (32 bits, 4 bytes)
  • resq : réserve un quadruple mot (64 bits, 8 bytes)

Quelques exemples :

a: resd 1  ; a occupe 1 double mot en mémoire (32 bits)
b: resq 1  ; b occupe 1 quad word en mémoire (64 bits)
tab: resb 8  ; tab occupe 8 octets

Attention les données ne sont pas typées ! Dans l’exemple ci-dessus, la variable a peut être de type int ou float ou un autre type sur 32 bits.

Déclaration de variables globales initialisées : section data

La déclaration de variables globales initialisées est réalisée à l’aide de la syntaxe suivante :

nomvariable:  taille  valeur

La taille est définie à l’aide des pseudo-instructions de préfixe d :

  • db : réserve un octet (8 bits, 1 byte)
  • dw : réserve un mot (16 bits, 2 bytes)
  • dd : réserve un double mot (32 bits, 4 bytes)
  • dq : réserve un quadruple mot (64 bits, 8 bytes)

Les valeurs peuvent être spécifiées en notation décimale, héxadécimale, ou sous forme de caractères.
Quelques exemples :

x: dd 4
y: dq 0xffffffffffffffff ; y contient 64 bits à 1
str: db 'hello'
; str contient 5 octets, chaque octet contient la valeur d'un caractère

Déclaration des constantes : section rodata

Les constantes sont déclarées dans la section rodata (read-only data) :

section .rodata
str:	db "Hello!",10,0	; 10 pour \n, 0 pour terminer la chaîne

Les constantes, contrairement aux variables globales sont placées à l’exécution du programme dans un segment de mémoire virtuelle en lecture seule.
Les accès mémoire sont donc vérifiés et une erreur de segmentation est provoquée en cas de tentative d’écriture.

Code du programme : section text

Le code du programme, c’est à dire les instructions à exécuter, est placé dans la section text.
Des labels sont placés devant certaines instructions pour indiquer le début d’un bloc de code, par exemple une fonction :

section .text
label1 : instr1
         instr2
         ...
label2 : ...
         ...

Les appels de fonctions ainsi que les instructions disponibles sont présentés plus bas.

Importation/Exportation de symboles

Les symboles sont les noms des fonctions et des variables du programme. Un fichier assembleur peut définir et faire appel à ses propre fonctions et variables.

Il est également possible de faire appel à des fonctions ou des variables définies en-dehors du programme, par exemple si un programme doit afficher un message à l’écran il peut faire appel à la fonction printf définie dans la bibliothèque standard. Dans ce cas il faut préciser que la définition de printf est à l’extérieur du programme :

extern printf

Si un fichier assembleur fait appel à un symbole externe sans cette déclaration, son assemblage (transformation en fichier objet) s’effectuera correctement mais l’étape de l’édition de liens affichera une erreur “undefined reference to …”.

Les fonctions ou variables déclarées dans un fichier assembleur peuvent également être exportées de manière à être exploitées par d’autres fichiers. Par défaut les définitions des variables et fonctions d’un fichier assembleur sont locales à celui-ci. De manière à les rendre accessibles, il faut préciser que leur définition est globale :

global mafonction, mavariable

L’omission de cette déclaration provoquera, comme dans le cas précédent une erreur “undefined reference to …”.

Instructions assembleur

Sortir d’une fonction : ret

Le programme suivant se termine directement !

; déclaration globale du symbole main, sinon erreur
; à l'édition de lien "undefined reference to main" !
global main
section .text   ; début de la section de code
; le code de la fonction main commence ci-dessous et contient seulement
; l'instruction ret qui indique la fin de la fonction.
main:    ret

Ce programme est équivalent au programme C suivant :

int main() {}

Pour compiler le fichier assembleur précédent, en partant du principe qu’il se nomme simple.asm :

nasm -f elf64 simple.asm
gcc -o main simple.o

Déplacer des données : mov

L’instruction mov permet de déplacer des données entre 2 registres ou entre 1 registre et la mémoire.
Le code C suivant effectue une copie du contenu de la variable a dans la variable b :

int a = 4;
int b;
int main() {
  b = a;
}

Cette copie est réalisée en assembleur par l’utilisation de l’instruction mov. ATTENTION : il n’est pas possible de déplacer une donnée directement entre 2 zones de la mémoire. Il faut donc passer la valeur de a dans un registre avant de pouvoir la replacer dans b :

section .data
a: dd 5     ; int a = 5
section .bss
b: resd 1  ; int b;
section .text
main: mov eax, [a] ; contenu de a dans eax
      mov [b], eax  ; contenu de eax dans b
      ret

Les [] autour des noms des variables indiquent qu’on souhaite en récupérer le contenu, la valeur.

Opérations arithmétiques, logiques, de décalage de base

ATTENTION : Comme pour les opérations mémoire, il n’est pas possible d’effectuer des opérations entre 2 données en mémoire directement.

Quelques instructions logiques :

  • and : “et” logique bit à bit entre 2 données
  • or : “ou” logique bit à bit entre 2 données
  • xor : “ou exclusif” logique bit à bit entre 2 données
  • not : “non” logique bit à bit sur une donnée

Quelques instructions arithmétiques :

  • add : addition
  • sub : soustraction
  • mul : multiplication
  • inc : incrémentation
  • dec : décrémentation

Quelques instructions de décalage :

  • shr d, n : décalage vers la droite de n bits sur la donnée d (shift right)
  • shl d, n : décalage vers la gauche de n bits sur la donnée d (shift left)

Structures conditionnelles

La structure conditionnelle est réalisée en 2 étapes :

  1. comparaison de 2 valeurs
  2. saut vers la branche du code correspondant au résultat de la comparaison précédente

La comparaison de 2 valeurs est effectuée par l’instruction cmp qui compare deux valeurs et qui remplit les bits du registre EFLAGS.
Un bit du registre EFLAGS est par exemple destiné à stocker 1 si la première valeur passée à cmp est plus grande que la seconde et 0 dans le cas contraire.
D’autres bits du registre EFLAGS stockent également si les valeurs sont identiques ou différentes, etc.
Le contenu du registre EFLAGS n’est pas accessible directement.

Le saut vers la branche du code à exécuter en fonction du résultat de la comparaison s’effectue à l’aide de la famille d’instruction jX qui prend en argument un label au début du code à exécuter :

  • je : jump if equal
  • jne : jump if different
  • jg : jump if greater
  • jge : jump if greater or equal
  • jl : jump if lesser
  • jle : jump if lesser or equal

Les instructions précédentes récupèrent les bits qui les concernent dans EFLAGS et effectuent le branchement si les bits sont à 1, sinon le programme poursuit son exécution après l’instruction.

Exemple C :

if(a > b) {
  a+=1;
}
...

Traduction en assembleur :

        cmp r1, r2 ; a et b sont dans r1 et r2
        jg suite ; saut vers suite si r1 > r2
        jmp suite2 ; saut (jump) vers la sortie de la conditionnelle
suite:  inc r1 ; a+=1
sortie: ...

Il faut noter qu’en inversant la condition de saut on économise l’instruction jmp :

        cmp r1, r2 ; a et b sont dans r1 et r2
        jle sortie ; saut vers sortie si r1 <= r2
        inc r1 ; sinon a+=1
sortie: ...

Boucles

Les boucles sont réalisées à partir des mêmes instructions que pour la structure conditionnelle à la différence que le saut se fait en arrière vers un code déjà exécuté.
Exemple d'une boucle infinie en C :

while(1) {
}

Code équivalent en assembleur :

debut : jmp debut

Dans le cas d'une boucle for il faut ajouter un compteur :
Exemple d'une boucle for en C :

for(i = 0 ; i < 10 ; ++i) {
  ...
}

Code équivalent en assembleur :

debut : cmp r1, 10  ; r1 contient la valeur de i
        je fin   ; terminaison de la boucle lorsque i == 10
        ... ; code dans la boucle for
        inc r1   ;  ++i
        jmp debut   ; on passe à l'itération suivante
fin:

Appels de fonction

Déclaration

Une fonction assembleur est définie à partir d'un label sur sa première instruction. Une fonction se termine par l'appel à l'instruction ret qui retourne à l'instruction suivant l'appel de la fonction :

fct : ...   ; code de la fonction
      ...
      ret

Appel

Une fonction est appelée par l'instruction call suivie du label vers le début de la fonction :

      call fct

Paramètres

Les paramètres d'une fonction sont passés de différentes manières, suivant leur nombre et leur type. Se reporter au lien AMD 64 ABI dans la section liens externes pour une documentation complète sur le sujet.
Dans le cas où on souhaite passer moins de 6 paramètres entiers ou pointeurs à une fonction, ceux-ci sont passés par les registres de manière à éviter les opérations sur la pile.

Les arguments entiers sont stockés respectivement dans les registres (ou sous-parties de ces registres) suivants :

  1. rdi
  2. rsi
  3. rdx
  4. rcx
  5. r8
  6. r9

Si une fonction possède le prototype suivant int fct(int a, int* p, int b), ses arguments seront stockés respectivement dans edi pour a, rsi pour p, edx pour b.

ATTENTION : Les registes rbx, rbp, r12, r13, r14 et r15 "appartiennent" à la fonction appelante et leur contenu doit être préservé par la fonction appelée. Si ces registres sont nécessaires dans la fonction appelée, leur contenu peut être stocké sur la pile et restauré avant le retour à la fonction appelante.

Valeur de retour

La valeur de retour d'une fonction doit être placée dans le registre rax.

Utilisation de la pile

En cas d'utilisation de la pile dans une fonction, il faut s'assurer de préserver la pile de la fonction appelante. Pour cela, on sauvegarde les adresses de base et de sommet de pile de la fonction appelante stockées respectivement dans les registres rbp et rsp.

Exemple d'appel de fonction

Fonction ajoutant 1 à une valeur entière 32 bits :

...
mov edi, 5   ; paramètre de la fonction = 5
call plusun   ; plusun(5);
mov [a], eax ;  a = plusun(5)
...
plusun : mov eax, edi   ; valeur retour = valeur entrée
         inc eax ; ++valeur retour
         ret

Code équivalent en C :

int plusun(int x) {
  return ++x;
}
...
a = plusun(5);
...

Ressources

Exemples assembleur 64 bits

Liens externes

  1. Gentle Introduction to x86-64 Asembly
  2. AMD64 Application Binary Interface (pdf)
  3. NASM documentation