print · rss · source

< Un premier shell | TutoOS | Annexe A - Compilation séparée en assembleur sous Unix >


Implémenter les signaux

Les sources

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


Un simple problème d'affichage, conséquences en terme d'architecture

Un simple problème d'affichage
Au chapitre précédent, nous avons implémenté un mini-shell et une commande cat. Bien que l'ensemble fonctionne convenablement, il y a un problème d'affichage car le shell affiche directement l'invite de saisie sans attendre la fin de la commande cat :

Attendre la fin d'un processus : l'appel sys_wait()
Nous voudrions ici que le shell attende la fin de la commande cat avant de continuer. Plusieurs solutions sont envisageables. L'appel système sys_exec() pourrait par exemple bloquer en attendant la fin de la commande exécutée. Mais celà poserait un nouveau problème car le shell bloquerait alors pour toutes les commandes lancées et ça n'est pas nécessairement ce que nous voulons. En fait, nous voudrions que le shell bloque pour certaines commandes, comme la commande cat, mais pas pour d'autres, par exemple dans le cas du lancement d'un serveur http ou ftp. Une solution est d'implémenter un nouvel appel qui permette d'attendre explicitement la fin d'un processus : l'appel système sys_wait().

Processus parent et enfant, une question de vocabulaire ?
Un processus peut en créer un nouveau grâce à l'appel sys_exec() puis attendre éventuellement sa terminaison avec sys_wait(). Pour distinguer les deux processus, on parle communément de processus parent et de processus enfant. Le processus parent qui invoque l'appel sys_wait() bloque jusqu'à la terminaison du processus fils.

Informer le processus père du bon déroulement grâce à un code de retour
La terminaison d'un processus peut être normale ou bien consécutive à une erreur. Il est souvent important pour un processus père d'être tenu informé des raisons ayant conduit à la terminaison de son processus fils. Une solution est que le fils transmette un code de retour au père au moment où il se termine. Mais comment ? Nous avons déjà vu que l'appel sys_exit() sert à clore proprement un processus. Cet appel va en plus positionner la valeur de retour dans un nouveau champ de la struct process, le champ status. Ensuite, et nous verrons comment, le processus père va récupèrer cette valeur grâce à l'appel sys_wait(). Notez que par convention, dans le monde Unix, une valeur de retour de 0 indique un déroulement normal du processus tandit que toute autre valeur signifie une erreur.

Un processus père qui a plusieurs processus enfants
Jusque là, nous avons évoqué le cas d'un processus père qui attend la fin de son fils. Mais pourquoi se limiter à un seul processus fils ? Un processus devrait pouvoir lancer en parrallèle plusieurs fils. Mais cette fonctionnalité a pour conséquence que l'appel sys_wait() soit générique, au sens où le père ne va pas attendre la terminaison d'un processus fils en particulier mais celle de n'importe lequel de ses fils. Le processus père doit donc disposer d'une liste de ses fils. La notion de filiation n'est donc plus une simple question de vocabulaire ! Quant à l'appel sys_wait(), en plus du code de retour, il doit remonter le pid du processus fils qui s'est terminé :

pid = wait(&status); /* wait remonte le pid et le code de retour */

Terminaison d'un processus et état zombie
Sous Unix, pour dire qu'un processus s'est terminé, on dit qu'il est mort. Processus "parent", processus "enfant", "mort" d'un processus... le vocabulaire issus du système Unix est très imagé, mais vous verrez que celà ne s'arrête pas là !
Un processus père qui utilise sys_wait() pour attendre la fin de l'un de ses fils doit pouvoir :

  • savoir le quel de ses fils s'est terminé
  • récupérer le code de retour de ce fils mort

Le moyen qui permet celà est assez logique et simple à implémenter, mais je vais avoir besoin d'un tout petit rappel !

Jusqu'à présent, la struct process d'un processus contient un champ state qui indique sont état. La valeur 0 signifie que l'entrée est libre, et il n'y a en réalité plus de processus associé à la structure, alors que la valeur 1 signifie que le processus est en court d'exécution ou prèt à être exécuté. Quelle valeur associer à un processus qui vient de se terminer ?
Quand un fils est mort, il n'est plus exécutable, donc les ressources qu'il utilisait peuvent être rendues au système, à une exception prèt. La struct process, qui contient notamment le champ status, ne peut être libérée tant que le père n'a pas pris connaissance de l'état de son fils. Le fils est donc dans un état intermédiaire. Bien que mort, l'entrée qu'il utilise dans la table des processus n'est pas libérée. On dit qu'il est à l'état de "zombie" !

