GERBELOTBARILLON.COM

Parce qu'il faut toujours un commencement...

L'assembleur

Le document qui suit va décrire les composants que l'on retrouve sur une machine équipée de composants Intel-like et dont le système d'exploitation sera Linux, les architectures des ordinateurs étant bien différentes si l'on utilise une plateforme de type ARM ou une plateforme de type Motorola, ou Power, ou Mac...

Pour écriture des programmes en langage d'assemblage, les outils utilisés seront NASM pour la génération de langage machine à partir d'un code source assembleur, ainsi que l'outil bien connu GCC pour l'édition de liens et donc la production d'un exécutable fonctionnel sous Linux en version 64 bits.

Introduction


Le langage d'assemblage est situé une couche au-dessous du langage C, déjà bien proche de la machine. Il est une représentation "humainement compréhensible" du langage machine. Il est tellement proche du système qu'il existe un langage d'assemblage pour chaque processeur. Il a été inventé vers le début des années 1950 et l'un des premiers ordinateurs à recevoir un programme écrit en assembleur fut l'IBM 701 en 1954. C'était un ordinateur qui coûtait la bagatelle de 8000 US$ par mois soit environ 80000 de nos dollars d'aujourd'hui avec l'inflation. Utilisé pour le calcul scientifique, l'IBM 701 a été devancé par l'Univac 1103 pour le calcul météorologique. Mais je m'égare...

Autant le dire tout de suite, l'assembleur est compliqué, voire très compliqué. Ce n'est pas aussi simple à utiliser que du Python mais c'est extrêmement plus rapide et efficace que l'intégralité des autres langages du fait que, lors de la programmation en assembleur, c'est comme si vous programmiez directement le processeur de votre ordinateur. Il n'y a pas d'intermédiaire entre votre code et votre machine. ATTENTION : tout peut se faire en assembleur mais il n'y a absolument aucun garde-fou sur ce que vous pouvez faire et les bug peuvent être terribles...

Il y a plusieurs syntaxes utilisées par les langages d'assemblage en fonction des architectures des micro processeurs : Intel, Motorola, AT&T, MIPS, ARM... et donc des jeux d'instructions distincts. L'aasembleur dispose d'opérations élémentaires permettant de manipuler la mémoire, de réaliser des calculs, des comparaisons et des sauts dans la mémoire.

Mais pourquoi utiliser un langage comme celui-ci si c'est si compliqué ? Par curiosité d'esprit, du hacking, de l'optimisation de programmes existants ou autres. La courbe d'apprentissage est longue et difficile mais comme disait l'autre : pour la beauté du geste...

Les registres


Les ordinateurs d'aujourd'hui sont tous équipés de processeurs 64-bits. Cela signifie qu'ils sont capables de traiter en une fois des flots d'instructions composés de 64 bits, soit 8 octets en parallèle.

Le PC dispose de 16 registres, des zones de mémoire très rapides, situées dans le processeur lui-même. La littérature nomme ces registres des registres généraux mais en fait ils sont presque tous assignés à des tâches particulières.

Il y a 16 registres de 64 bits, que l'on peut découper en registres 32 bits, en considérant les 32 bits de poids faible. De même pour ces registres 32 bits que l'on peut subdiviser en registres 16 bits en prenant les bits de poids faible. Enfin nous pouvons obtenir les séries de registres 8 bits que nous utilisions sur nos chers ordinateurs à base de processeurs Intel 8086, 80286 ou 80386. Le schéma ci-après sera probablement plus parlant que la prose précédente...

Registre 64 bits Registre 32 bits Registre 16 bits Registre 8 bits high Registre 8 bits low Usage
RAXEAXAXAHAL Le registre RAX (Accumulatoeur) permet le passage de paramètres aux fonctions. Parmi les 4 registres RAX, RBX, RCX et RDX c'est RAX qui est généralement le plus rapide et donc conseillé pour ces transferts d'informations.
RBXEBXBXBHBL Le registre RBX (Base) est un vrai registre à usage général qui peut contenir des simples valeurs, ou pour servir de valeur de base lors des accès à des tableaux (indice 0 en quelque sorte).
RCXECXCXCHCL RCX (Compteur) est un registre plutôt utilisé dans les instructions de comptage et d'index de boucles telles LOOP, SCAS, ...
RDXEDXDXDHDL Le registre RDX (Data) est à utiliser en tant que backup du registre RAX si jamais ce dernier est déjà utilisé dans la même séquence d'instructions.
RSIESISI-SIL Utiliser le registre RSI (Source Index) lorsque des opérations de lecture en mémoire sont à réaliser.
RDIEDIDI-DIL Utiliser le registre RDI (Destination Index) lorsque des opérations d'écriture en mémoire sont à réaliser.
RBPEBPBP-BPL Le registre RBP ne doit être manipulé qu'avec précaution car il contient l'adresse de départ de la pile (Base Pointer)
RSPESPSP-SPL Le registre RSP, le pendant de RBP, contient le sommet de la pile (Sommet Pile, Stack Pointer).
R8R8DR8W-R8B
R9R9DR9W-R9B
R10R10DR10W-R10B
R11R11DR11W-R11B
R12R12DR12W-R12B
R13R13DR13W-R13B
R14R14DR14W-R14B
R15R15DR15W-R15B

