print · rss · source

< Un système multi-tâches simple | TutoOS | Booter avec Grub >


Un noyau multi-tâches avec des appels systèmes préemptibles

Sources

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


Un scheduler qui tient compte du contexte d'exécution

Une pile noyau par tâche : pourquoi ?

Quand une tâche fait un appel système, la pile noyau est automatiquement utilisée par le processeur. Les routines de traitement de cet appel utilisent alors cette pile pour stocker des données, passer des paramètres à des fonctions, etc. Une fois que l'appel système est terminé, les données temporaires sont dépilées et la pile noyau retourne à son état initial, telle qu'elle était avant l'appel.

Si l'on autorise les interruptions et la commutation de tâches pendant un appel système, voilà ce qui risque de se passer :

  1. Par exemple, la tâche A fait un appel système et utilise la pile noyau pour traiter cet appel.
  2. L'ordonanceur est activé alors que l'appel n'est pas terminé et il donne la main à la tâche B.
  3. La tâche B fait elle aussi un appel système, et elle utilise donc elle aussi la pile du noyau qui contenait déja les données en cours pour le traitement de l'appel de la tâche A. A ce moment, la pile noyau contient des données de A et des données de B.
  4. L'ordonnanceur active de nouveau la tâche A et celle-ci reprend sans se douter que la pile a été modifiée. Comme elle va utiliser la pile en supposant qu'elle est dans l'état où elle l'avait laissé, elle va corrompre les données de la tâche B.
  5. La tâche B reprend mais la pile est corrompue... et c'est le drame !

Pour éviter ce scénario catastrophe, il existe une solution très simple : il suffit que chaque tâche ait sa propre pile noyau !

Une pile noyau par tâche : comment

La fonction load_task() est modifiée afin d'associer à chaque tâche sa propre pile noyau : process.c.

#include "lib.h"
#include "mm.h"

#define __PLIST__
#include "process.h"


void load_task(u32 * code_phys_addr, u32 * fn, unsigned int code_size)
{
        u32 page_base, pages, kstack_base;
        u32 *pd;
        int i;

        /* Copie du code a l'adresse specifiee */
        memcpy((char *) code_phys_addr, (char *) fn, code_size);

        /* Mise a jour du bitmap */
        page_base = (u32) PAGE(code_phys_addr);

        if (code_size % PAGESIZE)
                pages = code_size / PAGESIZE + 1;
        else
                pages = code_size / PAGESIZE;

        for (i = 0; i < pages; i++)
                set_page_frame_used(page_base + i);

        /* Creation du repertoire et des tables de pages */
        pd = pd_create(code_phys_addr, code_size);

        kstack_base = (u32) get_page_frame();
        if (kstack_base > 0x400000) {
                printk("not enough memory to create a kernel stack\n");
                return;
        }

        /* Initialisation des registres */
        p_list[n_proc].pid = n_proc;
        p_list[n_proc].regs.ss = 0x33;
        p_list[n_proc].regs.esp = 0x40001000;
        p_list[n_proc].regs.eflags = 0x0;
        p_list[n_proc].regs.cs = 0x23;
        p_list[n_proc].regs.eip = 0x40000000;
        p_list[n_proc].regs.ds = 0x2B;
        p_list[n_proc].regs.es = 0x2B;
        p_list[n_proc].regs.fs = 0x2B;
        p_list[n_proc].regs.gs = 0x2B;
        p_list[n_proc].regs.cr3 = (u32) pd;

        p_list[n_proc].kstack.ss0 = 0x18;
        p_list[n_proc].kstack.esp0 = kstack_base + PAGESIZE;

        p_list[n_proc].regs.eax = 0;
        p_list[n_proc].regs.ecx = 0;
        p_list[n_proc].regs.edx = 0;
        p_list[n_proc].regs.ebx = 0;

        p_list[n_proc].regs.ebp = 0;
        p_list[n_proc].regs.esi = 0;
        p_list[n_proc].regs.edi = 0;

        n_proc++;
}

Le code ci-dessous alloue à une tâche sa propre pile noyau. Par simplicité, une pile occupe une page :

kstack_base = (u32) get_page_frame();

/* ... */

p_list[n_proc].kstack.ss0 = 0x18;
p_list[n_proc].kstack.esp0 = kstack_base + PAGESIZE;

