Mastering Command-Line Debugging: Effective Linux Coding with Native Tools
Reliance on GUI debuggers breaks down on headless nodes, CI agents, or in incident response over SSH at 2am. When real reliability is required, native Linux command-line tools—gdb, strace, ltrace—become indispensable.
Case: Diagnosing a Segmentation Fault with Core Tools
Typical scenario: a C binary segfaults on production (Ubuntu 22.04, gcc 11.3.0), no X server available.
Minimal reproducible example:
#include <stdio.h>
void crash(int t) {
int *p = NULL;
if (t > 0)
*p = 1; // deliberate segfault
}
int main() {
crash(2);
puts("done");
return 0;
}
Build with debug symbols:
gcc -g -O0 crash.c -o crash
Note: Omitting -O0
can result in optimized-out variables, making debugging harder.
Workflow: Native Debugging
1. Pinpoint Crash Location (gdb
)
gdb ./crash
(gdb) run
Results:
Program received signal SIGSEGV, Segmentation fault.
0x0000000000401136 in crash (t=2) at crash.c:6
6 *p = 1;
Check stack trace and variable state:
(gdb) bt
#0 crash (t=2) at crash.c:6
#1 0x0000000000401150 in main () at crash.c:11
(gdb) print p
$1 = (int *) 0x0
Known issue: optimized binaries (-O2
or higher) can obfuscate call stacks and variable visibility.
2. System Call Tracing (strace
)
Useful when a process fails due to permissions, missing files, or system resource exhaustion. Consider:
strace -f -e trace=process,network,open,read,write ./crash
Output will display all file/network operations. Example failure when open()
fails:
openat(AT_FDCWD, "/etc/nonexistent", O_RDONLY) = -1 ENOENT (No such file or directory)
Tip: Store trace output for post-mortem review:
strace -o crash.strace ./crash
3. Library Call Analysis (ltrace
)
To trace calls to dynamically linked library functions:
ltrace ./crash
Typical for debugging memory issues or incorrect library usage. Example output:
puts("done") = 5
__libc_start_main(0x401140, 1, 0x7fffffffe378, ...) = 0
Side note: ltrace
may not work correctly with statically linked or stripped binaries.
Advanced Tactics
- Conditional breakpoints in gdb:
break crash if t == 2
- Inspect memory at fault:
x/4x $sp
- Attach to a live process:
pidof myservice gdb -p <PID>
- Filter noisy trace:
strace -e open,read,write ./crash
- Disassemble at crash point:
disas crash
Practical Observations
- Native debugging tools are mandatory on remote targets, within minimal Docker containers, or during CI/CD troubleshooting.
- They reveal low-level detail: syscalls, ABI handshakes, and unexpected errno values.
- Common mistakes: omitting
-g
, forgetting symbols are stripped from packaged binaries, ignoring ASLR side effects.
Gotcha: Debian derivatives often strip debug symbols in /usr/bin
; install corresponding -dbgsym
packages to restore introspection.
Conclusion (Placed Early for Emphasis)
Native command-line debugging is less about nostalgia and more about capability: when GUIs are unavailable, precision matters, or performance must be investigated to the syscall or malloc() boundary.
Reference Table
Tool | Area | Command Example | Note |
---|---|---|---|
gdb | Source-level debug | gdb -q ./mybin | Use -g flag when compiling |
strace | Syscall trace | strace -e open ./mybin | Use -o out.log to save trace |
ltrace | Library call trace | ltrace ./mybin | Limited on statically linked binaries |
Non-obvious Tip
When debugging memory corruption where errors occur long after corruption (e.g., double free, use-after-free), pair valgrind
with gdb via:
valgrind --vgdb=yes --vgdb-error=0 ./crash
Then attach gdb as Valgrind pauses execution, allowing for stepwise inspection.
Successful Linux programming means more than getting a binary to run. It requires visibility into every layer—source, syscalls, and symbols—especially when standard dev tools are unavailable. Invest the time to master these, and you'll recover faster when the inevitable failure lands in your lap.