< 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 :
- Par exemple, la tâche A fait un appel système et utilise la pile noyau pour traiter cet appel.
- L'ordonanceur est activé alors que l'appel n'est pas terminé et il donne la main à la tâche B.
- 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.
- 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.
- 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 :
- les deux premières instruction basculent sur la nouvelle pile noyau
- si la tâche est en mode utilisateur, elle empile les valeurs sauvegardées des registres ss et esp
- elle empile les valeurs sauvegardées des registres eflags, cs et eip
- 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 >