Suite à une interruption, le processeur empile automatiquement des données sur la pile noyau de la tâche interrompue, sans compter celles ajoutées par les différentes routines du noyau. Il est crucial qu'à l'issue d'une interruption, la pile noyau utilisée soit remise dans le même état qu'avant l'interruption.

L'ordonnanceur

La complexité de l'ordonnanceur provient essentiellement des difficultés induites par la gestion des différentes piles noyau et utilisateur.

Le code de l'ordonnanceur est réparti dans deux fichiers :

  • schedule.c contient la partie préparatoire au changement de contexte
#include "types.h"
#include "gdt.h"
#include "process.h"


void switch_to_task(int n, int mode)
{
        u32 kesp, eflags;
        u16 kss, ss, cs;

        current = &p_list[n];

        /* charger tss */
        default_tss.ss0 = current->kstack.ss0;
        default_tss.esp0 = current->kstack.esp0;

        /*
         * Empile les registres ss, esp, eflags, cs et eip necessaires a la
         * commutation. Ensuite, la fonction do_switch() restaure les
         * registres, la table de page du nouveau processus courant et commute
         * avec l'instruction iret.
         */

        ss = current->regs.ss;
        cs = current->regs.cs;
        eflags = (current->regs.eflags | 0x200) & 0xFFFFBFFF;

        if (mode == USERMODE) {
                kss = current->kstack.ss0;
                kesp = current->kstack.esp0;
        } else {                /*KERNELMODE */
                kss = current->regs.ss;
                kesp = current->regs.esp;
        }

        asm("   mov %0, %%ss; \
                mov %1, %%esp; \
                cmp %[KMODE], %[mode]; \
                je next; \
                push %2; \
                push %3; \
                next: \
                push %4; \
                push %5; \
                push %6; \
                push %7; \
                ljmp $0x08, $do_switch"

                :: \
                "m"(kss), \
                "m"(kesp), \
                "m"(ss), \
                "m"(current->regs.esp), \
                "m"(eflags), \
                "m"(cs), \
                "m"(current->regs.eip), \
                "m"(current), \
                [KMODE] "i"(KERNELMODE), \
                [mode] "g"(mode)
            );
}

void schedule(void)
{
        struct process *p;
        u32 *stack_ptr;

        /* Stocke dans stack_ptr le pointeur vers les registres sauvegardes */
        asm("mov (%%ebp), %%eax; mov %%eax, %0": "=m"(stack_ptr) : );

        /* Si il n'y a pas de processus charge et qu'au moins un est pret, on le charge */
        if (current == 0 && n_proc) {
                switch_to_task(0, USERMODE);
        }
        /*
         * Si il y a un seul processus (qu'on laisse tourner) ou aucun
         * processus, on retourne directement.
         */

        else if (n_proc <= 1) {
                return;
        }
        /* Si il y a au moins deux processus, on commute vers le suivant */
        else if (n_proc > 1) {
                /* Sauver les registres du processus courant */
                current->regs.eflags = stack_ptr[16];
                current->regs.cs = stack_ptr[15];
                current->regs.eip = stack_ptr[14];
                current->regs.eax = stack_ptr[13];
                current->regs.ecx = stack_ptr[12];
                current->regs.edx = stack_ptr[11];
                current->regs.ebx = stack_ptr[10];
                current->regs.ebp = stack_ptr[8];
                current->regs.esi = stack_ptr[7];
                current->regs.edi = stack_ptr[6];
                current->regs.ds = stack_ptr[5];
                current->regs.es = stack_ptr[4];
                current->regs.fs = stack_ptr[3];
                current->regs.gs = stack_ptr[2];

                if (current->regs.cs != 0x08) {
                        current->regs.esp = stack_ptr[17];
                        current->regs.ss = stack_ptr[18];
                } else {        /* Interruption pendant un appel systeme */
                        current->regs.esp = stack_ptr[9] + 12;
                        current->regs.ss = default_tss.ss0;
                }

                /* Sauver le tss */
                current->kstack.ss0 = default_tss.ss0;
                current->kstack.esp0 = default_tss.esp0;

                /* Choix du nouveau processus (un simple roundrobin) */
                if (n_proc > current->pid + 1)
                        p = &p_list[current->pid + 1];
                else
                        p = &p_list[0];

                /* Commutation */
                if (p->regs.cs != 0x08)
                        switch_to_task(p->pid, USERMODE);
                else
                        switch_to_task(p->pid, KERNELMODE);
        }
}
  • sched.asm contient le code qui effectue effectivement le changement de contexte
