PSE Seance 2

De Ensiwiki
Aller à : navigation, rechercher
AttentionCette page est maintenue uniquement par les enseignants. Afin de ne pas perturber le déroulement des cours, elle n'a pas vocation à être modifiée par les élèves. Mais si vous avez des modifications à proposer, merci d'en discuter ou d'envoyer un e-mail aux auteurs de la page (cf. historique)

Mycomputer.png  Deuxième Année  CDROM.png  Informatique 

Note (CR) : version obsolète de la séance 2, conservée temporairement pour extraire les infos

Dans cette deuxième séance de Pratique du Système, on va implanter la gestion du temps dans le noyau commencé à la séance précédente. A la fin de la séance, on attend que le système affiche en haut à droite de l'écran une horloge indiquant depuis combien de temps le système a démarré, sous la forme HH:MM:SS.

Mise en place du traitement des interruptions

Principe général

Pour gérer le temps (et le clavier à la séance 3), on va devoir utiliser un mécanisme fondamental des systèmes : les interruptions. Le principe général de ce mécanisme est le suivant :

  1. on est en train d'exécuter un programme quelconque
  2. un évenement « prioritaire » (e.g. on appuie sur une touche du clavier, un circuit horloge indique qu'un certain laps de temps est écoulé, etc.) provoque un signal appelé « interruption » qui est transmis au processeur
  3. le processeur arrête l'exécution du programme en cours, et commence à exécuter un programme spécial appelé « traitant d'interruption » dont le but est de gérer l'évenement en question
  4. une fois l'évenement géré, le processeur reprend l'exécution du programme initial à l'endroit où il s'était arrêté

Ce mécanisme en apparence simple entraine plusieurs difficultés qu'on va devoir gérer comme détaillé ci-dessous.

Initialisation de la table des vecteurs d'interruption

Chaque source d'interruption porte un numéro unique (sur Pentium, il y en a 256) afin de permettre au processeur de l'identifier. Lorsque le processeur reçoit l'interruption N, il consulte la Nième case d'une table appelée « table des vecteurs d'interruption » (Interrupt Description Table) pour trouver l'adresse en mémoire du traitant à appeler. Cela impose évidemment que cette table soit elle-même a une adresse connue du processeur : dans notre cas, il s'agira de l'adresse 0x1000.

Chaque entrée de l’IDT occupe 2 mots consécutifs de 4 octets chacun et a le format suivant :

  • le premier mot de l'entrée est composé de la constante sur 16 bits KERNEL_CS (bits 31 à 16) et des 16 bits de poids faibles de l'adresse du traitant (bits 15 à 0) ;
  • le deuxième mot est composé des 16 bits de poids forts de l'adresse du traitant (bits 31 à 16) et de la constante 0x8E00 (bits 15 à 0).

La constante KERNEL_CS est définie dans segment.h et précise ce qu'on appelle un descripteur de segment. On ne rentrera pas dans les détails du fonctionnement de la mémoire sur Pentium dans ce TP.

L'adresse (sur 32 bits) du traitant à activer est donc répartie sur les deux mots composant l'entrée dans la table, avec 16 bits dans chaque mot.

La constante 0x8E00 sert à préciser un certain nombre de choses dépassant le cadre de ce TP, dont notamment le fait que l’exécution du traitant se fait interruptions masquées : un traitant d'interruption ne peut donc pas être lui-même interrompu.

Ecriture d'un traitant d'interruption

Un traitant d'interruption est un programme très particulier qui ne s'écrit pas comme un programme classique car on doit prendre en compte de façon précise l'état du processeur aux moments où on entre et sort du traitant.

Une fois qu'il a trouvé l'adresse du traitant à appeler dans la table des vecteurs d'interruption, le processeur sauvegarde en mémoire deux informations importantes avant de passer la main au traitant : le contenu du registre des indicateurs %eflags et le compteur ordinal (qui pointe sur la prochaine instruction à exécuter dans le programme interrompu).

Le processeur ne sauvegarde notamment pas les registres généraux (%eax, %ebx, etc.) : c'est donc à la charge du traitant de sauvegarder ceux susceptibles d'être modifiés (et seulement ceux-ci). La façon la plus simple de le faire est d'utiliser les instructions push et pop qui copient et lisent respectivement des valeurs dans la pile d'exécution du traitant.

Lorsqu'on commence à traiter une interruption, on doit le signaler à un composant matériel appelé « contrôleur d'interruptions » (Programmable Interrupt Controller) dont on parlera plus bas. Cette étape est nécessaire pour permettre à ce contrôleur de se remettre à écouter d'autres interruptions éventuelles : elle doit donc être réalisée le plus tôt possible dans le traitant (en pratique, juste après la sauvegarde des registres généraux utilisés).

Pour cela, on va encore utiliser les opérations de communication avec les ports qu'on a vu à la séance précédente. Pour acquitter une des interruptions qu'on manipule dans ce TP, on doit envoyer la commande sur 8 bits 0x20 sur le port de commande 0x20. Comme on veut le faire en assembleur directement après la sauvegarde des registres généraux, on utilisera le bout de code suivant :

movb $0x20, %al
outb %al, $0x20

A la fin de l'exécution du traitant, on doit utiliser une instruction particulière pour revenir au programme initial : iret (Interrupt Return) dont le fonctionnement se rapproche de l'instruction ret qu'on utilise classiquement à la fin d'une fonction, mais qui permet en plus de rétablir les indicateurs et le compteur ordinal originaux.

Utilisation d'une l'horloge matérielle comme source d'interruption

Gestion de l'IRQ0

L'interruption que l'on va utiliser dans cette séance est celle générée par un composant appelé « horloge programmable » (Programmable Interval Timer) dont le fonctionnement sera détaillé plus bas. En résumé, cette horloge va générer périodiquement des signaux pour signifier l'écoulement du temps.

L'horloge est connectée au contrôleur d'interruptions dont on a parlé plus haut. Lorsqu'elle émet un signal, celui-ci est transmis au contrôleur d'interruption via un canal appelé IRQ (Interrupt Request). Dans le cas de l'horloge, il s'agit du canal IRQ0. Le contrôleur d'interruption transmet ce signal au processeur sous la forme d'une interruption : dans le cas de l'horloge, le contrôleur est programmé pour émettre l'interruption 32.

Il est possible de « masquer » ou « démasquer » chaque IRQ individuellement : si une IRQ est masquée, les signaux transmis seront ignorés par le contrôleur d'interruption. Dans ce TP, le masquage ou démasquage d'une IRQ se fera en deux temps :

  1. il faut d'abord lire la valeur actuelle du masque sur le port de données 0x21 grâce à la fonction inb
  2. l'octet récupéré est en fait un tableau de booléens tel que la valeur du bit N décrit l'état de l'IRQ N : 1 si l'IRQ est masquée, 0 si elle est autorisée : il faut donc forcer la valeur du bit N à la valeur souhaitée (sans toucher les valeurs des autres bits) et envoyer ce masque sur le port de données 0x21 grâce à la fonction outb.

Il est aussi possible de désactiver globalement toutes les sources d'interruption externes au processeur : c'est ce qu'on doit faire pour la majorité du code noyau, car celui-ci doit effectuer des opérations de façon atomique. Mais on doit aussi les activer aux endroits opportuns pour permettre l'arrivée et la prise en compte des interruptions utiles comme celle en provenance de l'horloge. On utilise pour cela les fonctions void sti(void) (active les interruptions externes) et void cli(void) (désactive toutes les interruptions externes). En résumé, la fonction kernel_start de votre noyau devrait au final ressembler à ceci :

void kernel_start(void)
{
// initialisations
...
// démasquage des interruptions externes
sti();
// boucle d'attente
while (1) hlt();
}

Utilisation de l'horloge programmable

L'horloge programmable est un composant pouvant être paramétré de façon à générer des signaux à la fréquence voulue. Le circuit que l'on utilise dans ce TP émet un signal sur l'IRQ0 à une fréquence par défaut de 0x1234DD Hz (environ 1,19 MHz), ce qui est beaucoup trop rapide pour l'utilisation qu'on veut en faire.

Il est possible de régler la fréquence des signaux en utilisant les ports d'entrée-sorties associés à l'horloge programmable. Si on souhaite par exemple que l'horloge émette un signal toutes les 20 ms (50 Hz), on procedera de la façon suivante :

  1. on envoie la commande sur 8 bits 0x34 sur le port de commande 0x43 grace à la fonction outb : cette commande indique à l'horloge que l'on va lui envoyer la valeur de réglage de la fréquence sous la forme de deux valeurs de 8 bits chacunes qui seront émises sur le port de données ;
  2. on envoie les 8 bits de poids faibles de la valeur de réglage de la fréquence sur le port de données 0x40 : cela peut se faire simplement par outb((QUARTZ / CLOCKFREQ) % 256, 0x40);QUARTZ vaut 0x1234DD et CLOCKFREQ vaut 50 ;
  3. on envoie ensuite les 8 bits de poids forts de la valeur de réglage sur le même port 0x40.

On note que comme la valeur de réglage de la fréquence est limité à 16 bits, on ne pourra pas régler la fréquence d'émission du signal d'horloge à 1 Hz : il faudra donc que le traitant de l'interruption associé intègre un compteur pour savoir quand mettre à jour l'affichage du temps à l'écran.

Travail à réaliser

Le travail demandé peut être découpé de la façon suivante :

  1. écrire le traitant de l'interruption 32 qui affiche à l'écran le temps écoulé depuis le démarrage du système : ce traitant doit commencer par une partie en assembleur pour sauvegarder les registres et acquitter l'interruption, mais la partie gérant l'affichage doit être faite dans une fonction en C (on attire au passage votre attention sur l'existence dans la mini-libc fournie d'une fonction sprintf qui vous sera vraisemblablement utile)
  2. initialiser l'entrée 32 dans la table des vecteurs d'interruptions, grace à une fonction void init_traitant_IT(int num_IT, void (*traitant)(void)) à écrire (cette fonction vous sera utile pour la séance 3)
  3. régler la fréquence de l'horloge programmable : la fréquence d'émission des signaux par l'horloge doit être une constante globale de votre système, afin de permettre facilement de la changer
  4. démasquer l'IRQ0 pour autoriser les signaux en provenance de l'horloge : on vous recommande fortement de gagner du temps sur la séance 3 en écrivant tout de suite une fonction void masque_IRQ(int num_IRQ, int masque) prenant en paramètre le numéro de l'IRQ (entre 0 et 7) à gérer ainsi qu'un boolean indiquant si on souhaite masquer ou démasquer l'IRQ en question
  5. démasquer les interruptions externes grâce à un appel à la fonction sti() comme expliqué dans le squelette de code donné plus haut