Les interruptions

Cet article traite du fonctionnement globale des interruptions sur notre architecture x86, en partant de la table des interruptions jusqu'au Programmable Interrupt Controller (PIC).

Types d'interruptions

L'architecture x86 possède plusieurs classes d'interruptions :

Interrupt Descriptor Table (IDT)

Cette table est une structure stockée en mémoire constituée de 256 entrées (appelées gates) de 8 octets. Cette structure fait donc 256 * 8 = 2048 octets. L'adresse de cette structure est donnée au processeur via l'instruction lidt au démarrage du kernel.

Lorsque le processeur doit traiter une interruption (exception, IRQ ou software interrupt), il parcours alors cette table et déclenche la gate associée si possible.

Chaque entrée définie l'offset de l'interrupt handler, ou Interrupt Service Routine (ISR), son segment ainsi que des flags, tels que la présence ou non d'une ISR. Chaque gate peut être de type task, interrupt ou trap, comme on peut le voir sur la figure suivante tirée du Intel Manual:

Intel Manual - Figure 6-2

Les interrupt et trap gates sont identiques dans leur structure, et nous n'aborderons pas les task gates. La différence entre une interrupt et une trap gate est qu'une interrupt gate va masquer les interruptions avant d'exécuter l'ISR avant de restorer l'ancienne valeur.

Le code suivant permet de comprendre comment configurer une gate en mode interrupt (le mode utilisé dans le projet système). Nous partons du principe que notre IDT démarre à l'adresse 0x1000:

1
2
3
4
5
6
7
void idt_set_interrupt_gate(uint8_t index, uint32_t isr) {
    uint32_t *base = (uint32_t *) 0x1000;
    // * 2 car chaque gate fait 2 mots (8 octets)
    uint32_t *gate = base + ((ptrdiff_t) index * 2);
    gate[0] = (KERNEL_CS << 16) | (isr & 0xFFFF);
    gate[1] = (isr & 0xFFFF0000) | 0x8E00;
}

Nous utilisons ici l'arithmétique des pointeurs de type uint32_t, donc l'adresse avance en réalité de index * 2 * 4.

Il faut faire attention à l'endianness !

Dans ce code, on peut noter l'utilisation de deux constantes, d'abord KERNEL_CS qui est une constante prédéfini par le projet au segment mémoire utilisé pour le code kernel, ainsi que 0x8E00 qui représente les flags de la gate: P = 1 (present), PDL = 0 (niveau 0, privilège le plus élevé), 01110 pour le type de gate (interrupt gate 32 bits). On pourrait par exemple utiliser les flags 0x8F00 pour une trap gate.

Interrupt Flag (IF)

L'architecture défini un registre appelé EFLAGS (dont la définition de chaque bit peut être trouvée sur wikipedia). Ce registre contient différents bits correspondants à des états du processeurs, comme le Carry flag, Sign flag ou Zero flag qui sont définis par les instructions d'opérations et permettent les instructions conditionnelles.

Ce registre contient également un bit important pour les interruptions : le Interrupt flag, si ce bit est défini à 1, les interruptions "maskable" sont globalement activé sur le processeur. Ce bit peut être mis à 1 avec l'instruction sti (Set Interrupt) ou mis à 0 avec cli (Clear Interrupt).

Les interruptions "maskable" ne concernent que les interruptions externes provenant du PIC. Il n'est donc pas possible de désactiver les exceptions ou les software interrupts.

Les interruption externes sont généralement masquées dans le kernel.

Interrupt Service Routine (ISR)

