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.

Simulation Pour effectuer les différentes simulations de cette page, ouvrez l’ensemble du répertoire 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:

  1. Calculer l’adresse pour récupérer les opérandes.
  2. Récupérer les opérandes depuis la mémoire.
  3. Effectuer le calcul entre les opérandes.
  4. Écrire le résultat en mémoire. Ce fonctionnement est également illustré par la figure ci-dessous.

Étapes pour la manipulation de données dans une architecture chargement-rangement (ici RISC-V)

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) et lw (32 bits, word) permettent d’effectuer des opérations de chargement (load).
  • sb (8 bits, byte), sh (16 bits, half) et sw(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.

Simulation 1 On considère que les registres SCRATCH_REG sont placés consécutivement en mémoire. À partir de leurs adresses, déduisez-en leur taille.
Simulation 2 Le jeu d’instructions RISC-V contient plusieurs instructions pour réaliser des rangements ou chargements en mémoire. Quelles instructions doivent être utilisées pour ranger / charger une valeur complète dans / depuis un registre SCRATCH_REG ?
Simulation 3 Dans le fichier 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.
Simulation 4 Dans ce cas d’utilisation, le mode d’adressage base + offset peut-il permettre d’optimiser votre code ?
Simulation 5 Si nécessaire, modifiez votre code pour effectuer les mêmes opérations en utilisant seulement 7 instructions.
Simulation 6 On considère à présent que l’on souhaite ranger 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.

Simulation 7 On souhaite charger la valeur 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 ?
Simulation 8 Exécutez à présent l’opération li t0, 2. Analysez le désassemblé et comparez avec votre solution précédente.
Simulation 9 Exécutez à présent l’opération 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.

Simulation 10 Ajoutez à votre programme une pseudo-instruction 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).

Simulation 11 À partir des instructions natives RISC-V, réalisez une opération de copie du registre 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).

Simulation 12 Proposez une séquence d’instruction permettant de calculer l’opération NON logique du registre 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.

Simulation 13 L’assembleur RISC-V met à disposition une pseudo-instruction RISC-V 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,
  • .word indique la taille (ici 32 bits),
  • 0 est la valeur à l’initialisation.
Simulation 14 Reprenez votre traduction du pseudo-code de la partie chargement - rangement. Utilisez la pseudo-instruction la modifiez l’algorithme pour stocker res0 et res1 dans deux emplacements de la section .data de votre fichier src/asm/main.S.
Simulation 15 Avec la même directive, créez maintenant deux donnée 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.
Simulation 16 À partir des informations du linker script, déduisez les deux mémoires où sont placées les données 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.

Simulation 17 Dans le fichier 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.
Simulation 18 À partir du désassemblé, déterminez quelles sont les instructions utilisées pour implémenter 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).

Simulation 19 À partir du désassemblé et du chronogramme, retrouvez la valeur de l’adresse de retour dans le registre 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.

Simulation 20 Modifiez votre fonction func_add pour qu’elle effectue l’addition des deux arguments a0 et a1 et qu’elle renvoie le résultat via a0.
Simulation 21 Modifiez le fichier 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: 0 OU a = a.
  • L’élément neutre du ET logique est 1: 1 ET a = 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.

Simulation 22

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 à :

  • 0b01 les bits [1:0] du registre GPIOB_MODE0 (0x1c011080),
  • 0b1 le bit [0] du registre GPIOB_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.

Simulation 23 De même, écrivez la fonction gpio_pin_rst permettant de mettre à 0 l’état de la broche 0 du GPIO B.
Simulation 24 Modifiez vos fonctions pour qu’elles prennent en argument le numéro de la broche (de 0 à 15).
Simulation 25 Dans le fichier 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.

Simulation 26 Compilez à présent le même code en activant au niveau du compilateur l’extension RISC-V Zbs puis exécutez-le sur le processeur core_1. Pour cela, utilisez la commande 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 ?
Simulation 27 Quelles sont les nouvelles instructions utilisées ? Pour chacune d’elles, indiquez la fonctionnalité correspondante.

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.

Simulation 28 On suppose deux valeurs entières non-signées sur N bits. Combien de bits sont nécessaires pour représenter tous les résultats possibles ?
Simulation 29 Développez une fonction 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.
Simulation 30 Dans le fichier 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.
Simulation 31 Toujours dans le fichier 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.

Simulation 32 Utilisez à présent la commande: 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é.
Simulation 33 Quelle est la nouvelle instruction utilisée ?
Simulation 34 Ajoutez à présent la fonction 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:

  • mtime qui compte le temps écoulé depuis le dernier reset effectué.
  • mcycle qui compte le nombre de cycles d’exécution depuis le dernier reset effectué.
  • minstret qui 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.

Simulation 36 Exécutez deux lectures du registre 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);
  }