global do_switch

do_switch:
        ; recuper l'adresse de *current
        mov esi, [esp]
        pop eax                 ; depile @current

        ; prepare les registres
        push dword [esi+4]      ; eax
        push dword [esi+8]      ; ecx
        push dword [esi+12]     ; edx
        push dword [esi+16]     ; ebx
        push dword [esi+24]     ; ebp
        push dword [esi+28]     ; esi
        push dword [esi+32]     ; edi
        push dword [esi+48]     ; ds
        push dword [esi+50]     ; es
        push dword [esi+52]     ; fs
        push dword [esi+54]     ; gs

        ; enleve le mask du PIC
        mov al, 0x20
        out 0x20, al

        ; charge table des pages
        mov eax, [esi+56]
        mov cr3, eax

        ; charge les registres
        pop gs
        pop fs
        pop es
        pop ds
        pop edi
        pop esi
        pop ebp
        pop ebx
        pop edx
        pop ecx
        pop eax

        ; retourne
        iret

Le design de l'ordonnanceur est plus compliqué car :

  • Il doit prendre en compte le contexte de la tâche lors de son interruption.
  • Chaque tâche a sa propre pile noyau.
  • L'ordonnanceur change de pile noyau pendant la commutation.
  • La pile noyau ne contient pas le même nombre de registres empilés selon que la tâche interrompue était en mode utilisateur ou en mode noyau. L'interruption d'une tâche en mode utilisateur provoque automatiquement l'empilement des registres ss, esp, eflags, cs et eip. Quand l'interruption survient alors que la tâche est en mode noyau, les seuls registres empilés sont : eflags, cs et eip.

Schémas de la pile noyau après l'interruption d'une tâche en mode utilisateur :

Schémas de la pile noyau après l'interruption d'une tâche en mode noyau :

Toutes les contraintes supportées par l'ordonnanceur peuvent se résumer à :

  • sauvegarder les registres de la tâche interrompue
  • remettre la pile noyaux dans l'état précédent l'interruption avant de quitter celle-ci

Le schéma suivant représente la pile noyau lors de l'appel à la fonction schedule() dans le cas où le processeur était en mode utilisateur lors de l'interruption : pile

Attention ! Cette représentation de la pile illustre le cas où le processeur était en mode utilisateur lors de l'interruption.

Dans le cas où le processeur était en mode noyau au moment de l'interruption, le schéma est très sensiblement différent car les registres ss et esp n'ont pas été empilés.

Notre but est de sauvegarder l'état de l'ensemble des registres de la tâche interrompue, ce qui ne pose aucun problème pour l'ensemble d'entre eux (eax, ebx, etc.). La seule difficulté est de retrouver les valeurs de ss et de esp juste avant l'interruption d'horloge car si l'interruption est survenue pendant un appel système, on ne peut procéder de la même façon pour retrouver ces valeurs car elles n'ont pas été empilées :

if (current->regs.cs != 0x08) {
    current->regs.esp = stack_ptr[17];
    current->regs.ss = stack_ptr[18];
}
else { /* interruption pendant un appel systeme */
    current->regs.esp = stack_ptr[9] + 12; /* equivalent a : &stack_ptr[17] */
    current->regs.ss = default_tss.ss0;
}
  • Le premier cas est celui de l'interruption d'une tâche en mode utilisateur. L'appel système a sauvegardé tous les registres, dont ss et esp, sur la pile noyau de la tâche. C'est un cas assez simple déjà vu au chapitre précédent.
  • Le deuxième cas concerne l'interruption d'une tâche en mode noyau. Le processeur utilisait la même pile avant l'interruption et esp pointait juste au sommet des registres empilés par l'interruption. Pour retrouver cette valeur, plusieurs calculs sont possibles :
    • stack_ptr[9] contient la valeur du pointeur de pile juste après l'interruption (et avant l'appel à push et à pushad). Avant l'interruption, esp pointait sur la même pile mais juste avant les trois registres eflags, cs et eip empilés par l'interruption. Nous avons donc : 3 registres * 4 octets par registre = 12. stack_ptr[9] + 12 pointe donc au sommet de la pile.
    • Le sommet de la pile est situé 17 registres au-dessus du pointeur stack_ptr, on pourrait donc utiliser l'un des deux calculs suivants : stack_ptr + 17 * 4 ou &stack_ptr[17].

