Comment fonctionnent les appels système macOS en 2026
Ce qui se passe vraiment quand un programme arm64 sur Mac émet une instruction SVC — du stub utilisateur jusqu'au dispatcher BSD, et retour.
Un appel système est la frontière où le code utilisateur cesse d'être maître et où le noyau prend la main pour un instant. Sur macOS moderne — Apple Silicon comme Intel — presque tout ce qu'un programme fait d'intéressant finit par franchir cette frontière : ouvrir un fichier, allouer de la mémoire, signer un hash, dessiner à l'écran. Comprendre comment cette frontière est franchie est le socle de toute programmation système, de tout reverse engineering, et de toute recherche en sécurité sérieuse sur la plateforme.
Ce guide parcourt le chemin complet : comment la requête quitte votre processus, comment XNU la classe et la dispatch, et comment le résultat revient.
Trois familles, une seule instruction de trap
XNU expose trois familles distinctes d'appels système, toutes acheminées par la même instruction de trap sur arm64 :
- Syscalls BSD — la surface façon POSIX que tout programmeur Unix connaît :
open,read,write,kqueue, et ~480 autres. Numéros positifs. - Mach traps — héritage du micro-noyau Mach de Carnegie Mellon, toujours bien vivant dans XNU.
mach_msg_trap,task_for_pid,semaphore_wait_trap, et ~60 autres. Numéros négatifs. - Appels diagnostic / privés — calls de debug et internes Apple, utilisés par le noyau lui-même et quelques daemons de confiance.
L'instruction qui franchit la frontière est la même dans chaque cas :
svc #0x80 ; le Supervisor Call qui déclenche un syscall macOS
Ce qui les distingue : le registre x16 — le numéro du syscall — et un encodage de classe dans ses bits hauts.
Étape 1 : le stub utilisateur
Quand vous appelez open() en C, vous n'appelez pas le noyau directement. Vous appelez un petit stub dans libsystem_kernel.dylib. Sur arm64 il ressemble à ceci :
_open:
mov x16, #5 ; SYS_open
svc #0x80 ; trap vers le noyau
b.cs __cerror ; si carry, gère errno
ret
#5 est le numéro BSD de open. Le stub reste discret : il place le numéro dans x16, exécute le SVC, et transmet le résultat. Les arguments restent dans x0–x7 au passage ; le noyau les lit directement depuis la frame de trap.
Sur x86_64 (Intel et Rosetta 2), l'équivalent utilise l'instruction syscall et un numéro 32-bit qui encode famille + index :
mov eax, 0x2000005 ; SYSCALL_CLASS_UNIX << 24 | 5
syscall
Les quatre classes :
| Classe | Code | Famille |
|---|---|---|
SYSCALL_CLASS_UNIX | 2 | syscalls BSD |
SYSCALL_CLASS_MACH | 1 | traps Mach |
SYSCALL_CLASS_MDEP | 3 | dépendant-machine |
SYSCALL_CLASS_DIAG | 4 | diagnostic |
Sur arm64 l'encodage équivalent vit dans le signe de x16 : positif pour UNIX, négatif pour MACH.
Étape 2 : le handler de trap
Quand le SVC se déclenche, le CPU saute dans le vecteur d'exception de XNU. Le handler dans osfmk/arm64/sleh.c (sleh_synchronous) décode l'exception, voit que c'est un SVC, et regarde x16 :
- x16 > 0 → chemin syscall BSD. Le handler appelle
unix_syscall64()dansbsd/dev/arm64/systemcalls.c, qui cherche le syscall dans la tablesysent[]générée depuisbsd/kern/syscalls.master. - x16 < 0 → chemin Mach trap. Le handler appelle
mach_call_munger64()dansosfmk/arm64/bsd_arm64.c, qui indexemach_trap_table[]par la valeur absolue.
C'est cette séparation qui fait qu'une même instruction SVC peut signifier deux choses totalement différentes — le noyau lit d'abord le signe de x16.
Étape 3 : dispatch
Pour les syscalls BSD, unix_syscall64() fait trois choses :
- Valider le numéro par rapport à
sysent_size. Hors limites →nosys(), qui retourneENOSYS. - Copier les arguments depuis la frame utilisateur vers la struct d'arguments du syscall, avec extension de signe typée et validation de pointeurs.
- Appeler le handler —
sysent[N].sy_call(p, &args, &retval). Ce handler est l'implémentation réelle (ex.sys_open()pour le syscall #5).
Le handler tourne en mode noyau, mais l'espace d'adressage du processus reste mappé, pour pouvoir copyin() / copyout() entre tampons utilisateur et noyau en sécurité.
Pour les Mach traps, mach_call_munger64() joue le même rôle sur mach_trap_table[] — sauf que la table est bien plus courte, et chaque entrée encode le nombre d'arguments.
Étape 4 : le retour
Quand le handler termine, le chemin s'inverse :
- La valeur de retour va dans x0 (et x1 pour les syscalls dont le type est plus large).
- Si le handler BSD a retourné non-zéro, le noyau positionne le flag carry dans le PSTATE de la frame.
- Le noyau émet
eret(Exception Return), qui restaure l'état utilisateur et reprend après le SVC. - Dans le stub,
b.cs __cerrorbranche si carry est positionné ;__cerrormet x0 danserrnoet retourne -1.
Les Mach traps n'utilisent pas la convention du carry flag. Ils retournent un kern_return_t directement — KERN_SUCCESS (0) ou un code KERN_* — et l'appelant compare manuellement.
Pourquoi c'est utile en pratique
Connaître ce chemin compte pour trois publics :
- Travail de performance : chaque syscall coûte au minimum une entrée noyau, un copyin, un copyout, et une exception return. Sur Apple Silicon c'est ~150 ns d'overhead avant même le travail réel. Le batching avec
readv/writev/keventenlève l'essentiel. - Reverse engineering : quand vous voyez
svc #0x80précédé demov x16, #N, vous identifiez immédiatement le syscall par numéro. Idem pourmov eax, 0x2nnnnnnsur binaires Intel. Le catalogue /syscall est un lookup numéro → nom. - Sécurité : chaque syscall est observable côté noyau. L'injection de code finit toujours par
task_for_pidsuivi demach_vm_writeetthread_create_running— trois Mach traps qui, combinés, donnent l'exécution arbitraire.
Connaître la frontière, c'est aussi savoir où regarder quand quelque chose cloche.
Pour aller plus loin
- Le post suivant de cette série décortique la séparation Mach traps vs syscalls BSD.
- Le plongeon
task_for_pidtraite du Mach trap le plus sensible côté sécurité. - Pour tracer du trafic réel, voir Tracer les syscalls macOS avec dtrace sur Apple Silicon.
Et si vous voulez juste la donnée : chaque syscall de chaque release XNU livrée par Apple est dans le catalogue de référence.