Skip to content

Niveau 4 · Chapitre 4.3

Appels système et niveaux de privilège

Le système d’exploitation comme une machine dotée d’instructions supplémentaires : pourquoi les programmes ne peuvent pas toucher au matériel, ce qui se passe à l’aller vers le noyau et au retour, comment reviennent les erreurs, quels appels système fait un vrai programme — tracés avec un strace fait maison — et ce qu’ils coûtent.

Tanenbaum décrit le système d’exploitation comme un niveau à part entière : la machine du système d’exploitation. Elle offre tout ce qu’offre l’ISA, plus un ensemble d’instructions nouvelles — ouvrir un fichier, y lire, créer un processus, allouer de la mémoire, envoyer un paquet. Les instructions ordinaires s’exécutent toujours directement sur le matériel. Les nouvelles, les appels système, sont exécutées par le noyau pour le compte du programme. Du point de vue du programme, read n’est qu’une instruction de plus, lente et très puissante.

Le chapitre sur les déroutements a montré le côté matériel : l’instruction syscall, le passage en mode noyau, le coût. Ce chapitre regarde la même frontière du côté du système d’exploitation.

Pourquoi les programmes ne peuvent pas le faire eux-mêmes

Un programme en mode utilisateur peut calculer n’importe quoi, mais il ne peut pas toucher à la machine. Les instructions qui commandent le matériel — lire et écrire les ports d’E/S, charger des tables de pages, désactiver les interruptions, arrêter le CPU, changer de privilège — provoquent une faute si du code utilisateur les tente. Les tables de pages interdisent la mémoire du noyau au mode utilisateur. Tout ce qui touche un périphérique, un autre processus ou les données du noyau doit donc être demandé.

C’est tout l’intérêt : l’isolation. Le noyau peut vérifier chaque demande — ce processus a-t-il le droit d’ouvrir ce fichier ? ce tampon est-il bien dans sa propre mémoire ? —, et un programme ne peut ni lire la mémoire d’un autre, ni faire planter le pilote du disque, ni garder le CPU pour toujours. La même conception équipe tous les systèmes modernes, avec en pratique les deux mêmes niveaux : les anneaux 0 (noyau) et 3 (utilisateur) du x86, les anneaux 1 et 2 restant inutilisés ; EL1 et EL0 sur ARM64 ; les modes S et U de RISC-V.

La protection joue aussi dans l’autre sens. Depuis Meltdown et les attaques voisines de 2018, et même avant, les noyaux ne veulent pas toucher la mémoire utilisateur par accident : SMEP et SMAP sur x86, PAN sur ARM font échouer le CPU si le noyau exécute du code utilisateur ou lit de la mémoire utilisateur en dehors des quelques routines prévues pour cela. Et depuis Meltdown, Linux passe même, sur les puces x86 concernées, à des tables de pages séparées à chaque entrée dans le noyau (KPTI), pour que le mode utilisateur ne puisse pas lire la mémoire du noyau par spéculation — une raison de plus pour laquelle les appels système sont devenus plus lents.

Aller dans le noyau et revenir

Sous Linux x86-64, un appel système se déroule ainsi :

  1. La fonction de la bibliothèque C — write, getpid… — place le numéro d’appel dans rax et jusqu’à six arguments dans rdi, rsi, rdx, r10, r8, r9, puis exécute syscall.
  2. Le CPU sauvegarde l’adresse de retour dans rcx et les indicateurs dans r11, passe en anneau 0 et saute au point d’entrée du noyau, que le noyau a rangé dans un registre spécifique au modèle (MSR) au démarrage.
  3. Le code d’entrée du noyau passe sur la pile du noyau, sauvegarde les registres utilisateur, et se sert de rax comme index dans sa table des appels système, un tableau de pointeurs de fonction.
  4. Le gestionnaire vérifie ses arguments. Les pointeurs demandent une attention particulière : un programme pourrait passer l’adresse de la mémoire du noyau, ou d’un emplacement inexistant, donc le noyau ne les déréférence jamais directement. Il copie les données dans un sens et dans l’autre avec copy_from_user et copy_to_user, qui vérifient les adresses et se remettent proprement d’un défaut de page.
  5. Le résultat revient dans rax, les registres utilisateur sont restaurés, et sysret revient à l’instruction qui suit syscall, en anneau 3.

