Odpalasz strace na procesie, który robi miliony syscalli na sekundę, i throughput spada o dwa rzędy wielkości. Podpinasz gdb do działającej usługi — i cały proces staje w miejscu. To nie są przypadkowe koszty źle napisanych narzędzi. To architektura ptrace: jednego wywołania systemowego, na którym stoi praktycznie cały ekosystem debugowania w Linuksie. strace i czytanie syscalli, gdb, ltrace, frameworki do reverse engineeringu — wszystkie sprowadzają się do tego samego interfejsu kernela i dziedziczą jego ograniczenia w pakiecie.
Zrozumienie mechaniki ptrace odpowiada na trzy pytania, które prędzej czy później zadaje sobie każdy, kto debuguje na Linuksie: dlaczego śledzenie syscalli jest tak kosztowne, dlaczego gdb -p potrafi odbić się od systemu z Operation not permitted, i czym właściwie jest breakpoint, skoro procesor nie ma pojęcia o istnieniu twojego kodu źródłowego.
Jeden syscall, cały ekosystem debuggerów
ptrace to multiplekser. Jedno wywołanie — ptrace(request, pid, addr, data) — w którym pierwszy argument wybiera operację: PTRACE_TRACEME, PTRACE_ATTACH, PTRACE_PEEKDATA, PTRACE_GETREGS, PTRACE_CONT i kilkadziesiąt innych requestów, których pełną listę dokumentuje ptrace(2). Kernel nie ma osobnego „API debuggera” — ma ten jeden syscall i zestaw semantyk zbudowanych wokół zatrzymywania procesu.
Model jest asymetryczny: tracer i tracee. Relacja działa per-thread — każdy wątek śledzony jest osobno — i jest wyłączna: jeden tracee ma dokładnie jednego tracera. Dlatego nie podepniesz gdb do procesu, który już wisi pod strace; kernel zwróci EPERM i koniec dyskusji. Komunikacja jest brutalnie prosta: tracee zatrzymuje się w jednym z kilku rodzajów stopów, kernel powiadamia tracera przez waitpid(), tracer grzebie w zamrożonym procesie — rejestry, pamięć, sygnały — i decyduje, jak go wznowić. Wszystko, co robi debugger, od wypisywania syscalli po step-debugging, to kompozycja tej jednej pętli: stop, inspekcja, wznowienie.
Trzy drogi przejęcia procesu
PTRACE_TRACEME + execve()
Klasyczny wzorzec „uruchom pod debuggerem”. Proces potomny po fork() i copy-on-write woła ptrace(PTRACE_TRACEME, ...) — deklaruje: zgadzam się być śledzony przez rodzica — a następnie wykonuje execve(). Po udanym execve kernel dostarcza dziecku SIGTRAP, więc nowy program zatrzymuje się, zanim wykona pierwszą własną instrukcję. Dokładnie w tym punkcie strace ./app i gdb ./app zaczynają sesję: mają proces zamrożony na starcie, z pełną kontrolą, zanim cokolwiek się wydarzy.
PTRACE_ATTACH
Podpięcie do już działającego procesu. ATTACH wysyła tracee SIGSTOP, co ma skutki uboczne: proces przechodzi przez group-stop widoczny dla reszty systemu, a sekwencja attach + stop jest podatna na race’y z sygnałami przychodzącymi w międzyczasie. Przez lata debuggery żyły z tymi niejednoznacznościami, obudowując je heurystykami — i przez lata było to źródło subtelnych bugów w samych narzędziach do debugowania.
PTRACE_SEIZE
Nowszy interfejs, zaprojektowany po lekcjach z ATTACH: przejmuje tracee bez zatrzymywania go. Proces działa dalej, a tracer zatrzymuje go dopiero wtedy, gdy faktycznie chce — jawnym PTRACE_INTERRUPT. SEIZE raportuje też stopy w sposób jednoznacznie rozróżnialny (PTRACE_EVENT_STOP), co eliminuje całą klasę pytań „czy to był mój SIGSTOP, czy cudzy”. Współczesny strace -p pod spodem używa właśnie SEIZE. Jeżeli piszesz własny tracer w 2026 i zaczynasz od ATTACH — zaczynasz od legacy.
Signal-delivery-stop — tracer widzi sygnały pierwszy
Śledzenie zmienia semantykę dostarczania sygnałów. Każdy sygnał adresowany do tracee — poza SIGKILL — najpierw zatrzymuje proces i ląduje u tracera. Dopiero tracer, wznawiając tracee, decyduje: dostarczyć sygnał dalej, podmienić na inny albo wyciszyć całkowicie. Mechanika dyspozycji, handlerów i przerywanych syscalli to osobny temat — rozebrany w anatomii sygnałów i handlerów w Linuksie — ale z perspektywy ptrace kluczowy jest sam fakt przechwycenia: debugger stoi w środku ścieżki dostarczania.
To dlatego strace pokazuje linie --- SIGCHLD --- pomiędzy syscallami, gdb potrafi złapać SIGSEGV, zanim proces umrze, a „wyciszenie” sygnału w debuggerze w ogóle jest możliwe. I to też dlatego proces pod debuggerem jest behawioralnie innym procesem: zmieniają się timingi dostarczania, EINTR pojawia się w miejscach, w których nigdy go nie widziałeś, a heisenbug — z definicji — znika dokładnie wtedy, gdy zaczynasz patrzeć. SIGKILL pozostaje jedynym wyjątkiem: zabija tracee natychmiast i żaden tracer tego nie zatrzyma.
Syscall-stopy, czyli minimalny strace w C
Sercem strace jest request PTRACE_SYSCALL: wznów tracee i zatrzymaj go na najbliższej granicy wywołania systemowego. Każdy syscall generuje dwa stopy — syscall-enter-stop przed wejściem do kernela i syscall-exit-stop po wyjściu. W enter-stopie numer syscalla i argumenty siedzą w rejestrach (na x86-64: orig_rax, rdi, rsi, rdx…), w exit-stopie wynik czeka w rax. Z opcją PTRACE_O_TRACESYSGOOD syscall-stopy raportują się jako SIGTRAP | 0x80, dzięki czemu odróżnisz je od zwykłego dostarczania sygnałów bez zgadywania.
Poniżej kompletny, minimalny tracer: uruchamia program i wypisuje numer oraz wynik każdego syscalla. Sześćdziesiąt linii, zero magii.
#define _GNU_SOURCE
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>
#include <sys/user.h>
#include <sys/wait.h>
#include <unistd.h>
/* Wznawia tracee do najbliższego syscall-stopu.
* Sygnały niebędące syscall-stopem dostarcza z powrotem przy wznowieniu.
* Zwraca 0 na syscall-stopie, -1 gdy tracee zakończył pracę. */
static int wait_for_syscall(pid_t child) {
int status, sig = 0;
for (;;) {
if (ptrace(PTRACE_SYSCALL, child, NULL, (void *)(long)sig) == -1) {
perror("ptrace(PTRACE_SYSCALL)");
return -1;
}
sig = 0;
if (waitpid(child, &status, 0) == -1) {
perror("waitpid");
return -1;
}
if (WIFEXITED(status) || WIFSIGNALED(status))
return -1; /* tracee nie żyje */
if (WIFSTOPPED(status)) {
int stopsig = WSTOPSIG(status);
if (stopsig == (SIGTRAP | 0x80)) /* PTRACE_O_TRACESYSGOOD */
return 0; /* syscall-stop */
sig = stopsig; /* signal-delivery-stop:
wstrzyknij przy wznowieniu */
}
}
}
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "usage: %s <program> [args...]\n", argv[0]);
return EXIT_FAILURE;
}
pid_t child = fork();
if (child == -1) {
perror("fork");
return EXIT_FAILURE;
}
if (child == 0) { /* tracee */
if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) {
perror("ptrace(PTRACE_TRACEME)");
_exit(127);
}
execvp(argv[1], &argv[1]); /* stop: SIGTRAP po execve */
perror("execvp");
_exit(127);
}
int status; /* tracer */
if (waitpid(child, &status, 0) == -1 || !WIFSTOPPED(status)) {
fprintf(stderr, "tracee nie doszedł do execve\n");
return EXIT_FAILURE;
}
if (ptrace(PTRACE_SETOPTIONS, child, 0,
PTRACE_O_EXITKILL | PTRACE_O_TRACESYSGOOD) == -1) {
perror("ptrace(PTRACE_SETOPTIONS)");
return EXIT_FAILURE;
}
while (wait_for_syscall(child) == 0) { /* syscall-enter-stop */
struct user_regs_struct regs;
if (ptrace(PTRACE_GETREGS, child, NULL, ®s) == -1) {
perror("ptrace(PTRACE_GETREGS)");
break;
}
long long nr = (long long)regs.orig_rax;
if (wait_for_syscall(child) != 0) { /* syscall-exit-stop */
fprintf(stderr, "syscall(%lld) = ?\n", nr);
break;
}
if (ptrace(PTRACE_GETREGS, child, NULL, ®s) == -1) {
perror("ptrace(PTRACE_GETREGS)");
break;
}
fprintf(stderr, "syscall(%lld) = %lld\n", nr, (long long)regs.rax);
}
return EXIT_SUCCESS;
}
Kilka decyzji w tym kodzie jest nieprzypadkowych. PTRACE_O_EXITKILL sprząta relację: gdy tracer umrze, tracee dostaje SIGKILL, zamiast wisieć w stopie do końca świata. Funkcja wait_for_syscall wstrzykuje z powrotem sygnały, które nie są syscall-stopami — bez tego pierwszy lepszy SIGCHLD albo SIGALRM rozjeżdża parzystość enter/exit i „wyniki” przestają odpowiadać wywołaniom. Odczyt rejestrów przez PTRACE_GETREGS jest specyficzny dla x86-64; przenośny kod używa PTRACE_GET_SYSCALL_INFO, który zwraca numer syscalla, argumenty i wynik w strukturze niezależnej od architektury. Prawdziwy strace dokłada do tego dekodowanie argumentów (stringi, struktury, flagi), śledzenie potomków przez PTRACE_O_TRACEFORK i tuzin innych mechanizmów — ale pętla w środku jest dokładnie ta.
Jak gdb ustawia breakpoint
Breakpoint to nie funkcja procesora zarezerwowana dla debuggerów (poza ograniczoną pulą sprzętowych watchpointów). To wandalizm za zgodą kernela: gdb przez PTRACE_POKETEXT nadpisuje pierwszy bajt instrukcji pod wskazanym adresem bajtem 0xCC — jednobajtowym opcodem int3. Kiedy wykonanie trafia w to miejsce, procesor generuje pułapkę, kernel tłumaczy ją na SIGTRAP, a SIGTRAP — jak każdy sygnał śledzonego procesu — trafia najpierw do tracera. gdb cofa RIP o jeden, przywraca oryginalny bajt, wykonuje tę jedną instrukcję przez PTRACE_SINGLESTEP, wstawia 0xCC z powrotem i wznawia proces. Cała iluzja „zatrzymałem się na linii 47″ to sekwencja podmian bajtów i sygnałów.
Dwie rzeczy z tego wynikają. Po pierwsze: sekcja .text jest zmapowana bez prawa zapisu, ale ptrace pisze przez interfejs kernela, z pominięciem ochrony stron widzianej przez sam proces — to ten sam mechanizm ochrony pamięci, który przy zwykłym zapisie z userspace skończyłby się tym, co opisuje anatomia segfaulta od MMU po core dump. Po drugie: adres breakpointu trzeba najpierw znaleźć, a między adresem w binarce a adresem w pamięci stoi ASLR, relokacja i dynamic linker — jak segmenty trafiają do pamięci, zanim ruszy main(), rozbiera ELF i dynamic linking.
Skąd bierze się narzut ptrace
Policzmy koszt jednego śledzonego syscalla w trybie PTRACE_SYSCALL. Dwa stopy na wywołanie; każdy stop to zamrożenie tracee, przełączenie kontekstu do tracera, w tracerze co najmniej waitpid() i PTRACE_GETREGS — czyli kolejne syscalle — i przełączenie kontekstu z powrotem. Cztery context switche i kilka wywołań systemowych narzutu na jedno śledzone getpid(), które samo kosztuje ułamek mikrosekundy. Do tego czytanie pamięci tracee: klasyczne PTRACE_PEEKDATA zwraca jedno słowo maszynowe na wywołanie, więc podejrzenie 4-kilobajtowego bufora write() to 512 osobnych syscalli. Nowsze tracery ratują się process_vm_readv(), które kopiuje całe bloki w jednym wywołaniu — ale stopów nie eliminuje nic.
Stąd spowolnienia o dwa rzędy wielkości na workloadach syscall-heavy — i stąd pierwsza zasada higieny: strace na produkcji to decyzja, nie odruch. Da się ją podejmować mądrzej. strace --seccomp-bpf wstrzykuje do kernela filtr seccomp, który przepuszcza nieinteresujące syscalle bez zatrzymywania procesu — stop dzieje się tylko dla wywołań z -e trace=.... A eBPF idzie o krok dalej: zbiera dane po stronie kernela bez zatrzymywania tracee w ogóle, za cenę utraty tego, co w ptrace najcenniejsze — możliwości ingerencji w stan procesu.
| Metryka | ptrace (PTRACE_SYSCALL) | ptrace + seccomp-BPF | eBPF (tracepointy) |
|---|---|---|---|
| Koszt jednego zdarzenia | ≥4 przełączenia kontekstu + syscalle tracera | Jak ptrace, ale tylko dla filtrowanych syscalli | Setki nanosekund w kernelu, bez stopu |
| Wpływ na tracee | Stop-the-world dwa razy na każdy syscall | Stop wyłącznie na dopasowanych wywołaniach | Brak zatrzymań; mały, mierzalny narzut |
| Modyfikacja stanu (rejestry, pamięć, sygnały) | Pełna | Pełna dla zatrzymanych wywołań | Brak — obserwacja read-only |
| Wymagane uprawnienia | YAMA ptrace_scope lub CAP_SYS_PTRACE | Jak ptrace + instalacja filtra seccomp | CAP_BPF + CAP_PERFMON albo root |
YAMA, czyli dlaczego gdb -p rzuca „Operation not permitted”
Klasyczny model uprawnień ptrace był prosty: ten sam UID może się podpiąć. Z perspektywy bezpieczeństwa to katastrofa — dowolny skompromitowany proces użytkownika mógłby przeczytać pamięć jego przeglądarki, menedżera haseł czy agenta SSH, bo „przecież ten sam UID”. Moduł YAMA dokłada sysctl kernel.yama.ptrace_scope z czterema poziomami: 0 — model klasyczny; 1 — attach tylko do własnych bezpośrednich potomków (domyślny na większości dystrybucji); 2 — attach tylko z CAP_SYS_PTRACE; 3 — attach wyłączony na stałe, aż do rebootu.
$ cat /proc/sys/kernel/yama/ptrace_scope
1
$ gdb -p "$(pidof myapp)"
ptrace: Operation not permitted.
$ sudo sysctl -w kernel.yama.ptrace_scope=0 # doraźnie i świadomie, nie na stałe
Stąd asymetria, która regularnie myli ludzi: strace ./app działa zawsze — proces sam zgłasza się przez PTRACE_TRACEME jako dziecko tracera — ale strace -p PID na tej samej maszynie odbija się od poziomu 1. To nie jest bug w twoim strace ani „zepsute uprawnienia”. To polityka LSM działająca dokładnie tak, jak ją skonfigurowano. Wyjątek per-proces daje prctl(PR_SET_PTRACER, pid) — tak działają crash-handlery pokroju Breakpada, które muszą wpuścić zewnętrzny proces na dump własnej pamięci.
Kiedy ptrace, a kiedy nie
ptrace to chirurgia na żywym organizmie: pełna kontrola, dotykanie stanu, zatrzymany pacjent. Ten poziom inwazyjności jest właściwy w debugowaniu incydentu — i niewłaściwy jako stały element systemu. Reguły, które się bronią: strace i gdb do weryfikacji konkretnej hipotezy o konkretnym procesie, z akceptacją kosztu; ciągła obserwacja syscalli na produkcji — eBPF, nie ptrace. Jeżeli twój „observability agent” trzyma procesy pod ptrace, to nie jest observability, tylko permanentny debugger na produkcji — ze wszystkimi stopami i narzutami, o których była mowa wyżej.
I sprawa metodologiczna: ptrace daje ci mikroskop, nie kompas. Zatrzymywanie procesu i oglądanie rejestrów ma sens dopiero wtedy, gdy wiesz, czego szukasz — narzędzie służy weryfikacji hipotez, nie ich generowaniu, co rozkłada na czynniki debugowanie hipotezami zamiast zgadywania.
Insight do zabrania: cały ekosystem debugowania Linuksa — strace, gdb, ltrace i każdy tracer, który kiedyś napiszesz — to pętla na czterech operacjach jednego syscalla: zatrzymaj, obejrzyj, zmień, wznów. Wszystkie ich supermoce (przechwytywanie sygnałów, breakpointy, podglądanie pamięci) i wszystkie ograniczenia (narzut, jeden tracer na tracee, YAMA) są własnościami tej pętli. Kto rozumie pętlę, tego żaden debugger już nie zaskoczy.


