print · rss · source

< Créer et exécuter une tâche | TutoOS | Gérer la mémoire - utiliser la pagination >


Un noyau mono-tâche qui implémente des appels systèmes

Sources

Le package contenant les sources est téléchargeable ici : kernel_MonoTask_Syscall.tgz
Pour naviguer dans l'arborescence : MonoTask_Syscall


Des appels système pour accéder aux services du noyau

Pourquoi ?

Le mécanisme de segmentation empèche le code utilisateur d'accéder librement aux périphériques ou au noyau. Pour accéder à ces ressources, par exemple pour écrire dans la mémoire vidéo ou sur disque, le code utilisateur utilise des services implémentés au niveau du noyau : les appels systèmes. Le noyau illustré ici implémente un appel système permettant d'afficher un message à l'écran.

Appeler une routine privilégiée à l'aide d'une interruption logicielle

Nous avons vu aux chapitres précédent que des interruptions peuvent êtres déclenchées par les périphériques ou directement par le processeur en cas d'exception. Un troisième type d'interruption existe, ce sont les interruptions logicielles, déclenchées volontairement par le code noyau ou utilisateur à l'aide de l'instruction int. Ce sont ces interruptions qui vont nous servir pour implémenter les appels système.

Une interruption logicielle qui utilise un Trap Gate

Nous avons déjà détaillé la façon dont fonctionnent les interruptions matérielles. Les interruptions logicielles fonctionnent exactement de la même façon à deux différences prèt :

  • nous allons utiliser un descripteur de Trap Gate au lieu d'un descripteur de type Interrupt Gate. La seule différence entre les deux est qu'un Trap Gate ne désactive pas les interruptions.
  • nous allons initialiser différement le champ DPL du descripteur

Descripteur système de type Trap Gate

Le DPL (Descriptor Privilege Level) est utilisé pour contrôler l'accès au segment selon le niveau de privilège du code appelant. La valeur 3 indique que toutes les applications, même les moins privilégiées, peuvent utiliser le trap gate tandis que la valeur 0 restreint son utilisation au seul code noyau. Comme nous voulons que le trap gate soit utilisable par les applications utilisateur, il faut un DPL de 3 :

Il est initialisé à l'aide du code suivant :

init_idt_desc(0x08, (u32) _asm_syscalls, 0xEF00, &kidt[48]); /* appels systeme - int 0x30 */

Grâce à ce descripteur, suite à une interruption de type int 0x30, le processeur va exécuter la routine de service _asm_syscalls.

Comment passer des paramètres à l'appel système ?

Nous avons vu ci-dessus qu'un appel système se fait très simplement grâce aux interruptions logicielles. Le noyau peut ainsi fournir des services aux applications utilisateur, mais comment passer des paramètres à ces services ? Deux solutions sont très courantes :

  • passer les paramètres via la pile utilisateur
  • passer les paramètres en les plaçant dans les registres, la plus simple à implémenter

Nous allons voir comment implémenter la deuxième solution. Elle a le mérite d'être simple, mais le nombre réduit de registres sur les architectures i386 limite le nombre de paramètres que l'on peut passer par cette méthode (eax, ebx, ecx, edx, edi et esi).

Dans notre implémentation d'un appel système qui affiche une chaîne de caractère à l'écran, les registres utilisés sont :

  • eax, qui contient le numéro d'appel système
  • ebx, qui contient l'adresse de la chaîne de caractères à afficher

Le code qui réalise l'appel :

asm("mov %0, %%ebx; mov $0x01, %%eax; int $0x30" :: "m" (msg));

La routine d'interruption (1)

La routine de traitement de l'interruption est semblable à celles déjà vu pour les interruptions matérielles. Elle commence par sauvegarder les registres utilisateur et par initialiser le registre DS pour qu'il pointe sur le segment de données du noyau. Ensuite, elle pousse sur la pile le registre eax, qui contient le numéro de l'appel système, pour le transmettre à la fonction do_syscalls() appelée juste après :

_asm_syscalls:
        SAVE_REGS
        push eax                 ; transmission du numero d'appel
        call do_syscalls
        pop eax
        RESTORE_REGS
        iret

La routine d'interruption (2)

La fonction do_syscalls() effectue le vrai travail de gestion de l'interruption. Le principe de cette fonction est simple :

  • déterminer l'appel système grâce au numéro passé en argument à la fonction
  • récupérer les paramètres de l'appel, placés dans les registres et poussés sur la pile
#include "types.h"
#include "gdt.h"
#include "screen.h"

void do_syscalls(int sys_num)
{
        u16 ds_select;
        u32 ds_base;
        struct gdtdesc *ds;
        uchar *message;

        if (sys_num == 1) {
                asm("   mov 44(%%ebp), %%eax    \n \
                        mov %%eax, %0           \n \
                        mov 24(%%ebp), %%ax     \n \
                        mov %%ax, %1"
: "=m"(message), "=m"(ds_select) : );

                ds = (struct gdtdesc *) (GDTBASE + (ds_select & 0xF8));
                ds_base = ds->base0_15 + (ds->base16_23 << 16) + (ds->base24_31 << 24);

                print((char*) (ds_base + message));
        } else {
                print("syscall\n");
        }

        return;
}

