< Gérer les interruptions du clavier | TutoOS | Les appels systèmes >
Le package contenant les sources est téléchargeable ici : kernel_MonoTask.tgz
Pour naviguer dans l'arborescence : MonoTask
Nous avons vu comment créer un programme qui peut à tout moment accèder à n'importe quel endroit de la mémoire et exécuter n'importe quelle instruction. Cette liberté a un prix : le risque de corrompre des données ou d'exécuter une instruction indésirable. Si ce risque est un mal nécessaire pour le noyau qui doit nécessairement pouvoir tout faire, il n'est pas souhaitable que le code d'un utilisateur bénéficie des mêmes permissions. Ce dernier ne doit pas pouvoir accéder aux données des autres utilisateurs ou corrompe l'ensemble du système (intentionnellement ou non).
Sous Unix, on distingue deux types de mode d'exécution d'un programme : le mode noyau (ou privilégié ou encore superviseur) et le mode utilisateur :
Par exemple, sur un système de type Unix, un programme utilisateur peut seulement accéder à ses propres données et il ne peut pas, par exemple, rebooter la machine.
La famille de processeur i386 offre plusieurs méthodes pour exécuter du code en mode utilisateur. La méthode décrite ici est celle du software task switching. Pour commuter une tâche par cette méthode, le noyau doit :
iret
Celà peut sembler un peut compliquer, mais pourtant il n'en est rien. Ces différents points sont expliqués en détail ci-dessous.
Dans les chapitres précédents, nous avons vu comment utiliser le mécanisme de segmentation pour adresser l'ensemble de la mémoire en mode protégé. Le mécanisme de segmentation permet de restreindre la mémoire accessible par une tâche utilisateur en lui associant des descripteurs de segment dont les champs base et limite définissent une plage de mémoire restreinte. La sécurisation est également étendue par l'utilisation du champ DPL des descripteurs qui permet d'empêcher l'utilisation de certaines instructions critiques.
Dans notre implémentation, nous avons une seule tâche en mode utilisateur dont l'espace accessible est restreint à :
Le noyau, quant à lui, peut adresser l'ensemble de la mémoire (celà n'est pas clairement représenté dans le schéma ci-dessous) :
Les descripteurs de segment associés à la tâche utilisateur sont créés et initialisés de la façon suivante :
Pour cette première implémentation, la tâche utilisateur est une fonction très simple qui boucle sur elle même :
De façon arbitraire, il a été décidé que le code de la tâche doit résider en 0x30000
. Pour le moment, nous n'avons pas vraiment de moyen de créer un exécutable et de le charger en mémoire via un chargeur ELF ou quelque chose de similaire. Nous allons donc tout simplement créer une fonction dans le noyau et copier le code de cette fonction à l'endroit voulu. Comme c'est une toute petite fonction qui occupe au maximum 100 octets, elle est chargée en mémoire par le code suivant :
Le TSS (Task State Segment) est une structure qui permet de garder en mémoire l'état des registres lors d'une commutation de tâche ou d'une interruption. Dans notre cas, le TSS va uniquement servir à garder en mémoire les indications relatives à la pile du noyau (à savoir les valeurs des registres SS et ESP). Cette structure, utilisée de façon assez diverse selon l'implémentation de la commutation de tâche, peut résider n'importe où en mémoire. Elle est localisable grâce à un descripteur particulier dans la GDT : le descripteur de TSS. Un sélecteur de segment spécifique, le Task Register (TR), permet de pointer sur ce descripteur :
La structure du TSS : schéma
L'initialisation du TSS se fait en plusieurs étapes :
ltr
ss0
et esp0
du TSS) avec les valeurs courantes se fait très simplement :
Intel(c) propose plusieurs mécanismes pour effectuer une commutation de tâche. La commutation hardware est "presque" entièrement gérée par le processeur. Elle est cependant rarement utilisée car :
La commutation software, à l'inverse, est comparativement simple à mettre en oeuvre et n'a pas de limitation concernant le nombre de processus possible. Pour commuter une tâche de façon soft, il suffit de :
iret
L'instruction iret
sert normalement à retourner d'une interruption. Dans le cas présent, une petite astuce à la base de notre implémentation va se servir de iret
pour donner la main à une tâche non-privilégiée.
Quand une tâche utilisateur s'exécute et qu'une interruption survient, le processeur va automatiquement sauvegarder sur la pile noyau différents registres lui permettant de retourner dans le contexte utilisateur une fois le traitement de l'interruption terminé :
Une fois l'interruption traitée, l'instruction iret
fait les choses suivantes :
Pour commuter en mode non-privilégié, il suffit donc de :
iret
.
Le code ci-dessous effectue un saut vers la fonction task1()
en mode utilisateur :
On commence par désactiver les interruptions.
Nous avons vu plus haut que le descripteur de segment de pile est à l'offset 0x30
dans la GDT et qu'il définit un espace mémoire de 4k débutant à l'adresse 0x20000
. Le sommet de la pile est donc en 0x30000
. On commence par placer le futur sélecteur et le futur pointeur de pile utilisateur sur la pile noyau.
Lors de l'appel à iret
, nous voulons que le processeur mette à jour le segment de pile SS avec celui qui est à l'index 0x30
de la GDT (la pile utilisateur). Mais nous voulons aussi que le processeur passe à un niveau d'exécution différent (ici, moins privilégié). On indique ce niveau d'exécution au processeur en l'ajoutant dans le champ RPL (Requested Privilege Level) du sélecteur :
Dans notre exemple, nous voulons passer en mode utilisateur (valeur de privilège 3, on parle aussi de passer en mode ring 3). Il faut donc utiliser le sélecteur 0x30 + 3 = 0x33 pour utiliser le segment de pile utilisateur en tant qu'utilisateur. Sans celà, le processeur va déclencher une exception de type General Protection fault.
On place le registre EFLAGS sur la pile en ayant pris soin de désactiver le bit Nested Task (NT) et en ayant activé le bit Interrupt Flag (IF).
On place sur la pile le sélecteur et le pointeur de code utilisateur. Comme avec le segment de pile, nous voulons que le processeur passe à un niveau d'exécution différent, ici moins privilégié. On indique ce niveau de privilège, ici 3, au processeur en l'ajoutant à la valeur du sélecteur. Dans notre exemple, il faut donc utiliser le sélecteur 0x20 + 3 = 0x23 pour utiliser le segment de code utilisateur avec un niveau de privilège de 3.
A ce stade là, la pile est prète pour basculer vers la tâche utilisateur.
Cette instruction met à jour la valeur de default_tss.esp0
, qui contient la valeur du pointeur de pile noyau utilisé par les interruptions pendant que la tâche utilisateur s'exécute.
On initialise le sélecteur de segment de données en le faisant pointer sur le descripteur de la partie utilisateur. On applique la même remarque que vu précedement avec les segments de code et de pile et on utilise donc le sélecteur 0x28 + 3 = 0x2B.
L'instruction iret
est normalement utilisée pour retourner d'une interruption. Elle dépile l'adresse de retour (cs et eip), le registre eflags et les informations de pile (ss et esp).
On note que :
Quand le processeur est en mode utilisateur et qu'une interruption est reçue, il doit passer en mode privilégié pour exécuter l'ISR appropriée. En cas de changement de privilège, le processeur fait automatiquement les choses suivantes :
ss0
et esp0
correspondant aux registres SS et ESP de la pile noyau
Mais celà n'est pas suffisant. Pour sauvegarder entièrement le contexte d'exécution de la tâche utilisateur, il faut stocker quelque part, par exemple en les empilant sur la pile noyau, les autres registres du processeur susceptibles d'êtres modifiés par la routine d'interruption.
Pour illustrer ce point, à l'origine notre handler pour l'interruption IRQ0 était :
Nous devons le modifier afin qu'il sauvegarde les registres (on remarque aussi qu'il initialise le sélecteur de segment de données DS afin que le noyau puisse accéder au segment de données) :
La compilation n'apporte aucune surprise :
$ tar xfz kernel_MonoTask.tgz $ cd MonoTask $ make
En revanche, pour tester le nouveau noyau, il faut utiliser bochs en mode debug.
La fenêtre ci-dessous montre ce qui se passe après l'exécution de l'instruction iret
. L'instruction d'après est en mode utilisateur et adresse le bon segment de code. Un point est affiché toutes les 100 interruptions d'horloge, ce qui prouve que les interruptions fonctionnent correctement malgré le passage en mode utilisateur :
< Gérer les interruptions du clavier | TutoOS | Les appels systèmes >