< Un premier shell | TutoOS | Annexe A - Compilation séparée en assembleur sous Unix >
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
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é :
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 :
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 :
sys_exec()
, crée un processus fils.
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.
state
à 0
puis il l'enleve de sa liste de processus enfants.
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 :
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.
struct process
Les champs suivants sont ajoutés à la struct process
:
parent
pointe sur le processus parent
child
pointe sur la liste chainée des processus enfants
sibling
pointe sur les processus frères
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 :
Initialisation de la liste des enfants. Au départ, cette liste est vide :
Mise à jour de la liste des enfants du parent :
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 :
Les enfants du processus qui se termine se voient attribuer un nouveau père :
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
SIGCHILD
n'est reçu alors
SIGCHILD
est supprimé
Fin
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 :
Le fichier signal.h définit les signaux ainsi que les routines de service par défaut.
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 :
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.
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
SIG_IGN
) alors
SIG_DFL
) alors
SIGHUP
, SIGINT
ou SIGQUIT
alors
sys_exit()
SIGCHLD
(mort d'un enfant) alors
sys_wait()
par le père se chargera du travail !)
Fin
sys_sigaction()
L'appel système sys_sigaction()
permet d'associer à un signal l'adresse d'une fonction utilisateur :
À 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.
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.
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 :
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 :
La valeur de %eip
est alors définie sur la pile par :
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()
.
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.
< Un premier shell | TutoOS | Annexe A - Compilation séparée en assembleur sous Unix >