Avertissement : Cet article a été traduit de l’anglais par un LLM. La précision n’est pas garantie. Vous pouvez lire l’article original en anglais.
Le langage assembleur (ASM) est le langage de programmation de plus bas niveau que l’on puisse trouver. Il est très proche des instructions du processeur et, par conséquent, il existe une multitude de langages assembleurs, chacun conçu pour une architecture informatique spécifique. L’ASM est utilisé dans diverses situations, comme pour les programmes sensibles aux performances, le code de démarrage des systèmes ou la rétro-ingénierie de programmes.
Dans l’article d’aujourd’hui, nous étudierons NASM (« Netwide Assembler »), qui est un langage ASM pour l’architecture Intel x86. Il peut être utilisé pour écrire des programmes 16 bits, 32 bits et 64 bits et est considéré comme l’un des assembleurs les plus populaires pour Linux (bien qu’il puisse aussi être utilisé avec Mac et Windows).
Cet article passera en revue la plupart des choses que vous devez savoir pour pouvoir commencer à coder en NASM ou pour comprendre le code NASM si vous souhaitez faire de la rétro-ingénierie de programmes. Bien que certaines choses changent selon le système d’exploitation pour lequel vous codez, les concepts généraux restent les mêmes, donc ce tutoriel peut quand même vous être utile, même si vous ne prévoyez pas d’utiliser Linux.
Comme vous pouvez l’imaginer, l’assembleur est un sujet assez long et complexe, il n’est donc pas possible de couvrir 100% des choses. Si vous voulez en savoir plus sur NASM, vous pouvez vous référer à cette documentation.
Structure d’un programme NASM#
Tout d’abord, étudions un morceau de code écrit en NASM. L’extrait ci-dessous présente un code « Hello World » et sera utilisé pour introduire certains concepts principaux et comment le code NASM est structuré. Certaines des notions seront discutées plus en profondeur dans les parties suivantes.
global _start
section .text
_start:
mov rax, 1 ; Set the function to call (1 is write)
mov rdi, 1 ; Set the first argument of write (fd 1)
mov rsi, msg ; Set the 2nd argument, the text to write
mov rdx, msg.len ; Set the 3rd argument, lengh to write
syscall ; Call write w/ previously defined things
mov rax, 60 ; Set the function (syscall) exit (id 60)
xor rdi, rdi ; Set the exit return to be 0
syscall ; Execute the syscall exit
section .data
msg: db "Hello, world!",10 ; Assign "Hello, world!\n" to the var msg
.len: equ $ - msg ; Assign len(msg) to msg.lenPremièrement, nous pouvons observer que le code est divisé en sections. Il n’y en a que deux dans notre exemple (.text et .data), mais il y en a quelques autres que vous pouvez utiliser (notez que cela changera selon le système d’exploitation cible : par exemple, Windows utiliserait .code au lieu de .text).
.textest la section où vous écrirez votre code ASM.rax,rdi,rsietrdxsont appelés registres, etmov,syscalletxorsont des instructions. Nous les verrons dans la partie suivante.datavous permet d’allouer statiquement des objets globaux et statiques initialisés pour la durée de l’exécution du programme.bssvous permet de réserver de l’espace pour des objets globaux et statiques non initialisés.rodataest identique à.dataavec la différence que les variables déclarées ici seront en lecture seule (c’est-à-dire constantes)
Les éléments suivants qui ressortent sont _start et msg. Ce sont ce qu’on appelle des étiquettes (labels). _start est l’équivalent de la fonction main dans un langage de plus haut niveau comme C ou C++. C’est là que le programme commencera son exécution. Alternativement, il est aussi possible d’utiliser des étiquettes pour définir des fonctions et comme points de saut (un peu comme goto en C - nous en reparlerons plus tard). Les étiquettes sont aussi utilisées pour définir des variables, comme msg dans notre exemple ; nous en verrons davantage plus tard également.
Une chose que vous remarquerez sous msg est le .len. Une étiquette qui commence par un point est appelée étiquette locale et sera associée à la précédente étiquette non locale. Dans notre exemple, elle sera appelée msg.len.
Le code est probablement assez explicite mais ; est utilisé pour mettre des commentaires : tout ce qui est placé après ne sera pas interprété.
Registres#
Les registres sont des emplacements de stockage conservés à l’intérieur du processeur, ce qui les rend très rapides. Il y en a 17, et certains ont une attribution d’usage spécifique (par exemple passer des arguments à une fonction). Techniquement, tous peuvent être modifiés à volonté (sauf rip), même s’il existe des conventions indiquant comment cela devrait être fait.
| 64 bits | 32 bits | 16 bits | 8 bits | Commentaire |
|---|---|---|---|---|
| rax | eax | ax | al | Pour fournir le numéro d’appel système Pour fournir la valeur de retour de la fonction Registre sauvegardé par l’appelant |
| rcx | ecx | cx | cl | 4ème paramètre de fonction Registre sauvegardé par l’appelant |
| rdx | edx | dx | dl | 3ème paramètre de fonction Registre sauvegardé par l’appelant |
| rbx | ebx | bx | bl | Registre sauvegardé par l’appelé |
| rsi | esi | si | sil | 2ème paramètre de fonction Registre sauvegardé par l’appelant Pointeur source pour les instructions de chaîne |
| rdi | edi | di | dil | 1er paramètre de fonction Registre sauvegardé par l’appelant |
| rsp | esp | sp | spl | Pointeur de pile (élément du sommet) Registre sauvegardé par l’appelant |
| rbp | ebp | bp | bpl | Pointeur de base de pile Registre sauvegardé par l’appelé |
| r8 | r8d | r8w | r8b | 5ème paramètre de fonction Registre sauvegardé par l’appelant |
| r9 | r9d | r9w | r9b | 6ème paramètre de fonction Registre sauvegardé par l’appelant |
| r10 | r10d | r10w | r10b | Registre sauvegardé par l’appelant |
| r11 | r11d | r11w | r11b | Registre sauvegardé par l’appelant |
| r12 | r12d | r12w | r12b | Registre sauvegardé par l’appelé |
| r13 | r13d | r13w | r13b | Registre sauvegardé par l’appelé |
| r14 | r14d | r14w | r14b | Registre sauvegardé par l’appelé |
| r15 | r15d | r15w | r15b | Registre sauvegardé par l’appelé |
| rip | eip | Prochaine instruction à exécuter (rip ne peut pas être accédé directement par le programmeur) |
Vous remarquerez qu’il n’y a que 6 registres pouvant être utilisés pour passer des paramètres à une fonction. Ils ne permettent de passer que des entiers ou des pointeurs. Pour passer des paramètres de plus de 64 bits, ou pour en passer plus de 6, ils doivent être poussés sur la pile, avec le premier argument au sommet.
Vous remarquerez aussi qu’il existe deux types de registres : « sauvegardés par l’appelé » et « sauvegardés par l’appelant ». Il s’agit en fait d’une convention plutôt que de quelque chose de strict, mais ce que cela signifie est que :
- Les registres sauvegardés par l’appelant (volatils) sont destinés à un usage général et à contenir des informations temporaires. Ils peuvent être réécrits par n’importe quelle sous-routine
- Les registres sauvegardés par l’appelé (non volatils) sont destinés à contenir des valeurs à longue durée de vie et doivent être préservés entre les appels. C’est-à-dire : une fonction est censée les sauvegarder dans la pile au début et les restaurer depuis celle-ci à la fin (si la fonction veut utiliser ces registres)
Instructions#
Le prochain concept important en NASM est celui des instructions, qui sont essentiellement des mots-clés nous permettant de dire à l’ordinateur quoi faire. Cette partie vise à lister les principales.
Déplacement de données#
| Instruction | Effet |
|---|---|
mov dest, src | Copier la valeur de src dans dest |
Fonctions#
| Instruction | Effet |
|---|---|
syscall | Appeler une fonction Voir le code dans la première partie ou la partie « Exécuter du code ASM et structure de fichiers plus complexe » pour plus de détails sur son utilisation. Cette page liste le code et les arguments à utiliser pour les fonctions courantes |
int code | Envoyer un signal d’interruption. Peut être une autre façon de faire syscallVoir l’exemple Linux sur Wikipedia. Les appels de fonctions pour Linux x32 sont définis dans sys/syscall.h |
call label | Permettre d’appeler une étiquette définie (c’est-à-dire une fonction - pouvant provenir d’un autre fichier) |
push item | Pousser un élément sur la pile |
pull item | Extraire un élément de la pile vers un registre |
Opérations arithmétiques#
| Instruction | Effet |
|---|---|
inc dest | dest = dest + 1 |
dec dest | dest = dest - 1 |
add dest, src | dest = dest + src |
sub dest, src | dest = dest - src |
shr dest, k | dest = dest >> k |
shl dest, k | dest = dest << k |
xor dest, src | dest = dest ^ src |
shl dest, src | dest = dest & src |
shl dest, src | dest = dest | src |
Sauts et conditions#
| Instruction | Effet |
|---|---|
jmp location | Sauter à l’emplacement (peut être un registre ou une étiquette) |
test reg, const | Comparaison bit à bit d’un registre et d’une constante. jz ou jnz doit suivre |
jz label | Sauter à l’étiquette si les bits n’étaient pas égaux à 0 |
jnz label | Sauter à l’étiquette si les bits étaient égaux à 0 |
cmp x, y | Comparer x et y. Doit être suivi de jn, jne, jg, jge, ji, ou jil |
je label | Sauter à l’étiquette si x est égal à y |
jne label | Sauter à l’étiquette si x est différent de y |
jg label | Sauter à l’étiquette si x est supérieur à y |
ji label | Sauter à l’étiquette si x est inférieur à y |
jge label | Sauter à l’étiquette si x est supérieur ou égal à y |
jil label | Sauter à l’étiquette si x est inférieur ou égal à y |
Pour plus d’exemples, vous pouvez consulter cette antisèche qui est très utile.
Types de données#
Dans l’exemple au début de cet article, nous avions la ligne de code suivante : msg: db "Hello, world!,10", et nous avons expliqué que c’était une variable msg recevant la valeur Hello, world!\n. db ici est le type de variable. Contrairement à un langage de plus haut niveau où vous auriez int pour stocker des nombres, char pour stocker un seul caractère, … les types en NASM servent simplement à indiquer combien d’espace vos données occuperont. Les types disponibles sont listés dans le tableau suivant.
| Type de données | Suffixe | Assignation de données | Taille (bits) |
|---|---|---|---|
| Byte | db | resb | 8 |
| Word | dw | resw | 16 |
| Double word | dd | resd | 32 |
| Quad word | dq | resq | 64 |
| Ten bytes | dt | rest | 80 |
| Octo Word | do | reso | 128 |
| Y Word | dy | resy | 256 |
| Z Word | dz | resz | 512 |
Dans notre exemple précédent, nous pouvons voir que nous utilisons un suffixe db, ce qui fonctionne car un caractère fait 8 bits.
Une chose que nous n’avons pas vue auparavant est la colonne d’assignation de données. Cela est utilisé dans la section .bss pour pouvoir définir combien d’espace nous voulons réserver. Par exemple :
.bss
buffer: resb 64 ; reserve 64 bytes
wordvar: resw 1 ; reserve a word
realarray: resq 10 ; array of ten realsNotez qu’il existe plusieurs façons d’écrire des valeurs en NASM. Le code suivant montre les différentes manières qui peuvent être utilisées. Remarquez que lors de l’écriture en bases autres que 10, les valeurs auront un suffixe (par exemple h pour hexadécimal, b pour binaire, …) ou un préfixe (par exemple 0x pour hexadécimal, 0o pour octal, …). Voir la partie 3.4.1 de la documentation pour plus de détails.
db 0x55 ; just the byte 0x55
db 0x55,0x56,0x57 ; three bytes in succession
db 'a',0x55 ; character constants are OK
db 'hello',13,10,'$' ; so are string constants
dw 0x1234 ; 0x34 0x12
dw 'a' ; 0x61 0x00 (it is just a number)
dw 'ab' ; 0x61 0x62 (character constant)
dw 'abc' ; 0x61 0x62 0x63 0x00 (string)
dd 0x12345678 ; 0x78 0x56 0x34 0x12
dd 1.234567e20 ; floating-point constant
dq 0x123456789abcdef0 ; eight byte constant
dq 1.234567e20 ; double-precision float
dt 1.234567e20 ; extended-precision float
mov ax,200 ; decimal
mov ax,0200 ; still decimal
mov ax,0200d ; explicitly decimal
mov ax,0d200 ; also decimal
mov ax,0c8h ; hex
mov ax,$0c8 ; hex again: the 0 is required
mov ax,0xc8 ; hex yet again
mov ax,0hc8 ; still hex
mov ax,310q ; octal
mov ax,310o ; octal again
mov ax,0o310 ; octal yet again
mov ax,0q310 ; octal yet again
mov ax,11001000b ; binary
mov ax,1100_1000b ; same binary constant
mov ax,1100_1000y ; same binary constant once more
mov ax,0b1100_1000 ; same binary constant yet again
mov ax,0y1100_1000 ; same binary constant yet againUn autre concept important en NASM est l’adresse effective : un opérande d’une instruction qui référence la mémoire. La syntaxe sera une expression contenue entre crochets. L’extrait de code suivant montre quelques exemples d’utilisation :
; Accessing a variable
msg ; The msg variable address
byte[msg] ; The value of the first byte of the variable msg
byte[msg + 1] ; The value of the second byte of the msg variable
word[msg] ; The value of the first two bytes of the msg variable
; Various operations
cmp BYTE [rdi], 0h ; Check if the first byte of rdi is 0hUne dernière chose que je souhaite aborder dans cette partie est la portion .len: equ $ - msg de notre premier exemple :
equest utilisé pour définir un symbole comme une valeur constante. Quand il est utilisé, ce sera toujours pour attribuer une étiquette. La définition est absolue et ne peut pas être modifiée ultérieurement- Le
$est l’adresse de la position actuelle. Puisque nous avons définimsgjuste avant, nous pouvons savoir que la longueur demsgsera la distance en octets obtenue paradresse actuelle - adresse de msg
Exécuter du code ASM et structure de fichiers plus complexe#
Pour terminer cet article, nous allons écrire un court programme en ASM. Il récupérera les arguments du programme, et affichera ceux-ci ainsi que leur taille. Pour les besoins de l’exemple, nous écrirons une fonction strlen dans un fichier différent du fichier main.
global my_strlen:function ; We declare the label as a global function
section .text
my_strlen: ; This is the function we will call later
xor rax, rax ; We set the return value as 0
while:
cmp BYTE[rdi], 0h ; If this is the end of the string (\0)
je end ; Then we jump to the label end
inc rax ; We increment the return value by one
inc rdi ; We continue to the next char in the string
jmp while ; We jump to the label while (start of the loop)
end:
ret ; We reached the end of the string, return raxSi vous avez tout lu jusqu’ici, rien dans la fonction my_strlen ne devrait vous surprendre. Une chose qui n’avait pas été mentionnée auparavant est que vous devez déclarer votre étiquette comme une fonction globale si vous voulez pouvoir l’appeler depuis un autre fichier. Passons à la partie principale du programme.
global main
; We use the extern keyword to be able to use the functions defined outside of the file
extern printf
extern my_strlen
section .text
main:
mov r10, rdi ; We save argv into r10
mov r11, 0 ; We initialize r11 to 0 and will use it as a loop counter
loop:
; rsi is the address of the first argv
; We use counter * 8 to be able to get the address of argv[r11]
mov r12, qword [rsi + r11 * 8]
; We call our my_strlen function and give argv[r11] as an argument
; It will return the result in rax
mov rdi, r12
call my_strlen
; The prinf call will overwrite some registers, we save them in the stack
push r10
push r11
push r12
push rsi
; We set the printf arguments, and call the function
mov rdi, printf_format
mov rsi, r11
mov rdx, r12
mov rcx, rax
mov rax, 0 ; We need to set this to 0 or the program will segfault
call printf
; Once we called printf, we restore the registers from the stack
pop r10
pop r11
pop r12
pop rsi
; We increase our counter and check that r11 < argc. If so, we jump to the loop label
inc r11
cmp r10, r11
jg loop
; We call exit(0)
mov rax, 60
xor rdi, rdi
syscall
section .data
; The string we will pass to printf as required by the prototype
; Unless write in the first example, we won't give printf the numbers of characters to write, so we need to string to end with '\0'
printf_format: db "The argument number %d ('%s') is %d characters long.",10,0Nous pouvons ensuite compiler notre programme comme suit, et voir que la sortie est celle attendue.
user@vm1:/tmp/test$ nasm -f elf64 main.S && nasm -f elf64 function.S
user@vm1:/tmp/test$ gcc -o a.out -no-pie main.o function.o
user@vm1:/tmp/test$ ./a.out 1234 123 12345
The argument number 0 ('./a.out') is 7 characters long.
The argument number 1 ('1234') is 4 characters long.
The argument number 2 ('123') is 3 characters long.
The argument number 3 ('12345') is 5 characters long.Cette fois encore, vous ne devriez pas être trop surpris par le contenu du fichier, sauf pour une chose. Nous avons mentionné précédemment que les programmes NASM devraient commencer par _start, mais notre programme ici commence par main. La raison est que nous utilisons gcc pour l’édition de liens, et il génèrera le _start lui-même avant d’appeler la fonction main.
Si nous voulions utiliser _start, nous aurions pu compiler avec ld. Le problème est que c’est moins pratique lors de l’utilisation de fonctions externes comme printf (voir ceci pour plus d’informations). Si nous voulions compiler notre premier exemple, ce serait cependant simple à faire avec ld :
user@vm1:/tmp/test$ nasm -f elf64 example.S
user@vm1:/tmp/test$ ld -o a.out example.oUne dernière chose qui pourrait vous intriguer concernant la compilation est pourquoi nous utilisons -no-pie avec GCC. La raison est que sur Ubuntu, GCC génèrera des exécutables indépendants de la position (PIE), avec lesquels notre code actuel n’est pas compatible. L’argument -no-pie indique à GCC de ne pas générer un exécutable PIE, mais nous avons aussi la possibilité de faire call printf wrt ..plt dans le code, ce qui rendrait notre code indépendant de la position.
Ressources#
- Netwide Assembler (Wikipedia)
- x86 calling conventions (Wikipedia)
- x64 Cheat Sheet (Doeppner - brown.edu)
- Notes on x86-64 programming (filliatr - lri.fr)
- The Netwide Assembler: NASM (documentation NASM)
- NASM Intel x86 Assembly Language Cheat Sheet (Bencode.net)
- Intel® 64 and IA-32 Architectures Software Developer’s Manual (Intel)
- NASM Assembly Language Tutorials (asmtutor.com)
- NASM Tutorial (lmu.edu - ray)
- Linux System Call Table for x86_64 (blog.rchapman.org)
- NASM Manual - Local labels (tortall.net)
Crédits#
- Photo de couverture par Markus Spiske sur Unsplash