Je suis Charlie

Autres trucs

Accueil

Seulement les RFC

Seulement les fiches de lecture

Mon livre « Cyberstructure »

Ève

Un peu de langage d'assemblage du RISC-V sur Linux

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 :

  • Une nouvelle instruction, BLE (Branch if Lower or Equal) qui teste ses arguments et saute à une étiquette (ici, loop) selon le résultat de la comparaison. Grâce à cette instruction, on peut écrire toutes les structures de contrôle (ici, une boucle).
  • Une autre instruction, SB (Store Byte) pour écrire dans la mémoire (et non plus dans un registre du processeur).
  • Le pointeur de pile, SP (Stack Pointer), alias du registre x2.
  • Les directives à l'assembleur comme .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 pgcd.s et 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 liLoad 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 :

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)