Les erreurs reviennent sous forme de nombres négatifs : −2 est ENOENT (fichier inexistant), −9 EBADF (descripteur de fichier invalide), −13 EACCES (permission refusée), −38 ENOSYS (appel système inexistant). La fonction enveloppe de la bibliothèque C les convertit dans la convention que voient les programmes C : elle range le code positif dans la variable globale errno et renvoie −1.

Le simulateur implémente un minuscule noyau avec quatre des appels système de Linux — write, exit, getpid et getppid — et renvoie ENOSYS pour les autres :

Live · Trois appels système, dont un qui échoue

À 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.

program— ▸ is the next instruction
  1. .data
  2. msg: .asciz "hi\n"
  3. .text
  4. mov eax, 39 ; getpid()
  5. syscall
  6. mov ebx, eax ; keep the result: 1000
  7. mov eax, 1 ; write(
  8. mov edi, 9 ; fd 9, which isn't open,
  9. lea rsi, [rip+msg] ; buffer,
  10. mov edx, 3 ; 3 bytes)
  11. syscall ; rax = -9: EBADF
  12. mov eax, 1 ; write(fd 1 = standard output, ...)
  13. mov edi, 1
  14. syscall ; rax = 3
  15. mov eax, 60 ; exit(0)
  16. xor edi, edi
  17. syscall
step 0
Loading emulator…
The process as the operating system sees it: an address space of code, globals, heap and stack.

getpid renvoie 1000, l’écriture sur le descripteur 9 renvoie −9, et l’écriture sur la sortie standard affiche hi et renvoie 3, le nombre d’octets écrits. Observez rcx après chaque syscall : l’instruction elle-même l’écrase avec l’adresse de retour, et r11 avec les indicateurs, c’est pourquoi la convention d’appel considère ces deux registres comme détruits par un appel système. Tous les autres registres reviennent inchangés.

Ce que demande un vrai programme

Pour voir les appels système d’un vrai programme, Linux a strace. Il repose sur ptrace, l’appel système qu’utilisent aussi les débogueurs : le traceur demande au noyau d’arrêter le processus tracé à chaque entrée et sortie d’appel système, et lit ses registres à chaque fois. Une version minimale tient en une quarantaine de lignes de C. Voici ce qu’elle a affiché pour le programme C printf("hello\n"), compilé normalement, sous Linux ARM 64 bits (appels semblables regroupés) :

brk             ()                                    = 0xaaab17737000   ← où est le tas ?
mmap            ()                                    = 0xffffbd4d4000
faccessat       ("/etc/ld.so.preload")                = -2               ← ENOENT : fichier inexistant
openat          ("/etc/ld.so.cache")                  = 3                ← l’index des bibliothèques du chargeur
fstatat, mmap, close                                                     ← projeter le cache, le fermer
openat          ("/lib/aarch64-linux-gnu/libc.so.6")  = 3
read            ()                                    = 0x340            ← les en-têtes ELF de la libc
fstatat, mmap ×4, munmap ×2, mprotect, close                             ← projeter les segments de la libc
set_tid_address, set_robust_list, rseq                                   ← préparer le thread principal
mprotect ×3                                                              ← passer les données relogées en lecture seule
prlimit64, munmap, fstatat
getrandom       ()                                    = 8                ← une clé aléatoire pour les contrôles de malloc
brk ×2                                                                   ← agrandir le tas pour le tampon de stdout
write           ("hello\n")                           = 6
exit_group      (0)

Trente-deux appels système, dont un seul fait le travail demandé par le programme. Les autres sont le chargeur dynamique qui trouve et projette la bibliothèque C, repasse des pages en lecture seule après la relocation et prépare l’état du thread ; puis malloc obtient une clé aléatoire qui lui sert à détecter les doubles libérations (le contrôle du chapitre sur le tas), et printf agrandit le tas pour son tampon de sortie. Compilé avec -static, le même programme fait 15 appels système ; ls / en fait 74, les groupes les plus nombreux étant mmap (14), mprotect et close (8 chacun).

