Debugging C: GDB, Valgrind & Sanitizers
Master the art of seeing inside broken C programs — using GDB, Valgrind, and sanitizers to catch memory bugs, undefined behavior, and crashes before they find you in production.
Overview
Every programmer eventually faces the moment when their program does something completely wrong — a crash, a silent corruption, a number that should be 42 but is 1073741824 — and printf is no longer enough. That moment is where debugging tools become your superpower.
This module is about learning to see inside a running (or dead) C program with surgical precision. We cover three complementary families of tools: GDB (the GNU Debugger) for interactive inspection of program state at any point in time; Valgrind/Memcheck for dynamic memory error detection that catches mistakes C's runtime never notices; and AddressSanitizer/UndefinedBehaviorSanitizer (ASan/UBSan), compiler-instrumented guards that catch errors the instant they happen, with almost no setup cost.
Along the way we also discuss core dumps — frozen snapshots of a crashed process that let you do post-mortem debugging — and when each approach is the right tool to reach for. Understanding these tools is not optional for systems programmers: memory bugs in C are the single largest source of real-world security vulnerabilities, and being fast at finding them is what separates professional kernel and systems contributors from everyone else.
The overarching theme is the debugging mindset: form a hypothesis, gather evidence, narrow the search space, repeat. The tools you learn here are all just instruments for that scientific process. Master the mindset and the tools are easy to learn; skip the mindset and even the best tools will frustrate you.
The Linux kernel is written in C — millions of lines of it — and every subsystem maintainer expects submitted patches to be free of memory errors, undefined behavior, and use-after-free bugs before they land in your cover letter. The kernel's own CI infrastructure runs builds under KASAN (Kernel Address Sanitizer, a direct descendant of ASan) and UBSAN on every proposed patch. If you cannot read a backtrace, you cannot contribute.
Beyond kernel work, these skills are directly transferable to every piece of systems software you will ever touch: device drivers, embedded firmware, databases, compilers, language runtimes. The debugging muscle you build in this module — knowing how to set a watchpoint on a corrupted pointer, read a Valgrind error report, interpret an ASan stack trace — is the same muscle that lets senior engineers confidently debug a 300,000-line codebase they have never seen before. Hiring committees for kernel-adjacent roles routinely test debugging skills directly. Build them now, and they compound for decades.
Debugging Tool Selection Flow
Decision tree for choosing the right debugging approach based on the symptom. In practice, combine ASan+UBSan for all development builds as a baseline.
The concepts
01The Debugging Mindset: Science, Not Guessing
Before touching any tool, internalize the method. Debugging is applied science:
- Observe: what does the program actually do?
- Hypothesize: what single specific thing could explain it?
- Predict: if your hypothesis is right, what else must be true?
- Test: run an experiment that can falsify the hypothesis.
- Repeat: your experiment either confirms, refutes, or refines the hypothesis.
The key word is falsify. A good experiment is one that, if your hypothesis is wrong, gives you a different result. This sounds obvious but most beginners skip it — they throw changes at a program hoping something sticks. That is not debugging; it is thrashing.
Printf debugging is the most primitive form of this loop: insert print statements, observe output, form hypothesis. It works, and experienced engineers use it constantly. Its limits are: you cannot pause execution and explore state you didn't think to print; you cannot replay execution; and you leave behind scaffolding that must be cleaned up.
Interactive debuggers (GDB) let you stop time, look at anything, and change state. Dynamic analysis tools (Valgrind, ASan) run your program in an instrumented environment that catches entire classes of bugs automatically. Neither replaces the scientific mindset — they just give you better instruments.
A real heuristic from kernel developers: when a bug is hard, the first thing you write is a minimal reproducible test case. Shrink the program until the bug still exists. Small programs are far easier to debug than large ones.
/* Minimal reproducible case discipline:
* Before reaching for GDB, reduce the problem.
* If a 5000-line program crashes, does this crash too?
*/
#include <stdio.h>
#include <string.h>
int main(void) {
char buf[8];
/* Deliberately minimal: does strcpy with len > 8 still segfault? */
strcpy(buf, "Hello, World!"); /* overflow: yes it does */
printf("%s\n", buf);
return 0;
}
02Compiling for Debugging: The Role of -g, -O0, and Debug Info
A compiled binary is a translation of your source code into machine instructions. By default, compilers optimize aggressively: they reorder instructions, eliminate variables, inline functions, and fold constants. All of that makes the binary faster but makes debugging nearly impossible — the debugger cannot map a machine instruction back to a source line because the mapping was destroyed.
The -g flag tells the compiler to emit debug information (usually in DWARF format on Linux) embedded in the binary. DWARF is a rich data format that records:
- Which machine instruction corresponds to which source line
- Where each local variable lives at each point (in a register? on the stack at offset -16?)
- The types of all variables (so GDB can display
int *pproperly) - Call frame information (how to reconstruct the call stack)
-O0 disables all optimizations. This makes the compiled code almost a 1-to-1 translation of your source, so every source line does exactly one thing, variables are always in memory (not just registers), and stepping through code in GDB does what you expect.
-O2 -g is common in production: optimized code with debug info attached. Stepping through this in GDB is confusing (lines jump around, variables say <optimized out>) but it is still useful for post-mortem analysis.
Frame pointers: Modern compilers often omit the frame pointer register (rbp on x86-64) to free it for general use. This breaks stack unwinding in some tools. -fno-omit-frame-pointer restores it — Valgrind and some profilers benefit from it.
For development, always use: gcc -g -O0 -Wall -Wextra -fno-omit-frame-pointer your_file.c -o your_binary
# Compile with full debug info and no optimization
gcc -g -O0 -Wall -Wextra -fno-omit-frame-pointer buggy.c -o buggy
# Verify debug info is present
file buggy
# → buggy: ELF 64-bit LSB executable, ... with debug_info, not stripped
# See the DWARF debug sections
readelf -S buggy | grep debug
# → .debug_info .debug_abbrev .debug_line .debug_str ...
# Strip debug info for a release build (separate file for crash reporting)
objcopy --only-keep-debug buggy buggy.dbg
objcopy --strip-debug buggy
objcopy --add-gnu-debuglink=buggy.dbg buggy
03GDB Fundamentals: Starting, Stopping, and Inspecting
GDB (GNU Debugger) is an interactive program that controls another program — the inferior — and lets you stop it at any point, inspect all of its state, and resume. Think of it as a time-travel microscope for running processes.
Starting GDB:
gdb ./program— load the program, don't run it yetgdb ./program core— load a core dump for post-mortemgdb -p PID— attach to an already-running process- Inside GDB:
run arg1 arg2orrstarts the program
Breakpoints tell GDB to stop when execution reaches a specific location:
break main— stop at the start ofmainbreak file.c:42— stop at line 42 of file.cbreak function_name— stop at any call to that functionbreak *0x401234— stop at a specific address (useful when source isn't available)info breakpoints— list all breakpointsdelete 2— delete breakpoint number 2condition 3 x > 10— only trigger breakpoint 3 whenx > 10
Stepping through code:
next(orn) — execute the next source line, skipping over function callsstep(ors) — execute the next source line, stepping into function callsfinish— run until the current function returns, then stopcontinue(orc) — resume execution until the next breakpointstepi/nexti— step one machine instruction at a time
Examining state:
print x(orp x) — print the value of variablexprint *ptr— dereference and printprint arr[0]@10— print 10 elements of arrayarrstarting at index 0display x— automatically printxevery time execution stopsx/10xw 0xaddr— examine memory: 10 items, hex format, word-sizedinfo locals— all local variables in current frameinfo registers— all register values
The call stack:
backtrace(orbt) — print the full call stackbt full— print stack with local variables at each frameframe 3— switch to frame 3 (0 is innermost)up/down— move between frames
/* buggy.c — a simple use-after-free to debug */
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
void process(char *data) {
printf("Processing: %s\n", data);
free(data); /* freed here */
}
int main(void) {
char *buf = malloc(32);
strcpy(buf, "hello");
process(buf);
printf("Length: %zu\n", strlen(buf)); /* use after free! */
return 0;
}
/* GDB session:
$ gcc -g -O0 buggy.c -o buggy
$ gdb ./buggy
(gdb) break process
(gdb) run
Breakpoint 1, process (data=0x555555559260 "hello") at buggy.c:6
(gdb) print data
$1 = 0x555555559260 "hello"
(gdb) next <- advances to free(data)
(gdb) next <- free() executed, data pointer now dangling
(gdb) finish <- return from process()
(gdb) print buf <- buf still holds old (freed) address
$2 = 0x555555559260 "" <- contents undefined after free
(gdb) backtrace
#0 main () at buggy.c:14
*/
04Watchpoints and Reverse Debugging: Catching Corruption in the Act
Breakpoints stop at a location. But sometimes you know a variable is getting corrupted — its value changes to garbage — but you have no idea where in the code it happens. This is the classic memory corruption scenario, and breakpoints alone are useless.
Watchpoints solve this: they tell GDB to stop execution whenever a specific memory address is read or written. The hardware (x86 debug registers DR0–DR3, ARM watchpoint registers) monitors the address and generates a debug trap the instant it is accessed — with zero overhead until it triggers.
(gdb) watch global_var # stop when global_var is written
(gdb) watch -l *ptr # watch the memory ptr points to, not ptr itself
(gdb) rwatch arr[5] # stop when arr[5] is READ
(gdb) awatch arr[5] # stop on any access (read or write)
Hardware watchpoints are limited (x86-64 has 4 debug registers). When you have more than 4, GDB falls back to software watchpoints, which are extremely slow (it single-steps and checks after every instruction).
Conditional watchpoints combine the power of both:
(gdb) watch x
(gdb) condition 1 x < 0 # only trigger when x goes negative
Reverse debugging is a mind-bending capability: GDB can record every instruction your program executes and then play them backwards. You set a breakpoint on the symptom (the crash, the wrong value), then step backwards in time until you find the cause:
(gdb) record # start recording execution
(gdb) continue # run until crash/wrong value
(gdb) reverse-next # go backwards one source line
(gdb) reverse-stepi # go backwards one instruction
This is expensive in time and memory but invaluable for "how did this variable get this value?" questions. The kernel-mode equivalent is rr (Mozilla's record-and-replay tool), which is widely used by kernel developers.
/* corruption.c — a classic "who wrote this variable?" puzzle */
#include <stdio.h>
static int counter = 0;
void increment(void) { counter++; }
void reset_wrong(void){ counter = -999999; } /* the culprit */
void do_work(void) { increment(); increment(); reset_wrong(); increment(); }
int main(void) {
do_work();
if (counter != 3)
printf("BUG: counter=%d, expected 3\n", counter);
return 0;
}
/* GDB watchpoint session:
$ gcc -g -O0 corruption.c -o corruption
$ gdb ./corruption
(gdb) start <- breaks at main
(gdb) watch counter <- hardware watchpoint on counter
(gdb) continue
Hardware watchpoint 2: counter
Old value = 0
New value = 1
increment () at corruption.c:5
(gdb) continue
Old value = 1, New value = 2 ...(at increment)
(gdb) continue
Old value = 2, New value = -999999
reset_wrong () at corruption.c:6 <- caught!
*/
05Backtraces and Core Dumps: Post-Mortem Debugging
When a program crashes (SIGSEGV, SIGABRT, etc.), the OS can save its entire memory image to disk as a core dump — a binary file containing all memory, register values, and thread state at the moment of death. This is forensics: you examine the scene after the fact.
Enabling core dumps:
ulimit -c unlimited # allow unlimited core file size (per-shell)
# Or permanently in /etc/security/limits.conf:
# * soft core unlimited
On modern Linux with systemd, cores are handled by systemd-coredump and stored in the journal. Retrieve with coredumpctl list and coredumpctl gdb.
The core file path is controlled by /proc/sys/kernel/core_pattern. A typical setting:
echo '/tmp/cores/core.%e.%p' > /proc/sys/kernel/core_pattern
# %e = executable name, %p = PID
Loading a core in GDB:
gdb ./program core.12345
(gdb) bt # see the backtrace at crash time
(gdb) frame 0 # examine the crashing frame
(gdb) info registers # exact register state at crash
(gdb) x/20i $rip-20 # disassemble around the crash point
Reading a backtrace: This is a critical skill. A backtrace reads bottom-up (frame 0 is the innermost, deepest call). Example:
#0 0x00007f... in __memcpy_avx_unaligned ()
#1 0x000055... in process_packet (buf=0x0, len=1500) at net.c:88
#2 0x000055... in handle_input () at main.c:42
#3 0x000055... in main () at main.c:100
Frame 1 tells you: process_packet was called with buf=0x0 (a null pointer!), and it called memcpy on it. The bug is in frame 2 calling process_packet with a null argument.
Stripped binaries: Production binaries often have debug symbols stripped to reduce size. GDB will show only addresses. Solutions:
- Keep the
.dbgfile with symbols and load it:gdb -ex 'set debug-file-directory /path/to/dbg' ./stripped_bin core - Use
addr2line -e binary 0xdeadbeefto map addresses to source lines if you have the unstripped binary eu-addr2linefrom elfutils is more robust
/* crash.c — reliably produces a segfault for core dump practice */
#include <stdio.h>
#include <string.h>
typedef struct { int id; char name[16]; } Record;
Record *find_record(Record *table, int n, int id) {
for (int i = 0; i < n; i++)
if (table[i].id == id) return &table[i];
return NULL; /* not found */
}
int main(void) {
Record table[] = {{1, "alice"}, {2, "bob"}};
Record *r = find_record(table, 2, 99); /* id 99 not present */
/* Forgot to check r != NULL: classic null-deref */
printf("Found: %s\n", r->name); /* SIGSEGV */
return 0;
}
/* Practice:
$ gcc -g -O0 crash.c -o crash
$ ulimit -c unlimited
$ ./crash <- crashes, produces core
$ gdb ./crash core
(gdb) bt
#0 0x... in main () at crash.c:17
(gdb) print r
$1 = (Record *) 0x0 <- null pointer, confirmed
(gdb) frame 0
(gdb) list <- show source around crash line
*/
06Valgrind Memcheck: The Memory Error Oracle
C gives you direct access to memory. It also gives you the full power to misuse it. Valgrind/Memcheck is a dynamic binary instrumentation tool: it translates every instruction in your program into instrumented code that tracks the validity and definedness of every byte in memory. It catches:
- Invalid read/write — accessing memory you don't own (heap overflow, use-after-free, stack overflow past valid array bounds).
- Use of uninitialised values — reading a variable before writing it. C never zeros memory by default; local variables contain stack garbage.
- Heap memory leaks — allocating memory with
mallocbut never freeing it. Memcheck tracks every allocation and at program exit reports what wasn't freed. - Invalid free — calling
free()on a pointer you didn't get frommalloc(), or freeing twice. - Mismatched allocation/deallocation — using
deleteto freemalloc-allocated memory (important in C++).
How it works (briefly): Valgrind runs your program in a synthetic CPU (JIT-compiled). It maintains two shadow memory bitmaps:
- Valid-address (A) bits: which addresses are currently mapped/accessible
- Valid-value (V) bits: which bytes contain defined (initialized) values
Every memory access checks the A bits. Every use of a value checks the V bits. This is why Valgrind slows programs by ~20x — it is running two programs simultaneously.
Reading Valgrind output:
==12345== Invalid read of size 4
==12345== at 0x108748: main (buggy.c:15)
==12345== Address 0x522d040 is 0 bytes after a block of size 40 alloc'd
==12345== at 0x4C2FB0F: malloc (in /usr/lib/valgrind/...)
==12345== by 0x108700: main (buggy.c:10)
The PID is 12345. You read 4 bytes at an address 0 bytes past the end of a 40-byte malloc. That is a one-past-the-end bug (reading arr[10] when arr has 10 elements).
Useful flags:
--leak-check=full— show full details of every leak (not just summary)--show-leak-kinds=all— include reachable leaks (pointers still exist but memory not freed)--track-origins=yes— for uninitialized value errors, track where the uninitialized memory came from (slower but invaluable)--error-exitcode=1— make Valgrind return non-zero on errors (useful in CI)--suppressions=file— ignore known false positives (system libraries often trigger these)
Critical limitation: Valgrind runs only on the program's heap. Stack overflows beyond the probed region and static buffer overflows (global arrays) are harder to catch. ASan (next concept) handles these better.
/* memleak.c — three common memory errors for Valgrind */
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
void uninit_read(void) {
int x; /* uninitialized */
if (x > 0) /* Valgrind: Conditional jump on uninitialised value */
printf("positive\n");
}
void heap_overflow(void) {
int *arr = malloc(10 * sizeof(int));
arr[10] = 99; /* one past the end: invalid write of size 4 */
free(arr);
}
void memory_leak(void) {
char *buf = malloc(64);
strncpy(buf, "leaked!", 63);
/* forgot free(buf) */
}
int main(void) {
uninit_read();
heap_overflow();
memory_leak();
return 0;
}
/* Run:
$ gcc -g -O0 memleak.c -o memleak
$ valgrind --leak-check=full --track-origins=yes ./memleak
Expect:
- Conditional jump depends on uninitialised value(s)
- Invalid write of size 4 (heap_overflow)
- LEAK SUMMARY: 64 bytes in 1 block definitely lost
*/
07AddressSanitizer (ASan): Compiler-Instrumented Memory Guards
Valgrind is powerful but slow (~20x). AddressSanitizer (ASan) is a compiler feature (supported by GCC and Clang) that instruments your code at compile time rather than interpreting it at runtime. The slowdown is typically only 2x, making it practical for large test suites.
Enable it: just add compiler flags:
gcc -g -O1 -fsanitize=address -fno-omit-frame-pointer your.c -o your
(ASan works better with at least -O1 because that enables inlining needed to produce clean backtraces.)
How ASan works: It divides memory into application memory and shadow memory (1 byte of shadow for every 8 bytes of app memory, so 1/8 overhead). Every memory access is compiled into:
/* Original: *ptr = value; */
/* ASan-instrumented roughly: */
if (IsPoisoned(ptr)) ReportError(ptr);
*ptr = value;
Memory regions that are not valid to access are "poisoned" in the shadow: malloc unpoisons the allocated region; free re-poisons it. The stack frames, global arrays, and the space between heap allocations ("redzones") are always poisoned.
What ASan catches:
- Heap buffer overflow and underflow
- Stack buffer overflow
- Global buffer overflow
- Use-after-free
- Use-after-return (accessing a stack variable after the function returned — needs
-fsanitize-address-use-after-return=always) - Use-after-scope (accessing a variable outside its
{...}block) - Double-free and invalid-free
- Memory initialization order fiasco (C++)
ASan output is extremely clear:
==1234==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000028
WRITE of size 4 at 0x602000000028 thread T0
#0 0x401234 in heap_overflow buggy.c:8
#1 0x401300 in main buggy.c:15
...
Shadow bytes around the buggy address:
0x602000000000: fa fa 00 00 00 00 00 00 00 00 fa fa ...
→ fa = heap redzone, 00 = accessible, f8 = freed memory
The shadow byte legend at the bottom of ASan output tells you exactly what kind of memory the bad access landed in — a freed heap chunk (f8), a stack redzone (f1), a global redzone (f9), etc. Learn these codes.
ASan + Valgrind do not mix — never run both simultaneously. Use Valgrind for leak detection (ASan's leak detection is good but Valgrind's is more mature), use ASan for speed in large test suites.
/* asan_demo.c — stack overflow AND use-after-free, one file */
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
void stack_overflow(void) {
char buf[8];
/* Write 16 bytes into an 8-byte stack buffer */
memset(buf, 'A', 16); /* stack-buffer-overflow */
printf("%c\n", buf[0]);
}
void use_after_free(void) {
int *p = malloc(sizeof(int));
*p = 42;
free(p);
*p = 99; /* heap-use-after-free */
}
int main(void) {
stack_overflow();
use_after_free();
return 0;
}
/* Build and run:
$ gcc -g -O1 -fsanitize=address -fno-omit-frame-pointer asan_demo.c -o asan_demo
$ ./asan_demo
==1== ERROR: AddressSanitizer: stack-buffer-overflow on address ...
WRITE of size 16 at ...
#0 in stack_overflow asan_demo.c:8
#1 in main asan_demo.c:15
(after fixing stack_overflow, run again for use-after-free report)
*/
08UndefinedBehaviorSanitizer (UBSan): Catching C's Dark Corners
C's specification declares many operations as undefined behavior (UB): the standard says nothing about what happens, so compilers are free to assume they never occur and optimize accordingly. This creates a category of bugs that are silent and insidious — the program may appear to work on your machine but produce completely wrong results on another, or in a different optimization level, or after a seemingly unrelated code change.
Common UB examples:
- Signed integer overflow:
INT_MAX + 1— result is undefined (compilers assume it never happens and optimize loops on that basis) - Null pointer dereference: technically UB before the access, allowing the compiler to remove null checks
- Shift overflow:
1 << 32on a 32-bitint - Out-of-bounds array access
- Misaligned pointer access: casting a
char *toint *and dereferencing - Floating point conversion overflow: converting a large double to int
- Using a NULL pointer (even before dereferencing, in some contexts)
UBSan is enabled with:
gcc -g -O1 -fsanitize=undefined your.c -o your
# Or combine with ASan (they are compatible):
gcc -g -O1 -fsanitize=address,undefined your.c -o your
UBSan output is a one-liner at the offending source location:
buggy.c:12:13: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
buggy.c:18:5: runtime error: null pointer passed as argument 1, which is declared to never be null
buggy.c:25:8: runtime error: shift exponent 32 is too large for 32-bit type 'int'
You can make UBSan abort immediately on the first error (useful in CI) with -fsanitize-undefined-trap-on-error or by setting the environment variable UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1.
The integer overflow UB gotcha in kernel code: The Linux kernel uses -fwrapv to make signed integer overflow wrap (like unsigned), opting out of the UB. Many kernel bugs historically came from UB optimizations removing bounds checks. UBSan is now mandatory in kernel CI builds.
/* ubsan_demo.c — a rogue's gallery of undefined behavior */
#include <stdio.h>
#include <limits.h>
#include <string.h>
void signed_overflow(void) {
int x = INT_MAX;
int y = x + 1; /* UB: signed integer overflow */
printf("%d\n", y);
}
void bad_shift(void) {
unsigned int x = 1U << 31; /* OK: unsigned */
int y = 1 << 31; /* UB on most platforms: shift into sign bit */
(void)y;
printf("%u\n", x);
}
void null_deref_ub(void) {
int *p = NULL;
/* UBSan catches this even before the dereference in some checks */
int x = *p; /* UB */
printf("%d\n", x);
}
int main(void) {
signed_overflow();
bad_shift();
/* null_deref_ub(); */ /* uncomment to test, will also crash */
return 0;
}
/* Build:
$ gcc -g -O1 -fsanitize=undefined ubsan_demo.c -o ubsan_demo
$ UBSAN_OPTIONS=print_stacktrace=1 ./ubsan_demo
Output:
ubsan_demo.c:7:15: runtime error: signed integer overflow: 2147483647 + 1
ubsan_demo.c:12:16: runtime error: left shift of 1 by 31 places cannot be
represented in type 'int'
*/
09GDB TUI and Pretty-Printers: Working Efficiently
Raw GDB text output is functional but slow. Three features dramatically improve productivity:
1. TUI (Text User Interface): GDB has a split-screen terminal UI. Press Ctrl-X A inside GDB (or start with gdb -tui). You get a source window at the top showing the current line, and a command window at the bottom. Use layout regs to add a register panel, layout asm for disassembly. The arrow keys scroll the source view; GDB commands go in the lower pane.
2. ~/.gdbinit — your configuration file: GDB reads this file at startup. Essential settings:
set history save on
set history filename ~/.gdb_history
set print pretty on # pretty-print structs
set print array on # arrays on multiple lines
set disassembly-flavor intel # Intel syntax (more readable than AT&T for x86)
set pagination off # don't pause with "---Type <return>---"
3. Python pretty-printers: GDB embeds a Python interpreter. You can write Python scripts that teach GDB how to display complex data structures. The standard library ships pretty-printers for STL containers (C++) and there are community printers for Linux kernel data structures.
For kernel debugging, the scripts/gdb/ directory in the kernel source contains Python scripts that add GDB commands like lx-ps (list processes), lx-dmesg (print kernel log), and pretty-printers for struct list_head, struct task_struct, etc. You load them with:
(gdb) source /path/to/linux/scripts/gdb/vmlinux-gdb.py
(gdb) lx-ps
4. pwndbg and gef: Third-party GDB plugins popular in security research and low-level debugging. They add color, automatic context display (registers + disassembly + stack) on every stop, heap visualization, and many convenience commands. Install: pip install pwndbg or follow gef's one-liner installer.
# .gdbinit — a practical starter configuration
set history save on
set history filename ~/.gdb_history
set history size 10000
set print pretty on
set print array on
set print array-indexes on
set disassembly-flavor intel
set pagination off
set confirm off
# Useful aliases
define pf
print *$arg0
end
define xxd
set $i = 0
while $i < $arg1
printf "%02x ", *(unsigned char*)($arg0 + $i)
set $i = $i + 1
end
printf "\n"
end
document xxd
xxd addr len -- hex dump len bytes starting at addr
end
# Auto-load kernel scripts when debugging vmlinux
# add-auto-load-safe-path /path/to/linux/scripts/gdb
10Printf Debugging: When and How to Do It Right
Printf debugging — inserting printf, fprintf(stderr, ...), or write() calls to trace execution — is not a sign of weakness. It is often the fastest tool for confirming a hypothesis quickly, and senior engineers use it constantly. The skill is knowing when to use it and how to do it cleanly.
When printf wins over GDB:
- The bug is timing-sensitive (a race condition): attaching GDB changes timing and the bug disappears (a Heisenbug)
- The program runs in an environment where GDB cannot attach (embedded target, kernel code running before scheduler init, inside an interrupt handler)
- You just need to confirm one specific value quickly and then move on
- The bug is in a tight loop that runs millions of times — interactive stepping is impractical
How to do it well:
- Always print to
stderr:fprintf(stderr, "[DEBUG] x=%d\n", x)— stdout may be buffered or redirected - Use
__FILE__,__LINE__,__func__for automatic location:fprintf(stderr, "%s:%d:%s: x=%d\n", __FILE__, __LINE__, __func__, x) - Build a debug macro that compiles to nothing in release builds:
#ifdef DEBUG
#define DBG(fmt, ...) fprintf(stderr, "[DBG %s:%d] " fmt "\n", \
__func__, __LINE__, ##__VA_ARGS__)
#else
#define DBG(fmt, ...) ((void)0)
#endif
- Force flush after printing in crash scenarios:
fflush(stderr)— if the program crashes immediately after printing, buffered output may be lost - In multi-threaded code, use
dprintf(STDERR_FILENO, ...)—dprintfwrites to a file descriptor directly and is async-signal-safe (safe inside signal handlers,write()is even safer) - In kernel code, use
printk(KERN_DEBUG "...", ...)— this is the kernel's printf, always available
The trap to avoid: leaving debug printfs in production code. The macro approach makes it easy to strip them: compile with -DDEBUG for debug builds, without it for release. The Linux kernel's pr_debug() works exactly this way — it compiles to nothing unless DEBUG is defined or dynamic debug is enabled.
/* debug_macro.c — production-quality debug tracing */
#include <stdio.h>
#include <string.h>
/* This entire mechanism compiles to ZERO BYTES in release builds */
#ifdef DEBUG
#define DBG(fmt, ...) \
fprintf(stderr, "[DBG %s:%d] " fmt "\n", \
__func__, __LINE__, ##__VA_ARGS__)
#else
#define DBG(fmt, ...) ((void)0)
#endif
/* Also handy: assert-with-message (kept in all builds) */
#define ASSERT(cond, fmt, ...) do { \
if (!(cond)) { \
fprintf(stderr, "ASSERT FAILED %s:%d: " fmt "\n", \
__FILE__, __LINE__, ##__VA_ARGS__); \
__builtin_trap(); \
} \
} while(0)
int divide(int a, int b) {
DBG("divide called: a=%d b=%d", a, b);
ASSERT(b != 0, "division by zero: a=%d", a);
int result = a / b;
DBG("result=%d", result);
return result;
}
int main(void) {
printf("%d\n", divide(10, 2));
printf("%d\n", divide(7, 0)); /* triggers ASSERT */
return 0;
}
/* Compile:
$ gcc -DDEBUG -g -O0 debug_macro.c -o debug_demo && ./debug_demo
[DBG divide:20] divide called: a=10 b=2
[DBG divide:23] result=5
5
ASSERT FAILED debug_macro.c:24: division by zero: a=7
*/
11Sanitizers in CI and the Kernel: KASAN and KUBSAN
The sanitizers you learned — ASan, UBSan — work in userspace. The Linux kernel has its own parallel implementations that run inside the kernel:
KASAN (Kernel Address Sanitizer): Enabled with CONFIG_KASAN=y. Works identically to ASan but in kernel address space. It catches heap buffer overflows, use-after-free, and use-after-scope in kernel code. There are two modes:
- Generic KASAN: software shadow memory, ~1.5x memory overhead, ~2x slowdown. Runs on any architecture.
- Hardware tag-based KASAN: uses ARM MTE (Memory Tagging Extension) on ARMv8.5+. Zero shadow memory overhead; the hardware enforces memory tags. This is becoming the default for ARM64 kernel testing.
KUBSAN (Kernel UBSan): CONFIG_UBSAN=y. Catches the same UB in kernel C code as UBSan does in userspace. The kernel patches several known false positives.
syzkaller: An automated kernel fuzzer from Google that generates random syscall sequences and runs them on kernels built with KASAN+KUBSAN+KCOV (coverage instrumentation). It has found thousands of kernel bugs. The publicly reported bugs are at syzkaller.appspot.com.
Typical kernel developer workflow:
# Build kernel with all sanitizers
make defconfig
./scripts/config -e KASAN -e UBSAN -e KASAN_INLINE
make -j$(nproc)
# Boot in QEMU for testing
qemu-system-x86_64 -kernel arch/x86/boot/bzImage \
-append "console=ttyS0 nokaslr" \
-initrd /path/to/initramfs.cpio.gz \
-nographic -m 2G
# KASAN report in dmesg:
# [ 123.456] BUG: KASAN: use-after-free in some_kernel_func+0x42/0x80
# [ 123.456] Read of size 8 at addr ffff888... by task kworker/...
# Call Trace:
# dump_stack
# print_address_description
# kasan_report
# some_kernel_func
# ...
When you contribute to the kernel, your patch will be tested against this infrastructure automatically. A KASAN report in response to your patch means your patch introduced a memory bug — fix it before resubmitting.
/* Example KASAN report structure (annotated, not runnable):
[ 1234.567890] ==================================================================
[ 1234.567890] BUG: KASAN: slab-use-after-free in packet_rcv+0x1a4/0x5a0
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
function + offset/total_size
[ 1234.567890] Read of size 8 at addr ffff88810a3b1e40 by task ksoftirqd/0/16
^^^^^^ ^^^^^^^^^^^^^^
access size task name/PID
[ 1234.567890] Allocated by task 1423:
[ 1234.567890] __kmalloc+0x...
[ 1234.567890] sk_prot_alloc+0x...
[ 1234.567890] sk_alloc+0x...
[ 1234.567890] Freed by task 1423:
[ 1234.567890] kfree+0x...
[ 1234.567890] __sk_free+0x...
[ 1234.567890] sk_free+0x...
/* The report tells you:
* 1. WHERE the bad access happened (packet_rcv)
* 2. WHAT was accessed (8 bytes at the address)
* 3. WHERE the memory was allocated (sk_alloc)
* 4. WHERE it was freed (__sk_free)
* This is everything you need to find the lifetime bug.
*/
Key insights
- ✦Debugging is the scientific method applied to software: hypothesize, predict, test, falsify. Tools are just instruments for that loop — without the method, no tool will save you.
- ✦Always compile with `-g -O0 -fsanitize=address,undefined` during development. The performance cost is irrelevant; the bugs you catch early are priceless.
- ✦A backtrace reads bottom-up: frame 0 is where execution died, but the *bug* is almost always in a higher frame that passed bad arguments or corrupted state.
- ✦Watchpoints are a superpower for memory corruption bugs. When you know *what* got corrupted but not *where*, a hardware watchpoint catches the culprit instantly.
- ✦Valgrind catches uninitialized memory reads that ASan misses (ASan does not track definedness, only addressability). For thorough analysis, run both.
- ✦Undefined behavior is not just 'unpredictable' — optimizing compilers *exploit* UB to transform your code in ways that remove safety checks. UBSan reveals these time bombs.
- ✦Core dumps are not failures — they are free bug reports. Enable them in every development environment and treat each one as a gift: it tells you exactly where and why the program died.
- ✦The Linux kernel uses KASAN and KUBSAN in CI for every patch. Mastering the userspace equivalents (ASan/UBSan) means you can already read kernel bug reports.
Common pitfalls
- ⚠Forgetting `-g` or using `-O2` without `-g`: GDB shows `<optimized out>` for variables and line numbers do not match. Always use `-g -O0` for debug builds.
- ⚠Running Valgrind and ASan simultaneously: they both intercept malloc and will conflict, producing nonsense output. Pick one per run.
- ⚠Not enabling core dumps (`ulimit -c unlimited`) and then having no evidence after a crash. Set this in your shell profile for development machines.
- ⚠Printing to stdout (buffered) instead of stderr in debug statements: if the program crashes, buffered stdout output may never appear. Always use stderr and fflush.
- ⚠Watching a *pointer variable* instead of the memory it points to: `watch ptr` fires when `ptr` changes; `watch *ptr` fires when the pointed-to value changes. Most corruption bugs need the latter.
- ⚠Trusting a backtrace from a stripped binary without symbol files: addresses without symbols tell you almost nothing. Keep `.dbg` files or the unstripped binary alongside production builds.
- ⚠Confusing ASan's 'heap-use-after-free' with Valgrind's same-named error: ASan catches it at the access point (precise), Valgrind catches it immediately (also precise), but ASan's shadow byte legend gives more context about the memory region type.
- ⚠Ignoring UBSan warnings as 'theoretical': signed integer overflow and shift UB are actively exploited by compiler optimizations to eliminate bounds checks in real production code.
Practice
- 01easy
Write a C program that has a heap buffer overflow (write one element past the end of a malloc'd array). Compile it with ASan and capture the full output. Then find the exact shadow byte in the ASan error report and look up what it means in the ASan documentation. Fix the bug and verify ASan reports clean.
- 02easy
Create a program with a function that corrupts a global variable. Use GDB to set a hardware watchpoint on that global variable and catch the exact line that corrupts it — without reading the source first (pretend you are debugging unknown code). Then write the gdb commands you used to a text file as a reproducible debug script.
- 03medium
Write a program that has three different undefined behaviors: signed integer overflow, an out-of-bounds array index, and a null pointer dereference. Compile with UBSan and ASan together (-fsanitize=address,undefined). For each UBSan report, explain in a comment WHY that operation is undefined according to the C standard (look at ISO C11 section references if you can find them).
- 04medium
Take any real open-source C project (suggest: busybox, redis, or any small Unix utility). Build it with -g -O0 -fsanitize=address,undefined. Run its test suite. If any sanitizer reports fire, write a minimal reproducer and a one-paragraph analysis of the root cause.
- 05hard
Simulate a kernel-style debugging session: write a C program that implements a linked list with a deliberate use-after-free bug (free a node but keep a pointer to it, then traverse the list). Use Valgrind with --track-origins=yes to get the full allocation/free/access chain. Then re-run under GDB with a watchpoint on the freed node's 'next' pointer to catch the access dynamically. Write up which tool gave you more actionable information and why.
- 06hard
Enable core dumps on your system and write a C program that deterministically crashes (null pointer dereference inside a deeply nested call chain, at least 6 frames deep). Generate the core dump, then perform a complete post-mortem analysis: identify the crash frame, the null pointer, which function passed the null value, and add a fix with a NULL guard. Document every GDB command you used.
Resources
The authoritative reference for every GDB command and concept; the chapter on 'Examining Memory' and 'Reverse Execution' are essential reading.
Covers Memcheck in detail including the shadow memory model, all error categories, and how to write suppression files.
The original ASan paper by Serebry et al. from Google — explains the shadow memory design, redzone placement, and the 2x overhead model. A short, readable paper that builds deep intuition.
Paste C code, enable -fsanitize=address or -g, and see exactly what instrumentation the compiler inserts. Invaluable for understanding what ASan/UBSan do under the hood.
Official kernel docs for KASAN including how to enable it, interpret reports, and the difference between generic and hardware tag-based modes.
How KUBSAN is configured and used in the kernel build system, with notes on intentional UB in kernel code and how it is suppressed.
Free online book; the debugging chapter covers GDB, core dumps, and strace in practical Linux context.
The core(5) man page explains core_pattern, coredumpctl, and all the % format specifiers — essential for setting up core dumps in development.
Mozilla's record-and-replay debugger — record a program once, replay it deterministically as many times as needed. Used heavily by Firefox and kernel developers to debug races and heisenbugs.
Checkpoints — you can now…
- ✓You can compile a C program with -g -O0 -fsanitize=address,undefined and explain what each flag does to the binary.
- ✓You can start GDB, set a breakpoint on a function name and a line number, step through code, print local variables and struct fields, and examine raw memory with x/Nxw.
- ✓You can set a hardware watchpoint on a global variable in GDB and demonstrate that execution stops the instant the variable is written, showing the old and new values.
- ✓You can enable core dumps, trigger a deliberate crash, load the core in GDB, read the backtrace, and identify which frame contains the null pointer or bad address.
- ✓You can read a Valgrind Memcheck report and identify: the type of error, the exact source line, where the memory was allocated, and (with --track-origins) where an uninitialized value came from.
- ✓You can read an ASan report and decode the shadow byte legend to determine whether the bad access landed in a heap redzone, freed memory, or a stack redzone.
- ✓You can write a debug macro that compiles to zero code in release builds and prints file/line/function in debug builds.
- ✓You can explain why signed integer overflow is undefined behavior in C and what the compiler is allowed to do as a result — and why the kernel uses -fwrapv.