Why this matters
The worst C bugs do not crash. Writing one element past an array, reading memory after freeing it, or using a variable that was never set usually works on your machine, on your input, today. Then it fails for a user with a slightly longer name, or after an unrelated change moves things around in memory, or it becomes a security hole that lets an attacker run code. You cannot find these bugs by testing outputs alone, because the output is often correct. You need tools that watch every memory access. This lesson covers the two families you will use for the rest of your C life: valgrind, which runs your unchanged program on a simulated CPU, and the sanitizers, which the compiler builds into your program.
valgrind: a CPU that checks everything
valgrind (its default tool is called memcheck) loads your program and runs it instruction by instruction on a synthetic CPU. Alongside every byte of your memory it keeps shadow information: is this byte addressable (part of a live allocation)? is it defined (has something been written to it)? It also replaces malloc and free with versions that remember every block. That lets it report:
- reads and writes outside any heap block (invalid read/write),
- use of memory after
free(use after free), - freeing the same block twice or freeing something that was never allocated,
- decisions based on uninitialized values (conditional jump depends on uninitialised value),
- blocks never freed (leaks), with the line that allocated them.
No recompilation is needed, though -g makes reports show file names and lines. The cost is speed: programs run 20 to 50 times slower.
$ gcc -std=c17 -Wall -Wextra -g -O0 -o prog leak.c
$ valgrind --leak-check=full ./prog
==25== Memcheck, a memory error detector
==25== Invalid write of size 4
==25== at 0x1091A2: main (leak.c:9)
==25== Address 0x4a55050 is 0 bytes after a block of size 16 alloc'd
==25== at 0x4844818: malloc (vg_replace_malloc.c:446)
==25== by 0x10917A: main (leak.c:7)
==25==
9
Ada
==25== HEAP SUMMARY:
==25== in use at exit: 6 bytes in 1 blocks
==25== total heap usage: 3 allocs, 2 frees, 4,118 bytes allocated
==25==
==25== 6 bytes in 1 blocks are definitely lost in loss record 1 of 1
==25== at 0x4844818: malloc (vg_replace_malloc.c:446)
==25== by 0x1091D7: main (leak.c:11)
==25==
==25== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)
(The ==25== is the process id and differs every run.) The program printed the right answers, yet valgrind found two bugs. How to read a report:
- The first line is the kind of error and the size of the access ("size 4" is an
int). - The first stack ("at ... by ...") is where it happened. Read down until you reach your own file.
- The "Address ... is" paragraph relates the bad address to a known block: "0 bytes after a block of size 16" means just past the end; "inside a block of size 4 free'd" means use after free, followed by where it was freed and where it was allocated.
- The leak summary classifies unfreed memory. Definitely lost: no pointer to the block exists any more, a real leak. Indirectly lost: reachable only through a definitely lost block (the rest of a leaked linked list). Still reachable: a pointer still exists at exit, for example a global; usually harmless.
Useful options: --leak-check=full (details for each leak), --track-origins=yes (where an uninitialized value came from), --error-exitcode=1 (exit 1 if any error, for scripts and tests), -q (only print errors).
Uninitialized values look like this:
$ valgrind -q ./prog
==20== Conditional jump or move depends on uninitialised value(s)
==20== at 0x109145: main (uninit.c:6)
valgrind does not complain when you merely copy garbage around; it reports when garbage affects what the program does, such as an if or a system call.
AddressSanitizer: checks compiled in
AddressSanitizer (ASan) takes the opposite approach: the compiler inserts a check before every memory access, and places poisoned red zones around every heap block, every stack array and every global. Touching a red zone or a freed block stops the program with a report.
$ gcc -std=c17 -g -fsanitize=address -o prog uaf.c
$ ./prog
=================================================================
==13==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 ...
READ of size 4 at 0x502000000010 thread T0
#0 0x564825cef252 in main /work/uaf.c:9
...
0x502000000010 is located 0 bytes inside of 4-byte region [0x502000000010,0x502000000014)
freed by thread T0 here:
#0 0x7f920434c8f8 in free ...
#1 0x564825cef21b in main /work/uaf.c:8
The program it ran:
int *n = malloc(sizeof *n);
*n = 42;
free(n);
printf("%d\n", *n); /* line 9: reading freed memory */
Without ASan this prints 42, because freeing a block does not erase it. ASan programs run only about twice as slow as normal, so you can use them for your whole test suite. ASan also includes LeakSanitizer, which prints leaks when the program exits.
UndefinedBehaviorSanitizer
UBSan (-fsanitize=undefined) checks for operations the C standard calls undefined behavior (the subject of the next lesson): signed integer overflow, shifting by too many bits, out of range array indexes where the size is known, misaligned pointers, and more. It prints a runtime error line and, by default, keeps going:
$ gcc -std=c17 -g -fsanitize=undefined,address -o prog shift.c
$ ./prog
shift.c:8:22: runtime error: shift exponent 32 is too large for 32-bit type 'int'
shift.c:9:21: runtime error: index 3 out of bounds for type 'int [3]'
==26==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ff54ab0002c ...
The two sanitizers combine well: -fsanitize=address,undefined is a good default for every debug build. Add -fno-sanitize-recover=all to make UBSan stop at the first error.
Which tool when
| valgrind | ASan | UBSan | |
|---|---|---|---|
| needs recompiling | no | yes | yes |
| slowdown | 20-50x | about 2x | small |
| heap overflow, use after free | yes | yes | no |
| stack and global array overflow | no | yes | partly (known sizes) |
| uninitialized values | yes | no | no |
| leaks | yes | yes (LeakSanitizer) | no |
| integer overflow, bad shifts | no | no | yes |
You cannot run a sanitized program under valgrind; pick one per run. A good habit: develop with -fsanitize=address,undefined, and run valgrind before you call something finished, especially to catch uninitialized reads.
Common mistakes
- Believing "it printed the right answer" means "no bug". Both examples in this lesson printed correct output.
- Ignoring the first error. Like compiler errors, memory errors cascade. Fix the first one, then rerun.
- Expecting valgrind to see stack overflows. Writing past a local array is invisible to memcheck. Use ASan for that.
- Forgetting
-g. Reports then show only addresses like0x1091A2instead ofleak.c:9. - Treating "still reachable" as a leak to panic about. It means memory was still pointed to at exit. Fix "definitely lost" first.
Recap
- valgrind runs the unchanged program on a simulated CPU and reports invalid reads/writes, use after free, uninitialized decisions and leaks. Slow, thorough, heap-only for bounds.
- ASan (
-fsanitize=address) compiles in red zones and checks; fast, and catches stack and global overflows too. - UBSan (
-fsanitize=undefined) catches overflow, bad shifts and other undefined behavior. - Read reports top-down: what kind of access, where it happened, where the block came from.
Going further: run valgrind --track-origins=yes ./prog on a program with an uninitialized variable and see it point at the declaration. The ASan documentation is in the GCC manual under "Instrumentation Options" (gcc.gnu.org/onlinedocs, search for -fsanitize; man gcc is not installed in the sandbox).