Le faccessat tracé montre aussi une erreur en situation réelle : le chargeur cherche /etc/ld.so.preload, reçoit −2 (ENOENT) parce que le fichier n’existe pas, et continue — une erreur est une réponse ordinaire, pas un plantage.

Combien, et avec quelle stabilité

Linux sur ARM 64 bits numérote ses appels système de 0 à 450, et le x86-64 en a un nombre comparable. Linux considère les numéros d’appels système et leur comportement comme une promesse stable : un binaire d’il y a vingt ans tourne encore, et les programmes peuvent faire des appels système directement, sans la bibliothèque C — c’est ce que fait l’environnement d’exécution de Go.

D’autres systèmes font une autre promesse. Les numéros d’appels système de Windows ne sont pas documentés et changent d’une version à l’autre ; les programmes doivent passer par les fonctions de ntdll.dll, qui connaît les numéros de la version en cours. macOS se réserve aussi le droit de changer ses appels système et ne prend en charge que les appels faits via sa bibliothèque système, libSystem — c’est pourquoi Go est passé par libSystem sur macOS en 2018, après qu’une mise à jour du noyau a cassé des programmes qui faisaient leurs appels système directement. OpenBSD va plus loin et bloque les appels système faits depuis ailleurs que sa bibliothèque C. La frontière est la même partout ; l’endroit où se trouve l’interface stable est un choix.

Ce que cela coûte, et comment l’éviter

Dans une machine virtuelle Linux sur un Apple M2, un appel système getppid — à peu près le moins coûteux qui soit — a pris 154 ns, et clock_gettime fait comme un vrai appel système 186 ns. Le même clock_gettime via le vDSO, la page de code du noyau projetée dans chaque processus (visible sous le nom [vdso] dans l’espace d’adressage), a pris 17 ns : il lit l’horloge dans une mémoire partagée sans entrer du tout dans le noyau.

C’est la stratégie générale pour rendre les appels système moins coûteux : en faire moins. Les programmes mettent leur sortie en tampon — printf accumule le texte et fait un seul write pour de nombreux appels. Les serveurs lisent et écrivent plusieurs tampons par appel. Et l’io_uring de Linux va plus loin : le programme et le noyau partagent des files en mémoire, le programme écrit ses demandes dans l’une et lit les résultats dans l’autre, et un seul appel système — ou aucun, avec un thread du noyau qui surveille la file — peut soumettre des centaines d’opérations.

À retenir

  • La machine du système d’exploitation est l’ISA plus des instructions nouvelles — les appels système — exécutées par le noyau.
  • Le mode utilisateur ne peut toucher ni au matériel, ni aux tables de pages, ni à la mémoire du noyau ; tout le reste se demande, pour que le noyau puisse le vérifier : c’est l’isolation. Les vrais systèmes utilisent deux niveaux de privilège (anneaux 0 et 3 du x86, EL1 et EL0 sur ARM).
  • Un appel système passe un numéro et des arguments dans des registres, déroute vers le noyau qui répartit via sa table des appels système, vérifie les pointeurs avec copy_from_user, et renvoie le résultat dans rax. Les erreurs sont des valeurs errno négatives, que la bibliothèque C transforme en −1 et errno.
  • syscall détruit rcx et r11 ; tous les autres registres sont préservés.
  • Un « hello » compilé dynamiquement fait 32 appels système, dont 31 pour charger et préparer la bibliothèque C ; tracé avec un strace minimal fondé sur ptrace.
  • Linux garde ses numéros d’appels système stables ; Windows et macOS ne prennent en charge que les appels passés par leurs bibliothèques système.
  • Les appels système coûtent ici autour de 150 à 190 ns ; le vDSO répond à clock_gettime en 17 ns sans entrer dans le noyau, et la mise en tampon et io_uring font faire plus de travail à moins d’appels.

Dans ce niveau

  1. 4.1Fichiers exécutables : ELF, PE et Mach-O
  2. 4.2Processus et espace d’adressage
  3. 4.3Appels système et niveaux de privilège
  4. 4.4Mémoire virtuelle et paginationPrévu
  5. 4.5Fichiers, périphériques et E/SPrévu
  6. 4.6Threads et synchronisationPrévu
  7. 4.7Virtualisation matérielle et hyperviseursPrévu
  8. 4.8Au cœur d’UNIX et de WindowsPrévu