Première rédaction de cet article le 9 juillet 2023
Un peu de distraction sans objectif utilitaire à court terme : programmons en langage d'assemblage (ce qui est souvent appelé par abus de langage « assembleur ») sur un processeur RISC-V, dans un environnement Linux.
Cet article vise un public qui sait programmer mais sans avoir fait (ou très peu) de langage d'assemblage. Car oui, un problème terminologique fréquent est de parler d'« assembleur » alors que, en toute rigueur, ce terme désigne le programme qui, comme un compilateur, va traduire le langage d'assemblage en code machine. Ce langage d'assemblage est un langage de bas niveau, ce qui veut dire qu'il est proche de la machine. C'est d'ailleurs un de ses intérêts : regarder un peu comment ça fonctionne sous le capot. On lit parfois que ce langage est tellement proche de la machine qu'il y a une correspondance simple entre une instruction en langage d'assemblage et une instruction de la machine mais ce n'est plus vrai depuis longtemps. Comme un compilateur, l'assembleur ne va pas traduire mot à mot le programme. Il a, par exemple, des « pseudo-instructions » qui vont être assemblées en une ou plusieurs instructions. L'informatique, aujourd'hui, est formée de nombreuses couches d'abstraction empilées les unes sur les autres et on ne peut pas vraiment dire qu'en programmant en langage d'assemblage, on touche directement à la machine. (D'ailleurs, certains processeurs contiennent beaucoup de logiciel, le microcode.)
Pourquoi programmer en langage d'assemblage alors que c'est bien plus facile en, par exemple, C (ou Rust, ou Go, comme vous voulez) ? Cela peut être pour le plaisir d'apprendre quelque chose de nouveau et de très différent, ou cela peut être pour mieux comprendre l'informatique. Par contre, l'argument « parce que cela produit des programmes plus rapides » (ou, version 2023, parce que ça économise des ressources et que l'ADEME sera contente de cet effort pour économiser l'énergie), cet argument est contestable ; outre le risque plus élevé de bogues, le programme en langage d'assemblage ne sera pas automatiquement plus rapide, cela dépendra des compétences du programmeur ou de la programmeuse, et programmer dans ce langage est difficile. (Le même argument peut d'ailleurs s'appliquer si on propose de recoder un programme Python en C « pour qu'il soit plus rapide ».)
Chaque famille de processeurs (x86, ARM, etc) a son propre code machine (qu'on appelle l'ISA pour Instruction Set Architecture) et donc son propre langage d'assemblage (une des motivations de C était de fournir un langage d'assez bas niveau mais quand même portable entre processeurs). Je vais utiliser ici un processeur de la famille RISC-V.
Si on veut programmer avec le langage de cette famille, on peut
bien sûr utiliser un émulateur comme
QEMU, mais c'est plus réaliste avec un vrai
processeur RISC-V. Je vais utiliser celui de ma carte Star 64. Un
noyau
Linux tourne sur
celle-ci et on verra qu'on sous-traitera certaines tâches à
Linux. Commençons par un programme trivial, éditons
trivial.s
:
addi x10, x0, 42
L'instruction addi
(Add
Immediate car son dernier argument est un littéral,
immediate data) fait l'addition, la destination
est le registre x10
. On
y met le résultat de l'addition du registre x0
(qui contient toujours zéro, un peu comme le pseudo-fichier
/dev/zero
) et du nombre 42. La liste complète
des instructions que connait le processeur RISC-V figure dans le
The
RISC-V Instruction Set Manual mais ce texte est
difficile à lire, étant plutôt orienté vers les gens qui conçoivent
les processeurs ou qui programment les assembleurs. (Et il ne
contient pas les pseudo-instructions.) Je me sers plutôt de
documents de synthèse comme celui de metheis ou bien
la RISC-V
Instruction-Set Cheatsheet.
On va assembler notre petit programme, avec l'assembleur
as
:
% as -o trivial.o trivial.s
Puis on fabrique un exécutable avec le relieur :
% ld -o trivial trivial.o ld: warning: cannot find entry symbol _start; defaulting to 00000000000100b0
Et l'avertissement ? Contrairement aux langages de plus haut niveau, qui s'occupent des détails comme de démarrer et d'arrêter le programme, le langage d'assemblage vous laisse maitre et, ici, je n'ai pas défini l'endroit où démarrer. Bon, ce n'est qu'un avertissement, exécutons le programme :
% ./trivial Illegal instruction (core dumped)
Ajoutons explicitement un point d'entrée pour traiter l'avertissement obtenu :
.global _start _start: addi x10, x0, 2
On n'a plus l'avertissement, mais le programme plante de la même
façon. C'est en fait à la fin du programme qu'il y a un problème :
le programme n'a pas d'instruction d'arrêt, l'assembleur n'en a pas
ajouté (contrairement à ce qu'aurait fait un compilateur C), et
l'« instruction » suivant addi
est en effet
illégale (c'est la fin du code). On va donc ajouter du code pour
finir proprement, en utilisant un appel
système Linux :
_start: addi x10, x0, 2 # Terminons proprement (ceci est un commentaire) li x10, 0 li x17, 93 # 93 = exit ecall
Cette fois, tout se passe bien, le programme s'exécute (il n'affiche rien mais c'est normal). Il faut maintenant expliquer ce qu'on a fait pour que ça marche.
On veut utiliser l'appel système exit
. Sur
une machine Linux, man 2 exit
vous donnera sa
documentation. Celle-ci nous dit qu'exit
prend
un argument, le code de retour. D'autre part, les conventions de
passage d'arguments de RISC-V nous disent qu'on met l'argument dans
le registre x10
, et, lorsqu'on fait un appel
système, le numéro de cet appel dans x17
. Ici,
93 est exit
(si vous voulez savoir où je l'ai
trouvé, grep _exit
/usr/include/asm-generic/unistd.h
). li
(Load Immediate) va écrire 0 (le code de retour)
dans x10
puis 93 dans
x17
. Enfin, ecall
va
effectuer l'appel système demandé, et on sort proprement. (Vous
noterez que li x10, 0
est parfaitement
équivalent à addi x10, x0, 0
, et l'assembleur
va produire exactement le même code dans les deux cas.) Pour
davantage de détails sur l'utilisation des appels système Linux,
voir Linux
System Calls for RISC-V with GNU GCC/Spike/pk.
Bon, ce programme n'était pas très passionnant, il additionne 0 +
2 et met le résultat dans un registre mais n'en fait rien
ensuite. On va donc passer à un programme plus démonstratif, un
Hello, world. Le
processeur n'a pas d'instructions pour les entrées-sorties, on va
sous-traiter le travail au système d'exploitation, via l'appel
système Linux write
, qui a le numéro 64
(rappelez-vous, il faut regarder dans
/usr/include/asm-generic/unistd.h
) :
.global _start _start: la x11, helloworld # Print addi x10, x0, 1 # 1 = standard output addi x12, x0, 13 # 13 bytes addi x17, x0, 64 ecall # Exit addi x10, x0, 0 addi x17, x0, 93 ecall .data helloworld: .ascii "Hello World!\n"
Qu'a-t-on fait ? On a réservé une chaine de caractères dans les
données, nommée helloworld
. On charge son
adresse (car man 2 write
nous a dit que les arguments étaient le descripteur de fichier de la
sortie, mis dans x10
, une adresse et la longueur de
la chaine) dans le registre
x11
, la taille à écrire dans
x12
et on appelle write
.
Pour compiler, on va arrêter de se servir de l'assembleur et du
relieur directement, on va
utiliser gcc qui les appelle comme il faut :
% gcc -nostdlib -static -o hello-world hello-world.s % ./hello-world Hello World!
C'est parfait, tout marche (normal, je ne l'ai pas écrit
moi-même). Le -nostdlib
était pour
indiquer à gcc qu'on n'utiliserait pas la bibliothèque C
standard, on veut tout faire nous-même (ou presque,
puisqu'on utilise les appels système du noyau Linux ; si on écrivait
un noyau, sans pouvoir compter sur ces appels système, la tâche
serait bien plus difficile).
Si vous lisez du code en langage d'assemblage écrit par d'autres,
vous trouverez peut-être d'autres conventions d'écriture. Par exemple, le
registre x10
peut aussi s'écrire
a0
, ce surnom rappelant son rôle pour passer
des arguments (alors que x
préfixe les
registres indépendamment de leur fonction). De même, des mnémoniques
comme zero
peuvent être utilisés, ce dernier
désignant x0
(registre qui, rappelons-le,
contient toujours zéro). La documentation de ces écritures possibles
est la « RISC-V
ABIs Specification ». On voit ces variantes dans l'écriture si
on demande au désassembleur de désassembler
le code trivial :
$ objdump -d trivial.o ... Disassembly of section .text: 0000000000000000 <_start>: 0: 00200513 li a0,2
Le addi x10, x0, 2
est rendu par l'équivalent
li a0, 2
.
Faisons maintenant quelque chose d'un tout petit peu plus
utile. On va décrémenter un nombre jusqu'à atteindre une certaine
valeur. Mais attention : afficher un nombre (pour suivre les progrès
de la boucle) n'est pas
trivial. write
ne sait pas faire, il sait juste
envoyer vers la sortie les octets. Ça marche bien avec des
caractères ASCII comme plus haut, mais pas avec des
entiers. On va donc tricher en utilisant des nombres qui
correspondent à des caractères ASCII imprimables. Voici le
programme :
.equ SYS_WRITE, 64 .equ SYS_EXIT, 94 .equ STDOUT, 1 .equ SUCCESS, 0 .global _start _start: # The decrement li t2, 1 # The final value (A) li t1, 65 # The first value (Z) then the current value li t0, 90 loop: # Store on the stack sb t0, 0(sp) # Print the current value (it is copied on the stack) li a0, STDOUT add a1, zero, sp addi a2, x0, 1 # 1 byte (parameter count of write() ) addi a7, x0, SYS_WRITE ecall # Decrement sub t0, t0, t2 # End of loop? ble t1, t0, loop # BLE : Branch If Lower Or Equal # Print the end-of-line li a0, STDOUT la a1, eol addi a2, x0, 1 addi a7, x0, SYS_WRITE ecall # Exit li a0, SUCCESS addi a7, x0, SYS_EXIT ecall .data eol: .ascii "\n"
C'était l'occasion de voir :
loop
) selon le résultat de la
comparaison. Grâce à cette instruction, on peut écrire toutes les
structures de contrôle (ici, une
boucle).x2
..equ
, qui affecte un nom plus lisible à une
valeur. La liste de ces directives (on avait déjà vu
.global
) est
disponible.
Nous avions besoin d'écrire en mémoire car
write
prend comme deuxième argument une
adresse. Comme nous n'avons pas encore alloué
de mémoire, utiliser la pile est le plus
simple. (Sinon, il aurait fallu réserver de la mémoire dans le
programme, ou bien utiliser les appels système
brk
ou mmap
après son lancement.)
Si on veut formater un nombre pour l'imprimer, c'est plus
compliqué, donc je copie le code écrit par code4eva
sur StackOverflow.
Voyons cela avec une fonction qui trouve le plus grand de deux
nombres (mis respectivement dans les registres
a0
et a1
, le résultat,
toujours en suivant les conventions d'appel, sera dans a0
) :
_start: # Les deux nombres à comparer li a0, -1042 li a1, 666 call max # On sauvegarde le résultat add s0, zero, a0 ... max: blt a0, a1, smallerfirst ret smallerfirst: add a0, zero, a1 ret
L'instruction call
appelle la fonction
max
, qui prendra ses arguments dans
a0
et a1
. La pseudo-instruction
ret
(Return, équivalente à
jalr x0, x1, 0
) fait revenir de
la fonction, à l'adresse sauvegardée par le
call
. Notez que call
est
en fait une pseudo-instruction (vous en avez la
liste), que l'assembleur traduira en plusieurs instructions
du processeur. C'est ce qui explique que vous ne trouverez pas
call
dans la spécification du jeu
d'instructions RISC-V. Le programme entier, avec affichage du
résultat, est dans
.
max.s
Souvent on n'écrit pas l'entièreté du programme en langage
d'assemblage, mais seulement les parties les plus critiques, du
point de vue des performances. La ou les fonctions écrites en
langage d'assemblage seront donc appelées par un programme en C ou
en Go. D'où l'importance de conventions partagées sur l'appel de
fonctions, l'ABI (Application Binary
Interface) qui permettront que les parties en C et celles
en langage d'assemblage ne se marcheront pas sur les pieds (par
exemple n'écriront pas dans des registres censés être stables), et
qui garantiront qu'on pourra lier des codes compilés avec des outils
différents. Voici un exemple où le programme principal, qui gère
l'analyse et l'affichage des nombres, est en C et le calcul en
langage d'assemblage. Il s'agit de la détermination du PGCD par l'algorithme d'Euclide, qui
est trivial à traduire en assembleur. Les sources sont
et pgcd.s
:
call-pgcd.c
% gcc -Wall -o pgcd call-pgcd.c pgcd.s %./pgcd 999 81 pgcd(999, 81) = 27
En regardant le source en langage d'assemblage, vous verrez que le
respect des conventions (les deux arguments dans
a0
et a1
, le résultat
retourné dans a0
) fait que, l'ABI étant
respectée, les deux parties, en C et en langage d'assemblage, n'ont
pas eu de mal à communiquer.
Autre exemple de programme, on compte cette fois de 100 à 1, en
appelant la fonction num_print
pour afficher le
nombre. Attention à bien garder ses variables importantes dans des
registres sauvegardés (mnémonique commençant par s) sinon la
fonction num_print
va les modifier. Le
programme est
.loop-print.s
Un dernier truc : le compilateur C peut aussi produire du code en langage d'assemblage, ce qui peut être utile pour apprendre. Ainsi, ce petit programme C :
void main() { int x = 1; x = x + 3; }
Une fois compilé avec gcc -S minimum.c
, il va
donner un fichier minimum.s
avec, entre autres
instructions de gestion du démarrage et de l'arrêt du programme :
li a5,1 sw a5,-20(s0) lw a5,-20(s0) addiw a5,a5,3 sw a5,-20(s0)
On y reconnait l'initialisation de x
à 1
(x
est stocké dans le registre
a5
avec un li
- Load Immediate, charger un littéral, son
écriture en mémoire vingt octets sous s0
(le registre s0
a été
initialisé plus tôt, pointant vers une zone de la pile) avec
sw
(Store Word), puis son
addition avec 3 par addiw
(Add
Immediate Word) et son retour en mémoire avec un
sw
. (L'instruction lw
me
semble inutile puisque a5
contenait déjà la
valeur de x
.)
Quelques documents et références pour finir :
.global
) et pour celle des
pseudo-instructions, je recommande le RISC-V Assembly Programmer's Manual.Version PDF de cette page (mais vous pouvez aussi imprimer depuis votre navigateur, il y a une feuille de style prévue pour cela)
Source XML de cette page (cette page est distribuée sous les termes de la licence GFDL)