Architecture des processeurs 3: ISA RISC-V
Avant même de commencer à commencer à concevoir un processeur, il est nécessaire de définir l’architecture de jeu d’instructions (ISA) qu’il implémentera. Cet élément est essentiel afin de définir comment les parties matérielles et logicielles du système pourront intérragir. C’est à ce niveau que sont définies les instructions (et doc opérations) disponibles, la taille des données manipulées, le nombre de registres etc. Différents types d’architectures de jeux d’instructions existent, avec leurs outils et écosystèmes respectifs.
Sur cette page, nous allons nous intéresser à l’ISA RISC-V, une architecture dont la spécification est libre et ouverte. À partir de la compilation et de la simulation de différents programmes, l’objectif sera comprendre plusieurs spécificités de cette architecture. Notamment, après un rappel sur les architectures de type chargement - rangement, nous verrons comment sont implémentées certaines opérations simples sous la forme de pseudo-instructions, mais aussi l’intérêt d’un jeu d’instructions extensible.
riscv-sim avec Visual Studio Code.
Dans le terminal de l’IDE, configurez l’environnement du simulateur puis placez-vous dans le répertoire sw/uarch/isa.Architecture chargement - rangement (load - store)
Description
L’architecture RISC-V est une architecture de processeur de type chargement - rangement (load - store). Cela signifie que la plupart des opérations sont structurées autour d’un ensemble de registres internes au processeur (32 registres dans le jeu d’instructions de base). Seules des instructions spécifiques permettent de réaliser des transferts de données depuis / vers la mémoire, respectivement les chargements (load) et les rangements (store).
1: lui x8, 0x16000
2: addi x8, x8, 0x200
3: lw x5, 0(x8)
4: lw x6, 4(x8)
5: or x7, x5, x6
6: sw x7, 16(x8)
Ainsi, cela mène généralement à l’utilisation de séquences d’instructions similaires à celle ci-dessus. Le processeur doit alors généralement:
- Calculer l’adresse pour récupérer les opérandes.
- Récupérer les opérandes depuis la mémoire.
- Effectuer le calcul entre les opérandes.
- Écrire le résultat en mémoire. Ce fonctionnement est également illustré par la figure ci-dessous.