Pour indiquer cet état de zombie, le champ state est mis à -1. C'est une valeur pratique dans le sens où les processus exécutables ont leur champ state avec une valeur supérieure à 0 et les processus non exécutables une valeur inférieure ou égale.

Avertir un processus d'un évènement à l'aide d'un signal
Un processus père possède la liste de ses fils et il peut donc savoir lesquels sont à l'état de zombie grâce au champ state de leur struct process. Il ne serait cependant pas efficient pour le père de scruter continuellement l'état de ses fils pour savoir lesquels sont morts. Nous avons donc besoin d'un système de signalisation. Dans Pépin, le champ signal de la struct process est utilisé pour signaler un évènement au processus en question. Ce champ est vérifié par l'ordonnanceur au moment où le processus est préempté. Au cas où un signal a été envoyé, le comportement du processus est adapté en conséquence. Notez que l'utilisation de signaux pour avertir un processus de la mort de l'un de ses fils n'est pas la seule utilisation possible d'un système de signalisation ! Dans Pépin, le champ signal contient plusieurs bits et à chaque bit correspond un signal particulier lié à un évènement particulier.

Cycle de vie et mort d'un processus
Le scénario de vie d'un processus est donc le suivant :

  1. Un processus père, à l'aide de l'appel système sys_exec(), crée un processus fils.
  2. Le fils s'exécute.
  3. Quand le fils se termine, il appelle sys_exit() grâce auquel il rend ses ressources au système, dépose un code de retour dans le champ status de son entrée de la table des processus, et devient un zombie.
  4. Un signal est envoyé au processus père pour le prévenir que l'un de ses enfants est mort.
  5. Le père scrute la liste de ses enfants, repère le zombie, recueille son code de retour, le libère en mettant son champ state à 0 puis il l'enleve de sa liste de processus enfants.
  6. Après que le père ait récupéré le pid et le code de retour, l'appel sys_wait() retourne.

Une routine personnalisée pour réagir à un signal : l'appel sys_sigaction()
Le problème de cette architecture est que si le père n'attend pas son fils via l'appel sys_wait(), le fils restera indéfiniment un zombie. Celà pose un problème car les zombies utilisent des entrées dans la table des processus au risque de provoquer une pénurie. Il faut donc que le père utilise l'appel sys_wait(). Mais le père peut avoir autre chose à faire que d'attendre la terminaison de son fils. Comment résoudre ce problème ?

Nous avons évoqué le fait qu'un signal puisse être envoyé à un processus pour le prévenir d'un évènement particulier, comme par exemple la mort d'un enfant. A ce moment, nous aimerions bien que le père invoque d'une façon ou d'une autre l'appel sys_wait() afin de libérer son fils de son état de zombie. Il suffit pour le père, à la reception du signal indiquant la mort de l'un de ses enfants, d'exécuter une routine personnalisée contenant l'appel à sys_wait(). Cette solution, standard dans le monde Unix, est implémentée au moyen d'un nouvel appel système : sys_sigaction().

Mort d'un processus père
Deux problèmes se posent quand un processus père meurt :

  • Le premier est de savoir qui désormais va s'occuper de ses enfants !
  • Le second concerne le premier processus, qui par définition n'a pas de parents et donc aucun processus pour le libérer.

La solution imaginée pour Pépin est de créer un premier processus permanent, qui ne meurt jamais et qui adopte les processus orphelins.

Implémenter la filiation entre processus

De nouveaux champs dans la struct process

Les champs suivants sont ajoutés à la struct process :

  • le champ parent pointe sur le processus parent
  • le champ child pointe sur la liste chainée des processus enfants
  • le champ sibling pointe sur les processus frères
struct process *parent;         /* Processus parent */
struct list_head child;         /* Processus enfants */
struct list_head sibling;       /* Processus conjoints */

À la création du processus

Ces nouveaux champs sont initialisés lors de la création du processus par la fonction load_task(). Dans tous les extraits suivants, la variable previous pointe sur le processus parent. Le code suivant attribut un parent au nouveau processus :

/* Pointe sur le processus parent (ou le pid 0 si le parent est mort) */
if (previous->state != 0)
        p_list[pid].parent = previous;
