The bug that crashes somewhere else
In the first two guides of this rung you learned to stop a program with a breakpoint, read a backtrace, and pick apart a core dump after a crash. Those tools are superb when the crash points at the culprit. But a whole family of memory bugs has a cruel habit: the line that does the damage is not the line that crashes. You write one byte past the end of a heap block, nothing happens, the program runs on happily, and ten function calls later `malloc()` returns garbage or `free()` aborts — because you quietly stomped on the bookkeeping the allocator keeps next to your block. The segfault, if you even get one, is the symptom; the real wound is somewhere upstream.
This is the symptom-versus-root-cause gap, and it is exactly where a plain debugger struggles. By the time the program dies, the evidence is cold: the corrupting write finished long ago and left no trace at the scene of the crash. You can step through with a debugger forever and never see it, because nothing looks wrong at the moment of impact. What you need is a tool that watches the crime as it is committed — that checks every single memory access against what your program is actually allowed to touch.
What Valgrind actually does
Valgrind is not a debugger that you drive; it is a virtual CPU. When you run `valgrind ./a.out`, your real machine code is not executed directly. Instead Valgrind translates each block of your instructions, on the fly, into instrumented instructions that it runs itself — adding a little check around every load, store, allocation, and free. Its most-used tool, Memcheck, is the one for memory bugs, and it is the default. You don't recompile your program for it; you run your ordinary executable under the simulator.
How does it know a read is illegal? Memcheck keeps a shadow of your program's memory. For every byte your program owns, it tracks two extra facts: is this byte addressable (does your program have a right to touch it at all?) and is it defined (has a real value been written, or is it still uninitialized garbage?). It updates this shadow as you go: `malloc()` marks fresh bytes addressable-but-undefined, writing into them marks them defined, and `free()` marks them un-addressable again. Then on every access it asks the shadow first. A load from an un-addressable byte is an invalid read; a branch that depends on an undefined byte is a use of uninitialized memory. That bookkeeping is why Memcheck can catch the crime in the act instead of reading the corpse.
Reading a Memcheck report
The single most important habit is to compile with debug info first: `gcc -g -O0 main.c -o a.out`. The `-g` gives Memcheck the line numbers and variable names it needs (the same debug info gdb wanted in guide 1), and keeping optimization low at `-O0` means the reported line is the line you actually wrote. Then run `valgrind ./a.out` and read the report from the bottom up — the last lines summarise leaks, the body of the report describes each illegal access with a backtrace.
// bug.c : read one past the end of a 4-int block
int *a = malloc(4 * sizeof(int)); // a[0..3] are valid
if (a == NULL) return 1; // always check malloc
for (int i = 0; i <= 4; i++) // BUG: i == 4 is out of bounds
a[i] = i; // a[4] writes past the block
free(a);
$ gcc -g -O0 bug.c -o bug
$ valgrind ./bug
==12345== Invalid write of size 4
==12345== at 0x4005AA: main (bug.c:5)
==12345== Address 0x5204050 is 0 bytes after a block of size 16 alloc'd
==12345== at 0x4838DEF: malloc (vg_replace_malloc.c:...)
==12345== by 0x40059E: main (bug.c:2)Read that report like a sentence. "Invalid write of size 4" tells you the kind of crime and how many bytes; the `at ... main (bug.c:5)` line is the smoking gun — the exact source line that did it. The phrase "0 bytes after a block of size 16" is Memcheck telling you the geography: you wrote immediately past a 16-byte allocation (our four `int`s), which is a textbook heap buffer overflow caused by the classic off-by-one of `i <= 4` instead of `i < 4`. And it even shows the second backtrace, the `alloc'd ... by main (bug.c:2)` lines, pointing at where the block was born. You are handed both the scene of the crime and the block's birthplace.
Leaks, use-after-free, and double-free
Memcheck's other great strength is catching the lifetime bugs that never crash at all. A memory leak is the quietest of these: you `malloc()` a block, lose the last pointer to it, and never `free()` it. The program keeps working, it just slowly bloats. When your program exits, Memcheck walks the heap and reports any blocks still reachable by no live pointer. Run it with `valgrind --leak-check=full ./a.out` and it groups the leaks and gives each one the allocation backtrace — so you learn not just that 16 bytes leaked, but the exact `malloc()` call that produced the orphaned block.
The two more dangerous lifetime bugs are about touching memory you have already given back. A use-after-free is when you `free(p)` and then read or write `*p` anyway — `p` is now a dangling pointer, aimed at memory the allocator may have already handed to someone else. A double-free is calling `free(p)` twice, which corrupts the allocator's internal free list and tends to blow up much later. Both are undefined behavior in C, and both are nearly invisible to a plain debugger. Memcheck catches them precisely, because the moment you `free()` a block it marks those bytes un-addressable, so the very next `*p` trips the alarm with a backtrace at the offending line.
A workflow, and its honest limits
- Reproduce the bug with the smallest input you can — a slow simulator wants a small case.
- Recompile with `gcc -g -O0` so backtraces carry your real line numbers and variable names.
- Run `valgrind --leak-check=full ./a.out`, passing your program's own arguments after it.
- Fix the FIRST error it reports, then re-run — early corruption often cascades into a flood of later, fake-looking errors.
- Repeat until Memcheck prints "All heap blocks were freed -- no leaks are possible" and zero errors.
Now the honest limits, because a tool you trust blindly is dangerous. Memcheck is brilliant on the heap but largely blind to stack and global overruns — it cannot see you overrun a local array `char buf[8]` the way it sees a heap block, because those bytes are not handed out by `malloc()`. It only reports bugs on the path your test actually exercised; a use-after-free down a branch you never ran stays hidden. And it finds the first illegal access, which is the symptom's true cause only if that access really is the root — sometimes the wrong value was computed correctly-but-from-bad-data several steps earlier. Valgrind narrows the search enormously; it does not think for you.
This is also why the next guide reaches for the sanitizers. AddressSanitizer is compiled into your program with `-fsanitize=address`, so it does catch stack and global overruns that Memcheck misses, and it runs only a few times slower instead of tens of times. The trade is that you must rebuild for it, whereas Valgrind runs any binary as-is. They are complementary, not rivals: reach for Valgrind when you can't recompile or want its deep leak analysis, and for the sanitizers when you can rebuild and want speed plus stack coverage. Either way, you have crossed from guessing where memory broke to being told, with a line number.