Après avoir sélectionné une nouvelle tâche, pointée par *p, l'ordonnanceur teste si cette nouvelle tâche à activer avait été interrompue en mode utilisateur ou en mode noyau. La fonction switch_to_task() est appelé avec le pid de la tâche à activer et le mode dans lequel il faut l'activer :

/* commutation */
if (p->regs.cs != 0x08)
    switch_to_task(p->pid, USERMODE);
else
    switch_to_task(p->pid, KERNELMODE);

La fonction switch_to_task()

La fonction switch_to_task() prépare la commutation :

  • elle change de pile noyau et passe sur la pile de la nouvelle tâche
  • elle empile les registres nécessaire à l'instruction iret
  • elle passe la main à la fonction do_switch() qui charge les registres et effectue la commutation

Les variables current->regs.ss, current->regs.cs et current->regs.eflags ne peuvent être directement utilisées dans la fonction asm(). Ces trois affectations initialisent des variables qui pourront elles être passées en paramètre à la fonction assembleur :

ss = current->regs.ss;
cs = current->regs.cs;
eflags = (current->regs.eflags | 0x200) & 0xFFFFBFFF;

Ce code récupère les paramètres de la pile noyau pour la nouvelle tâche. Si celle-ci est en mode utilisateur, on va utiliser une pile noyau "propre". Si en revanche elle est en mode noyau, il suffit de reprendre les valeurs ss et esp sauvegardées lors du changement de contexte :

if (mode == USERMODE) {
    kss = current->kstack.ss0;
    kesp = current->kstack.esp0;
}
else { /*KERNELMODE */
    kss = current->regs.ss;
    kesp = current->regs.esp;
}

Cette fonction effectue un gros travail :

  1. les deux premières instruction basculent sur la nouvelle pile noyau
  2. si la tâche est en mode utilisateur, elle empile les valeurs sauvegardées des registres ss et esp
  3. elle empile les valeurs sauvegardées des registres eflags, cs et eip
  4. elle passe la main à la fonction do_switch() qui charge les registres avec les valeurs sauvegardées et effectue la commutation avec iret
asm("   mov %0, %%ss; \
        mov %1, %%esp; \
        cmp %[KMODE], %[mode]; \
        je next; \
        push %2; \
        push %3; \
        next: \
        push %4; \
        push %5; \
        push %6; \
        push %7; \
        ljmp $0x08, $do_switch"
\
        :: \
        "m" (kss), \
        "m" (kesp), \
        "m" (ss), \
        "m" (current->regs.esp), \
        "m" (eflags), \
        "m" (cs), \
        "m" (current->regs.eip), \
        "m" (current), \
        [KMODE] "i" (KERNELMODE), \
        [mode] "g" (mode)
);

Utiliser les TrapGate pour rendre les appels systèmes interruptibles

Les appels systèmes sont rendus interruptibles :

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

Pour tester le fait qu'ils sont bien interruptibles, une temporisation est ajoutée pour les rendre plus lent : syscalls.c.

#include "types.h"
#include "lib.h"
#include "io.h"

void do_syscalls(int sys_num)
{
        char *u_str;
        int i;

        if (sys_num == 1) {
                asm("mov %%ebx, %0": "=m"(u_str) :);
                for (i = 0; i < 100000; i++);   /* temporisation */
                cli;
                printk(u_str);
                sti;
        } else {
                printk("unknown syscall %d\n", sys_num);
        }

        return;
}

L'utilisation de la fonction cli peut cependant être requis car il n'est pas souhaitable qu'un appel système soit interrompu à certains moments critiques de son exécution. Notament ici, pour avoir un affichage cohérent, j'ai préféré désactiver les interruptions le temps de l'affichage de la chaîne.


< Un système multi-tâches simple | TutoOS | Booter avec Grub >

print · rss · source
Page last modified on October 19, 2010, at 09:11 AM