Was ist ein Systemaufruf?
Ein SystemaufrufSystemaufruf (System Call)Kontrollierter Einsprung aus einem Anwendungsprogramm in den Kernel, um eine privilegierte Dienstleistung anzufordern, z. B. eine Datei zu lesen. (system call, kurz syscall) ist der einzige reguläre Weg, auf dem ein Programm im Benutzermodus Dienste des Kernels anfordern kann. Alles, was über reines Rechnen im eigenen Speicher hinausgeht, führt früher oder später über einen Systemaufruf:
| Bereich | Typische Systemaufrufe |
|---|---|
| Prozesse | clone, execve, exit_group, wait4, kill |
| Dateien | openat, read, write, close, stat, getdents64 |
| Speicher | mmap, munmap, brk, mprotect |
| Netzwerk | socket, connect, accept4, sendto, recvfrom |
| Zeit und Informationen | clock_gettime, getpid, uname |
| Vielzweck | ioctl, prctl, bpf, io_uring_enter |
Linux kennt einige hundert Systemaufrufe. Zusammen bilden sie die wichtigste stabile Schnittstelle des Kernels, ein ABIABI (Application Binary Interface)Schnittstelle auf Maschinenebene: welche Register Argumente tragen, wie Datenstrukturen im Speicher liegen usw. Die Systemaufruf-ABI von Linux bleibt stabil.. Ein Programm, das vor 20 Jahren kompiliert wurde, läuft auch auf einem aktuellen Kernel noch. Linus Torvalds’ oberste Regel lautet: „We don’t break user space.“ Ein Systemaufruf kann ergänzt, aber praktisch nie entfernt oder in seinem Verhalten geändert werden.
Der Weg eines Systemaufrufs
Wie läuft so ein Aufruf ab? Das folgende Diagramm geht den Weg eines read()-Aufrufs auf x86-64 Schritt für Schritt durch. Mit „Weiter“ springst du zum nächsten Schritt.
-
Schritt 1 von 8
Die Anwendung ruft read() auf
Für das Programm ist read() eine gewöhnliche C-Funktion. Tatsächlich ruft es damit eine kleine Wrapper-Funktion in der C-Bibliothek (glibc) auf – noch läuft alles im Benutzermodus (Ring 3).
-
Schritt 2 von 8
glibc bereitet den Systemaufruf vor
Der Wrapper legt die Systemaufruf-Nummer in rax (read hat auf x86-64 die Nummer 0) und die Argumente in rdi, rsi und rdx. Dann führt er den Maschinenbefehl syscall aus.
Register rax0 (read) rdi3 (fd) rsibuf rdx4096 -
Schritt 3 von 8
Die CPU wechselt in den Kernelmodus
Der syscall-Befehl wechselt in Ring 0, sichert die Rücksprungadresse in rcx und die Flags in r11 und springt an die Adresse, die der Kernel beim Booten im Register MSR_LSTAR hinterlegt hat. Die Anwendung kann dieses Ziel nicht beeinflussen.
Register rax0 (read) rdi3 (fd) rsibuf rdx4096 rcxRücksprungadresse r11RFLAGS -
Schritt 4 von 8
Einsprung im Assembler-Code
entry_SYSCALL_64 schaltet auf den Kernel-Stack des Tasks um, wechselt bei Bedarf die Seitentabellen (Schutz vor Meltdown, „PTI“) und speichert alle Register des Benutzerprogramms in einer struct pt_regs auf dem Stack.
Register rax0 (read) rdi3 (fd) rsibuf rdx4096 -
Schritt 5 von 8
Verteilung an den richtigen Handler
do_syscall_64() prüft, ob die Nummer gültig ist, und ruft über x64_sys_call() den passenden Handler auf – hier __x64_sys_read(). Ungültige Nummern liefern -ENOSYS.
Register rax0 (read) -
Schritt 6 von 8
Der Kernel erledigt die eigentliche Arbeit
ksys_read() sucht die geöffnete Datei zum Dateideskriptor, vfs_read() prüft Rechte und reicht die Anfrage an das zuständige Dateisystem weiter. Die Daten stammen meist aus dem Page Cache und werden mit copy_to_user() in den Puffer der Anwendung kopiert.
-
Schritt 7 von 8
Rückweg in den Benutzermodus
Das Ergebnis landet in rax. Vor der Rückkehr prüft der Kernel, ob noch etwas zu tun ist – etwa ein Signal zustellen oder einen anderen Task einplanen. Dann stellt er die Register wieder her und kehrt mit sysret (oder iret) nach Ring 3 zurück.
Register raxAnzahl Bytes oder −errno -
Schritt 8 von 8
glibc wertet das Ergebnis aus
Ist rax negativ (z. B. −EBADF), schreibt der Wrapper den Betrag nach errno und gibt −1 zurück. Andernfalls erhält das Programm die Anzahl gelesener Bytes – für die Anwendung war alles nur ein Funktionsaufruf.
Register raxAnzahl Bytes oder −errno
Entscheidend ist Schritt 3: Die Anwendung kann zwar den Wechsel in den Kernel auslösen, aber nicht bestimmen, wo der Kernel weiterarbeitet. Das Sprungziel hat der Kernel beim Booten in ein privilegiertes CPU-Register (MSR_LSTAR) geschrieben. Nur so ist garantiert, dass jeder Eintritt in den Kernel durch dieselbe, sorgfältig geprüfte Tür führt.
Die Aufrufkonvention auf x86-64
Für Systemaufrufe gilt eine feste Konvention, welche Register welche Werte tragen:
| Zweck | Register |
|---|---|
| Nummer des Systemaufrufs | rax |
| Argumente 1 bis 6 | rdi, rsi, rdx, r10, r8, r9 |
| Rückgabewert | rax |
vom syscall-Befehl überschrieben | rcx (Rücksprungadresse), r11 (RFLAGS) |
Die Konvention ähnelt der für gewöhnliche Funktionsaufrufe (System V AMD64 ABI) mit einem Unterschied: Das vierte Argument steht in r10 statt in rcx, weil der syscall-Befehl rcx für die Rücksprungadresse braucht. Mehr als sechs Argumente gibt es nicht. Systemaufrufe, die mehr Daten benötigen, erhalten einen Zeiger auf eine Struktur.
Auf ARM64 steht die Nummer in x8, die Argumente in x0 bis x5, und der Befehl heißt svc #0. Wichtig: Die Nummern unterscheiden sich zwischen Architekturen. read ist auf x86-64 die Nummer 0, auf ARM64 die 63.
Ein Systemaufruf ohne C-Bibliothek
Um zu zeigen, dass dahinter keine Magie steckt, führt dieses kleine Programm write() direkt mit Inline-Assembler aus, ganz ohne glibc-Wrapper:
// raw_write.c – übersetzen mit: gcc -O2 -o raw_write raw_write.c
static long raw_write(int fd, const void *buf, unsigned long len)
{
long ret;
__asm__ volatile ("syscall"
: "=a" (ret) /* Rückgabe in rax */
: "a" (1L), /* rax = 1 → write */
"D" ((long)fd), /* rdi = 1. Argument */
"S" (buf), /* rsi = 2. Argument */
"d" (len) /* rdx = 3. Argument */
: "rcx", "r11", "memory"); /* vom syscall überschrieben */
return ret;
}
int main(void)
{
raw_write(1, "Hallo direkt vom Kernel!\n", 25);
return 0;
}
Im Alltag braucht man das natürlich nicht. Für Systemaufrufe ohne eigenen Wrapper bietet die glibc die generische Funktion syscall(), etwa syscall(SYS_gettid).
Auf der Seite des Kernels
Die Systemaufruf-Tabelle
Welche Nummer zu welchem Systemaufruf gehört, steht für x86-64 in der Datei arch/x86/entry/syscalls/syscall_64.tbl. Ein Ausschnitt:
# <Nummer> <ABI> <Name> <Einsprungpunkt>
0 common read sys_read
1 common write sys_write
2 common open sys_open
3 common close sys_close
...
39 common getpid sys_getpid
Beim Bauen des Kernels entsteht daraus Code, der die Nummer dem passenden Handler zuordnet.
Ein Handler: SYSCALL_DEFINE
Systemaufrufe werden im Kernel mit den Makros SYSCALL_DEFINE0 bis SYSCALL_DEFINE6 definiert. Die Ziffer gibt die Zahl der Argumente an. Der Handler für read in fs/read_write.c ist erfreulich kurz:
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
return ksys_read(fd, buf, count);
}
Das Makro erzeugt mehrere Funktionen: den eigentlichen Handler, einen architekturspezifischen Wrapper (auf x86-64 __x64_sys_read), der die Argumente aus den gesicherten Registern in struct pt_regs holt, sowie Metadaten für Tracing-Werkzeuge. ksys_read sucht dann zum Dateideskriptor die geöffnete Datei und ruft vfs_read auf. Ab hier ist das VFS zuständig (Kapitel 10).
Vorsicht mit Zeigern aus dem User Space
Auffällig ist die Annotation __user am Parameter buf. Sie markiert einen Zeiger, der in den User Space zeigt und vom Programm stammt, also nicht vertrauenswürdig ist. Der Kernel darf einen solchen Zeiger nie direkt dereferenzieren, denn:
- Er könnte auf Kernel-Speicher zeigen. Ohne Prüfung könnte ein Programm den Kernel dazu bringen, geheime Daten auszulesen oder zu überschreiben.
- Er könnte ungültig sein oder auf eine Seite zeigen, die gerade ausgelagert ist.
Stattdessen verwendet der Kernel spezielle Kopierfunktionen:
/* Daten vom Kernel in den Puffer der Anwendung kopieren */
if (copy_to_user(buf, kernel_data, len))
return -EFAULT; /* Zeiger war ungültig */
/* Daten aus der Anwendung in den Kernel holen */
if (copy_from_user(&args, uargs, sizeof(args)))
return -EFAULT;
Diese Funktionen prüfen, ob der gesamte Bereich im User Space liegt (access_ok()). Tritt beim Kopieren ein Seitenfehler auf, behandelt der Kernel ihn sauber: Er lädt die Seite nach oder bricht mit -EFAULT ab, statt abzustürzen. Das Werkzeug sparse prüft beim Bauen anhand der __user-Annotation, ob irgendwo ein solcher Zeiger direkt verwendet wird.
Tiefer eintauchenSMAP und SMEP: Schutz in Hardware
Moderne x86-Prozessoren bieten zwei Schutzfunktionen, die Linux nutzt:
- SMEP (Supervisor Mode Execution Prevention) verhindert, dass die CPU im Kernelmodus Code aus User-Space-Seiten ausführt. Ein Angreifer kann den Kernel so nicht einfach in vorbereiteten Schadcode im eigenen Speicher springen lassen.
- SMAP (Supervisor Mode Access Prevention) verhindert, dass der Kernel auf User-Space-Seiten zugreift, außer er erlaubt es ausdrücklich. Genau das tun
copy_to_user()und Co.: Sie schalten den Zugriff mit dem Befehlstackurz frei und mitclacwieder ab.
Ein versehentlich direkt dereferenzierter __user-Zeiger führt mit SMAP also sofort zu einem Fehler statt zu einer stillen Sicherheitslücke. ARM64 hat mit PAN und PXN vergleichbare Mechanismen.
Der Einsprungpunkt im Detail
Tiefer eintauchenentry_SYSCALL_64 und do_syscall_64()
Der erste Kernel-Code nach dem syscall-Befehl ist Assembler: entry_SYSCALL_64 in arch/x86/entry/entry_64.S. In diesem Moment läuft die CPU zwar schon im Kernelmodus, aber noch auf dem Stack des Benutzerprogramms, dem der Kernel nicht trauen darf. Die ersten Befehle sind deshalb besonders heikel:
swapgstauscht die GS-Basisadresse, damit der Kernel seine Per-CPU-Daten erreicht.- Bei aktivem PTI wird auf die Kernel-Seitentabellen umgeschaltet (Wechsel von
CR3). - Der Stackpointer wird auf den Kernel-Stack des aktuellen Tasks gesetzt.
- Alle Register des Benutzerprogramms werden als
struct pt_regsauf diesem Stack gesichert. - Erst dann geht es in C weiter, mit dem Aufruf von
do_syscall_64.
In vereinfachter Form macht do_syscall_64() Folgendes:
/* stark vereinfacht */
bool do_syscall_64(struct pt_regs *regs, int nr)
{
nr = syscall_enter_from_user_mode(regs, nr); /* seccomp, Tracing, Audit */
if (nr < NR_syscalls)
regs->ax = x64_sys_call(regs, nr); /* Handler aufrufen */
else if (nr != -1)
regs->ax = -ENOSYS; /* unbekannte Nummer */
syscall_exit_to_user_mode(regs); /* Signale, Neu-Scheduling */
return sysret_possible; /* darf schnell per SYSRET zurückgekehrt werden? */
}Seit Linux 6.9 ruft x64_sys_call() die Handler über eine große switch-Anweisung statt über eine Tabelle mit Funktionszeigern auf. Indirekte Sprünge sind anfällig für spekulative Angriffe (Branch History Injection), direkte Aufrufe nicht.
Auf dem Rückweg prüft syscall_exit_to_user_mode(), ob noch Arbeit ansteht: ob ein Signal zugestellt werden muss oder ob der Scheduler inzwischen einen anderen Task bevorzugt. Ist alles erledigt, kehrt die CPU mit sysretq (in Sonderfällen mit dem langsameren, aber robusteren iretq) in den Benutzermodus zurück.
Tiefer eintauchenseccomp: Systemaufrufe filtern
Bevor der eigentliche Handler läuft, kann seccomp eingreifen. Ein Prozess kann einen Filter (ein kleines BPF-Programm) installieren, der für jeden Systemaufruf anhand von Nummer und Argumenten entscheidet: erlauben, mit einem Fehlercode ablehnen, den Prozess beenden oder an einen Überwachungsprozess melden. Ein einmal gesetzter Filter lässt sich nicht mehr lockern, und er wird an Kindprozesse vererbt.
Container-Laufzeiten wie Docker, Browser wie Chrome und Firefox sowie systemd-Dienste (SystemCallFilter=) nutzen seccomp, um die Angriffsfläche des Kernels zu verkleinern: Ein Prozess, der nur 50 Systemaufrufe braucht, sollte die übrigen gar nicht erst ausführen können. Mehr dazu in Kapitel 15.
Fehlerbehandlung: errno
Auf Kernelebene ist die Fehlermeldung schlicht: Ein Handler gibt im Fehlerfall eine negative Fehlernummer zurück, etwa -ENOENT (−2, „Datei nicht gefunden“) oder -EACCES (−13, „Zugriff verweigert“). Werte von −4095 bis −1 gelten als Fehler, alles andere als gültiges Ergebnis.
Die C-Bibliothek übersetzt das in die bekannte POSIX-Konvention: Sie schreibt den Betrag in die thread-lokale Variable errnoerrnoVariable der C-Bibliothek (pro Thread), in der die Fehlerursache des letzten fehlgeschlagenen Aufrufs steht, z. B. ENOENT für „Datei nicht gefunden“. und gibt −1 zurück. In strace sieht man beide Ebenen auf einmal:
openat(AT_FDCWD, "/gibt/es/nicht", O_RDONLY) = -1 ENOENT (No such file or directory)
Was ein Systemaufruf kostet
Ein Systemaufruf ist teurer als ein Funktionsaufruf. Die CPU muss die Privilegstufe wechseln, Register sichern und wiederherstellen und auf betroffenen Prozessoren Schutzmaßnahmen gegen Spectre und Meltdown ausführen, etwa den Wechsel der Seitentabellen bei PTI. Außerdem verdrängt der Kernel-Code Daten der Anwendung aus den Caches. Je nach CPU und aktiven Schutzmaßnahmen kostet ein einfacher Systemaufruf einige zehn bis wenige hundert Nanosekunden, ein Funktionsaufruf dagegen eher eine Nanosekunde.
Deshalb gibt es mehrere Techniken, um Systemaufrufe einzusparen:
- Pufferung in der C-Bibliothek:
printf()undfwrite()sammeln Daten und rufenwrite()erst auf, wenn der Puffer voll ist. - Bündeln:
readv()/writev()verarbeiten mehrere Puffer,sendmmsg()mehrere Netzwerkpakete undgetdents64()viele Verzeichniseinträge auf einmal. - io_uring: Anwendung und Kernel teilen sich zwei Ringpuffer im Speicher. Die Anwendung trägt viele I/O-Aufträge ein, der Kernel legt die Ergebnisse ab. Im Idealfall ist für sehr viele Operationen nur ein einziger Systemaufruf nötig.
- vDSO: Manche Aufrufe brauchen den Kernelmodus gar nicht.
Das vDSO: Systemaufrufe ohne Kernel
Viele Programme fragen sehr häufig die Uhrzeit ab. Dafür muss man nicht in den Kernel wechseln, die Information muss nur aktuell sein. Der Kernel blendet deshalb in jeden Prozess eine kleine, schreibgeschützte Bibliothek ein, das vDSOvDSOVirtual Dynamic Shared Object: eine kleine, vom Kernel in jeden Prozess eingeblendete Bibliothek, mit der einige Aufrufe (z. B. clock_gettime) ohne Moduswechsel auskommen. (virtual dynamic shared object), zusammen mit einer Datenseite, die er laufend mit Zeitinformationen aktualisiert.
Ruft ein Programm clock_gettime() auf, springt die glibc direkt in die vDSO-Funktion. Die liest die Zeit aus der gemeinsamen Datenseite und dem Zeitstempelzähler der CPU, ganz ohne Moduswechsel. Auf x86-64 stellt das vDSO clock_gettime, gettimeofday, time, getcpu und clock_getres bereit. Deshalb tauchen diese Aufrufe in strace meist gar nicht auf.
Systemaufrufe beobachten
Selbst ausprobieren: Systemaufrufe mit strace sichtbar machen
# Alle Systemaufrufe von „cat“ beim Lesen einer Datei
strace cat /etc/hostname
# Nur bestimmte Aufrufe anzeigen
strace -e trace=openat,read,write cat /etc/hostname
# Statistik: welche Aufrufe wie oft und wie lange?
strace -c ls /usr/bin > /dev/null
# Nummern der Systemaufrufe nachschlagen (Debian/Ubuntu-Pfad)
grep -E '__NR_(read|write|getpid) ' /usr/include/x86_64-linux-gnu/asm/unistd_64.h
# Das vDSO im eigenen Adressraum finden
grep -E 'vdso|vvar' /proc/self/mapsSchon ein einfaches cat erzeugt Dutzende Systemaufrufe. Die meisten davon gehören zum Programmstart: Der dynamische Linker öffnet und mappt Bibliotheken (openat, mmap), richtet Speicherschutz ein (mprotect) und fragt Systeminformationen ab. Die eigentliche Arbeit von cat sind nur die wenigen read- und write-Aufrufe am Ende.