Pour rappel les bits de poids faible sont ceux les plus à droite de la valeur exprimée en binaire.

Imaginons que nous ayons le nombre 42 à mettre dans le registre 64 bits rax. Les représentations binaires de ce nombre dans les différents registres seront les suivantes :

Représentation de la valeur 42 dans les registres
rax 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00101010
eax 00000000 00000000 00000000 00101010
ax 00000000 00101010
ah 00000000
al 00101010

Les registres, même s'ils sont définis comme généraux, disposent pour certains d'affectations préférentielles :

rax, rbx, rcx, rdx Registres généraux pour le stockage des différentes valeurs et paramètres pris en charge dans les appels fonctionnels et les retours de ces fonctions.
rbp, rsp Registres utilisés pour la gestion du pointeur de pile. rbp est le Base Pointer (début de la stack mémoire) et rsp est le Stack Pointer (le haut de la pile en mémoire).

Un registre existe également pour la gestion des flags. Un flag est un indicateur qui spécifie ce qu'il s'est passé suite à un calcul, un retour de fonction, une valeur nulle obtenue dans un calcul, une retenue est apparue sur une opération, ... Pour le nom du registre, c'est tout simplement rflags. C'est un registre dont certains bits vont donc être modifiés pour refléter le résultat d'une action précédente (calcul, saut en mémoire, retour de fonction, ...).

Nom Symbole Bit Description
Carry CF0 L'instruction précédente a généré une retenue (addition, soustraction, ...)
Parity PF2 Le dernier octet dispose d'un nombre pair de bits à 1
Adjust AF4 Opérations BCD (Binary Coded Decimal)
Zero ZF 6 L'instruction précédente a eu un résultat nul
Sign SF 8 Bit de signe mis à 1 suite à la dernière instruction
Direction DF 10 Direction des opérations de chaines de caractères (incrément ou décrément)
Overflow OF 11 L'instruction précédente a généré un dépassement de buffer

Un autre registre RIP est utilisé pour pointer sur l'instruction en cours d'exécution. Il n'est pas possible de lire ou écrire dans le registre RIP et donc n'est pas disponible pour la programmation en général. Cependant la valeur de ce registrer est visible dans les étapes de debug.

Il existe également 8 registres de 80 bits dont l'utilisation se retrouve avec les nombres à virgule flottante et pour l'exécution d'instructions MMX 64 bits. Les processeurs récents disposent également de 8 registres 128 bits XMM pour les traitements des instructions SSE et SSE2.

Eléments d'un programme

