wiki:AS6-TME-B1

INDEX

DOCS → [Config] [MIPS U] [MIPS K] [markdown] [CR.md]
COURS → [1 (+code) (+outils)] [2] [3] [4] [5] [6] [7] [8] [9]
TME → [1] [2] [3] [4] [5] [6] [7] [8] [9]
CODE → [gcc + soc] [1] [2] [3] [4] [5] [6] [7] [8] [9]

1 - Du boot au premier programme user

Ce TME est sans doute le plus chargé de tous les TME du module parce qu'il porte sur le tout démarrage du système et il va jusqu'à l'exécution d'une application utilisant des services du noyau grâce aux syscalls. Le code reste néanmoins petit parce que le nombre de services est faible. Il y a également des questions sur la chaine de compilation, mais là encore la complexité est raisonnable.

Si vous avez des difficultés, tant sur le code que sur les outils, il est important de relire le cours, de poser des questions, de faire des recherches sur internet, etc. puisque vous aurez besoin d'avoir compris ces bases pour aller plus loin. Soyez proactif, vous seul savez ce que vous ne comprenez pas. 🙂

A. Travaux dirigés

A1. Analyse de l'architecture

Les trois figures ci-dessous donnent des informations sur l'architecture du prototype almo1 sur lequel vous allez travailler.

  • À gauche, vous avez un schéma de connexion simplifié.
  • Au centre, vous avez la représentation des 4 registres internes du contrôleur de terminal TTY nécessaires pour commander un couple écran-clavier.
  • À droite, vous avez la représentation de l'espace d'adressage implémenté pour le prototype.

almo1.png TTY.png espace_adresse.png

Questions

  1. Il y a deux mémoires dans almo1 : RAM et ROM. Qu'est-ce qui les distinguent et que contiennent-elles ?
  2. Qu'est-ce l'espace d'adressage du MIPS ? Quelle taille fait-il ?
    Quelles sont les instructions du MIPS permettant d'utiliser ces adresses ? Est-ce synonyme de mémoire ?
  3. Qu'est-ce l'espace d'adressage de l'application ?
  4. Dans quel composant matériel se trouve le code de démarrage et à quelle adresse est-il placé dans l'espace d'adressage et pourquoi à cette adresse ?
  5. Quel composant permet de faire des entrées-sorties dans almo1 ?
    Citez d'autres composants qui pourraient être présents dans un autre SoC ?
  6. Il y a 4 registres dans le contrôleur de TTY, à quelles adresses sont-ils placés dans l'espace d'adressage ?
    Comme ce sont des registres, est-ce que le MIPS peut les utiliser comme opérandes pour ses instructions (comme add, or, etc.) ?
    Dans quel registre faut-il écrire pour envoyer un caractère sur l'écran du terminal (implicitement à la position du curseur) ?
    Que contiennent les registres TTY_STATUS et TTY_READ ?
    Quelle est l'adresse de TTY_WRITE dans l'espace d'adressage ?
  7. Le contrôleur de TTY peut contrôler de 1 à 4 terminaux. Chaque terminal dispose d'un ensemble de 4 registres (on appelle ça une carte de registres, ou en anglais une register map). Ces ensembles de 4 registres sont placés à des adresses contiguës. S'il y a 2 terminaux (TTY0 et TTY1), à quelle adresse est le registre TTY_READ de TTY1 ?
  8. Que représentent les flèches bleues sur le schéma (plus haut à gauche) ? Pourquoi ne vont-elles que dans une seule direction ?
  9. Que fait le contrôleur DMA et donner des différences par rapport au contrôleur de TTY ?

A2. Programmation assembleur

L'usage du code assembleur est réduit au minimum. Il est utilisé uniquement où c'est indispensable. C'est le cas du code de démarrage. Ce code ne peut pas être écrit en C pour au moins une raison importante. Le compilateur C suppose la présence d'une pile et d'un registre du processeur contenant le pointeur de pile, or au démarrage les registres sont vides (leur contenu n'est pas significatif). Dans cette partie, nous allons nous intéresser à quelques éléments de l'assembleur qui vous permettront de comprendre le code en TP.

