Architecture des processeurs 2: Exécution d'un programme
Dans un système informatique, un programme ou un logiciel est une suite d’opérations plus ou moins simples décrivant le fonctionnement attendu. Ces opérations sont transmises sous la forme d’instructions que le processeur pourra interpréter avant d’effectuer les tâches correspondantes. Quelque soit le jeu d’instructions ou le type de processeur, ce fonctionnement reste toujours le même.
Sur cette page, nous allons voir comment un programme est exécuté sur un processeur et certains des éléments nécessaires à la compilation. Pour cela, l’objectif sera également de prendre en main l’environnement de simulation de processeurs RISC-V. L’ensemble des expérimentations, sur cette page et les suivantes, seront réalisés à partir de plusieurs modèles de microarchitectures simulées.
sw/uarch/exec et configurez l’environnement du simulateur.
Analyse d’une exécution
Pour les différentes analyses des simulations, nous aurons besoin de retrouver plusieurs types d’informations dans les fichiers sources et générés.
Tout d’abord, nous allons nous concentrer sur la fonction principale main exécutée.
Celle-ci se trouve dans le fichier src/asm/main.S.
Un extrait est disponible ci-dessous:
.section .text
.globl main
main:
# main save
addi sp, sp, -4
sw ra, 0(sp)
# main body
addi t0, zero, 1
# main end
lw ra, 0(sp)
addi sp, sp, 4
ret
On distingue plusieurs parties dans ce bout de code:
.section .textpermet de séparer le contenu en plusieurs sections. Typiquement,.textest utilisé pour indiquer du code,.datades données,.rodatades données en lecture seulement etc. Cette séparation est ensuite utile pour organiser le code en mémoire lors de la compilation.main:permet de créer une étiquette: un nom associé à cet emplacement mémoire. C’est ce que l’on utilise pour nommer une fonction ou une variable. C’est particulièrement utile pour réaliser des opérations sur les adresses sans spécifier directement la valeur de l’adresse. Ceci est possible grâce à l’édition des liens réalisée durant la compilation. La directive.globl mainpermet de rentre accessible cette étiquette à l’extérieur du fichier.- Les lignes suivantes sont les instructions situées au sein de notre fonction
main. Le coeur de la fonction (oubodyici), que l’on modifiera pour les futures simulations, est situé au centre: les instructions précédentes servent à sauvegarder l’état du système, les instructions qui suivent à le restaurer. Ici, la fonctionnalité principale de notre fonctionmainest réalisée par l’instructionaddi t0, zero, 1, qui place la valeur immédiate1dans le registret0(oux5).
make exec (plus d’informations sur la page du simulateur).En plus du binaire ou de la représentation du contenu mémoire au format hexadécimal (.hex), l’un des fichiers générés est le code désassemblé (.lst).
Voici ci-dessous le code désassemblé de la fonction main vue précédemment:
08000000 <main>:
8000000: ffc10113 addi x2,x2,-4 # c03fffc <__global_pointer$+0x3f7fc>
8000004: 00112023 sw x1,0(x2)
8000008: 00100293 li x5,1
800000c: 00012083 lw x1,0(x2)
8000010: 00410113 addi x2,x2,4
8000014: 00008067 ret
La structure générale est similaire au code du main.S qui utilisé déjà le langage d’assemblage.
Cependant, on retrouve également plusieurs autres informations:
- Une première colonne indique l’adresse mémoire des différents éléments. Par exemple, la fonction
mainest ici placée à l’adresse0x08000000. - Une deuxième colonne nous donne le code hexadécimal équivalent à chaque instruction. Par exemple,
0xffc10113correspond à l’instructionaddi x2,x2,-4. - Une troisième colonne indique les différentes instructions. Elle est finalement proche du fichier en langage d’assemblage précédent.
- Par fois, des annotations supplémentaires sont placées dans une quatrième colonne. Lors de la manipulation d’adresses, elles indiquent le lien avec l’étiquette la plus proche.
Dans notre cas, ce fichier nous apprend notamment que notre instruction addi t0, zero, 1 (équivalente à la pseudo-instruction li x5,1) est placée à l’adresse 0x08000008.
Ainsi, à partir de cela, il nous sera possible de suivre l’avancée de son exécution durant la simulation.
La commande make exec réalise également l’exécution du programme sur le simulateur après la compilation.
make view pour visualiser le chronogramme de l’exécution, semblable à celui ci-dessous.
Chaque chronogramme généré représente l’état des signaux internes cycle par cycle. Quatre signaux généraux sont parituclièrmenent utiles pour suivre l’avancée de l’exécution:
clockreprésente le signal d’horloge.resetreprésente le signal de reset.cyclecompte le nombre de cycles d’horloge depuis le dernier reset.- Enfin,
PCindique l’adresse de la dernière instruction complètement exécutée.
Dans le cas d’un processeur RISC-V, la plupart des résultats des instructions sont stockés dans des registres.
Leurs états sont représentés dans le groupe de signaux GPR.
En cliquant dessus, il devient possible de voir l’évolution cycle par cycle de chacun d’entre eux.
Ainsi, pour analyser l’avancée d’une exécution, on procède généralement ainsi:
- On récupère l’adresse de l’instruction dans le code désassemblé (ici
0x08000008). - On analyse la valeur de
PCdans le chronogramme pour retrouver le cycle où l’instruction est complètement exécutée (iciPCvaut0x08000008au cycle83). - Le registre de destination de l’instruction étant
x5/t0, on regarde sa valeur à partir du cycle83. On constate alors qu’il prend bien la valeur0x00000001.
main.S pour ajouter l’instruction xori t2, zero, 5.
De la même manière, retrouvez son adresse mémoire, le cycle où son exécution est terminée et vérifiez le résultat obtenu dans le registre de destination.Compilation
Généralement, lors de l’utilisation d’un environnement de programmation intégré, la plupart des étapes sont partiellement simplifiées / cachées. C’est notamment le cas de la phase de compilation. Dans cette partie, nous allons chercher à comprendre quels sont les éléments nécessaires permettant l’exécution d’un programme (même très simple) sur un processeur (ici simulé).
sw/uarch/exec, reprenez le programme de base et lancez la commande make exec.Dans le cadre de ce simulateur, lorsque vous lancez cette commande, quatre sous-étapes sont réellement réalisées:
- La compilation va prendre les fichiers source en entrée et générer les suites d’instructions équivalentes selon les niveaux d’optimisations et autres contraintes demandées.
- L’édition des liens va permettre de générer un fichier exécutable
.elfen utilisant les bonnes valeurs d’adresses des fonctions ou librairies dynamiques, tout en respectant les directives mémoires fixées par le linker script. - Le désassemblage (utile uniquement pour le déboggage) va permettre de générer un fichier texte
.lstdécrivant l’état final du fichier exécutable.elf. - Enfin, pour l’exécution, les mémoires du systèmes sont initialisées avec le code et les données correspondantes à partir des des fichiers
.hexgénérés. Le résultat peut être visualisé dans un fichier au format.vcd(format pour la représentation de chronogramme).
La figure ci-dessous résume les différentes étapes de la chaîne de compilation.
L’un des fichiers à fournir à la compilation est le linker script.
Son rôle est de définir, pour un processeur donné, les différentes informations internes sur l’organisation mémoire: les mémoires présentes, ce qu’elles contiennent comme informations, les opérations qu’elles peuvent effectuer etc.
Pour le processeur core_0, le fichier se nomme core/core_0/script.ld.
Au sein de ce type de fichier, on retrouve généralement deux parties essentielles:
- La partie
MEMORYdonne des indications sur les différentes mémoires du système. Notamment, on y retrouve leur nom, leur utilisation (rpour lecture,wpour écriture etxpour exécution), leur adresse de base et leur taille. - La partie
SECTIONdonne des indications sur où doivent être placées les différentes parties du code et des données en mémoire. Cette partie s’appuye directement sur les sections vues précédemment dans le fichiersrc/asm/main.S. Dans le cas d’un fichier de plus haut niveau compilé, par exemple du langage C, le compilateur se charge d’ajouter les différentes parties du code dans différentes ections prédéfinies. Par exemple, il peut placer les constantes dans des sections.rodata, le code dans des.textetc.
MEMORY.
Quelles informations sur les mémoires du processeur core_0 peut-on retrouver dans core/core_0/script.ld ?Le fichier sw/uarch/common/start.S contient le code de démarrage (boot) du système.
C’est donc à cet endroit que sont placés les premières instructions exécutées.
Généralement, on utilise l’étiquette _start pour indiquer la première instruction.
Après compilation, le fichier correspondant est le fichier start.o.
/* Boot ROM */
.boot BOOT_PC :
{
KEEP(*start.o(.text*));
*start.o(.rodata*);
*start.o(.srodata*);
} > BOOT
Les lignes ci-dessus du linker script permettent de placer l’ensemble du contenu des sections .text et .rodata dans la mémoire BOOT, à partir de la valeur BOOT_PC.
La directive KEEP permet de garantir que la section de code spécifiée sera bien conservée à l’endroit indiqué, et pas réorganisé.
PC du processeur est matériellement initialisée à une adresse fixée par l’implémentation.
Pourquoi le code contenu dans _start_ doit-il être placé cette adresse bien précise ?
Déduisez-en l’adresse de la première instruction exécutée ici. /* Stack */
.stack (ORIGIN(PRAM) + LENGTH(PRAM) - STACK_SIZE) (NOLOAD):
{
. = . + STACK_SIZE;
_stack = . ;
} > PRAM
La pile mémoire est une zone mémoire allouée statiquement à la compilation.
Ainsi, une section spécifique du linker script dans SECTION est utilisée pour cela: .stack dont une version simplifiée est disponible ci-dessus.
La ligne .stack (ORIGIN(PRAM) + LENGTH(PRAM) - STACK_SIZE) permet de placer le début de la section .stack à une adresse précise.
La ligne . = . + STACK_SIZE; permet d’allouer une zone mémoire de la taille STACK_SIZE.
Enfin, la ligne _stack = . ; permet de placer une étiquette à la fin de cette zone mémoire.
C’est cette valeur qui servira pour la création du pointeur de pile sp (stack pointer).
_stack est-elle placée à la fin de la zone ?
Quelle est donc la valeur attendue ?.data) et où elles sont situées en mémoire.Les mémoires utilisées dans un système peuvent avoir des caractéristiques différentes.
Notamment, on retrouve en général des mémoires dites persistantes mais qui sont généralement accessibles en lecture seulement (ROM), ou des mémoires volatiles mais qui peuvent lues et écrites par le processeur (RAM).
Se pose alors le cas du contenu mémoire qui doit être initialisé au démarrage mais qui peut être modifié ensuite (par exemple une vraible globale uint8_t var = 8;): où doit-il être placé ?
Pour cela, une technique consiste à allouer deux zones mémoires:
- une dans une ROM persistante où l’on stocke les valeurs d’initilisation.
- une dans une RAM où l’on pourra effectuer les futures opérations.
Un programme d’initialisation sera alors responsable au démarrage d’effectuer la copie des valeurs de la ROM vers la RAM.
En plus de lancer l’initialisation de la mémoire, le programme de démarrage doit également préparer l’état des différents registres.
Lors du démarrage du système, c’est généralement le rôle des premières instructions exécutées.
L’objectif est alors de s’assurer que les bonnes valeurs sont placées dans certains registres essentiels afin de garantir que la suite de l’exécution s’exécutera correctement.
Par exemple, dans le cas de l’ISA RISC-V, on considère que les valeurs des registres x1 à x31 ne sont pas fixées au démarrage par le matériel: c’est donc au logiciel de les écrire.
Selon l’Application Binary Interface (ABI) de l’ISA RISC-V, on utilise par convention le registre x2 pour contenir l’adresse de la pile mémoire: on parle alors de pointeur de pile (stack pointer).
Un alias utilisé pour x2 est donc sp.
La pile mémoire étant un mécanisme essentiel pour stocker des informations temporaires (comme les variables locales en langage C), il est important d’initialiser ce pointeur au démarrage pour qu’il pointe vers la bonne zone mémoire.
sw/uarch/common/start.S. Quelle ligne permet d’initialiser le registre sp ?
À partir du chronogramme, déduisez la valeur d’initilisation du pointeur de pile.
Correspond-elle à la valeur attendue ?Une fois l’initialisation du système effectuée, le dernier rôle du programme de démarrage est ensuite de lancer l’exécution du programme principale main.
Pour cela, il redirige donc l’exécution vers l’adresse mémoire correspondante.
main ?
En analysant la valeur du signal PC et le contenu du code désassemblé, déduisez le type d’opération correspondant à cette instruction.