⚙️ Compiler & Toolchain Mastery
The debugging arsenal: gdb, valgrind & sanitizers
▶ Open the interactive lesson — free, no signup"Segmentation fault (core dumped)" — no line number, no hint, no mercy. You've almost certainly met that message already, and staring harder at the code was your only move. The tools in this lesson turn the same crash into a report naming the exact line that blew up, the line that freed the memory, and the line that allocated it — most bug hunts end in under a minute once you know which tool to reach for.
Every C bug you'll ever hunt falls into a few classes — crashes, leaks, scribbled-over memory, wrong answers — and each class has a purpose-built tool that finds it in seconds. This lesson is your armory tour. Rule zero applies to all of them: compile with -g, or every tool speaks in raw memory addresses instead of your source lines.
printf debugging, done properly
No shame in it — but do it right:
#include <stdio.h>
/* stderr (unbuffered!) + file, line, and the expression itself */
#define DBG(fmt, ...) \
fprintf(stderr, "[%s:%d] " fmt "\n", __FILE__, __LINE__, __VA_ARGS__)
int main(void) {
int balance = 100, price = 30;
for (int i = 0; i < 3; i++) {
balance -= price;
DBG("i=%d balance=%d", i, balance);
}
printf("final: %d\n", balance);
return 0;
}Why stderr? It's unbuffered: output appears immediately. stdout is buffered, so when your program crashes, its last buffered lines die with it — and you end up debugging the wrong location because the final printf "never printed". If you must use stdout, fflush(stdout) after each probe.
gdb: the time machine
This program crashes. Watch a real session find the bug in five commands:
#include <stdio.h>
#include <string.h>
int length_of(const char *s) {
return (int)strlen(s);
}
int main(void) {
const char *words[] = {"alpha", "beta", NULL};
int total = 0;
for (int i = 0; i <= 3; i++) /* hmm... */
total += length_of(words[i]);
printf("total = %d\n", total);
return 0;
}$ gcc -g -Og crash.c -o crash $ gdb -q ./crash Reading symbols from ./crash... (gdb) run Program received signal SIGSEGV, Segmentation fault. __strlen_avx2 () at ../sysdeps/x86_64/multiarch/strlen-avx2.S:74 (gdb) backtrace #0 __strlen_avx2 () at ../sysdeps/x86_64/multiarch/strlen-avx2.S:74 #1 0x0000555555555151 in length_of (s=0x0) at crash.c:5 #2 0x0000555555555185 in main () at crash.c:12 (gdb) frame 2 # jump to OUR code #2 0x0000555555555185 in main () at crash.c:12 12 total += length_of(words[i]); (gdb) print i $1 = 2 (gdb) print words[i] $2 = 0x0 # i=2 is the NULL sentinel — the loop bound <= 3 is the bug. (gdb) quit
The crash is in strlen, but the bug is in frame #2: our loop condition i <= 3 walks past the NULL terminator of the array. backtrace + frame + print is the core gdb workflow — you'll use those three more than everything else combined. Honorable mentions: break file:line, next (step over) vs step (step into), watch var (break when a variable changes), and run < input.txt.
Even better: if your system saves core dumps (ulimit -c unlimited, or via systemd-coredump), you can autopsy a crash after the fact with gdb ./crash core — same backtrace, no need to reproduce it live.
🧠 Checkpoint: The gdb backtrace bottoms out inside __strlen_avx2. Where should you look for the bug?
- In glibc’s strlen — file a bug report
- In the innermost frame only
- Walk up the backtrace to the first frame that is YOUR code, and inspect its variables
- Nowhere — backtraces are unreliable after SIGSEGV
Show answer
Walk up the backtrace to the first frame that is YOUR code, and inspect its variables — Library code is almost never the culprit — it faithfully crashed on the garbage we fed it (a NULL pointer). "frame N" up to your own code, then print the local variables: that is the standard segfault autopsy.
valgrind: the memory auditor
Valgrind's memcheck runs your unmodified binary in a CPU emulator, tracking every byte of heap. Leaks, use of uninitialized values, bad frees — nothing escapes (at ~20× slowdown):
#include <stdlib.h>
#include <string.h>
char *dup_string(const char *s) {
char *copy = malloc(strlen(s) + 1);
strcpy(copy, s);
return copy; /* caller owns this... supposedly */
}
int main(void) {
for (int i = 0; i < 3; i++) {
char *c = dup_string("hello");
(void)c; /* ...but nobody frees it */
}
return 0;
}$ gcc -g leak.c -o leak $ valgrind --leak-check=full ./leak ==41337== Memcheck, a memory error detector ==41337== Command: ./leak ==41337== ==41337== HEAP SUMMARY: ==41337== in use at exit: 18 bytes in 3 blocks ==41337== total heap usage: 3 allocs, 0 frees, 18 bytes allocated ==41337== ==41337== 18 bytes in 3 blocks are definitely lost in loss record 1 of 1 ==41337== at 0x48468F3: malloc (in vgpreload_memcheck-amd64-linux.so) ==41337== by 0x109162: dup_string (leak.c:5) ==41337== by 0x1091A6: main (leak.c:12) ==41337== ==41337== LEAK SUMMARY: ==41337== definitely lost: 18 bytes in 3 blocks ==41337== indirectly lost: 0 bytes in 0 blocks ==41337== possibly lost: 0 bytes in 0 blocks ==41337== ERROR SUMMARY: 1 errors from 1 contexts
Read the verdicts like a coroner: definitely lost = leaked, no pointer to it remains — fix these. still reachable = a global still points at it at exit — untidy but usually harmless. The stack trace shows the allocation site: valgrind tells you where the leaked memory was born, and your job is to find where it should have died.
🧠 Checkpoint: Valgrind reports definitely lost: 18 bytes in 3 blocks allocated at dup_string (leak.c:5). What does the location tell you?
- Line 5 is where you must add free()
- Where the leaked memory was allocated — you still must find where it should have been freed
- The exact line where the pointer was overwritten
- That malloc itself is buggy
Show answer
Where the leaked memory was allocated — you still must find where it should have been freed — Valgrind can only know the birthplace of the block. The fix belongs wherever ownership ends — here, main’s loop should free(c) each iteration. Freeing inside dup_string would return dangling memory!
Sanitizers: valgrind's speed-demon cousins
AddressSanitizer (ASan) and UndefinedBehaviorSanitizer (UBSan) are compiled into the binary — only ~2× slowdown, and they catch things valgrind can't (stack overflows! signed overflow!). This is the flag you met in the gcc-flags lesson, now in action on a use-after-free:
#include <stdlib.h>
int main(void) {
int *p = malloc(4 * sizeof(int));
p[0] = 42;
free(p);
return p[0]; /* reading freed memory: UB */
}$ gcc -g -fsanitize=address,undefined uaf.c -o uaf
$ ./uaf
=================================================================
==5124==ERROR: AddressSanitizer: heap-use-after-free on address
0x604000000010 at pc 0x55e2f52412a1 bp 0x7ffc3c3f5b20
READ of size 4 at 0x604000000010 thread T0
#0 0x55e2f52412a0 in main uaf.c:7
freed by thread T0 here:
#0 0x7f1c2bcb6642 in free
#1 0x55e2f5241264 in main uaf.c:6
previously allocated by thread T0 here:
#0 0x7f1c2bcb78a7 in malloc
#1 0x55e2f5241234 in main uaf.c:4
SUMMARY: AddressSanitizer: heap-use-after-free uaf.c:7 in main
# accessed at line 7, freed at line 6, born at line 4. Case closed.Three stack traces: where the bad access happened, where the memory was freed, and where it was allocated. That's usually the whole investigation, done. Run your test suite under -fsanitize=address,undefined routinely — many teams gate every merge on it.
🧠 Checkpoint: Which bug can AddressSanitizer catch that valgrind memcheck fundamentally cannot?
- heap buffer overflow
- stack buffer overflow
- memory leak
- use of uninitialized heap memory
Show answer
stack buffer overflow — ASan instruments the code at compile time, so it plants red zones around STACK arrays too. Valgrind works on unmodified binaries and can’t see stack frame layout — stack smashes sail right past it. (Uninitialized reads are the reverse: memcheck’s specialty, not ASan’s.)
Before it even runs: static analysis
GCC's -fanalyzer explores paths through your code at compile time and narrates bugs like a detective novel — double frees, NULL derefs, leaks — complete with a numbered path of events. clang-tidy does the same from the Clang world, plus style checks. They produce false positives; treat them as a very sharp code review, not a verdict. And when the mystery is "what is my program even doing with the OS?", strace ./app prints every syscall — the tool of choice for "why can't it find my config file?" (answer: it's opening a path you didn't expect).
Which tool for which bug?
▶ This spot has an interactive flow widget — open the interactive lesson to play with it.
| symptom | reach for |
|---|---|
| segfault / crash | ASan first (best report), gdb for interactive digging, core dump for post-mortem |
| memory leak | valgrind --leak-check=full, or ASan's leak checker (on by default at exit) |
| heisenbug / wrong values at -O2 only | UBSan — it's almost always undefined behavior |
| wrong answer, no crash | gdb breakpoints + watch, strategic stderr printf |
| weird interaction with OS/files | strace |
| bug not written yet | -Wall -Wextra, -fanalyzer, clang-tidy |
Don't stack them: ASan and valgrind fight over the same memory tricks — run one or the other, never both on the same binary. And keep a plain -g -Og build around: sanitizer binaries are for hunting, not shipping.
Practice
▶ This spot has an interactive editor widget — open the interactive lesson to play with it.
One tool family remains — the linker's — and with it, the final lesson of the course: building and using libraries.
▶ Practice this lesson interactively (with live gcc)