else
        p_list[pid].parent = &p_list[0];

Initialisation de la liste des enfants. Au départ, cette liste est vide :

/* Initialise la liste des enfants du nouveau processus */
INIT_LIST_HEAD(&p_list[pid].child);

Mise à jour de la liste des enfants du parent :

/* Ajoute le nouveau processus dans la liste des enfants du parent */
if (previous->state != 0)
        list_add(&p_list[pid].sibling, &previous->child);
else
        list_add(&p_list[pid].sibling, &p_list[0].child);

À la mort du processus

Quand le processus se termine, l'appel système sys_exit() reporte l'information auprès du père et des enfants. En ce qui concerne le père, la mise à jour se fait indirectement (nous verrons plus loin comment), par l'envoi d'un signal de terminaison :

/* Met a jour la liste des processus du pere */
if (current->parent->state > 0)
        set_signal(&current->parent->signal, SIGCHLD);
else
        printk("WARNING: sys_exit(): process %d without valid parent\n", current->pid);

Les enfants du processus qui se termine se voient attribuer un nouveau père :

/* Donne un nouveau pere aux enfants */
list_for_each_safe(p, n, &current->child) {
        proc = list_entry(p, struct process, sibling);
        proc->parent = &p_list[0];
        list_del(p);
        list_add(p, &p_list[0].child);
}

Attendre la fin d'un processus avec l'appel système sys_wait()

Il s'agit d'un appel très simple qui bloque le processus jusqu'à ce que l'un de ses enfants soit mort :

Algorithme : sys_wait
Paramètre : code de retour &status
Retour : pid
Début

Tant que aucun signal SIGCHILD n'est reçu alors
le processus ne fait rien
Pour chaque processus enfant
Si l'enfant est à l'état zombie alors
Le pid est récupéré
Le code de retour est récupéré
Le fils est libéré
Le fils est enlevé de la liste des processus fils
Le signal SIGCHILD est supprimé
La fonction retourne

Fin

syscalls/sys_wait.c

#include "list.h"
#include "lib.h"
#include "io.h"
#include "process.h"
#include "signal.h"


int sys_wait(int* status)
{
        int pid;
        struct list_head *p, *n;
        struct process *children;

        //// printk("DEBUG: sys_wait(): [%d] wait for children death\n", current->pid);

        while (0 == is_signal(current->signal, SIGCHLD))
                ;

        printk("DEBUG: sys_wait(): [%d] has a dead children\n", current->pid);

        cli;

        /* Recherche du fils mort */
        list_for_each_safe(p, n, &current->child) {
                children = list_entry(p, struct process, sibling);
                if (children->state == -1) {
                        pid = children->pid;            /* recupere le pid */
                        *status = children->status;     /* recupere le status */
                        children->state = 0;            /* libere le fils */
                        list_del(p);                    /* enleve le fils de sa liste */
                        clear_signal(&current->signal, SIGCHLD);        /* efface le signal SIGCHLD */
                        break;
                }
        }

        sti;

        return pid;
}

Implémenter les signaux

A la base de l'implémentation des signaux, deux champs ont été ajoutés dans la struct process. Le champ signal, sur 32 bits, est destiné à recevoir les signaux. Un signal est reçu quand un bit est mis à 1. Le tableau sigfn pointe sur les routines de service à activer lors de la réception d'un signal :

u32 signal;
void* sigfn[32];

Le fichier signal.h définit les signaux ainsi que les routines de service par défaut.

#define SIGHUP           1
#define SIGINT           2
#define SIGQUIT          3
#define SIGBUS           7
#define SIGKILL          9
#define SIGUSR1         10
#define SIGSEGV         11
#define SIGUSR2         12
#define SIGPIPE         13
#define SIGALRM         14
#define SIGTERM         15
#define SIGCHLD         17
#define SIGCONT         18
#define SIGSTOP         19

#define SIG_DFL         0       /* default signal handling */
#define SIG_IGN         1       /* ignore signal */

#define set_signal(mask, sig)   *(mask) |= ((u32) 1 << (sig - 1))
#define clear_signal(mask, sig) *(mask) &= ~((u32) 1 << (sig - 1))
#define is_signal(mask, sig)    (mask & ((u32) 1 << (sig - 1)))

int dequeue_signal(int);
int handle_signal(int);

Traiter les signaux