Questions

  1. Nous savons que l'adresse du premier registre du TTY est 0xd0200000 est qu'à cette adresse se trouve le registre TTY_WRITE du TTY0. Le code permettant d'écrire le code ASCII 'x' sur le terminal 0 pourrait-être
    lui   $4, 0xD020
    ori   $4, $4, 0x0000   // cette instruction ne sert a rien puisqu on ajoute 0, mais je la mets pour le cas general
    ori   $5, $0, 'x'
    sb    $5, 0($4)        // Notez que l'immédiat 0 devant ($4) n est pas obligatoire mais on s'obligera à le mettre
    
    Le problème avec le code précédent est que l'adresse du TTY est un choix de l'architecte du prototype et s'il décide de placer le TTY ailleurs dans l'espace d'adressage, il faudra réécrire le code. Il est préférable d'utiliser une étiquette pour désigner cette adresse : on suppose désormais que l'adresse du premier registre du TTY se nomme __tty_regs_map. Le code assembleur ne connait pas l'adresse, mais il ne connaît que le symbole. Ainsi, pour écrire 'x' sur le terminal 0, nous devons utiliser la macro instruction la $r, label. Cette macro-instruction est remplacée lors de l'assemblage du code par une suite composée de deux instructions lui et ori. Il existe aussi la macro instruction li qui demande de charger une valeur sur 32 bits dans un registre. Pour être plus précis, les macro-instructions
    la $r, label
    li $r, 0x87654321
    
    sont remplacées par
    lui $r, label>>16
    ori $r, $r, label & 0xFFFF
    lui $r, 0x8765
    ori $r, $r, 0x4321
    
    Réécrivez le code de la question précédente en utilisant la et li
  2. En assembleur pour sauter à une adresse de manière inconditionnelle, on utilise les instructions j label et jr $r. Ces instructions permettent-elles d'effectuer un saut à n'importe quelle adresse ?
  3. Vous avez utilisé les directives .text et .data pour définir les sections où placer les instructions et les variables globales, mais il existe la possibilité de demander la création d'une nouvelle section dans le code objet produit par le compilateur avec la directive .section name,"flags"
    • name est le nom de la nouvelle section. On met souvent un .name pour montrer que c'est une section et
    • "flags" informe du contenu : "ax" pour des instructions, "ad" pour des données (ceux que ça intéresse pourront regarder là https://frama.link/20UzK0FP)
    Écrivez le code assembleur créant la section ".mytext" et suivi de l'addition des registres $5 et $6 dans $4
  4. À quoi sert la directive .globl label ?
  5. Écrivez une séquence de code qui affiche la chaîne de caractère "Hello" sur TTY0. Ce n'est pas une fonction et vous pouvez utiliser tous les registres que vous voulez. Vous supposez que __tty_regs_maps est déjà défini.
  6. En regardant le dessin de l'espace d'adressage du prototype almo1, dites à quelle adresse devra être initialisé le pointeur de pile pour le kernel. Rappelez pourquoi c'est indispensable de le définir avant d'appeler une fonction C et écrivez le code qui fait l'initialisation, en supposant que l'adresse du pointeur de pile vaut celle que représente le nom __kdata_end

A3. Programmation en C

Vous savez déjà programmer en C, mais vous allez voir des syntaxes ou des cas d'usage que vous ne connaissez peut-être pas encore. Les questions qui sont posées ici n'ont pas toutes été vues en cours, mais vous connaissez peut-être les réponses, sinon ce sera l'occasion d'apprendre.

Questions

  1. Quels sont les usages du mot clé static en C ?
  2. Pourquoi déclarer des fonctions ou des variables extern ?
  3. Comment déclarer un tableau de structures en variable globale ? La structure est nommée test_s, elle a deux champs int nommés a et b. Le tableau est nommé tab et a 2 cases.
  4. Quelle est la différence entre #include "file.h" et #include <file.h> ?
  5. Comment définir une macro-instruction C uniquement si elle n'est pas déjà définie ? Écrivez un exemple.
  6. Comment être certain de ne pas inclure plusieurs fois le même fichier .h ?
  7. Supposons que la structure tty_s et le tableau de registres de TTY soient définis comme suit. Écrivez une fonction C int getchar(void) bloquante qui attend un caractère tapé au clavier sur le TTY0. Nous vous rappelons qu'il faut attendre que le registre TTY_STATUS soit différent de 0 avant de lire TTY_READ.
    struct tty_s {
        int write;          // tty's output address
        int status;         // tty's status address something to read if not null)
        int read;           // tty's input address
        int unused;         // unused address
    };
    extern volatile struct tty_s __tty_regs_map[NTTYS];
    
  8. Savez-vous à quoi sert le mot clé volatile ? Nous n'en avons pas parlé en cours, mais c'est nécessaire pour les adresses des registres de périphérique, une idée ... ?

A4. Compilation

Pour obtenir le programme exécutable, nous allons utiliser :

  • gcc -o file.o -c file.c
    • Appel du compilateur avec l'option -c qui demande à gcc de faire le préprocessing puis la compilation c pour produire le fichier objet file.o
  • ld -o bin.x -Tkernel.ld files.o ...
    • Appel de l'éditeur de lien pour produire l'exécutable bin.x en assemblant tous les fichiers objets .o, en les plaçant dans l'espace d'adressage et résolvant les liens entre eux (quand un .o utilise une fonction ou une variable définie dans un autre .o).
  • objdump -D file.o > file.o.s ou objdump -D bin.x > bin.x.s
    • Appel du désassembleur qui prend les fichiers binaires (.o ou .x) pour retrouver le code produit par le compilateur à des fins de debug ou de curiosité.

Questions

Le fichier kernel.ld décrit l'espace d'adressage et la manière de remplir les sections dans le programme exécutable.

__tty_regs_map   = 0xd0200000 ; 
__boot_origin    = 0xbfc00000 ; 
__boot_length    = 0x00001000 ; 
__ktext_origin   = 0x80000000 ; 
__ktext_length   = 0x00020000 ;
[... question 1 ...]
__kdata_end      = __kdata_origin + __kdata_length ; 

MEMORY {
    boot_region  : ORIGIN = __boot_origin,  LENGTH = __boot_length
    ktext_region : ORIGIN = __ktext_origin, LENGTH = __ktext_length
[... question 2 ...]
}

SECTIONS {
    .boot : {
        *(.boot)    
    } > boot_region
[... question 3 ...]
    .kdata : {
        *(.*data*)      
    } > kdata_region
}
  1. Le fichier commence par la déclaration des variables donnant des informations sur les adresses et les tailles des régions de mémoire. Ces symboles n'ont pas de type et ils sont visibles de tous les programmes C, il faut juste leur donner un type pour que le compilateur puisse les exploiter, c'est ce que nous avons fait pour extern volatile struct tty_s __tty_regs_map[NTTYS]. En regardant dans le dessin de la représentation de l'espace d'adressage, complétez les lignes de déclaration des variables pour la région kdata_region
  2. Le fichier contient ensuite la déclaration des régions (dans MEMORY{...}) qui vont être remplies par les sections trouvées dans les fichiers objets. Comment modifier cette partie (la zone [... question 2 ...]) pour ajouter les lignes correspondant à la déclaration de la région kdata_region ?
  3. Enfin le fichier contient comment sont remplies les régions avec les sections. Complétez les lignes correspondant à la description du remplissage de la région ktext_region. Vous devez la remplir avec les sections .text issus de tous les fichiers.

Nous allons systématiquement utiliser des Makefiles pour la compilation du code, mais aussi pour lancer le simulateur du prototype almo1. Pour cette première séance, les Makefiles ne permettent pas de faire des recompilations partielles de fichiers. Les Makefiles sont utilisés pour agréger toutes les actions que nous voulons faire sur les fichiers, c'est-à-dire : compiler, exécuter avec ou sans trace, nettoyer le répertoire. Nous avons recopié partiellement le premier Makefile pour montrer sa forme et poser quelques questions, auxquels vous savez certainement répondre.

# Tools and parameters definitions
# ------------------------------------------------------------------------------
NTTY   ?= 2 #                          default number of ttys

CC      = mipsel-unknown-elf-gcc #     compiler
LD      = mipsel-unknown-elf-ld #      linker
OD      = mipsel-unknown-elf-objdump # desassembler
SX      = almo1.x #                    prototype simulator

CFLAGS  = -c #                         stop after compilation, then produce .o
CFLAGS += -Wall -Werror #              gives almost all C warnings and considers them to be errors
CFLAGS += -mips32r2 #                  define of MIPS version
CFLAGS += -std=c99 #                   define of syntax version of C
CFLAGS += -fno-common #                do not use common sections for non-static vars (only bss)
CFLAGS += -fno-builtin #               do not use builtin functions of gcc (such as strlen)
CFLAGS += -fomit-frame-pointer #       only use of stack pointer ($29)
CFLAGS += -G0 #                        do not use global data pointer ($28)
CFLAGS += -O3 #                        full optimisation mode of compiler
CFLAGS += -I. #                        directories where include files like <file.h> are located
CFLAGS += -DNTTYS=$(NTTY) #            #define NTTYS with the number of ttys in the prototype    

# Rules (here they are used such as simple shell scripts)
# ------------------------------------------------------------------------------
help:
    @echo "\nUsage : make <compil|exec|clean> [NTTY=num]\n"
    @echo "        compil  : compiles all sources"
    @echo "        exec    : executes the prototype"
    @echo "        clean   : clean all compiled files\n"

compil:
    $(CC) -o hcpu.o $(CFLAGS) hcpua.S
    @$(OD) -D hcpua.o > hcpua.o.s
    $(LD) -o kernel.x -T kernel.ld hcpu.o
    @$(OD) -D kernel.x > kernel.x.s

exec: compil
    $(SX) -KERNEL kernel.x -NTTYS $(NTTY)

clean:
    -rm *.o* *.x* *~ *.log.* proc?_term? 2> /dev/null || true
  1. Au début du fichier se trouve la déclaration des variables du Makefile, quelle est la différence entre =, ?= et += ?
  2. Où est utilisé CFLAGS ? Que fait -DNTTYS=$(NTTY) et pourquoi est-ce utile ici ?
  3. Si on exécute make sans cible, que se passe-t-il ?
  4. à quoi servent @ et - au début de certaines commandes ?

A5. Les modes d'exécution du MIPS

Dans cette section, nous allons nous intéresser à ce que propose le processeur MIPS concernant les modes d'exécution. Ce sont des questions portant sur l'usage des modes en général et le comportement du MIPS vis-à-vis de ces modes en particulier. Dans la section A7, nous verrons le code de gestion des changements de mode dans le noyau.

Questions

  1. Le MIPS propose deux modes d'exécution, rappelez quels sont ces deux modes et à quoi ils servent? (Nous l'avons dit dans le descriptif de la séance).
  2. Commencez par rappeler ce qu'est l'espace d'adressage du MIPS et dîtes ce que signifie « une adresse X est mappée en mémoire ».
    Dîtes si une adresse X mappée en mémoire est toujours accessible (en lecture ou en écriture) quelque soit le mode d'exécution du MIPS.
  3. Le MIPS propose des registres à usage général (GPR General Purpose Register) pour les calculs ($0 à $31). Le MIPS propose un deuxième banc de registres à l'usage du système d'exploitation, ce sont les registres système (dit du coprocesseur 0).
    Comment sont-ils numérotés? Chaque registre porte un nom correspondant à son usage, quels sont ceux que vous connaissez: donner leur nom, leur numéro et leur rôle? Peut-on faire des calculs avec des registres? Quelles sont les instructions qui permettent de les manipuler?
  4. Le registre status est composé de plusieurs champs de bits qui ont chacun une fonction spécifique.
    Décrivez le contenu du registre status et le rôle des bits de l'octet 0 (seulement les bits vus en cours).
  5. Le registre cause est contient la cause d'appel du kernel.
    Dites à quel endroit est stockée cette cause et donnez la signification des codes 0, 4 et 8
  6. Le registre C0_EPC est un registre 32 bits qui contient une adresse. Vous devriez l'avoir décrit dans la question 2.
    Expliquez pourquoi ce doit être l'adresse de l'instruction qui provoque une exception qui doit être stockée dans C0_EPC?
  7. Nous avons vu trois instructions utilisables seulement lorsque le MIPS est en mode kernel, lesquelles? Que font-elles?
    Est-ce que l'instruction syscall peut-être utilisée en mode user?
  8. Quelle est l'adresse d'entrée dans le noyau?
  9. Que se passe-t-il quand le MIPS entre dans le noyau, après l'exécution de l'instruction syscall?
  10. Quelle instruction utilise-t-on pour sortir du noyau et entrer dans l'application ? Dîtes précisément ce que fait cette instruction dans le MIPS.

