07 · Debugging & Tracing (gdbserver, strace)¶
Embedded debugging is different in one specific way: the machine running the program is not the machine running the debugger. The target has 64 MB of RAM, no compiler, no source code and stripped binaries; your laptop has the source, the symbols and the cross-toolchain. Every technique here is a variation on bridging that gap — and on the harder problem of diagnosing a failure that happened at 3 a.m. on a device you cannot reach.
Start with the cheap tools¶
Before attaching a debugger, exhaust the things that cost nothing:
root@target:~# dmesg -T --level=err,warn
[Mon Aug 4 09:14:22 2026] mmc1: Timeout waiting for hardware interrupt.
[Mon Aug 4 09:14:23 2026] EXT4-fs (mmcblk0p3): warning: mounting fs with errors
root@target:~# dmesg -w # follow, like tail -f
root@target:~# cat /proc/sys/kernel/printk
7 4 1 7
Those four numbers are current, default, minimum and boot-time console log
levels. Raising the first to 8 sends debug messages to the console:
On a slow serial console this can dominate CPU time and change the timing of the bug you are chasing — a real form of heisenbug.
Then the process view:
root@target:~# ps -eo pid,ppid,stat,rss,comm --sort=-rss | head -5
PID PPID STAT RSS COMMAND
310 1 Ssl 18244 appd
201 1 Ss 820 dropbear
root@target:~# cat /proc/310/status | grep -E "VmRSS|Threads|State"
State: S (sleeping)
Threads: 4
VmRSS: 18244 kB
root@target:~# ls -l /proc/310/fd | wc -l
41
root@target:~# cat /proc/310/stack # kernel stack of a hung task
A process count of open file descriptors that grows monotonically is a
descriptor leak, and it is visible from /proc alone — no tooling needed.
strace: what the program asked the kernel to do¶
strace is the highest value-per-effort tool in embedded debugging,
because most "the app is broken" reports are really "the app opened the
wrong path" or "the app got EACCES".
root@target:~# strace -f -e trace=openat,connect -p 310
strace: Process 310 attached with 4 threads
[pid 310] openat(AT_FDCWD, "/etc/appd/config.toml", O_RDONLY) = -1 ENOENT (No such file or directory)
[pid 310] openat(AT_FDCWD, "/usr/share/appd/config.toml", O_RDONLY) = 4
[pid 312] connect(6, {sa_family=AF_INET, sin_port=htons(8883),
sin_addr=inet_addr("10.0.4.9")}, 16) = -1 ECONNREFUSED
That output answers two support tickets at once: the config is coming from the fallback path, and the broker is refusing connections.
Useful invocations:
$ strace -f -tt -T -o /tmp/trace.log ./appd # follow forks, timestamps, durations
$ strace -c -p 310 # summary: syscall counts and time
$ strace -e trace=%file ./appd # all filesystem-touching calls
$ strace -e trace=%network ./appd # sockets only
$ ltrace ./appd # library calls (needs dynamic linking)
strace -c is how you find that a daemon is calling stat() 40,000 times
a second on a file that does not exist. -T shows how long each call took,
which turns "it feels slow" into a number.
The cost: strace stops the process at every syscall via ptrace. Slowdowns
of 10–100× are normal. It is a diagnostic tool, never a monitoring one.
Cross-debugging with gdbserver¶
The split is simple: gdbserver (a few hundred KB, no symbols needed) runs
on the target; the full cross-gdb runs on your host with the unstripped
binary and the source.
On the target:
root@target:~# gdbserver :2345 /usr/sbin/appd --verbose
Process /usr/sbin/appd created; pid = 415
Listening on port 2345
Or attach to something already running:
On the host:
$ aarch64-poky-linux-gdb build/appd
GNU gdb (GDB) 14.1
Reading symbols from build/appd...
(gdb) set sysroot /srv/sdk/sysroots/cortexa53-poky-linux
(gdb) set substitute-path /usr/src/debug /home/dev/appd
(gdb) target remote 192.168.7.20:2345
Remote debugging using 192.168.7.20:2345
Reading symbols from /srv/sdk/.../lib/ld-linux-aarch64.so.1...
(gdb) break sensor_read
Breakpoint 1 at 0x4008a4: file sensor.c, line 42.
(gdb) continue
Breakpoint 1, sensor_read (fd=4) at sensor.c:42
42 ssize_t n = read(fd, buf, sizeof(buf));
(gdb) bt
#0 sensor_read (fd=4) at sensor.c:42
#1 0x0000000000400a10 in poll_loop () at main.c:88
#2 0x0000000000400b3c in main () at main.c:120
(gdb) info threads
(gdb) p *cfg
$1 = {interval = 5, path = 0x412030 "/dev/modem", retries = 3}
set sysroot is the step everyone forgets. Without it gdb loads your
host's libc symbols against the target's libc addresses and prints
confident nonsense for every backtrace that crosses a library boundary.
Compile with -g and, on optimised builds, keep -O2 -g rather than
dropping to -O0: you want to debug what you ship. Yocto's
dbg-pkgs/-dbg packages carry the separated debug symbols.
Post-mortem: core dumps¶
Live debugging is a luxury. Field failures need core dumps.
root@target:~# ulimit -c unlimited
root@target:~# cat /proc/sys/kernel/core_pattern
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h
root@target:~# coredumpctl list
TIME PID UID SIG COREFILE EXE
Mon 2026-08-04 09:31:02 UTC 415 1000 11 present /usr/sbin/appd
root@target:~# coredumpctl info 415
Signal: 11 (SEGV)
Command Line: /usr/sbin/appd --verbose
Stack trace of thread 415:
#0 0x0000ffff8a1c2b40 sensor_read (appd)
On a small system, skip systemd-coredump and write plain files:
Then analyse on the host — never on the target:
$ aarch64-poky-linux-gdb build/appd core.appd.415.1754301062
Core was generated by `/usr/sbin/appd --verbose'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x0000ffff8a1c2b40 in sensor_read (fd=-1) at sensor.c:42
(gdb) bt full
Note fd=-1 — the crash is a missing error check on an open() that
failed, which the strace above already hinted at.
Tracing the kernel side¶
root@target:~# cd /sys/kernel/debug/tracing
root@target:/sys/kernel/debug/tracing# cat available_tracers
timerlat osnoise hwlat blk function_graph wakeup function nop
root@target:/sys/kernel/debug/tracing# echo function > current_tracer
root@target:/sys/kernel/debug/tracing# echo mmc_* > set_ftrace_filter
root@target:/sys/kernel/debug/tracing# echo 1 > tracing_on; sleep 2; echo 0 > tracing_on
root@target:/sys/kernel/debug/tracing# head -6 trace
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
appd-310 [001] d..1 812.44219: mmc_request_start <-mmc_start_request
perf gives you the profiling view when the problem is "slow" rather than
"wrong":
root@target:~# perf top -p 310
root@target:~# perf record -g -p 310 -- sleep 10 && perf report --stdio
Both need kernel config (CONFIG_FTRACE, CONFIG_PERF_EVENTS,
CONFIG_DEBUG_FS) that a size-optimised production kernel often omits —
which is exactly why you keep a debug image variant built from the same
sources.
Traps¶
Debugging traps
- Symbol mismatch. Debugging with a binary that is not bit-identical to the one on target gives plausible, wrong line numbers. Keep the unstripped build artefact for every release you ship, archived with the image.
- Missing
set sysroot. Backtraces through libc become fiction. - strace in production. The 10–100× slowdown will trip watchdogs (module 9) and reboot the board you were debugging.
- Cores written to the rootfs. A 200 MB core dump on a read-only or
nearly-full rootfs either fails or fills the filesystem. Point
core_patternat the data partition, and cap the size. - Debug builds that behave differently.
-O0can hide a race that only exists at-O2. Debug the optimised build; accept "value optimized out". - Raising
printkto 8 changes timing enough to hide timing bugs, and on a 115200-baud console can make the system unusably slow. - debugfs mounted in a shipping image exposes kernel internals to any root-adjacent process. Keep it in the debug variant only.
Cheat sheet¶
| Command / item | Purpose |
|---|---|
dmesg -T --level=err,warn / dmesg -w |
Kernel errors with timestamps / follow |
echo 8 > /proc/sys/kernel/printk |
Raise console log verbosity |
/proc/<pid>/{status,fd,stack,maps} |
Memory, descriptors, kernel stack, mappings |
strace -f -tt -T -o log ./prog |
Trace syscalls: forks, timestamps, durations |
strace -c -p PID |
Syscall count/time summary — find hot or failing calls |
strace -e trace=%file,%network |
Filter to filesystem / socket calls |
gdbserver :2345 <prog> |
Debug stub on target |
gdbserver :2345 --attach PID |
Attach to a running process |
target remote <ip>:2345 |
Connect from cross-gdb on the host |
set sysroot <sdk sysroot> |
Required for correct library symbols |
set substitute-path <build> <src> |
Map recorded build paths to your sources |
bt full / info threads / p expr |
Backtrace with locals / threads / evaluate |
core_pattern + coredumpctl list/info |
Post-mortem capture and inspection |
/sys/kernel/debug/tracing (ftrace) |
Kernel function and event tracing |
perf record -g / perf top |
Sampling profiler for "it's slow" |
On verification
The command forms and gdb options here follow the documented interfaces for gdb, gdbserver, strace and ftrace; the transcripts are representative rather than captured, since no cross-toolchain or target build was run while writing this page. The outputs are useful as a guide to what to look for, not as literal expected text for your toolchain version.
How It Actually Works¶
strace works via PTRACE_SYSCALL, which stops the tracee at every
syscall boundary — it is not a log the kernel keeps. strace attaches
with ptrace(PTRACE_SEIZE/ATTACH, ...), then loops on
ptrace(PTRACE_SYSCALL, ...) + waitpid(): the kernel delivers a
SIGTRAP to the tracer twice per syscall — once on entry (registers
hold the syscall number and arguments, readable via
PTRACE_GETREGS) and once on exit (return value now in the return-value
register) — and the tracee is fully stopped between those two points.
That double-stop-per-syscall is exactly why straced programs run
10–100x slower: every single syscall now costs two extra context
switches into the tracer.
gdbserver splits the debugger into a thin remote stub and a full GDB,
talking the Remote Serial Protocol. On target, gdbserver uses
ptrace() just like strace does, but instead of decoding syscalls
itself it exposes register/memory read-write and breakpoint control as a
text protocol ($g#67-style packets) over a TCP socket or serial line.
Your host-side cross-gdb does all the symbol resolution, source
mapping, and expression evaluation locally — it just sends RSP commands
for Yhat the low-level “write byte at address / read register” actions
and reconstructs a source-level debugging session on top. This split is
exactly why gdbserver binaries can be tiny (no DWARF parser needed
on-target) while your host gdb needs the unstripped binary with
matching debug symbols to make sense of the addresses coming back.
A core dump is a PT_NOTE+PT_LOAD-segment snapshot written by the
kernel's own coredump code path, triggered by specific fatal signals.
On SIGSEGV/SIGABRT/etc. with no handler, do_coredump() walks the
process's VMAs and writes each mapped, dumpable region as an ELF
PT_LOAD segment, plus a PT_NOTE segment holding NT_PRSTATUS (saved
registers at the moment of the fault) and NT_FILE (which files were
mapped where) — that's why gdb prog core can show you the exact
faulting line and stack even though the process no longer exists: the
register state and the mapped memory are both literally embedded in the
file, gdb just needs the original binary to resolve symbols against
those addresses.
ftrace's function tracer works by binary-patching function
prologues at boot. With CONFIG_FTRACE and -pg-style
instrumentation, every kernel function gets a mcount/__fentry__ call
stub compiled into its prologue; at boot, ftrace's dyn_ftrace
machinery patches nearly all of those stubs back to no-ops for zero
runtime cost when tracing is off, then re-patches only the ones you
enable (set_ftrace_filter) to jump into the tracer when you actually
turn tracing on — live code patching of the running kernel image, not a
recompile, which is what makes flipping a trace point on/off at runtime
cost effectively nothing when it's off.
Exercise¶
(1) Write a small C program with a deliberate null-pointer dereference,
cross-compile it with -O2 -g, run it under gdbserver in QEMU, and get a
full backtrace from cross-gdb on your host — with and without
set sysroot, and describe the difference in what you see. (2) Take the
same program and diagnose it only from a core dump: set core_pattern,
crash it, copy the core to the host, and produce bt full. (3) Use
strace -c on a busy process (busybox httpd serving requests will do)
and identify the single most-called syscall; then use strace -T to find
the slowest individual call. (4) One paragraph: a field unit reboots every
few hours and you cannot reach it. List — in the order you would implement
them — the four artefacts you would add to the next firmware release so
the failure diagnoses itself, and note for each one what it costs in flash,
RAM or CPU.