Architektur im Überblick
Quelldateien
Diese Seite wurde aus den folgenden Quelldateien erstellt:
Das Projekt ebpftracer implementiert einen Syscall-Tracer auf Basis von eBPF (Extended Berkeley Packet Filter), der speziell für das Tracing von Child-Prozessen konzipiert ist. Die Architektur folgt einem klassischen User-Space/Kernel-Space-Split, wobei der Kernel-Teil mittels eBPF-Programmen Syscall-Ereignisse erfasst und der User-Space-Teil für Prozessmanagement, Event-Verarbeitung und Ausgabe zuständig ist.
Komponenten der eBPF-Architektur
Die Architektur von ebpftracer gliedert sich in drei Hauptkomponenten: den Main-Prozess (src/main.c:19-103) für Prozess-Lebenszyklusmanagement, den Loader (src/loader.c:1-242) für BPF-Initialisierung und Event-Handling, sowie den Controller (src/controller.c:1-121) als Kernel-Space-eBPF-Programm.
正在加载图表渲染器...
Erklärung der Architekturkomponenten:
-
User-Space-Komponenten: Der Main-Prozess (src/main.c:64-76) initialisiert den Loader, der wiederum das BPF-Skeleton-Objekt verwaltet (src/loader.c:23-27). Die Event-Verarbeitung erfolgt über Callbacks, die an spezifische Syscall-Handler dispatchen.
-
Kernel-Space-Hooks: Zwei Tracepoint-Hooks überwachen
raw_syscalls/sys_enterundraw_syscalls/sys_exit(src/controller.c:32-119). Diese werden bei jedem Syscall-Eintritt und -Austritt getriggert. -
BPF-Maps als Kommunikationskanäle: Drei Maps verbinden User- und Kernel-Space –
target_pid_mapfür PID-Filterung (src/controller.c:11-16),eventsals Ring Buffer für Event-Streaming (src/controller.c:18-22), undsyscalls_mapfür Metadaten-Lookup (src/controller.c:24-30). -
Synchronisationsmechanismus: Der Child-Prozess sendet
SIGSTOPan sich selbst, wodurch der Parent das BPF-Programm attachen kann, bevor die Ausführung mitSIGCONTfortgesetzt wird (src/main.c:44-76).
Datenstrukturen und Event-Format
Die zentrale Datenstruktur für die Kernel-User-Kommunikation ist struct syscall_event (src/controller.h:19-39), die mittels eines Union speichereffizient zwischen Enter- und Exit-Events unterscheidet.
c1// Kernstruktur für Event-Übertragung (controller.h:19-39) 2struct syscall_event { 3 event_mode mode; // EVENT_SYS_ENTER oder EVENT_SYS_EXIT 4 long syscall_nr; // Syscall-Nummer 5 6 union { 7 struct { 8 char name[32]; // Syscall-Name 9 int num_args; // Anzahl Argumente 10 unsigned long args[MAX_SYSCALL_ARGS]; // Roh-Argumente 11 } enter; 12 13 struct { 14 long retval; // Rückgabewert 15 } exit; 16 }; 17};
Wichtige Konstanten und Grenzen:
| Konstante | Wert | Beschreibung | Quelle |
|---|---|---|---|
MAX_SYSCALL_NR | 335 | Maximale Syscall-Nummer auf x86_64 | src/controller.h:6 |
MAX_SYSCALL_ARGS | 6 | Maximale Argumentanzahl pro Syscall | src/controller.h:9 |
| Ring Buffer Größe | 256 KB | 256 * 1024 Bytes für Event-Puffer | src/controller.c:21 |
Die Metadaten-Struktur struct tracer_syscall_info (src/controller.h:42-45) speichert Name und Argumentanzahl für jeden Syscall-Typ. Diese wird sowohl im Kernel-Space als BPF-Map (src/controller.c:24-30) als auch im User-Space als statisches Array (src/loader.c:29-31) vorgehalten.
Union-basiertes Event-Design: Die Verwendung eines Union spart Speicher im Ring Buffer, da niemals Enter- und Exit-Daten gleichzeitig benötigt werden. Das mode-Feld dient als Diskriminator, um festzustellen, welcher Union-Zweig gültig ist (src/controller.h:21).
Tracepoint-Hooks im Kernel
sys_enter Tracepoint
Der sys_enter-Hook (src/controller.c:33-87) wird bei jedem Syscall-Eintritt aufgerufen und führt folgende Schritte aus:
-
PID-Filterung: Zunächst wird die PID des aktuellen Prozesses ermittelt und gegen
target_pid_mapgeprüft (src/controller.c:35-41). Bei Nichtübereinstimmung wird das Event verworfen (return -1). -
Syscall-Nummer-Validierung: Die Syscall-ID wird gegen
MAX_SYSCALL_NRgeprüft, um Out-of-Bounds-Zugriffe zu verhindern (src/controller.c:44-47). -
Metadaten-Lookup: Die
syscalls_mapliefert Name und Argumentanzahl für den Syscall (src/controller.c:49-54). -
Ring-Buffer-Reservierung: Speicher wird im Ring Buffer reserviert (src/controller.c:56-60). Bei Fehlschlag wird das Event still verworfen.
-
Argumenterfassung: Bis zu 6 Argumente werden mittels
BPF_CORE_READsicher ausgelesen (src/controller.c:78-83). Das#pragma unrollsorgt für Compile-Time-Unrolling der Schleife.
c1// Argumenterfassung mit Boundary-Check (controller.c:68-83) 2int num_args = info->num_args; 3if (num_args > MAX_SYSCALL_ARGS) { 4 num_args = MAX_SYSCALL_ARGS; // Cap auf Maximum 5} 6if (num_args < 0) { 7 num_args = 0; // Negative Werte abfangen 8} 9 10#pragma unroll 11for (int i = 0; i < MAX_SYSCALL_ARGS; i++) { 12 if (i < num_args) { 13 e->enter.args[i] = BPF_CORE_READ(ctx, args[i]); 14 } 15}
sys_exit Tracepoint
Der sys_exit-Hook (src/controller.c:90-119) ist schlanker, da nur der Rückgabewert erfasst wird:
-
PID-Filterung: Identisch zum Enter-Hook (src/controller.c:93-99).
-
Event-Erstellung: Nur
mode,syscall_nrundretvalwerden gesetzt (src/controller.c:112-114). -
Submission: Das Event wird mittels
bpf_ringbuf_submitan den User-Space gesendet (src/controller.c:117).
Fehlerbehandlung in Tracepoints: Beide Hooks verwenden "fail-silent"-Semantik – bei Fehlern (Ring Buffer voll, Map-Lookup fehlgeschlagen) wird -1 zurückgegeben, ohne das System zu beeinträchtigen. Dies ist typisch für eBPF-Programme, die keine Panics im Kernel verursachen dürfen.
Event-Verarbeitung im User-Space
Event-Handler-Dispatching
Der zentrale Event-Handler (src/loader.c:100-120) empfängt Events vom Ring Buffer und dispatcht sie an spezifische Handler:
c1// Dispatch-Logik (loader.c:104-120) 2if (e->mode == EVENT_SYS_ENTER) { 3 if (e->syscall_nr < MAX_SYSCALL_NR && enter_handlers[e->syscall_nr]) { 4 enter_handlers[e->syscall_nr](g_target_pid, e); // Spezifischer Handler 5 } else { 6 handle_sys_enter_default(g_target_pid, e); // Fallback 7 } 8} else if (e->mode == EVENT_SYS_EXIT) { 9 if (e->syscall_nr < MAX_SYSCALL_NR && exit_handlers[e->syscall_nr]) { 10 exit_handlers[e->syscall_nr](g_target_pid, e); 11 } else { 12 handle_sys_exit_default(g_target_pid, e); 13 } 14}
Dispatch-Mechanismus: Zwei Funktionszeiger-Arrays enter_handlers und exit_handlers (indiziert nach Syscall-Nummer) ermöglichen spezialisierte Handler für bestimmte Syscalls. Falls kein Handler registriert ist, greift der Default-Handler.
Polling-Schleife
Die Polling-Funktion (src/loader.c:208-214) nutzt ring_buffer__poll mit 100ms Timeout:
c1int bpf_loader_poll_events(void) { 2 if (!g_ring_buf) { 3 return -1; // Guard gegen NULL-Pointer 4 } 5 return ring_buffer__poll(g_ring_buf, 100); 6}
Die Hauptloop in main.c (src/main.c:80-82) pollt so lange, bis der Child-Prozess terminiert:
c1while (waitpid(pid, &status, WNOHANG) == 0) { 2 bpf_loader_poll_events(); // Non-blocking wait + event polling 3}
Prozess-Speicher lesen
Für bestimmte Syscalls (z.B. read, write) müssen String-Argumente aus dem Adressraum des Child-Prozesses gelesen werden. Die Hilfsfunktion read_string_from_process (src/loader.c:68-89) nutzt process_vm_readv:
c1long read_string_from_process(pid_t pid, unsigned long addr, 2 char *buffer, size_t size) { 3 struct iovec local_iov = {.iov_base = buffer, .iov_len = size}; 4 struct iovec remote_iov = {.iov_base = (void *)addr, .iov_len = size}; 5 ssize_t bytes_read = process_vm_readv(pid, &local_iov, 1, &remote_iov, 1, 0); 6 7 // Null-Terminierung sicherstellen 8 if ((size_t)bytes_read < size) { 9 buffer[bytes_read] = '\0'; 10 } else { 11 buffer[size - 1] = '\0'; 12 } 13 return bytes_read; 14}
Prozess-Lebenszyklus und Ressourcenmanagement
Fork-Exec-Muster mit SIGSTOP-Synchronisation
Der Main-Prozess verwendet ein ausgeklügeltes Synchronisationsmuster (src/main.c:26-53), um sicherzustellen, dass das BPF-Programm vor dem ersten Syscall des Child-Prozesses aktiv ist:
正在加载图表渲染器...
Synchronisationsdetails:
-
Child-Seite (src/main.c:32-53): Nach
fork()leitet der Child stdout/stderr nach/dev/nullum, um Tracing-Rauschen zu vermeiden. Dann sendet er sich selbstSIGSTOP. -
Parent-Seite (src/main.c:58-76): Der Parent wartet mit
waitpid(..., WUNTRACED)auf den gestoppten Child. Nach erfolgreichem BPF-Attach wirdSIGCONTgesendet. -
Race-Condition-Vermeidung: Ohne
SIGSTOPkönnte der Child bereits Syscalls ausführen, bevor das BPF-Programm attacht ist. Die Synchronisation garantiert, dass kein einziger Syscall verpasst wird.
Ressourcen-Cleanup
Die Cleanup-Funktion (src/loader.c:220-242) gibt alle Ressourcen in korrekter Reihenfolge frei:
c1void bpf_loader_cleanup(void) { 2 // 1. Ring Buffer freigeben 3 if (g_ring_buf) { 4 ring_buffer__free(g_ring_buf); 5 g_ring_buf = NULL; 6 } 7 8 // 2. BPF Skeleton zerstören 9 if (g_skel) { 10 controller_bpf__destroy(g_skel); 11 g_skel = NULL; 12 } 13 14 // 3. Syscall-spezifische State-Cleanup-Funktionen 15 read_common_cleanup(); 16 write_common_cleanup(); 17 open_common_cleanup(); 18 handle_close_cleanup(); 19 dup_common_cleanup(); 20 fd_cache_cleanup(); 21}
Cleanup-Reihenfolge: Zuerst werden aktive Datenstrukturen (Ring Buffer) freigegeben, dann das BPF-Objekt, schließlich zustandsbehaftete Handler. Dies verhindert Use-After-Free und Dangling-Pointer.
Modulabhängigkeiten und Abgrenzungen
正在加载图表渲染器...
Modul-Abgrenzungen:
| Modul | Verantwortung | Nicht-Zuständigkeit |
|---|---|---|
main.c | Prozess-Lebenszyklus, CLI-Entry-Point | BPF-Interna, Event-Formatierung |
loader.c | BPF-Initialisierung, Event-Dispatching | Tracepoint-Logik, Speicher lesen |
controller.c | Kernel-Space-Hooks, Event-Capture | User-Space-Logik, Prozessmanagement |
controller.h | Gemeinsame Datenstrukturen | Implementierung |
Kern-Designentscheidungen
1. Ring Buffer vs. Perf Buffer
Entscheidung: Ring Buffer (BPF_MAP_TYPE_RINGBUF) wurde gewählt (src/controller.c:18-22).
Begründung: Ring Buffer bietet effizienteres Memory-Management, garantierte Event-Reihenfolge und bessere Performance bei hohen Event-Raten im Vergleich zum älteren Perf Buffer. Die 256KB-Größe ist ein Kompromiss zwischen Speicherverbrauch und Pufferkapazität für Burst-Szenarien.
2. Skeleton-basiertes Loading
Entscheidung: Verwendung von controller_bpf Skeleton (src/loader.c:25).
Begründung: Skeletons (generiert von bpftool gen skeleton) eliminieren Runtime-Relocation, vereinfachen Map-Zugriffe und bieten typsichere APIs. Die Alternative (manuelles Object-Loading) wäre fehleranfälliger und weniger performant.
3. SIGSTOP-Synchronisation
Entscheidung: Child-Prozess stoppt sich selbst vor exec (src/main.c:47).
Begründung: Dies garantiert vollständiges Tracing ab dem allerersten Syscall. Alternativen wie ptrace(PTRACE_TRACEME) würden mehr Overhead verursachen und komplexe Race-Conditions einführen.
4. Union-basiertes Event-Design
Entscheidung: syscall_event verwendet Union für Enter/Exit (src/controller.h:24-38).
Begründung: Reduziert Event-Größe von ~120 Bytes auf ~72 Bytes (bei separaten Structs). Im Ring Buffer ist jeder Byte wichtig für Throughput. Der Diskriminator mode macht das Design sicher.
5. Handler-Dispatch-Table
Entscheidung: Funktionszeiger-Arrays für Syscall-Handler (src/loader.c:108-119).
Begründung: O(1) Dispatch-Komplexität, einfache Erweiterbarkeit für neue Syscall-Handler, und vermeidet riesige Switch-Statements. Die Alternative (Hash-Map) würde mehr Overhead bedeuten.
6. BPF_CORE_READ für Argumente
Entscheidung: Verwendung von BPF_CORE_READ statt direkter Pointer-Dereferenzierung (src/controller.c:81).
Begründung: CO-RE (Compile Once, Run Everywhere) Kompatibilität. Das eBPF-Programm funktioniert across Kernel-Versionen ohne Neukompilierung, da BPF_CORE_READ Relocations für Kernel-Struktur-Offsets handhabt.
Technologie-Stack und Auswahlkriterien
| Technologie | Verwendungszweck | Auswahlgrund | Alternative |
|---|---|---|---|
| eBPF | Kernel-Tracing | Sicher, effizient, keine Kernel-Module nötig | Kernel Module, ptrace |
| libbpf | BPF-User-Space-Library | Industriestandard, Skeleton-Support | bcc, cilium/ebpf |
| Ring Buffer Maps | Event-Streaming | Hohe Performance, garantierte Ordnung | Perf Buffer, Hash Maps |
| Tracepoints | Syscall-Hooks | Stabile Kernel-API, niedriger Overhead | kprobes, ftrace |
| process_vm_readv | Speicher lesen | Sicher, kein ptrace nötig | /proc/pid/mem, ptrace |
| SIGSTOP/SIGCONT | Prozess-Synchronisation | Einfach, zuverlässig | ptrace-Kontrollfluss |
| Meson | Build-System | Schnell, moderne Dependency-Verwaltung | CMake, Make |
| Skeleton | BPF-Objekt-Management | Typsicher, Compile-Time-Checks | Runtime Object Loading |
Startsequenz und Initialisierung
Die vollständige Startsequenz von ebpftracer gliedert sich in folgende Phasen:
Phase 1: Prozess-Fork
main() → fork() → Child: SIGSTOP → Parent: waitpid(WUNTRACED)
Phase 2: BPF-Initialisierung
bpf_loader_init() → controller_bpf__open() → Maps erstellen
bpf_loader_load_and_attach(pid) → controller_bpf__load() →
controller_bpf__attach() → target_pid_map update
Phase 3: Event-Loop
SIGCONT an Child → while(waitpid(WNOHANG) == 0) →
ring_buffer__poll(100ms) → callback → handler → Ausgabe
Phase 4: Cleanup
Child exit → bpf_loader_cleanup() → ring_buffer__free() →
controller_bpf__destroy() → handler cleanup
Bekannte Einschränkungen
-
Plattformabhängigkeit: Das Projekt ist auf x86_64 Linux beschränkt, da
MAX_SYSCALL_NRauf 335 hardcodiert ist (src/controller.h:6). Andere Architekturen (ARM, RISC-V) haben abweichende Syscall-Tabellen. -
Single-Process-Tracing: Nur ein Child-Prozess kann gleichzeitig getraced werden, da
target_pid_mapnur einen Eintrag fasst (src/controller.c:13). -
Keine Thread-Unterscheidung: Die PID-Filterung basiert auf TGID (Thread Group ID), sodass alle Threads eines Prozesses getraced werden (src/controller.c:35).
-
Argumentgröße begrenzt: Nur 6 Argumente werden erfasst (src/controller.h:9). Syscalls mit mehr Parametern (selten) werden trunkiert.
-
Ring Buffer Overflow: Bei extrem hoher Syscall-Rate können Events verworfen werden, da
bpf_ringbuf_reservefehlschlagen kann (src/controller.c:57-59). Es gibt keinen Backpressure-Mechanismus.
