ptrace — How strace and gdb Really Work

You run strace on a syscall-heavy process and throughput drops. Attach gdb to a live service, and execution stops. This is the cost of stopping execution and handing control to a debugger through ptrace. How large that cost becomes depends on the program and tracing mode.

Understanding the mechanics of ptrace answers three questions everyone debugging on Linux eventually asks: why tracing syscalls is so expensive, why gdb -p can bounce off the system with Operation not permitted, and what a breakpoint actually is, given that the CPU has no idea your source code exists.

One Syscall, the Entire Debugger Ecosystem

ptrace provides operations for controlling a traced thread. Diagnostic tools also use other interfaces, including perf, process_vm_readv() and eBPF.

The model is asymmetric: tracer and tracee. The relationship is per-thread — every thread is traced separately — and it’s exclusive: one tracee has exactly one tracer. That’s why you can’t attach gdb to a process already sitting under strace; the kernel returns EPERM and that’s the end of the discussion. The communication is brutally simple: the tracee stops in one of several kinds of stops, the kernel notifies the tracer via waitpid(), the tracer pokes around the frozen process — registers, memory, signals — and decides how to resume it. Everything a debugger does, from printing syscalls to step-debugging, is a composition of this one loop: stop, inspect, resume.

Three Ways to Take Over a Process

PTRACE_TRACEME + execve()

The classic “run under a debugger” pattern. The child process, right after fork() and copy-on-write, calls ptrace(PTRACE_TRACEME, ...) — declaring: I consent to being traced by my parent — and then executes execve(). After a successful execve the kernel delivers SIGTRAP to the child, so the new program stops before executing its first own instruction. This is exactly where strace ./app and gdb ./app start their session: they have the process frozen at startup, with full control before anything happens.

PTRACE_ATTACH

PTRACE_ATTACH attaches to a running thread and sends it SIGSTOP. Returning from the call does not mean the thread has stopped: the tracer must collect the event with waitpid(). A signal-delivery-stop and a subsequent group-stop are distinct events, which complicates handling competing signals.

PTRACE_SEIZE

The newer interface, designed after the lessons of ATTACH: it takes over the tracee without stopping it. The process keeps running, and the tracer stops it only when it actually wants to — with an explicit PTRACE_INTERRUPT. SEIZE also reports stops in an unambiguously distinguishable way (PTRACE_EVENT_STOP), which eliminates a whole class of “was that my SIGSTOP or someone else’s” questions. Modern strace -p uses SEIZE under the hood. If you’re writing your own tracer in 2026 and you start with ATTACH — you’re starting with legacy.

Signal-Delivery-Stop — the Tracer Sees Signals First

When an unblocked signal is about to be delivered to a traced thread, a signal-delivery-stop normally occurs. The tracer may forward, replace or suppress the signal. SIGKILL cannot be intercepted this way. A signal still blocked by the thread’s mask does not yet cause this stop.

A tracer cannot intercept or suppress SIGKILL. This does not guarantee immediate termination in every kernel state: an uninterruptible wait can delay the process’s exit.

Syscall Stops, or a Minimal strace in C

A typical PTRACE_SYSCALL sequence reports entry to and exit from a syscall. Do not assume strict alternation: the process may terminate inside a syscall, and execve(), signals and ptrace events complicate the sequence. PTRACE_GET_SYSCALL_INFO identifies the stop type on Linux 5.3 and later. PTRACE_O_TRACESYSGOOD distinguishes syscall stops from ordinary SIGTRAP.

The example below traces one thread on Linux 5.3 or later. It identifies entry and exit from the reported event type instead of counting stops in pairs. It does not trace descendants or fully handle group-stops.

Verification scope: the example compiled with gcc -Wall -Wextra. Runtime tracing could not be verified in the preparation environment; test it on a controlled Linux host permitting ptrace before use.

#define _GNU_SOURCE
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>
#include <linux/ptrace.h>
#include <sys/wait.h>
#include <unistd.h>

int main(int argc, char **argv) {
    if (argc < 2) return 2;
    pid_t child = fork();
    if (child == -1) { perror("fork"); return 1; }
    if (child == 0) {
        if (ptrace(PTRACE_TRACEME, 0, 0, 0) == -1) _exit(127);
        execvp(argv[1], argv + 1);
        _exit(127);
    }
    int status;
    if (waitpid(child, &status, 0) != child || !WIFSTOPPED(status)) return 1;
    if (ptrace(PTRACE_SETOPTIONS, child, 0,
               PTRACE_O_TRACESYSGOOD | PTRACE_O_EXITKILL) == -1) {
        perror("PTRACE_SETOPTIONS"); return 1;
    }
    int sig = 0, have_entry = 0;
    unsigned long long nr = 0;
    for (;;) {
        if (ptrace(PTRACE_SYSCALL, child, 0, sig) == -1) {
            perror("PTRACE_SYSCALL"); return 1;
        }
        do {
            if (waitpid(child, &status, 0) == child) break;
            if (errno != EINTR) { perror("waitpid"); return 1; }
        } while (1);
        if (WIFEXITED(status) || WIFSIGNALED(status)) break;
        if (!WIFSTOPPED(status)) continue;
        sig = 0;
        if (WSTOPSIG(status) == (SIGTRAP | 0x80)) {
            struct ptrace_syscall_info info = {0};
            if (ptrace(PTRACE_GET_SYSCALL_INFO, child, sizeof(info), &info) == -1) {
                perror("PTRACE_GET_SYSCALL_INFO"); return 1;
            }
            if (info.op == PTRACE_SYSCALL_INFO_ENTRY) {
                nr = info.entry.nr; have_entry = 1;
            } else if (info.op == PTRACE_SYSCALL_INFO_EXIT && have_entry) {
                fprintf(stderr, "syscall(%llu) = %lld\n", nr,
                        (long long)info.exit.rval);
                have_entry = 0;
            }
        } else {
            siginfo_t si;
            if (ptrace(PTRACE_GETSIGINFO, child, 0, &si) == -1) {
                fprintf(stderr, "Stop outside this simple tracer's scope\n");
                return 1; /* EXITKILL terminates the tracee */
            }
            sig = WSTOPSIG(status);
        }
    }
    return 0;
}

