Lesson 4 of 6
Valgrind runs your program on a simulated CPU, watching every memory access. Unlike the sanitizers, it needs no special build — it works on the binary you already have.
valgrind --leak-check=full ./your_program
It is Linux-first. On macOS support is unreliable on recent versions; use ASan there.
A real run
The same off-by-one program from lesson 11.2, built normally with just -g:
#include <iostream>
int sum_range(const int* values, int count) {
int total = 0;
for (int i = 0; i <= count; ++i) { // BUG: <= reads one past the end
total += values[i];
}
return total;
}
int main() {
int* data = new int[5];
for (int i = 0; i < 5; ++i) {
data[i] = i + 1;
}
std::cout << "sum: " << sum_range(data, 5) << "\n";
delete[] data;
return 0;
}
Running it under Valgrind produces:
==1653== Memcheck, a memory error detector
==1653== Command: ./buggy
==1653==
==1653== Invalid read of size 4
==1653== at 0x1091DC: sum_range(int const*, int) (buggy.cpp:6)
==1653== by 0x109268: main (buggy.cpp:17)
==1653== Address 0x4e21094 is 0 bytes after a block of size 20 alloc'd
==1653== at 0x48485C3: operator new[](unsigned long)
==1653== by 0x109208: main (buggy.cpp:12)
==1653==
sum: 15
==1653==
==1653== HEAP SUMMARY:
==1653== in use at exit: 0 bytes in 0 blocks
==1653== total heap usage: 3 allocs, 3 frees, 77,844 bytes allocated
==1653==
==1653== All heap blocks were freed -- no leaks are possible
==1653== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
The same four facts as ASan, in a different layout: an Invalid read of size 4 at line 6, reached
from line 17, 0 bytes after a block of size 20 allocated at line 12.
Note sum: 15 in the middle. Valgrind reports and lets the program continue, so you see the
correct-looking output alongside the error that produced it.
The ==1653== prefix is the process ID, on every line so Valgrind's output can be separated from
your program's.
Reading the leak summary
==1653== HEAP SUMMARY:
==1653== in use at exit: 0 bytes in 0 blocks
==1653== total heap usage: 3 allocs, 3 frees, 77,844 bytes allocated
==1653== All heap blocks were freed -- no leaks are possible
3 allocs, 3 frees is the line to check. When they differ, the difference is what leaked — and
with --leak-check=full Valgrind prints the allocation stack for each one.
Valgrind sorts leaks into categories, and the distinction is useful:
- Definitely lost — no pointer to it anywhere. A real leak; fix it.
- Indirectly lost — reachable only through something definitely lost, such as the children of a leaked tree node. Fixing the parent usually fixes these.
- Possibly lost — a pointer exists but points into the middle of the block, not its start. Sometimes a real leak, sometimes an interior pointer by design.
- Still reachable — a pointer still exists at exit. Often a global or a cache; not usually a bug, but worth a glance.
Start with definitely lost.
Valgrind versus AddressSanitizer
Both find overlapping bugs. The differences matter when choosing:
| | Valgrind | ASan | |---|---|---| | Rebuild needed | no | yes | | Slowdown | 10–50× | ~2× | | Uninitialised reads | yes | no | | Stack overflows | limited | yes | | Platform | Linux mainly | Linux, macOS, Windows | | Works on third-party binaries | yes | no |
Two rows decide most cases.
ASan is far faster, so it is what you run in CI on every commit.
Valgrind finds uninitialised reads, which ASan does not. That is a real gap — lesson 3.1's garbage variable is invisible to ASan and caught by Valgrind:
#include <iostream>
int main() {
int uninitialised; // never assigned
// Reading it is undefined behaviour. ASan does not check this;
// Valgrind reports "Conditional jump or move depends on uninitialised value".
if (uninitialised > 0) {
std::cout << "positive\n";
} else {
std::cout << "not positive\n";
}
return 0;
}
Note the program compiles, runs, and prints one of the two branches. Which one is not defined.
(Clang has -fsanitize=memory for this, but it requires every library to be rebuilt with it,
which is rarely practical.)
The practical answer: ASan for continuous testing, Valgrind when you are stuck — especially when a bug smells like uninitialised data, or when you cannot rebuild the binary.
Other Valgrind tools
Memcheck is the default. Valgrind is a framework:
valgrind --tool=callgrind ./program # call counts and cost per function
valgrind --tool=cachegrind ./program # cache hit and miss simulation
valgrind --tool=helgrind ./program # data races in threaded code
valgrind --tool=massif ./program # heap usage over time
Callgrind and Cachegrind reappear in Module 22 on performance; Helgrind in Module 21 on concurrency.
Useful flags
valgrind --leak-check=full \
--show-leak-kinds=all \
--track-origins=yes \
--error-exitcode=1 \
./program
--leak-check=full— a stack for every leak, not just a count.--show-leak-kinds=all— include still reachable, which is hidden by default.--track-origins=yes— for uninitialised values, say where the bad value came from. Slower, and usually worth it.--error-exitcode=1— exit non-zero on any finding, so CI fails.
That last one is what makes Valgrind usable in an automated build: without it, Valgrind exits 0 even when it reported errors, and the pipeline goes green.
Try it
Write a program with three separate problems: a leak, an invalid read past an array, and a read of an uninitialised variable. Run it under Valgrind and then under ASan, and note which finds which.
Checkpoint
- Valgrind needs no rebuild — it runs the binary you have.
valgrind --leak-check=full ./programis the everyday command.- Reports give the operation, the line, and the allocation the memory belongs to.
- Leaks are sorted: definitely lost is the category to fix first.
- Valgrind is 10–50× slower than native; ASan is roughly 2×.
- Valgrind finds uninitialised reads, which ASan does not.
- Use ASan in CI, Valgrind when stuck or when you cannot rebuild.
- Add
--error-exitcode=1or an automated build will pass despite findings.
Questions about this lesson
Log in to ask a question.