Dans le jeu d’instructions RISC-V 32 bits de base, 8 instructions permettent d’effectuer les accès mémoires:
lb(8 bits non-signés, byte),lbu(8 bits non-signés, byte unsigned),lh(16 bits non-signés, _half),lhu(16 bits non-signés, half unsigned) etlw(32 bits, word) permettent d’effectuer des opérations de chargement (load).sb(8 bits, byte),sh(16 bits, half) etsw(32 bits, word) permettent d’effectuer des opérations de rangement (store).
Chacune de ces instructions s’appuye sur le format d’adressage base + décalage (ou base + offset).
Cela signifie que l’adresse mémoire est calculée en additionnant la valeur d’un registre (base) et celle d’un immédiat (offset).
Ainsi, lw t0, 0(x2) correspond à l’opération t0 <- MÉMOIRE[x2 + 0] et sw t0, 8(x2) correspond à l’opération t0 <- MÉMOIRE[x2 + 8].
Ainsi, pour manipuler deux zones mémoires proches, pas besoin d’une opération supplémentaire (addition ou soustraction) sur l’adresse: cela peut être directement fait dans l’instruction.
Simulation
Pour comprendre le fonctionnement de l’architecture RISC-V, nous allons nous intéresser au pseudo-code suivant:
1. op0 <- MEMOIRE[0x10018000]
2. op1 <- MEMOIRE[0x10018004]
3. res0 <- op0 + op1
4. res1 <- op0 XOR op1
5. MEMOIRE[0x10018010] <- res0
6. MEMOIRE[0x10018014] <- res1
Il décrit une série d’opérations permettant de récupérer des données à deux adresses successives en mémoires, d’effectuer des calculs puis de ranger les résultats également en mémoire.
Il représente donc le fonctionnement classique d’une architecture de type chargement - rangement (load - store).
op0, op1, res0 et res1 ne sont ici que des noms de données temporaires, alors que MEMOIRE[0x10018000] représente la donnée en mémoire placée à l’adresse 0x10018000.
Les adresses mémoires utilisées dans ce pseudo-code correspondent à une zone mémoire spécifique utilisée dans le simulateur appelée SCRATCH_REG.
Ce sont en fait des registres qui sont accessibles en lecture et écriture uniquement en utilisant des accès mémoires.
Dans le cas du simulateur, ils ont la particularité de pouvoir être facilement visualisable sur le chronogramme.
Dans notres cas, 0x10018000 correspond au registre SCRATCH_REG0, 0x10018004 à SCRATCH_REG1 etc.
SCRATCH_REG sont placés consécutivement en mémoire.
À partir de leurs adresses, déduisez-en leur taille.SCRATCH_REG ?main.S (dans la partie main body), traduisez le pseudo-code précédent en langage d’assemblage RISC-V et exécutez-le sur le simulateur.
Vérifiez que les différents registres et zones mémoires contiennent bien les bonnes valeurs à la fin du programme.res1 à l’adresse 0x10019000.
Pourrait-on toujours utiliser la même base que précédemment ?
Déduisez-en la plage d’adresses que peut adresser une opération mémoire RISC-V à partir d’une même base.Pseudo-instructions
Comme mentionné précédemment, le jeu d’instructions RISC-V est de type RISC (Reduced Instruction Set Computer). L’objectif est de simplifier l’implémentation matérielle en n’utilisant que des opérations simples à implémenter et exécuter. Ainsi, les opérations plus complexes doivent être décomposées pour être réalisées. Le compilateur a alors un rôle primordial: il est responsable de choisir les séquences d’instructions les plus optimisées pour les calculs à effectuer.
Cependant, lorsque l’on programme en langage d’assemblage, nous n’avons pas accès aux optimisations du compilateur. L’implémentation de certaines opérations pourtant simples peut alors s’avérer ardue en n’utilisant que les instructions de base. Pour cela, l’assembleur met alors à disposition des pseudo-instructions correspondants à des séquences d’instructions natives. Elles peuvent être vus comme un système d’alias ou de macro.
li: chargement d’immédiat
Lors du développement de programmes, quelque soit le langage, on a régulièrement besoin d’initiliaser une variable à une valeur connue.
C’est le rôle des instructions manipulant des immédiats.
Cependant, tous les immédiats ne peuvent être encodés dans n’importe quel type d’instruction.
Dans le cadre de l’ISA RISC-V, la pseudo-instruction li permet de résoudre ce problème.
1 dans le registre t0.
Réalisez cette opération e utilisant une seule instruction.
Quel registre au comportement spécial est alors particulièrement utile ?li t0, 2.
Analysez le désassemblé et comparez avec votre solution précédente.li t1, 0x14000000.
Analysez le désassemblé et comparez avec vos résultats précédents.
Quel est l’intérêt d’utiliser la pseudo-instruction li ?nop: aucune opération
Au cours de l’exécution d’un programme, il peut arriver que l’on souhaite exécuter des opérations ne modifiant aucun registre.
L’intérêt est alors généralement de faire écouler du temps entre deux opérations.
Cela peut être utile par exemple pour implémenter certaines versions de fonctions delay.
Généralement, on appelle une instruction n’ayant aucun impact sur l’état architectural du processeur un nop (No-Operation).
L’assembleur RISC-V intègre ainsi une pseudo-instruction nop.
nop et analysez le résultat.
Quelle instruction est utilisée ?
Quel registre au comportement spécial est alors particulièrement utile ?mv: copie d’une donnée
Dans certains cas, il peut être utile de copier une valeur d’un registre vers un autre.
On appelle ce type d’opération un mv (Move).
t1 vers t2.not: inversion logique
Le langage d’assemblage RISC-V intègre trois opérations logiques de base: OU logique (or / ori), ET logique (and / andi) et XOR logique (xor / xori).
Cependant, une autre opération souvent utilisée est le NON logique (inversion des différents bits).
t2 et de la stocker dans t3.
Comparez votre proposition avec la pseudo-instruction not de l’assembleur RISC-V.la: chargement d’adresse
Que cela soit pour effectuer des redirections du flot de contrôle ou pour des accès mémoires, connaître l’adresse mémoire de certains éléments peut s’avérer nécessaire. Cependant, calculer ces adresses directement peut s’avérer très contreignant: l’ajout de la moindre instructions peut décaler plusieurs éléments en mémoire.
Pour cela, on utilise communément en programmation des étiquettes. L’intérêt est de nommer directement un emplacement mémoire et de laisser les outils de compilation et d’assemblage calculer les différentes adresses correspondantes. C’est ce qui est fait lorsque l’on utilise des variables ou des fonctions. L’étape d’édition des liens permet alors de remplacer ces noms par de véritables valeurs numériques.
la.
Dans votre programme, ajoutez la t4, main, compilez-le et analysez le désassemblé.
Que fait cette pseudo-instruction ?
À quoi correspond le contenu de t4 après l’exécution de l’instruction ?Aide supplémentaire (si nécessaire)
la correspond à l’opération Load Address.
L’ajout d’une instruction dans un programme ajoute implicitement la valeur correspondante en mémoire.
Dans le cas des données, il est nécessaire de faire cette allocation de mémoire explicitement.
Cela prend généralement la forme <nom>: .word 0 où:
<nom>est le nom (l’étiquette) de la variable,.wordindique la taille (ici 32 bits),0est la valeur à l’initialisation.
la modifiez l’algorithme pour stocker res0 et res1 dans deux emplacements de la section .data de votre fichier src/asm/main.S.ro0 et ro1 dans la
section .rodata du src/asm/main.S.
Initialisez-les à des valeurs différentes de 0.
Modifiez votre algorithme pour lire ces valeurs à la place de op0 et op1.ro0, ro1, res0 et res1.call/ret: appel de fonction
Afin de regrouper des fonctionnalités ou opérations utilisées à plusieurs endroits du programme, il peut être utile de créer des fonctions.
Du point de vue langage d’assemblage, une fonction prend la forme d’une suite d’instructions placées à un endroit de la mémoire.
Lorsque l’on souhaite l’exécuter (ou l’appeler), il est alors nécessaire de rediriger l’exécution vers cet endroit de la mémoire.
Une fois terminée, l’exécution doit être renvoyée vers à lasuite du code ayant fait l’appel.
Pour cela, on utilise généralement les pseudo-instructions call et ret, respectivement pour réaliser l’appel et le retour.
src/asm/main.S, appelez la fonction func_add en utilisant l’instruction call func_add.
Exécutez le code et vérifiez le bon fonctionnement en analysant l’évolution du PC.call et ret.Afin d’effectuer le retour de fonction avec ret, il est nécessaire de stocker l’adresse correspondante dans un registre.
L’ABI (Application Binary Interface) RISC-V définit le registre x1 comme le registre ra (Return Address).
x2 / ra.
Quelle est sa valeur par rapport à l’adresse de la pseudo-instruction call ? Pourquoi ?Pour concevoir des fonctions génériques et réutilisables, on utilise des valeurs appelés arguments. Elles sont semblables à des entrées du point de vue de la fonction: elle les utile pour effectuer les calculs. Une fois terminée, elle renvoie alors le résultat correspondant.
Comme la quasi-totalité des manipulations de données dans un processeur RISC-V, ces opérations sont effectuées à l’aide de registres.
Là encore, l’ABI RISC-V définit 8 registres pouvant être utilisés comme argument: les registres x10 à x17, alors numérotés de a0 à a7.
Dans le cas d’une implémentation 32 bits, cela signifie que jusqu’à 8 valeurs de 32 bits peuvent être transmises à une fonction.
Information supplémentaire
Lorsque l’on transmet des valeurs d’entrées à une fonction comme ici, on parle alors de passage d’argument par registre. C’est la méthode la plus simple et la plus rapide. Cependant, dans certains cas, on peut avoir besoin de transmettre plus de 8 arguments à une même fonction. Il est alors possible d’effectuer un passage d’argument par pile: les valeurs sont stockées dans l’ordre mais dans la pile mémoire.
Enfin, pour le renvoi du résultat depuis la fonction, le mécanisme est le même.
Les registres a0 et a1 sont alors utilisés.
En général, lorsque plus d’éléments doivent être renvoyés comme résultat, alors ils sont stockés directement en mémoire.
// Fonction réalisant une addition
uint32_t func_add (uint32_t, a, uint32_t b) {
return a + b;
}
Pour la suite, nous allons implémenter la fonction func_add ci-dessus uniquement en utilisant du langage d’assemblage.
func_add pour qu’elle effectue l’addition des deux arguments a0 et a1 et qu’elle renvoie le résultat via a0.src/asm/main.S pour effectuer deux appels de fonction: func_add(1, 2); et func_add(4 ,5);.
Sur le chronogramme, vérifiez les résultats obtenus à l’aide du registre a0.Extensions
L’ISA RISC-V de base contient un nombre limité d’instructions: 43 pour la version 32 bits, 58 pour la version 64 bits. Cependant, cette architecture est dite extensible: il est possible de rajouter des instructions selon les besoins des applications. Cela permet ainsi de concevoir des processeurs qui n’intègrent que les instructions dont ils ont besoin, selon les applications à exécuter. Généralement, pour un processeur RISC-V donné, on indique donc également les différentes extensions qu’il intègre. De même, les compilateurs donnent généralement la possibilité d’activer ou désactiver des extensions: ils génèreront du code en utilisant uniquement les instructions indiquées. Dans cette partie, nous allons voir l’intérêt de plusieurs extensions:
- Zbs (sous-ensemble de B) pour la manipulation de bits,
- M pour les multiplications,
- Zicsr / Zicntr pour l’ajout de compteurs de performances.
Manipulation de bits
Les jeux d’instructions sont généralement conçus pour pouvoir manipuler des données de plusieurs bits, généralement sur un ou plusieurs octets.
Dans le cas du jeu d’instructions RISC-V par exemple, les registres et la plupart des opérations permettent de manipuler des valeurs de 32 bits (ou 64 bits selon la version de l’ISA implémentée).
S’il existe des opérations dédiées pour manipuler un nombre réduit d’octets (e.g. lb permet de ne charger que 8 bits), aucune instruction de base ne permet la modification d’un bit spécifique.
Cette limitation est commune à la plupart des jeux d’instructions existants.
Or, pour certains cas d’utilisation, il s’avère parfois essentiel d’effectuer ce type de manipulation de données. C’est notamment le cas lors de l’utilisation des entrées et sorties sur un microcontrôleur. Chaque bit contrôlant alors l’état d’une sortie précise, il doit alors être possible de contrôler l’état de chacun d’entre eux indépendamment des autres. Pour cela, une alternative logicielle généralement utilisée est le masquage logique. Le principe du masquage est de venir appliquer une valeur (un masque) lors d’une opération afin de ne modifier que les bits ciblés et de préserver la valeur des autres. Pour cela, on utilise notamment les propriétés d’éléments neutres :
- L’élément neutre du OU logique est
0:0OUa=a. - L’élément neutre du ET logique est
1:1ETa=a.
Pour les simulations suivantes, on se propose de voir comment l’ISA RISC-V, malgré son nombre d’instructions réduit, permet entièrement d’implémenter ce genre de mécanismes logiciels.
Au sein du fichier src/asm/main.S, écrivez une fonction gpio_pin_set permettant de mettre à 1 l’état de la broche 0 (ou pin 0) du GPIO B (addresse de base 0x1c011080).
Pour cela, vous devez positionner à :
0b01les bits[1:0]du registreGPIOB_MODE0(0x1c011080),0b1le bit[0]du registreGPIOB_DOUT(0x1c0110a4).
Attention à ne pas modifier les valeurs des autres bits de ces registres.
Vous pouvez vérifier le résultat dans GtkWave en vous assurant que I/Os -> GPIOB -> PIN0_LED0 passe bien à 1, tandis que les autres bits dans I/Os -> GPIOB_mode et I/Os -> GPIOB_out restent inchangés.
gpio_pin_rst permettant de mettre à 0 l’état de la broche 0 du GPIO B.0 à 15).func.c, réalisez à présent les mêmes fonctionnalités mais en langage C
dans les fonctions c_gpio_pin_set et c_gpio_pin_rst.
De la même manière que pour les fonctions en langage d’assemblage, appelez ces fonctions depuis le fichier src/asm/main.S.
Après avoir vérifié que les fonctionnalités sont équivalentes, comparez les codes générés.Avec le jeu d’instructions RISC-V de base, il n’est possible d’effectuer de la manipulation de bits qu’à l’aide du masquage. Ces opérations peuvent cependant être trop lentes dans certains systèmes: il est nécessaire de lire le registre, calculer le masque et modifier la valeur avant de l’écrire.
Pour cela, une extension dédiée est disponible.
Notamment, elle contient des instructions permettant de mettre un bit précis à 1 à partir d’un immédiat, de le mettre à 0 _etc.
make exec CORE_NAME=core_1 SIM_ISA=rv32i_zicsr_zicntr_zifencei_zbs.
Que constatez-vous comme différences dans les fonctions désassemblées ?Multiplication
La multiplication est une opération arithmétique de base nécessaire pour de nombreux calculs. Cependant, son implémentation matérielle n’est pas si triviale et peut même s’avérer coûteuse. Elle implique généralement l’ajout d’une unité de calcul dédiée. Ainsi, dans le jeu d’instructions de base RISC-V, aucune instruction ne permet d’effectuer directement des multiplications entre deux nombres entiers. Lorsqu’une telle opération est nécessaire, elle donc être traduite en une séquence d’opérations équivalentes.
func_mul en langage d’assemblage RISC-V réalisant la multiplication de deux opérandes non-signées sur 8 bits.
Cette fonction récupèrera les deux valeurs par le biais des registres dédiés aux arguments a0 et a1.
Appelez la fonction depuis main pour la tester.
À partir du chronogramme, calculez le nombre de cycles écoulés entre l’appel de fonction et le retour.func.c, créez une fonction en C c_func_mul effectuant la même opération entre deux uint8_t et retournant un uint16_t.
Comparez le résultat avec votre précédente fonction func_mul.
Mesurez et comparez le nombre de cycles écoulés pour l’exécution de la fonction.func.c, ajoutez à présent la fonction c_func_mul_op en effectuant cette fois la multiplication en utilisant l’opérateur C dédié (*). Analysez le code désassemblé. Comment est traduit l'opérateur *` ?
Mesurez le nombre de cycles exécutés et comparez le résultat ainsi que le code obtenu par rapport aux précédents.La multiplication étant une opération arithmétique courante, il peut être essentiel selon les applications d’être capable d’exécuter cette opération en quelques cycles seulement. Pour cela, une extension RISC-V dédiée appelée M rajoute des instructions dédiées. Lorsqu’elles sont disponibles, le compilateur privilégie alors l’utilisation de ces instructions.
make exec SIM_ISA=rv32i_zicsr_zicntr_zifencei_m.
Effectuez la même analyse du code désassemblé et du nombre de cycle exécuté.c_func_mul_p2 qui effectue l’opération $res = op1 ∗ 2^{op2}$.
Quelle(s) opération(s) est(sont) utilisée(s) par le compilateur pour effectuer les multiplications nécessaires ?Mesure de cycles d’exécution
Les processeurs modernes étant des systèmes complexes, de nombreux mécanismes et évènements internes peuvent influencer l’exécution des instructions. Afin de permettre un contrôle et une étude de ces évènements même après fabrication, de nombreux compteurs sont généralement intégrés dans les processeur: on parle de compteurs de performances. Ils permettent par exemple de compter le nombre d’instructions exécutées, le nombre de cycles écoulés etc.
Ainsi, le jeu d’instructions RISC-V intègre deux extensions permettant de lire directement ces compteurs depuis le programme exécuté. L’extension Zicsr permet de rajouter les instructions pour les opérations sur des registres spéciaux appelés CSR (Control and Status Registers). Comme indiqué par leurs noms, ces registres rassemblent les différents mécanismes pour la gestion et le contrôle du système. L’extension Zicntr permet ensuite de rajouter trois compteurs de performances dans les CSR:
mtimequi compte le temps écoulé depuis le dernier reset effectué.mcyclequi compte le nombre de cycles d’exécution depuis le dernier reset effectué.minstretqui comptes le nombre d’instructions retirées (entièrement exécutées) depuis le dernier reset effectué.
L’instruction csrr rd, mcycle permet de mettre dans rd la valeur du registre mcycle au moment de l’exécution de l’instruction.
Information supplémentaire
Les compteurs mtime, mcycle et minstret sont des registres sur 64 bits.
Ainsi, csrr rd, mcycle ne lit que les 32 bits de poids faible.
Il est possible de lire les bits de poids fort avec l’instruction csrr rd, mcycleh.
Dexu pseudo-instructions équivalentes rdcycle et rdcycleh sont également disponibles.
mcycleh avant / après une autre instruction (e.g. un nop).
À quoi correspond la différence entre les deux valeurs ?Implémentation en C
Il est également possible de mesurer le nombre de cycles d’exécution en langage C.
Pour cela, il suffit d’insérer directement les instructions RISC-V.
Par exemple, voici ci-dessous une fonction __read_cycle() renvoyant une valeur sur 64 bits.
En indiquant au compilateur la fonction inline, cela permet d’insérer directement les instructions plutôt que de réaliser un appel de fonction classique.
inline __attribute__ ((always_inline)) uint64_t __read_cycle() {
uint32_t cycle;
uint32_t cycleh;
asm volatile (
"rdcycle %[r1]\n"
"rdcycleh %[r2]\n"
: [r1] "=r" (cycle), // output
[r2] "=r" (cycleh)
: // input
: // registers
);
return ((uint64_t) cycle) | (((uint64_t) cycleh) << 32);
}