PTRACE_O_EXITKILL terminates the tracee if the tracer dies. PTRACE_GET_SYSCALL_INFO distinguishes entry and exit even when the first observed syscall stop is the exit from execve(). Syscall numbers still depend on the architecture. The example deliberately exits on an unsupported stop; it is not a replacement for strace.

How gdb Sets a Breakpoint

A typical software breakpoint on x86 replaces an instruction’s first byte with 0xCC, the int3 opcode. The kernel reports the trap to the debugger as SIGTRAP. Processors also provide hardware breakpoints and watchpoints; this explanation concerns software breakpoints.

Two things follow. First: the .text section is mapped without write permission, but ptrace writes through the kernel’s interface, bypassing the page protection the process itself sees — the same memory protection mechanism that, on an ordinary userspace write, would end the way the anatomy of a segfault from MMU to core dump describes. Second: you first have to find the breakpoint’s address, and between the address in the binary and the address in memory stand ASLR, relocation and the dynamic linker — how segments land in memory before main() runs is taken apart in ELF and dynamic linking.

Where the ptrace Overhead Comes From

Tracing a syscall with PTRACE_SYSCALL normally requires stops at entry and exit. The tracer must be scheduled, inspect the event with additional system calls, and resume the tracee. Reading memory adds more work: on a 64-bit ABI, reading a 4 KiB buffer word by word through PTRACE_PEEKDATA takes 512 calls. process_vm_readv() can copy larger blocks in one call, but does not itself remove the tracing stops.

The overhead can be substantial for a syscall-heavy workload. Choose the scope and duration of tracing to answer a specific question; measurements under strace are not uninstrumented performance results.

Metricptrace (PTRACE_SYSCALL)ptrace + seccomp-BPFeBPF (tracepoints)
Cost per eventStops, scheduling and additional tracer syscallsSame as ptrace, but only for filtered syscallsBPF execution in the kernel; cost depends on the measurement
Impact on the traceeThe traced thread stops at syscall boundariesStops only on matched callsNo stops; small, measurable overhead
State modification (registers, memory, signals)FullFull for stopped callsNone — read-only observation
Required privilegesYAMA ptrace_scope or CAP_SYS_PTRACESame as ptrace + seccomp filter installationCAP_BPF + CAP_PERFMON or root

YAMA, or Why gdb -p Throws “Operation not permitted”

Matching UIDs do not fully describe ptrace permissions: dumpability, capabilities and LSM policy also matter. Yama adds restrictions through kernel.yama.ptrace_scope: 0 adds no Yama restrictions; 1 normally permits an ancestor–descendant relationship or an explicit PR_SET_PTRACER exception; 2 requires CAP_SYS_PTRACE; and 3 prohibits attach and TRACEME until reboot.

$ cat /proc/sys/kernel/yama/ptrace_scope
1
$ gdb -p "$(pidof myapp)"
ptrace: Operation not permitted.
$ sudo sysctl -w kernel.yama.ptrace_scope=0   # temporary and deliberate, not permanent

With ptrace_scope=1, starting your own child under a debugger usually works even when attaching to an unrelated process is denied. It does not “always work”: levels 2 and 3, and other security mechanisms, can restrict PTRACE_TRACEME too.

When ptrace Is the Right Tool — and When It Isn’t

ptrace is surgery on a living organism: full control, touching state, a stopped patient. That level of invasiveness is right for debugging an incident — and wrong as a permanent part of the system. Rules that hold up: strace and gdb to verify a specific hypothesis about a specific process, with the cost accepted; continuous syscall observation in production — eBPF, not ptrace. If your “observability agent” keeps processes under ptrace, that’s not observability — that’s a permanent debugger in production, with all the stops and overhead described above.

And the methodological point: ptrace gives you a microscope, not a compass. Stopping a process and staring at registers makes sense only once you know what you’re looking for — the tool helps gather observations and test hypotheses, which is what debugging with hypotheses instead of guessing takes apart.

When tracing changes a program’s behavior, return to this loop: stop, inspect, optionally modify, resume. Much of the debugger’s overhead and its effects on execution arise there.

Technical reference: ptrace(2).

1 thought on “ptrace — How strace and gdb Really Work”

  1. Hi there, just became aware of your blog through Google, and found
    that it is really informative. I am going to watch out for brussels.

    I’ll be grateful if you continue this in future.
    Many people will be benefited from your writing. Cheers!

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top