Une ISR est une fonction qui va être appelée par le processeur quand une interruption est déclenchée (on part du postulat que l'on ne change pas de niveau de privilège). Lorsque que l'ISR est appelée, le processeur va pousser sur la stack en cours d'utilisation les registres EFLAGS, CS et EIP. Il réalise ensuite un saut sur l'adresse de notre ISR et commence son exécution. Certaines exceptions pousse un code d'erreur après EIP.

Cette gestion particulière de la stack requiert donc une instruction particulière pour return de notre fonction: iret (instruction return). De plus, on va également vouloir sauvegarder/restorer les registres pour pouvoir les utiliser librement dans notre ISR.

Ces contraintes nous force généralement à implémenter les ISR en assembleur, afin de controller exactement les instructions prologue et épilogue de notre fonction.

Voici un exemple d'ISR en assembleur:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
my_isr_asm:
    # Nous utilisons les instructions pushad/popad 
    # qui pousse/restore les registres généraux.
    pushad
    # Clear Direction Flag, nécessaire car le 
    # compilateur GCC C part du principe que le
    # flag est à 0 quand il compile.
    cld
    # Une fois les registres sauvegardés, on peut
    # librement appeler une fonction défini en C.
    call my_isr
    popad
    iret

Avec le reste de l'ISR défini en C:

1
2
3
void my_isr(void) {
    printf("my_isr()\n");
}

On peut alors utiliser notre fonction pour définir une interrupt gate vu précédemment pour déclarer notre ISR auprès du processeur (sur l'interruption 60 par exemple):

1
2
3
4
5
6
// Prototype de notre fonction défini en assembleur.
void my_isr_asm(void);

void foo(void) {
    idt_set_interrupt_gate(60, (uint32_t) my_isr_asm);
}

Attention! Si vous écrivez une ISR gérant une interruption générée par le PIC, voir la section sur la commande EOI.

Programmable Interrupt Controller (PIC)

Les PIC (8259A) sont des composants externes connectés à notre processeur, ils permettent de recevoir des IRQs (externes) d'un timer ou un clavier par exemple. Ils signalent alors au processeur l'arrivé d'une IRQ, quand le processeur accepte la requête, il déclenche l'interruption associée à ce numéro d'IRQ (voir IDT).

Un PIC peut gérer 8 numéros d'IRQs différents. Ce nombre étant trop faible, deux PIC (master et slave) sont utilisés en cascade:

PIC 8259A master/slave

On voit que lorsque le slave PIC reçoit une IRQ, il déclenche à son tour l'IRQ 2 sur le master PIC, qui envoi à son tour l'information au processeur. Nous avons donc 15 IRQ utilisable avec ce montage.

Le contrôle de ces PIC est possible via les ports de commande et de data:

Dans les schémas ci-après concernant le PIC, le bit A0 indique quel port utiliser, A0 = 0 (commande) et A0 = 1 (data).

Masques du PIC

Un fois le PIC initialisé, on peut lire ou écrire le Operation Command Word (OCW) 1. Ce mot, comme on peut le voir sur le schéma ci-après, associe à chaque bit le masque de l'IRQ associée.

Définition de OCW1

Pour que le PIC accepte une IRQ, on doit mettre à 0 (démasqué) le bit correspondant sur le port de data, on peut le remettre à 1 (masqué) pour désactiver l'IRQ:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void irq_mask(uint8_t n, bool masked) {

    // Les IRQ 8 à 15 sont sur le slave PIC.
    bool slave = n >= 8;
    // Si on démasque une IRQ sur le slave, il  
    // faut démasquer l'IRQ 2 du master.
    if (slave && !masked) {
        irq_mask(2, false);
    }

    uint8_t mask_port = slave ? PIC_SLAVE_MASK : PIC_MASTER_MASK;
    uint8_t current_mask = inb(mask_port);
    uint8_t mask = 1 << (slave ? (n - 8) : n);
    outb(masked ? (current_mask | mask) : (current_mask & ~(mask)), mask_port);

}

End of Interrupt (EOI)

Pour toute ISR configuré pour des IRQ, il est important de signaler au PIC que l'ISR est terminé avant de retourner de l'ISR. Comme on peut le voir sur le schéma suivant, il faut envoyer le 6ème bit à 1 sur le port de data, donc 0x20.

Définition de OCW2

Il est également spécifié qu'avant d'envoyer une EOI à un slave PIC, il faut d'abord l'envoyer au master PIC.

1
2
3
4
5
6
void irq_eoi(uint8_t n) {
    outb(0x20, PIC_MASTER_CMD);
    if (n >= 8) {
        outb(0x20, PIC_SLAVE_CMD);
    }
}

Initialisation du PIC (bonus)

L'initialisation des PIC est déjà fourni pour le projet système. Les PIC sont alors configurés pour utiliser les numéros d'interruption 32 à 39 pour les IRQ 0 à 7 et les numéros 40 à 47 pour les IRQ 8 à 15. Vous pouvez ignorer cette section si cela ne vous intéresse pas.

Pour chacun de nos PIC, on doit envoyer 4 Initialization Command Word (ICW). Le premier mot à envoyer: ICW1, démarre l'initialisation et doit être envoyé sur le port de commande. La valeur à envoyé est 0x11, IC4 = 1 (on prévoit d'envoyer un ICW4), SNGL = 0 (cascading mode, car on a slave/master).

1
2
outb(0x11, 0x20); // master
outb(0x11, 0xA0); // slave
Définition de ICW1

On doit ensuite envoyer ICW2 (sur le port de data), qui contient l'offset du numéro d'interruption de notre PIC, dans notre cas on enverrait 0x20 pour le master (32-39) et 0x28 (40-47) pour le slave. On peut voir que les 3 bits de poids sont ignorés quand on est en mode 8086/8088 (notre cas).

1
2
outb(0x20, 0x21); // master
outb(0x28, 0xA1); // slave
Définition de ICW2

Le mot ICW3 nous permet de donner aux master/slave les informations sur la topologie utilisé, on informe le master qu'il a un slave sur l'IRQ 2 et on informe le PIC slave de son identifiant.

1
2
outb(0x04, 0x21); // master
outb(0x02, 0xA1); // slave
Définition de ICW3

Le dernier mot, ICW4 nous permet de spécifier que nous utiliserons le mode 8086/8088 correspondant à notre architecture, nous ne rentrerons pas dans les détails ici, mais tous les autres champs sont à 0.

1
2
outb(0x01, 0x21); // master
outb(0x01, 0xA1); // slave
Définition de ICW4

Le code final est donc le suivant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/* Initialize the master. */
outb(0x11, 0x20);
outb(0x20, 0x21);
outb(0x04, 0x21);
outb(0x01, 0x21);

/* Initialize the slave. */
outb(0x11, 0xA0);
outb(0x28, 0xA1);
outb(0x02, 0xA1);
outb(0x01, 0xA1);

Sources

<