Au moment où un processus est préempté pour être exécuté, l'ordonnanceur vérifie si celui-ci a reçu un signal pour le traiter :

/* Traite les signaux */
if ((sig = dequeue_signal(current->signal)))
        handle_signal(sig);

La fonction dequeue_signal() indique quel est le plus bas signal reçu (en partant de 0 jusqu'à 31). Si un signal est reçu, la fonction handle_signal() le traite. Ces deux fonctions sont définies dans le fichier signal.c.

#include "types.h"
#include "lib.h"
#include "process.h"
#include "signal.h"
#include "syscalls.h"

int dequeue_signal(int mask)
{
        int sig;

        if (mask) {
                sig = 1;
                while (!(mask & 1)) {
                        mask = mask >> 1;
                        sig++;
                }
        }
        else
                sig = 0;

        return sig;
}

int handle_signal(int sig)
{
        u32 *esp;

        printk("DEBUG: handle_signal(): signal %d for process %d\n", sig, current->pid);

        if (current->sigfn[sig] == (void*) SIG_IGN) {
                clear_signal(&current->signal, sig);
        }
        else if (current->sigfn[sig] == (void*) SIG_DFL) {
                switch(sig) {
                        case SIGHUP : case SIGINT : case SIGQUIT :
                                asm("mov %0, %%eax; mov %%eax, %%cr3"::"m"(current->regs.cr3));
                                sys_exit(1);
                                break;
                        case SIGCHLD :
                                break;
                        default :
                                clear_signal(&current->signal, sig);
                }
        }
        else {
                /*
                 * Sauvegarde des registres sur la pile utilisateur
                 */

                esp = (u32*) current->regs.esp - 20;

                asm("mov %0, %%eax; mov %%eax, %%cr3"::"m"(current->regs.cr3));

                /* Code assembleur qui appelle sys_sigreturn() */
                esp[19] = 0x0030CD00;
                esp[18] = 0x00000EB8;

                /* Sauvegarde des registres */
                esp[17] = current->kstack.esp0;
                esp[16] = current->regs.ss;
                esp[15] = current->regs.esp;
                esp[14] = current->regs.eflags;
                esp[13] = current->regs.cs;
                esp[12] = current->regs.eip;
                esp[11] = current->regs.eax;
                esp[10] = current->regs.ecx;
                esp[9] = current->regs.edx;
                esp[8] = current->regs.ebx;
                esp[7] = current->regs.ebp;
                esp[6] = current->regs.esi;
                esp[5] = current->regs.edi;
                esp[4] = current->regs.ds;
                esp[3] = current->regs.es;
                esp[2] = current->regs.fs;
                esp[1] = current->regs.gs;

                /* Adresse de retour pour %eip */
                esp[0] = (u32) &esp[18];

                /*
                 * Remplace les registres %esp et %eip pour executer la routine
                 * de service definie par l'utilisateur.
                 */

                current->regs.esp = (u32) esp;
                current->regs.eip = (u32) current->sigfn[sig];

                /* Efface le signal et retablit le handler par defaut */
                current->sigfn[sig] = (void*) SIG_DFL;
                if (sig != SIGCHLD)
                        clear_signal(&current->signal, sig);
        }

        return 0;
}

Modifier l'algorithme de l'ordonnanceur

La fonction handle_signal(), bien que possédant un algorithme simple, est l'une des plus complexe du noyau en raison des difficulté d'implémentation de la gestion des routines personnalisées :

Algorithme : handle_signal
Paramètre : signal
Début

Si la routine de service associée au signal est de type ignorer (SIG_IGN) alors
le signal est effacé
Sinon si la routine de service associée au signal est de type default (SIG_DFL) alors
Si le signal est SIGHUP, SIGINT ou SIGQUIT alors
le processus se termine avec l'appel à sys_exit()
Sinon si le signal est SIGCHLD (mort d'un enfant) alors
on ne fait rien (l'appel à sys_wait() par le père se chargera du travail !)
Sinon le signal est effacé
Sinon on exécute la fonction associée au signal et déterminée par l'utilisateur

Fin

Utiliser une fonction de traitement de signal personnalisée

Associer une fonction personnalisée avec l'appel sys_sigaction()

L'appel système sys_sigaction() permet d'associer à un signal l'adresse d'une fonction utilisateur :

asm(" mov %%ebx, %0   \n \
      mov %%ecx, %1"

      : "=m"(sig), "=m"(fn) :);

current->sigfn[sig] = fn;

Exécuter une fonction personnalisée - le problème posé

À la réception d'un signal, la méthode pour exécuter une fonction personnalisée est simple : il suffit de modifier le pointeur d'instruction %eip en le faisant pointer sur la fonction. Mais celà pose un premier problème car à l'issue de l'exécution de la fonction, le processus doit reprendre là où il en était et il faut donc sauvegarder quelque part la valeur originelle de %eip stockée dans current->regs->eip. Le second problème provient du fait que quand le processus est interrompu par l'ordonnanceur alors qu'il exécute cette fonction, les registres de la struct process son écrasés. Ça n'est donc pas uniquement la valeur de %eip qu'il faut sauvegarder mais bien l'ensemble des registres.

Sauvegarder le contexte sur la pile utilisateur

Une fois la fonction de traitement du signal terminée, le processus doit reprendre son fonctionnement normal. Malheureusement, la sauvegarde des registres dans la struct process a été écrasée lors de l'exécution de la fonction de traitement du signal. Avant d'exécuter cette fonction, il faut donc sauvegarder les registres quelque part. Une solution standard est de sauvegarder les registres de la struct process sur la pile utilisateur. Une fois la fonction de traitement du signal terminée, les registres de la struct process seront restaurés grâce à l'appel système sys_sigreturn().
Cette solution résout-elle tous les problèmes ? Non, car pour rester standard, l'appel à sys_sigreturn() ne doit pas être réalisé explicitement dans la fonction utilisateur mais automatiquement par le noyau.

Revenir d'une routine avec la technique du stack-smashing

Quand une fonction personnalisée se termine, elle fait comme n'importe quelle fonction : elle récupère sur la pile l'adresse eip qui indique où reprendre l'exécution. On peut donc penser qu'il suffit de mettre à cet endroit de la pile l'adresse de sys_sigreturn() pour exécuter automatiquement cette fonction. C'est correct ? Et bien non ! N'oubliez pas que la fonction personnalisée de traitement du signal s'exécute en mode utilisateur alors que la fonction sys_sigreturn() est dans l'espace d'adressage du noyau, il faut donc passer par un appel système.

L'appel permettant d'appeler la fonction sys_sigreturn() est le suivant :

mov eax, 14
int 0x30

Au retour de la routine de service, il faudrait que %eip pointe sur ce code et que en plus, celui-ci soit dans l'espace utilisateur. Une méthode est de copier directement le code assembleur de cet appel sur la pile utilisateur :

esp[19] = 0x0030CD00;
esp[18] = 0x00000EB8;

La valeur de %eip est alors définie sur la pile par :

esp[0] = (u32) &esp[18];

Ainsi, par cette implémentation, au retour de la fonction de traitement personnalisée, le registre %eip pointe sur l'adresse &esp[18] où est le code assembleur exécutant l'appel système pour sys_sigreturn().

Revenir avec sys_sigreturn()

La fonction sys_sigreturn(), qui est définie dans le fichier syscalls/sys_sigreturn.c, ne fait que rétablir les registres dans la struct process à partir de la sauvegarde effectuée sur la pile.

#include "types.h"
#include "io.h"
#include "process.h"
#include "schedule.h"
#include "syscalls.h"

void sys_sigreturn(void)
{
        u32 *esp;

        cli;

        asm("mov (%%ebp), %%eax; mov %%eax, %0": "=m"(esp):);
        esp += 17;

        current->kstack.esp0 = esp[17];
        current->regs.ss = esp[16];
        current->regs.esp = esp[15];
        current->regs.eflags = esp[14];
        current->regs.cs = esp[13];
        current->regs.eip = esp[12];
        current->regs.eax = esp[11];
        current->regs.ecx = esp[10];
        current->regs.edx = esp[9];
        current->regs.ebx = esp[8];
        current->regs.ebp = esp[7];
        current->regs.esi = esp[6];
        current->regs.edi = esp[5];
        current->regs.ds = esp[4];
        current->regs.es = esp[3];
        current->regs.fs = esp[2];
        current->regs.gs = esp[1];

        switch_to_task(0, KERNELMODE);
}

< Un premier shell | TutoOS | Annexe A - Compilation séparée en assembleur sous Unix >

print · rss · source
Page last modified on January 08, 2009, at 03:39 PM