Un programme assembleur, selon le format utilisé par NASM (d'autres assembleurs peuvent requérir d'autres syntaxes donc vérifiez bien les documentations associées aux langages d'assemblage), est composé de 3 parties :

Section data

Pour déclarer une variable, il faut se rappeler que nous allons travailler au niveau le plus bas de la machine. Donc il faudra bien spécifier le type ou, en d'autres termes, le nombre d'octets requis pour la variable. La syntaxe est relativement simple

octet  db 1 ; 1 seul octet en mémoire. Valeur mise à 1
mot    dw 0x42 ; un mot est une valeur sur deux octets (16 bits). Ici c'est la valeur hexadécimale 42 (soit 66 en décimal)
entier dd 88889999d : une valeur sur 4 octets exprimée en décimal
reel   dd 37.2 ; une valeur réelle sur 4 octets
quad   dq 1.1E0 ; un quad word (8 octets = 64 bits) positionné avec une valeur réelle
tword  dt 1234567890123456 ; un tword (10 octets) positionné à la valeur décimale 1234567890123456
chaine db "hello world",0 ; déclaration d'une suite de caractères terminée par un '0' (important)

Section bss

La section bss est exactement la même que la section data, sauf que les emplacements mémoire ne seront pas initialisés avant que le programme ne soit démarré. Pour ceux qui connaissent d'autres langages comme C, nous pouvons les considérer comme des pointeurs sur des zones qui seront initialisées lorsque le besoin s'en fera sentir, et qui devront être libérées avant la fin du programme. Ce dernier point est aujourd'hui à la base de la lutte entre C et Rust quant à la gestion mémoire...

Section text

Comme le nom l'indique, cette section contiendra la code du programme dans son intégralité. Un élément important à noter est qu'il faut indiquer à l'assembleur à quel endroit va réellement commencer le code du programme. Pour cela il suffira d'une instruction : global.

L'instruction global joue le même rôle que la fonction main() dans un certain nombre de langages, main() étant la première fonction recherchée et exécutée par le compilateur. En assembleur NASM, un équivalent serait :

section .data
  msg db "hello world",0
section .bss
section .text
  global main
main:
  mov rax,60
  mov rdi,0
  syscall
Le code précédent ne fait rien à part :

Appel d'une fonction externe

Parfois il est intéressant de ne pas réinventer la roue mais de pouvoir utiliser des fonctions externes déjà existantes. Il suffit de déclarer comme extern la fonction demandée. Par exemple, une fonction très utile est printf() afin de n'être pas obligé de refaire une fonction qui permet d'imprimer tout ce que l'on souhaite. C'est la fonction du langage C que nous utilisons.

extern printf
section .data
  msg db "hello world",0
  format db "Je vous dis : %s",10, 0
section .bss
section .text
  mov rdi,format
  mov rsi,msg
  mov rax,0
  call printf ; appel de la fonction externe printf()
  mov rax,60
  mov rdi,0
  syscall ; exit()

Nous avons vu jusqu'à maintenant que nous pouvions imprimer des chaines de caractères. Mais comment traiter les nombres entiers ou flottants ? Pour leur déclaration, nous utilisons dd ou dq pour symboliser les doubles et les quad ou les flottants. pour imprimer on utilisera printf() avec la bonne chaine de formatage %lf ou %d.

Il y a une différence dans la façon d'appeler les variables numériques, par rapport aux variables textes. Nous devons encadrer la variable numérique per des crochets [ et ].

extern printf
section .data
  radius dd 357
  pi dq 3.14
  formatflt db "flottant : %lf",10, 0
  formatint db "entier : %d",10, 0
section .bss
section .text
  mov   rdi,formatflt
  movq  xmm0,[pi]
  mov   rax,1
  call  printf ; appel de la fonction externe printf()
  mov   rdi,formatint
  mov   rsi,[radius]
  mov   rax,0 ; pour utiliser xmm
  call  printf ; appel de la fonction externe printf()
  mov   rax,60
  mov   rdi,0
  syscall ; exit()

On utilise la notation entre crochets pour les valeurs numériques car printf() requiert les valeurs et pas les adresses (comme c'est le cas pour les chaines de caractères).

Debug d'un programme

Sous Linux, le debug d'un programme est généralement fait avec l'outil gdb, en ligne de commande. Sous Windows ou Mac vous aurez d'autres logiciels. Je vous laisserai chercher de votre côté.

Lorsque l'on souhaite désassembler à partir d'une étiquette (nous avons un label main dans notre code), il suffit de faire <(gdb) disassemble main. Vous pourrez apercevoir un code légèrement différent de celui que vous avez pu écrire, notamment au niveau des appellations des registres qui seront toujours ajustées au mieux pour ne pas gaspiller les espaces mémoires des registres. En effet, placer une valeur 16 bits dans un registre 64 bits constitue un gaspillage. Pour l'efficacité, l'assembleur va placer par exemple la valeur dans le registre AX plutôt que RAX.

L'intérêt d'utiliser un debugger est de pouvoir examiner des objets en mémoire ou dans des registres. Pour cela, la commande x va devoir être utilisée. Elle est un alias pour la commande examine qui va être suivie d'une adresse mémoire.

Un autre usage du debugger est de positionner des points d'arrêt sur certaines parties du programme afin de pouvoir examiner les valeurs contenues en mémoire, dans les registres, ou autre. Pour positionner un point d'arrêt il suffit de faire (gdb) break <memory_address ou label>.

Lorsque des breakpoints sont positionnés, l'affichage de gdb montrera alors la prochaine instruction qui sera exécutée après le breakpoint.

La commande (gdb) info registers va afficher le contenu de l'ensemble des registres processeur.

Pêle-même, voici d'autres commandes pour GDB :