Tous les chapitres précédents suivaient un programme qui saute et appelle dans son propre code. Mais le contrôle peut aussi quitter le programme sans qu’il contienne le moindre saut : une instruction divise par zéro, touche une page non projetée ou demande un service au système ; ou une carte réseau finit de recevoir un paquet. À chaque fois, le CPU s’arrête, sauvegarde juste de quoi revenir, passe en mode noyau et exécute un gestionnaire (handler) choisi dans une table. Ce mécanisme est ce qui rend un système d’exploitation possible : c’est la seule entrée dans le noyau, et le seul moyen pour le noyau de reprendre le CPU à un programme qui ne le rend jamais.
Tanenbaum sépare ces événements en deux :
- les déroutements (traps), causés par le programme lui-même : synchrones et reproductibles — exécutez le même programme sur la même entrée, et ils surviennent à la même instruction à chaque fois ;
- les interruptions, causées par quelque chose d’extérieur au programme, en général les entrées-sorties : asynchrones, elles arrivent entre deux instructions quelconques.
Chaque ISA ajoute son propre vocabulaire. Intel appelle exceptions les événements synchrones et les divise en fautes, déroutements et abandons ; ARM appelle tout exception, interruptions comprises ; RISC-V appelle tout trap, divisé en exceptions et interruptions. Les idées sont les mêmes.
Ce que fait le matériel
Quand une exception ou une interruption est prise, le CPU exécute en matériel une courte séquence fixe, sans aucune instruction du programme :
- Sauvegarder le point de retour : l’adresse de l’instruction interrompue ou fautive, et les indicateurs.
- Changer de privilège : passer du mode utilisateur au mode noyau et, sur x86, sur la pile propre au noyau.
- Trouver le gestionnaire : chercher le numéro de l’événement dans une table préparée à l’avance par le noyau.
- Sauter vers le gestionnaire.
Le gestionnaire sauvegarde les registres qu’il utilise, traite l’événement, les restaure, puis exécute une instruction de retour d’exception qui annule d’un coup les étapes 1 et 2. Si tout est bien fait, le programme interrompu ne s’aperçoit de rien ; Tanenbaum parle de transparence.
| x86-64 | ARM64 | RISC-V | |
|---|---|---|---|
| table des gestionnaires | IDT : 256 entrées de 16 octets, repérée par le registre IDTR | une table de 16 points d’entrée de 128 octets chacun, en VBAR_EL1 | un point d’entrée dans stvec (ou une table en mode vectorisé) |
| où trouver la cause | le numéro de vecteur (0–255), plus un code d’erreur sur la pile | le registre de syndrome ESR_EL1 | le registre scause |
| adresse de retour rangée dans | la pile du noyau (avec les indicateurs, le pointeur de pile, le segment de code) | ELR_EL1 (indicateurs dans SPSR_EL1) | sepc (état dans sstatus) |
| instruction de retour | iretq | eret | sret |
Le x86 empile l’état sauvegardé en mémoire ; ARM64 et RISC-V le placent dans des registres spéciaux et laissent au gestionnaire le soin de les sauvegarder s’il en a besoin. Le livre décrit la table du x86 comme des descripteurs de segment de 8 octets ; en mode 64 bits, chaque entrée fait 16 octets, pour contenir une adresse de gestionnaire sur 64 bits.
Les exceptions : fautes, déroutements et abandons
Intel classe les exceptions selon l’endroit où reprend l’exécution :
- une faute est signalée avant que l’instruction se termine, et l’adresse sauvegardée désigne l’instruction fautive elle-même : le gestionnaire peut corriger le problème et la réexécuter. L’exemple clé est le défaut de page : le programme touche une page absente de la mémoire, le noyau la charge depuis le disque, et la même instruction se réexécute, avec succès. Le programme n’en sait rien. Ce redémarrage est ce qui rend la mémoire virtuelle possible.
- un déroutement (trap) est signalé après l’instruction, et l’exécution reprend à la suivante. Les points d’arrêt (
int3) et le pas à pas fonctionnent ainsi. - un abandon (abort) est irrécupérable, comme une machine check qui signale une erreur matérielle.
Redémarrer une instruction fautive exige des exceptions précises : quand le gestionnaire s’exécute, toutes les instructions précédentes doivent être terminées et aucune instruction suivante ne doit avoir rien modifié. C’est plus difficile qu’il n’y paraît dans un cœur à exécution dans le désordre, qui a des dizaines d’instructions en vol ; le tampon de réordonnancement existe en grande partie pour le garantir, en ne levant une exception que lorsque l’instruction fautive atteint sa tête.
Les exceptions x86 courantes, et ce qu’en fait Linux pour le programme :
| Vecteur | Nom | Levée par | Signal Linux |
|---|---|---|---|
| 0 | #DE erreur de division | division entière par zéro, ou quotient trop grand | SIGFPE |
| 1 | #DB débogage | pas à pas, points d’arrêt matériels | SIGTRAP |
| 3 | #BP point d’arrêt | int3 (l’octet 0xCC) | SIGTRAP |
| 6 | #UD opcode invalide | une instruction non définie, ou ud2 (0f 0b) | SIGILL |
| 13 | #GP protection générale | une instruction privilégiée en mode utilisateur, une adresse non canonique | SIGSEGV |
| 14 | #PF défaut de page | un accès à une page non projetée ou protégée | traité en silence, ou SIGSEGV |
| 18 | #MC machine check | une erreur matérielle | en général une panique du noyau |
Le simulateur lève la même erreur de division :
À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.
- mov eax, 7
- xor edx, edx
- xor ecx, ecx
- div ecx ; edx:eax / ecx, with ecx = 0
- mov eax, 1 ; never reached
En arrivant sur le div, la machine s’arrête avec #DE divide error. Sur un vrai système, le gestionnaire du vecteur 0 enverrait au processus le signal SIGFPE, et sans gestionnaire de signal, le processus meurt.
Toutes les ISA ne déroutent pas
Le livre cite le débordement et la division par zéro parmi les causes classiques de déroutement. C’est là que les ISA diffèrent le plus. Mesuré sur un Apple M2, avec le même programme C compilé pour ARM64 et pour x86-64 (exécuté sous Rosetta 2) :
volatile int zero = 0; | ARM64 | x86-64 |
|---|---|---|
7 / zero (entier) | renvoie 0, aucun déroutement | SIGFPE |
7.0 / 0.0 (virgule flottante) | inf, aucun déroutement | inf, aucun déroutement |
instruction non définie (udf / ud2) | SIGILL | SIGILL |
point d’arrêt (brk / int3) | SIGTRAP | SIGTRAP |
| lecture par un pointeur nul | SIGSEGV | SIGSEGV |
L’instruction de division d’ARM64, sdiv, renvoie simplement 0 pour un diviseur nul, et celle de RISC-V renvoie −1 (tous les bits à 1) ; aucune des deux ne déroute. Les concepteurs de ces ISA ont choisi de garder un diviseur simple et de laisser le logiciel vérifier s’il le souhaite, ce que les compilateurs C ne font pas : la division par zéro est un comportement indéfini. La division flottante par zéro ne déroute nulle part par défaut. IEEE 754 lui définit un résultat (l’infini) et se contente de positionner un indicateur d’état ; les déroutements qu’elle autorise sont désactivés sauf si un programme les active.
Le débordement entier, premier exemple du livre, ne déroute pas non plus sur les ISA courantes actuelles. Le x86 avait une instruction pour cela, INTO (l’octet 0xCE), qui déroutait si l’indicateur de débordement était positionné ; elle a été retirée du mode 64 bits — nasm la refuse avec instruction not supported in 64-bit mode. MIPS a un add qui déroute en cas de débordement, mais les compilateurs émettent addu, qui ne le fait pas. Les langages qui vérifient le débordement, comme Rust en mode débogage ou Swift, le font en logiciel, avec un saut conditionnel sur l’indicateur de débordement après chaque opération.
Les appels système : des déroutements volontaires
Un programme ne peut pas sauter dans le noyau : la mémoire du noyau n’est pas projetée pour lui, et y sauter provoquerait une faute. La seule entrée est un déroutement volontaire, une instruction dont le seul but est d’en provoquer un. Le noyau lit le numéro du service demandé et ses arguments dans des registres, fait le travail, et revient.
| x86-64 | ARM64 | RISC-V | |
|---|---|---|---|
| instruction | syscall (0f 05) | svc #0 | ecall |
| numéro d’appel dans | rax | x8 (Linux) | a7 |
| arguments dans | rdi, rsi, rdx, r10, r8, r9 | x0–x5 | a0–a5 |
Les programmes Linux 32 bits utilisaient int 0x80, une interruption logicielle passant par l’IDT. syscall est un chemin dédié, plus rapide, qui évite la consultation de la table. Le simulateur implémente les appels Linux write (numéro 1) et exit (numéro 60) :
À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.
- .data
- msg: .asciz "hello\n"
- .text
- mov eax, 1 ; write(
- mov edi, 1 ; fd 1 = standard output,
- lea rsi, [rip+msg] ; buffer,
- mov edx, 6 ; 6 bytes)
- syscall
- mov eax, 60 ; exit(
- mov edi, 3 ; status 3)
- syscall
Il affiche hello et se termine avec le code 3. Dans un vrai programme, les fonctions write et exit de la bibliothèque C sont de minces enveloppes autour de ces instructions ; le niveau du système d’exploitation regarde ce que fait le noyau de l’autre côté.
Entrer dans le noyau et en sortir coûte bien plus qu’un appel de fonction. Sur le M2 sous macOS, une boucle de getppid() — à peu près l’appel système le moins coûteux qui soit — a pris 92 ns par appel, contre 0,9 ns pour un appel de fonction ordinaire : un facteur d’environ 100. Dans une machine virtuelle Linux sur le même ordinateur, c’était 152 ns. L’instruction elle-même est rapide ; le coût vient du changement de pile, de la sauvegarde des registres et des protections de sécurité que les noyaux modernes appliquent à chaque entrée. C’est pourquoi on regroupe les appels système quand c’est possible, et pourquoi Linux répond à certains des plus fréquents, comme la lecture de l’horloge, depuis une page de données du noyau projetée dans chaque processus (le vDSO), sans aucun déroutement.
Les interruptions
Une interruption vient de l’extérieur du flot d’instructions : un disque qui termine un transfert, un paquet réseau qui arrive, une touche enfoncée, une minuterie qui expire. Le CPU vérifie entre deux instructions s’il y a des interruptions en attente, et quand l’une l’est et que les interruptions sont autorisées, il exécute la même séquence sauvegarde-bascule-saut que pour une exception, avec un numéro de vecteur fourni par le périphérique ou son contrôleur.
Tanenbaum décrit la conception classique : le périphérique active une ligne d’interruption sur le bus, le CPU accuse réception, et le périphérique place son numéro de vecteur sur le bus de données. Les PC de l’époque utilisaient un contrôleur d’interruptions 8259A pour arbitrer les priorités entre périphériques. Les machines modernes fonctionnent autrement :
- Chaque cœur a un APIC local (sur ARM, un redistributeur du GIC) qui reçoit les interruptions, les classe par priorité et les livre à son cœur.
- Les périphériques PCIe n’ont plus du tout de fils d’interruption. Ils signalent une interruption par un MSI (message-signaled interrupt) : une écriture mémoire ordinaire à une adresse spéciale, dont la donnée est le numéro de vecteur. Une carte réseau à plusieurs files peut viser un vecteur différent — et un cœur différent — pour chacune.
- Les cœurs s’interrompent mutuellement par des interruptions inter-processeurs (IPI), par exemple pour faire vider le TLB d’un autre cœur ou le faire changer de tâche.
- Une interruption de minuterie sur chaque cœur permet au noyau de reprendre le CPU à un programme qui boucle indéfiniment, et de passer à un autre : c’est le battement de cœur du multitâche préemptif.
- Les interruptions non masquables (NMI) existent toujours, comme le dit le livre, pour les urgences — et pour les profileurs et les chiens de garde qui doivent fonctionner même quand les interruptions sont désactivées.
Le schéma de priorités du livre survit lui aussi. Le gestionnaire d’une interruption prioritaire peut lui-même être interrompu par une interruption plus prioritaire encore, mais les moins prioritaires attendent. L’APIC local l’implémente en classant les interruptions par numéro de vecteur.
Quelle est l’activité réelle ? Sur une machine virtuelle Linux inactive à 24 cœurs, /proc/stat a compté environ 1 500 interruptions et 2 200 changements de contexte par seconde, sans aucun programme en cours. Chacun était un déroutement vers le noyau suivi d’un retour transparent — invisible pour tous les programmes interrompus.
À retenir
- Les exceptions (les déroutements de Tanenbaum) sont synchrones : causées par une instruction, au même endroit à chaque exécution. Les interruptions sont asynchrones : causées par les périphériques et les minuteries, entre deux instructions quelconques.
- Le matériel sauvegarde l’adresse de retour et les indicateurs, passe en mode noyau et saute à travers une table (l’IDT du x86,
VBAR_EL1sur ARM64,stvecsur RISC-V) ;iretq,eretetsretreviennent. - Une faute réexécute l’instruction fautive (les défauts de page rendent la mémoire virtuelle possible) ; un déroutement reprend après elle ; un abandon ne peut pas reprendre. Le redémarrage exige des exceptions précises.
- Ce qui déroute dépend de l’ISA : le x86 déroute sur une division entière par zéro, ARM64 renvoie 0 et RISC-V −1. Les erreurs flottantes et le débordement entier ne déroutent nulle part par défaut ; le x86 a retiré
INTOen mode 64 bits. - Les appels système sont des déroutements volontaires (
syscall,svc,ecall), environ 100 fois plus coûteux qu’un appel de fonction — 92 ns mesurées sur un M2. - Les interruptions modernes sont livrées par des APIC propres à chaque cœur, sous forme d’écritures mémoire MSI venant des périphériques PCIe, et d’IPI entre cœurs ; une interruption de minuterie fait tourner l’ordonnanceur.