Que fait exactement cette fonction ?

void do_syscalls(int sys_num)

La ligne ci-dessus permet de récupérer le numéro d'appel système passé en paramètre à la fonction (par le biais de l'instruction push eax). Dans notre implémentation, il n'y a pour le moment qu'un appel système, mais un noyau en a généralement plusieurs dizaines ou centaines.

if (sys_num == 1) {

Le bloc traite l'appel système numéro 1.

asm("   mov 44(%%ebp), %%eax    \n \
        mov %%eax, %0           \n \
        mov 24(%%ebp), %%ax     \n \
        mov %%ax, %1"
: "=m" (message), "=m" (ds_select) : );

Dans notre implémentation, les paramètres sont passés à l'appel système à l'aide des registres. Nous savons que l'adresse de la chaîne à afficher a été placée par le programme utilisateur dans le registre EBX. Mais à cet endroit de la fonction do_syscalls, rien ne nous garanti que ce registre n'ait pas été modifié. Heureusement, ce registre a été sauvegardé sur la pile noyau, ce qui permet de récupérer sa valeur.

Une fois récupérée la valeur de EBX, on devrait pouvoir afficher la chaîne sans problème ? Et bien non !
La chaîne de caractères contenant le message à afficher n'est toujours pas accessible car EBX contient seulement un déplacement et il faut récupérer l'adresse de la base du segment de données utilisateur pour connaître l'adresse physique exacte où se situe la chaîne. On obtient cette adresse à partir de la valeur sauvegardée du registre DS qui pointe sur le bon descripteur de segment dans la GDT.
Note : l'annexe sur les Stack Frame explique dans le détail comment récupérer des paramètres sur la pile.

ds = (struct gdtdesc*) (GDTBASE + (ds_select & 0xF8));
ds_base = ds->base0_15 + (ds->base16_23 << 16) + (ds->base24_31 << 24);

A partir de la valeur ds_select, on retrouve l'emplacement du descripteur de segment de données dans la GDT. La valeur du registre DS n'est pas utilisée telle qu'elle car le champ RPL a été positionné. Pour obtenir l'offset du descripteur dans la GDT, il faut appliquer le masque 0xF8. L'adresse de base est ensuite reconstituée à partir de ses différents champs.

print(ds_base + message);

Enfin, la fonction print() est appelée avec l'adresse physique de la chaîne à afficher.

} else {
    print("syscall\n");
}
return;

Pour les autres appels systèmes, on affiche seulement un petit message à l'écran et la fonction se termine.

Charger une tâche

Le code applicatif est assez simple et affiche simplement un message avant de boucler indéfiniment.

Le modèle d'organisation de la mémoire utilisé est décrit au chapitre précédent. Il spécifie que le code applicatif, défini dans par la fonction task1(), doit être en 0x30000. Comme ce code fait au maximum 100 octets, le noyau le recopie directement à l'adresse voulue :

/* copie de la fonction a son adresse */
memcpy((char*) 0x30000, (char*) &task1, 100); /* copie de 100 instructions */

Les données utilisateur, ici le message à afficher, doivent être stockées dans le segment entre 0x30000 et 0x31000. En principe, nous aimerions avoir une fonction utilisateur ressemblant à ceci :

void task1(void)
{
       char *msg = "hello world !\n";

Hélas, à ce stade du développement de notre noyau, ça n'est pas possible car la fonction task1() fait partie du noyau et n'a pas été compilée pour tenir compte du fait qu'elle serait relogée ailleurs. Nous sommes donc obligés de ruser un peu pour stocker la chaîne de caractère quelque part entre 0x30000 et 0x31000. Arbitrairement (enfin pas tout à fait, nous avons pris soin de prendre une valeur qui n'écrase pas le code de la fonction), la chaîne est placée en 0x30100 :

void task1(void)
{
       char *msg = (char*) 0x100; /* le message sera stocké en 0x30100 */
       msg[0] = 't';
       msg[1] = 'a';
       msg[2] = 's';
       msg[3] = 'k';
       msg[4] = '1';
       msg[5] = '\n';
       msg[6] = 0;

       asm("mov %0, %%ebx; mov $0x01, %%eax; int $0x30" :: "m" (msg));
       while(1);
       return; /* never goes there */
}

Une fois la chaîne placée en 0x30100, le code peut exécuter l'appel système permettant de l'afficher à l'écran :

asm("mov %0, %%ebx; mov $0x01, %%eax; int $0x30" :: "m" (msg));

Exécuter le noyau

La tâche en mode utilisateur fait un appel système au service d'affichage, en mode noyau, qui écrit dans la mémoire vidéo :


< Créer et exécuter une tâche | TutoOS | Gérer la mémoire - utiliser la pagination >

print · rss · source
Page last modified on October 26, 2010, at 08:43 AM