A6. Langage C pour la programmation système

La programmation en C, vous connaissez, mais quand on programme pour le noyau, c'est un peu différent. Il y a des éléments de syntaxe ou des besoins spécifiques.

Questions

  1. En assembleur, vous utilisez les sections prédéfinies .data et .text pour placer respectivement les data et le code ou alors vous pouvez créer vos propres sections avec la directive .section (nous avons utilisé cette possibilité pour la section .boot). Il est aussi possible d'imposer ou de créer des sections en langage C avec le mot clé __attribute__. Ce mot clé du C permet de demander certains comportements au compilateur. Il y a en a beaucoup (si cela vous intéresse vous pouvez regarder dans la doc de GCC sur les attributs. En cours, nous avons vu un attribut permettant de désigner ou créer une section dans laquelle est mise la fonction concernée. Quelle était la syntaxe de cet attribut (regardez sur le slide 37).
  2. En C, vous savez que les variables globales sont toujours initialisées, soit explicitement dans le programme lui-même, soit implicitement à la valeur 0. Les variables globales initialisées sont placées dans la section .data (ou plutôt dans l'une des sections data : .data, .sdata, .rodata, etc.) et elles sont présentes dans le fichier objet (.o) produit pas le compilateur. En revanche, les variables globales non explicitement initialisées ne sont pas présentes dans le fichier objet. Ces dernières sont placées dans un segment de la famille .bss. Le fichier ldscript permet de mapper l'ensemble des segments en mémoire. Pour pouvoir initialiser à 0 les segments bss par programme, il nous faut connaître les adresses de début et de fin où ils sont placés en mémoire.

    Le code ci-dessous est le fichier ldscript du kernel kernel.ld (nous avons retier les commentaires mais ils sont dans les fichiers).
    Expliquez ce que font les lignes 11, 12 et 15.
      1 SECTIONS
      2 {
      3     .boot : {
      4         *(.boot)            
      5     } > boot_region
      6     .ktext : {
      7         *(.text*)           
      8     } > ktext_region
      9     .kdata : {
     10         *(.*data*)          
     11         . = ALIGN(4);       
     12         __bss_origin = .;   
     13         *(.*bss*)           
     14         . = ALIGN(4);       
     15         __bss_end = .;      
     16     } > kdata_region
     17 }
    
  3. Nous connaissons les adresses des registres de périphériques. Ces adresses sont déclarées dans le fichier ldscript kernel.ld. Ci-après, nous avons la déclaration de la variable de ldscript __tty_regs_map. Cette variable est aussi utilisable dans les programmes C, mais pour être utilisable par le compilateur C, il est nécessaire de lui dire quel type de variable c'est, par exemple une adresse d'entier ou une adresse de tableau d'entiers, Ou encore, une adresse de structure.

    Dans le fichier kernel.ld:
    __tty_regs_map   = 0xd0200000 ; /* tty's registers map, described in devices.h */
    
    Dans le fichier harch.c :
     12 struct tty_s {
     13     int write;          // tty's output address
     14     int status;         // tty's status address something to read if not null)
     15     int read;           // tty's input address
     16     int unused;         // unused address
     17 }; 
     18 
     19 extern volatile struct tty_s __tty_regs_map[NTTYS];
    
    À quoi servent les mots clés extern et volatile ?
    Si NTTYS est une macro dont la valeur est 2, quelle est l'adresse en mémoire __tty_regs_map[1].read ?
  4. Certaines parties du noyau sont en assembleur. Il y a au moins les toutes premières instructions du code de boot (démarrage de l'ordinateur) et l'entrée dans le noyau après l'exécution d'un syscall. Le gestionnaire de syscall est écrit en assembleur et il a besoin d'appeler une fonction écrite en langage C. Ce que fait le gestionnaire de syscall est:
    • trouver l'adresse de la fonction C qu'il doit appeler pour exécuter le service demandé;
    • placer cette adresse dans un registre, par exemple $2;
    • exécuter l'instruction jal (ici, jal $2) pour appeler la fonction. Que doivent contenir les registres $4 à $7 et comment doit-être la pile?
  5. Vous avez appris à écrire des programmes assembleur, mais parfois il est plus simple, voire nécessaire, de mélanger le code C et le code assembleur. Dans l'exemple ci-dessous, nous voyons comment la fonction syscall() est écrite. Cette fonction utilise l'instruction syscall.
    Deux exemples d'usage de la fonction syscall() pris dans le fichier 04_libc/ulib/libc.c
      1 int fprintf (int tty, char *fmt, ...)
      2 {
      3     int res;
      4     char buffer[PRINTF_MAX];
      5     va_list ap;
      6     va_start (ap, fmt);
      7     res = vsnprintf(buffer, sizeof(buffer), fmt, ap);
      8     res = syscall (tty, (int)buffer, 0, 0, SYSCALL_TTY_PUTS);
      9     va_end(ap);
     10     return res;
     11 }
     12 
     13 void exit (int status)
     14 {
     15     syscall( status, 0, 0, 0, SYSCALL_EXIT);        // never returns
     16 }
    
    Le code de cette fonction est dans le fichier 04_libc/ulib/crt0.c
      1 //int syscall (int a0, int a1, int a2, int a3, int syscall_code)
      2 __asm__ (
      3 ".globl syscall     \n"         
      4 "syscall:           \n"         
      5 "   lw  $2,16($29)  \n"         
      6 "   syscall         \n"         
      7 "   jr  $31         \n"         
      8 );
    
    Combien d'arguments a la fonction syscall()? Comment la fonction syscall() reçoit-elle ses arguments ? A quoi sert la ligne 3 de la fonction syscall() et que se passe-t-il si on la retire ? Expliquer la ligne 5 de la fonction syscall(). Aurait-il été possible de mettre le code de la fonction syscall() dans un fichier .S ?

A7. Passage entre les modes kernel et user

Le noyau et l'application sont deux exécutables compilés indépendamment mais pas qui ne sont pas indépendants. Vous savez déjà que l'application appelle les services du noyau avec l'instruction syscall, voyons comment cela se passe vraiment depuis le code C. Certaines questions sont proches de celles déjà posées, c'est volontaire.

Questions

  1. Comment imposer le placement d'adresse d'une fonction ou d'une variable en mémoire?
  2. La fonction kinit() appelle la fonction __start() : kernel/kinit.c
    void kinit (void)
    {
        kprintf (banner);
    
        // put bss sections to zero. bss contains uninitialised global variables
        extern int __bss_origin;    // first int of bss section (defined in ldscript kernel.ld)
        extern int __bss_end;       // first int of above bss section (defined in ldscript kernel.ld)
        for (int *a = &__bss_origin; a != &__bss_end; *a++ = 0);
    
        extern int _start;          // _start is the entry point of the app (defined in kernel.ld)
        app_load (&_start);         // function to start the user app (defined in hcpua.S)
    }
    
    kernel/hcpua.S
    .globl app_load // ----------------------- void app_load (void * fun) called by kinit()
    app_load:                               // call when we exit kinit() function to go to user code
    
        mtc0   $4,      $14                 // put _start address in c0_EPC
        li     $26,     0x12                // define next status reg. value
        mtc0   $26,     $12                 // UM <- 1, IE <- 0, EXL <- 1
        la     $29,    __data_end           // define new user stack pointer
        eret                                // j EPC and EXL <- 0
    
    Dans le code précédent, $26 est un registre de travail pour le kernel. Quels sont les autres registres modifiés? Expliquez pour chacun la valeur affectée.
  3. Que faire avant l'exécution de la fonction main() du point de vue de l'initialisation? Et au retour de la fonction main()?
  4. Nous avons vu que le noyau est sollicité par des événements, quels sont-ils? Nous rappelons que l'instruction syscall initialise le registre c0_cause, comment le noyau fait-il pour connaître la cause de son appel?
  5. $26 et $27 sont deux registres temporaires que le noyau se réserve pour faire des calculs sans qu'il ait besoin de les sauvegarder dans la pile. Ce ne sont pas des registres système comme c0_sr ou c0_epc. En effet, l'usage de ces registres ($26 et $27) par l'utilisateur ne provoque pas d'exception du MIPS. Toutefois si le noyau est appelé alors il modifie ces registres et donc l'utilisateur perd leur valeur.
    Le code assembleur ci-après contient les instructions exécutées à l'entrée dans le noyau, quelle que soit la cause. Les commentaires présents dans le code ont été volontairement retirés (ils sont dans les fichiers du TP). La section .kentry est placée à l'adresse 0x80000000 par l'éditeur de lien. La directive .org (ligne 16) permet de déplacer le pointeur de remplissage de la section courante du nombre d'octets donnés en argument, ici 0x180. Pouvez-vous dire pourquoi ? Expliquer les lignes 25 à 28.

    kernel/hcpua.S
     15 .section    .kentry,"ax"     
     16 .org        0x180            
     22 
     23 kentry:                               
     24 
     25     mfc0    $26,    $13                     
     26     andi    $26,    $26,    0x3C          
     27     li      $27,    0x20                   
     28     bne     $26,    $27,    kpanic     
    
  6. Le gestionnaire de syscall est la partie du code qui gère le comportement du noyau lors de l'exécution de l'instruction syscall. C'est un code en assembleur présent dans le fichier kernel/hcpua.S que nous allons observer. Pour vous aider dans la compréhension de ce code, vous devez imaginer que l'instruction syscall est un peu comme un appel de fonction. Ce code utilise un tableau de pointeurs de fonctions nommé syscall_vector définit dans le fichier kernel/ksyscalls.c. Les lignes 47 à 54 sont chargées d'allouer de la place dans la pile.
    - Dessinez l'état de la pile après l'exécution de ces instructions.
    - Que fait l'instruction ligne 55 et quelle conséquence cela a-t-il?
    - Que font les lignes 57 à 62?
    - Et enfin que font les lignes 64 à 70 ?
    Les commentaires ont été laissés, vous devez juste mettre à quoi ça sert, sans détailler ligne à ligne.

    common/syscalls.h
    #define SYSCALL_EXIT        0       /* see exit()   in ulib/libc.c */
    #define SYSCALL_READ        1       /* see read()   in ulib/libc.c */
    #define SYSCALL_WRITE       2       /* see write()  in ulib/libc.c */
    #define SYSCALL_CLOCK       3       /* see clock()  in ulib/libc.c */
    #define SYSCALL_NR          32
    
    kernel/ksyscalls.c
    void *syscall_vector[] = {
        [0 ... SYSCALL_NR - 1] = unknown_syscall,   /* default function */
        [SYSCALL_EXIT        ] = exit,
        [SYSCALL_READ        ] = tty_read,
        [SYSCALL_WRITE       ] = tty_write,
        [SYSCALL_CLOCK       ] = clock,
    };
    
    kernel/hcpua.S
     45 syscall_handler:
     46 
     47     addiu   $29,    $29,    -8*4        // context for $31 + EPC + SR + syscall_code + 4 args
     48     mfc0    $27,    $14                 // $27 <- EPC (addr of syscall instruction)
     49     mfc0    $26,    $12                 // $26 <- SR (status register)
     50     addiu   $27,    $27,    4           // $27 <- EPC+4 (return address)
     51     sw      $31,    7*4($29)            // save $31 because it will be erased
     52     sw      $27,    6*4($29)            // save EPC+4 (return address of syscall)
     53     sw      $26,    5*4($29)            // save SR (status register)
     54     sw      $2,     4*4($29)            // save syscall code (useful for debug message)
     55     mtc0    $0,     $12                 // SR <- kernel-mode without INT (UM=0 ERL=0 EXL=0 IE=0)
     56 
     57     la      $26,    syscall_vector      // $26 <- table of syscall functions
     58     andi    $2,     $2,     SYSCALL_NR-1// apply syscall mask
     59     sll     $2,     $2,     2           // compute syscall index (mutiply by 4)
     60     addu    $2,     $26,    $2          // $2 <- & syscall_vector[$2]
     61     lw      $2,     ($2)                // at the end: $2 <- syscall_vector[$2]
     62     jalr    $2                          // call service function
     63 
     64     lw      $26,    5*4($29)            // get old SR
     65     lw      $27,    6*4($29)            // get return address of syscall
     66     lw      $31,    7*4($29)            // restore $31 (return address of syscall function)
     67     mtc0    $26,    $12                 // restore SR
     68     mtc0    $27,    $14                 // restore EPC
     69     addiu   $29,    $29,    8*4         // restore stack pointer
     70     eret                                // return : jr EPC with EXL <- 0
    

A8. Génération du code exécutable

Pour simuler le logiciel, il faut produire deux exécutables. Nous utilisons, ici, un Makefile hiérarchique et des règles explicites. Cela sort du cadre de l'architecture, mais vous avez besoin de ce savoir-faire pour comprendre le code, alors allons-y.

Questions

  1. Rappelez à quoi sert un Makefile?
  2. Vous n'allez pas à avoir à écrire un Makefile complètement. Toutefois, si vous ajoutez des fichiers source, vous allez devoir les modifier en ajoutant des règles. Nous avons vu brièvement la syntaxe utilisée dans les Makefiles de ce TP au cours n°1. Les lignes qui suivent sont des extraits de 03_klibc/Makefile (le Makefile de l'étape1). Dans cet extrait, quelles sont la cible finale, les cibles intermédiaires et les sources? A quoi servent les variables automatiques de make? Dans ces deux règles, donnez-en la valeur.
    kernel.x : kernel.ld obj/hcpu.o obj/kinit.o obj/klibc.o obj/harch.o
        $(LD) -o $@ -T $^
        $(OD) -D $@ > $@.s
    
    obj/hcpua.o : hcpua.S hcpu.h
        $(CC) -o $@ $(CFLAGS) $<
        $(OD) -D $@ > $@.s
    
  3. Dans le TP, à partir de la deuxième étape, nous avons trois répertoires de sources kernel, ulib et uapp. Chaque répertoire contient une fichier Makefile différent destiné à produire une cible différente grâce à une règle nommée compil, c.-à-d. si vous tapez make compil dans un de ces répertoires, cela compile les sources locales.
    Il y a aussi un Makefile dans le répertoire racine 04_libc. Dans ce dernier Makefile, une des règles est destinée à la compilation de l'ensemble des sources dans les trois sous-répertoires. Cette règle appelle récursivement la commande make en donnant en argument le nom du sous-répertoire où descendre :
    make -C <répertoire> [cible] est équivalent à cd <répertoire>; make [cible] ; cd ..
    Ecrivez la règle compil du fichier 04_libc/Makefile.
    04_libc/
    ├── Makefile        : Makefile racine qui invoque les Makefiles des sous-répertoires et qui exécute
    ├── common ────────── répertoire des fichiers commun kernel / user
    ├── kernel ────────── Répertoire des fichiers composant le kernel 
    │   └── Makefile    : description des actions possibles sur le code kernel : compilation et nettoyage
    ├── uapp ──────────── Répertoire des fichiers de l'application user seule
    │   └── Makefile    : description des actions possibles sur le code user : compilation et nettoyage
    └── ulib ──────────── Répertoire des fichiers des bibliothèques système liés avec l'application user
        └── Makefile    : description des actions possibles sur le code user : compilation et nettoyage
    

B. Travaux pratiques

Pour les travaux pratiques, vous devez d'abord répondre aux questions, elles ont pour but de vous faire lire le code et revoir les points du cours. Les réponses sont dans le cours ou dans les fichiers sources. Certaines ont déjà été traitées dans la partie TD, c'est normal. Ensuite, vous passez aux exercices pratiques.

Pour récupérer le code, référez-vous à la section Récupération du code du TP de la page principale de ce site.

Les premières étapes du TP sont uniquement dans le noyau et le MIPS est alors en mode kernel puis, à la fin, les applications de l'utilisateur s'exécutent en mode user, au-dessus d'une petite libc, à laquelle vous devez ajouter un service de copie de mémoire (memcpy)

B1. Saut dans la fonction kinit() du noyau en langage C

Dans ce premier programme, le code de boot entre dans le noyau par la fonction C kinit(), c'est une fonction et donc il faut absolument une pile d'exécution. C'est un tout petit programme, mais pour obtenir l'exécutable, vous devrez utiliser tous les outils de la chaîne de cross-compilation MIPS et pour l'exécuter vous devrez exécuter le simulateur du prototype.

Objectifs

  • produire un exécutable à partir d'un code en assembleur et en C
  • savoir comment afficher un caractère sur un terminal.
  • savoir analyser une trace d'exécution
  • Savoir comment et où déclarer la pile d'exécution du noyau.
  • Savoir comment afficher un caractère sur un terminal depuis un programme C.

Fichiers

01_init_c/
├── hcpua.S      : code dépendant du cpu matériel en assembleur
├── kernel.ld    : ldscript décrivant l'espace d'adressage pour l'éditeur de lien
├── kinit.c      : fichier en C contenant le code de démarrage du noyau, ici c'est la fonction kinit().
└── Makefile     : description des actions possibles sur le code : compilation, exécution, nettoyage, etc.

Questions

  1. Dans quel fichier se trouve la description de l'espace d'adressage du MIPS ? Que trouve-t-on dans ce fichier ?
  2. Dans quel fichier se trouve le code de boot et pourquoi, selon vous, avoir nommé ce fichier ainsi ?
  3. À quelle adresse démarre le MIPS ? Où peut-on le vérifier ?
  4. Que produit gcc quand on utilise l'option -c ?
  5. Que fait l'éditeur de liens ? Comment est-il invoqué ?
  6. De quels fichiers a besoin l'éditeur de liens pour fonctionner ?
  7. Dans quelle section se trouve le code de boot ? (la réponse est dans le code assembleur)
  8. Dans quelle région de la mémoire le code de boot est-il placé ? (la réponse est dans kernel.ld)
  9. Comment connaît-on l'adresse du registre de sortie du contrôleur de terminal TTY ?
  10. Quand faut-il initialiser la pile ? Dans quel fichier est-ce ? Quelle est la valeur du pointeur initial ?
  11. Dans quel fichier le mot clé volatile est-il utilisé ? Rappeler son rôle.

Exercices

  • Exécutez le programme en lançant le simulateur avec make exec, qu'observez-vous ?
  • Exécutez le programme en lançant le simulateur avec make debug.
    Cela exécute le programme pour une courte durée et cela produit deux fichiers trace0.s et label0.s.

    • trace0.s contient la trace des instructions assembleur exécutées par le processeur.
      Ouvrez trace.0.s et repérez ce qui est cité ici
      • On voit la séquence des instructions exécutées
      • La première colonne nous informe que les adresses lues sont dans l'espace Kernel
      • La seconde colonne sont les numéros de cycles
      • La troisième sont les adresses des instructions
      • La quatrième le code binaire des instructions
      • Le reste de la ligne contient l'instruction désassemblée
      • Lorsque les adresses ont un nom, c'est à dire qu'une étiquette leur a été attribuée, celle-ci est indiquée.
    • label0.s contient la séquence des appels de fonctions de l'exécutions. C'est en fait un extrait de la trace.
      Ouvrez le fichier label0.s et interprétez ce que vous voyez.
    • Si vous ouvrez le Makefile, vous pouvez voir que le mode d'optimisation du compilateur est O1 (regardez la définition de CFLAGS). Si vous demandez une optimisation en 02 ou 03, et que vous exécutez à nouveau votre programme en mode debug, qu'observez-vous dans la trace d'exécution ?
  • Ouvrez les fichiers kinit.o.s et kernel.x.s, le premier fichier est le désassemblage de kinit.o et le second est le désassemblage de kernel.x. Dans ces fichiers, vous avez plusieurs sections. Les sections .MIPS.abiflags, .reginfo et .pdr ne nous sont pas utiles (elles servent au chargeur d'application, elles contiennent des informations sur le contenu du fichier et cela ne nous intéresse pas).
    Notez l'adresse de kinit dans les deux fichiers, sont-ce les mêmes ? Sont-elles dans les mêmes sections ? Expliquez pourquoi.
  • Modifiez le code de kinit.c et afficher un second message ?

B2. Premier petit pilote pour le terminal

Le prototype de SoC que nous utilisons pour les TP est configurable. Il est possible par exemple de choisir le nombre de terminaux texte (TTY). Par défaut, il y en a un mais, nous pouvons en avoir jusqu'à 4. Nous allons modifier le code du noyau pour s'adapter à cette variabilité. En outre, nous allons ajouter un niveau d'abstraction qui représente un début de pilote de périphérique (device driver). Ce pilote, même tout petit, constitue une couche logicielle avec une API.

Objectifs

  • Savoir comment créer un début de pilote pour le terminal TTY.
  • Savoir comment décrire une API en C
  • Savoir appeler une fonction en assembleur depuis le C

Fichiers

02_driver/
├── harch.c      : code dépendant de l'architecture du SoC, pour le moment c'est juste le pilote du TTY
├── harch.h      : API du code dépendant de l'architecture
├── hcpu.h       : prototype de la fonction clock()
├── hcpua.S      : code dépendant du cpu matériel en assembleur
├── kernel.ld    : ldscript décrivant l'espace d'adressage pour l'éditeur de lien
├── kinit.c      : fichier en C contenant le code de démarrage du noyau, ici c'est la fonction kinit().
└── Makefile     : description des actions possibles sur le code : compilation, exécution, nettoyage, etc.

Questions

  1. Le code du driver du TTY est dans le fichier harch.c et les prototypes sont dans harch.h. Si vous ouvrez harch.h vous allez voir que seuls les prototypes des fonctions tty_read() et tty_write() sont présents. La structure décrivant la carte des registres du TTY est déclarée dans le .c. Pourquoi avoir fait ainsi ?
  2. Le MIPS dispose d'un compteur de cycles internes. Ce compteur est dans un banc de registres accessibles uniquement quand le processeur fonctionne en mode kernel. Nous verrons ça au prochain cours, mais en attendant nous allons quand même exploiter ce compteur. Pourquoi avoir mis la fonction dans hcpua.S ? Rappeler, pourquoi avoir mis .globl clock
  3. Compilez et exécutez le code avec make exec. Observez. Ensuite ouvrez le fichier kernel.x.s et regardez où a été placée la fonction clock().
    Est-ce un problème si kinit() n'est plus au début du segment ktext ? Pour répondre, posez-vous la question de qui a besoin de connaître l'adresse de kinit()

Exercices

  • Ecrire une fonction void Capitalize(void) appelée par la fonction kinit() qui lit une phrase terminée par un \n et la réécrit en ayant mis en majuscule la première lettre de chaque mot. Vous mettrez cette nouvelle fonction dans le fichier kinit.c (ce ne devrait pas être sa place mais c'est juste un exercice). Notez que vous ne pouvez pas utiliser la fonction `toupper()` parce que c'est une fonction de la glibc (la bibliothèque de la librairie de fonctions standards) et que là vous ne l'avez pas. Vous n'êtes pas sur Linux :-)

B3. Ajout d'une bibliothèque de fonctions standards pour le kernel (klibc)

Objectifs de l'étape

Le noyau gère les ressources matérielles et logicielles utilisées par les applications. Il a besoin de fonctions standards pour réaliser des opérations de base, telles qu'une fonction print ou une fonction rand. Ces fonctions ne sont pas très originales, mais elles recèlent des subtilités que vous ne connaissez peut-être pas encore, vous pouvez les regarder par curiosité. En outre, nous allons utiliser un Makefile définissant un graphe de dépendance explicite entre les fichiers cibles et les fichiers sources avec des règles de construction.

Fichiers

03_klibc/
├── kinit.c         : fichier contenant la fonction de démarrage du noyau
├── harch.h         : API du code dépendant de l'architecture
├── harch.c         : code dépendant de l'architecture du SoC
├── hcpu.h          : prototype de la fonction clock()
├── hcpua.S         : code dépendant du cpu matériel en assembleur
├── kernel.ld       : ldscript décrivant l'espace d'adressage pour l'éditeur de lien
├── klibc.h         : API de la klibc
├── klibc.c         : fonctions standards utilisées par les modules du noyau
└── Makefile        : description des actions possibles sur le code : compilation, exécution, nettoyage, etc.

Questions

  1. Ouvrez le fichier Makefile, En ouvrant tous les fichiers dessiner le graphe de dépendance de kernel.x vis-à-vis de ses sources?
    kernel.x : kernel.ld obj/hcpua.o obj/kinit.o obj/klibc.o obj/harch.o
    obj/hcpua.o : hcpua.S hcpu.h
    obj/kinit.o : kinit.c klibc.h harch.h hcpu.h
    obj/klibc.o : klibc.c klibc.h harch.h hcpu.h
    obj/harch.o : harch.c klibc.h harch.h hcpu.h
    
  1. Dans quel fichier se trouvent les codes dépendant du MIPS ?

Exercices

  • Le numéro du processeur est dans les 12 bits de poids faible du registre $15 (c0_cpuid) du coprocesseur système (à côté des registres c0_epc, c0_sr, etc.). Ajoutez la fonction int cpuid(void) qui lit le registre c0_cpuid et qui rend un entier contenant juste les 12 bits de poids faible.
    Vous pouvez vous inspirez fortement de la fonction int clock(void). Comme il n'y a qu'un seul processeur dans cette architecture, cpuid rend toujours 0.
    Ecrivez un programme de test (vous devrez modifier les fichiers hcpu.h, hcpua.S et kinit.c)

B4. Ajout de la librairie C pour l'utilisateur

Objectifs de l'étape

L'application utilisateur n'est pas censée utiliser directement les appels système. Elle utilise une librairie de fonctions standards (la libc POSIX, mais également d'autres) et ce sont ces fonctions qui réalisent les appels système. Toutes les fonctions de la libc n'utilisent pas les appels système. Par exemple, les fonctions int rand(void) ou int strlen(char *) (rendent, respectivement, un nombre pseudoaléatoire et la longueur d'une chaîne de caractères) n'ont pas besoin du noyau. Les librairies font partie du système d'exploitation mais elles ne sont pas dans le noyau.

Le terme « librairie » vient de l'anglais « library » qui signifie bibliothèque. On utilise souvent le mot librairie même si le sens en français n'est pas le même que celui en anglais. Disons que, dans notre contexte, les deux mots sont synonymes.

Normalement, les librairies système sont des « vraies » librairies au sens gcc du terme. C'est-à-dire des archives de fichiers objet (.o). Ici, nous allons simplifier et ne pas créer une vraie librairie, mais seulement un fichier objet libc.o contenant toutes les fonctions. Ce fichier objets doit être lié avec le code de l'application.

L'exécutable de l'application utilisateur est donc composé de deux parties : d'un côté, le code de l'application et, de l'autre, le code de la librairie libc (+ crt0). Nous allons répartir le code dans deux répertoires uapp pour les fichiers de l'application et ulib pour les fichiers qui ne sont pas l'application, c'est-à-dire la libc, le fichier crt0.c mais aussi le fichier ldscript user.ld.

On rappelle que le fichier crt0.c contient le code d'entrée dans l'application avec la fonction _start() appelée par la fonction kinit(). C'est aussi, dans ce fichier que l'on met le code assembleur de la fonction syscall_fct() permettant de revenir dans le noyau. En conséquence, crt0.c, c'est le pont entre le noyau et l'application.

Fichiers

04_libc/
├── Makefile        : Makefile racine qui invoque les Makefiles des sous-répertoires et qui exécute
├── common ────────── répertoire des fichiers commun kernel / user
│   └── syscalls.h  : API la fonction syscall et des codes de syscalls
├── kernel ────────── Répertoire des fichiers composant le kernel
│   ├── kinit.c     : fichier contenant la fonction de démarrage du noyau
│   ├── harch.h     : API du code dépendant de l'architecture
│   ├── harch.c     : code dépendant de l'architecture du SoC
│   ├── hcpu.h      : prototype de la fonction clock()
│   ├── hcpua.S     : code dépendant du cpu matériel en assembleur
│   ├── hcpuc.c     : code dépendant du cpu matériel en c
│   ├── klibc.h     : API de la klibc
│   ├── klibc.c     : fonctions standards utilisées par les modules du noyau
│   ├── ksyscalls.c : Vecteurs des syscalls
│   ├── kernel.ld   : ldscript décrivant l'espace d'adressage pour l'édition de liens du kernel
│   └── Makefile    : description des actions possibles sur le code kernel : compilation et nettoyage
├── uapp ──────────── Répertoire des fichiers de l'application user seule
│   ├── main.c      : fonction principale de l'application
│   └── Makefile    : description des actions possibles sur le code user : compilation et nettoyage
└── ulib ──────────── Répertoire des fichiers des bibliothèques système liés avec l'application user
├── crt0.c      : fonctions d'interface entre kernel et user, pour le moment : _start()
├── libc.h      : API pseudo-POSIX de la bibliothèque C
├── libc.c      : code source de la libc
├── user.ld     : ldscript décrivant l'espace d'adressage pour l'édition de liens du user
└── Makefile    : description des actions possibles sur le code user : compilation et nettoyage04_libc/

Questions

  1. Pour ce petit système, dans quel fichier sont placés tous les prototypes des fonctions de la libc? Est-ce ainsi pour POSIX sur LINUX?
  2. Dans quel fichier se trouve la définition des numéros de services tels que SYSCALL_EXIT ?
  3. Dans quel fichier se trouve le vecteur de syscall, c'est-à-dire le tableau syscall_vector[] contenant les pointeurs sur les fonctions qui réalisent les services correspondants aux syscall ?
  4. Dans quel fichier se trouve le gestionnaire de syscalls ?

Exercice

Pour finir ce TME (un peu long 🤪 ), vous allez juste ajouter une boucle d'affichage des caractères ASCII au début de la fonction main() en utilisant la fonction de la libc fputc(tty,c) (avec tty à 0 pour un affichage sur le terminal 0, et c la variable contenant le caractère à afficher, qui prendra toutes les valeurs entre 32 et 127.

  • Je vous donne le code dans le corrigé, mais ça fait seulement 2 lignes, alors je pense que vous n'en aurez pas besoin ! 🙂
  • Ensuite, quand ça marche, exécutez le programme en mode débug (make debug au lieu de make exec) et ouvrez le fichier trace0.s. A quel cycle, commence la fonction main() ?
  • Recompilez le kernel en utilisant le mode -O0(lettre 0 suivie du chiffre zéro), réexécutez l'application en mode debug et regardez à nouveau à quelle cycle commence la fonction main() ?
  • Pour finir, recompilez à nouveau le noyau en utilisant le mode -O3, réexécutez encore l'application en mode debug et regardez combien de cycles sont nécessaires pour exécuter la fonction fputc(). Pour ça, vous ouvrez le fichier trace0.s, vous cherchez le premier appel de fputc() (vous notez le cycle) et vous cherchez l'instruction eret qui marque la sortie du kernel (vous notez le cycle) et vous faites la différence ? Profitez en pour voir l'entrée dans le kernel, l'analyse de la cause, l'utilisation du vecteur de syscall, etc.
  • Refaites le calcul pour le deuxième appel de fputc(), que constatez-vous ? Avez-vous une explication ?
Last modified 9 months ago Last modified on Feb 13, 2024, 10:29:59 AM