Memory Errors and Their Diagnosis
C will not stop you making any of these mistakes, and none of them reliably crashes at the point of the error. What makes heap programming tractable is not care alone but tooling — and the tools are good enough that not using them is the real mistake.
- Recognize the five classes of memory error and the symptom each produces.
- Read an AddressSanitizer report and locate the defect from it.
- Use Valgrind when recompiling is not an option.
- Explain why a memory bug's symptom usually appears far from its cause.
- Adopt the habits that prevent each class rather than detecting it.
1The five classes
| Error | What it is | Typical symptom |
|---|---|---|
| Leak | Allocated, never freed | Memory grows over hours; eventual failure |
| Use after free | Access through a pointer after free | Corrupt data, or a crash much later |
| Double free | free called twice on one block | Allocator abort, or heap corruption |
| Overrun | Read or write past the end of a block | A neighbouring value changes silently |
| Invalid free | free on a stack or interior pointer | Immediate abort |
What unites them is action at a distance. The line that does the damage and the line that fails are usually far apart, often in unrelated code. A program can write past a buffer for months before a slightly different allocation pattern makes the corruption land somewhere fatal.
This is why debugging memory errors by reading code and adding printf is so unproductive, and why the tools in section 3 are not optional extras.
2Each class in detail
Leak
void process(void)
{
char *buffer = malloc(1024);
if (something_failed()) {
return; /* leaked: early return skips the free */
}
free(buffer);
}Leaks on the error path are by far the most common kind, because the happy path gets tested and the error path does not. A short-lived program can survive leaking — the operating system reclaims everything at exit — but a server or a long-running tool cannot. Week 27's goto cleanup idiom exists precisely to give every path one exit.
Use after free
free(node);
printf("%d\n", node->value); /* the block may already be reused */After free, the block belongs to the allocator, which may hand it to the next malloc. Reading gives stale or foreign data; writing corrupts whatever now lives there. It frequently appears to work, because the allocator has not yet reused the block — which is why it survives testing.
The defence is a habit: set the pointer to NULL immediately after freeing. A later use then crashes instantly at the guilty line instead of corrupting something.
Double free
free(p);
…
free(p); /* corrupts the allocator's bookkeeping */Usually arises when two pieces of code each believe they own a block — the ownership question from week 19, left unanswered. Modern allocators detect the simple case and abort with a message; the subtle cases silently corrupt the heap. The NULL habit fixes this too, since free(NULL) is defined to do nothing.
Overrun
int *a = malloc(10 * sizeof *a);
a[10] = 0; /* one past the end */Identical in nature to week 12's array overflow, but on the heap the neighbour is usually the allocator's own metadata — the block header recording sizes and links. Corrupting it means the next unrelated malloc or free crashes, with a backtrace pointing at innocent code.
Invalid free
int local = 5;
free(&local); /* not from malloc */
char *p = malloc(100);
free(p + 10); /* not the start of the block */Only a pointer returned by malloc, calloc, or realloc may be freed, and only the exact value returned. Keep the original pointer; walk a copy.
3The tools
AddressSanitizer
gcc -std=c17 -Wall -Wextra -g -fsanitize=address -o prog prog.c
./progA compiler instrumentation that checks every memory access. It catches overruns, use-after-free, double free, invalid free, and — with LeakSanitizer, which is on by default on Linux — leaks at exit. Costs roughly 2× in speed and 3× in memory, which is irrelevant during development.
Use it for every build while you are writing code. It is the single highest-value flag in this course.
Valgrind
valgrind --leak-check=full --track-origins=yes ./progRuns an unmodified binary under emulation, so no recompilation is needed — which matters when you did not build the program. It also detects uninitialized reads, which AddressSanitizer does not; --track-origins=yes then tells you where the uninitialized value came from. The cost is 10–50× slowdown.
| AddressSanitizer | Valgrind | |
|---|---|---|
| Recompilation | Required | Not required |
| Slowdown | ~2× | 10–50× |
| Overruns, use-after-free | Yes | Yes |
| Leaks | Yes | Yes, in more detail |
| Uninitialized reads | No (use MemorySanitizer) | Yes |
| Stack errors | Yes | Limited |
They are complements. Develop under AddressSanitizer; run Valgrind before a release, and when you need to inspect a binary you cannot rebuild.
Sanitizers do not combine freely. -fsanitize=address and -fsanitize=memory cannot be used together. -fsanitize=address,undefined does work, and is a good default — the undefined-behavior sanitizer catches the signed overflow and shift errors from weeks 4 and 7. Week 35 puts both into a continuous integration configuration.
4Reading a report
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
#0 0x55e in main leaky.c:42 <- where it was USED
#1 0x7f2 in __libc_start_main
0x602000000010 is located 0 bytes inside of 40-byte region
freed by thread T0 here:
#0 0x7f8 in free
#1 0x55d in main leaky.c:40 <- where it was FREED
previously allocated by thread T0 here:
#0 0x7f9 in malloc
#1 0x55c in main leaky.c:36 <- where it was ALLOCATEDRead it in four parts:
- The error type.
heap-use-after-free,heap-buffer-overflow,stack-buffer-overflow,double-free— each names the class from section 1. - The access.
READ of size 4tells you what the program was doing. - Three stack traces. Where it was used, where it was freed, where it was allocated. Those three lines are usually the entire diagnosis.
- The offset.
0 bytes inside of 40-byte region, or4 bytes to the right of, which pinpoints an off-by-one immediately.
5Worked example: five bugs, seeded and found
This takes the growable array from week 19 and introduces each defect behind a compile-time switch, so you can enable exactly one and watch the tool describe it.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
/* Build with -DBUG=1 … -DBUG=5 to enable one defect at a time.
Build with no -DBUG for the clean version. */
#ifndef BUG
#define BUG 0
#endif
typedef struct {
int *data;
size_t count;
size_t capacity;
} IntArray;
static bool array_init(IntArray *a, size_t capacity)
{
a->data = malloc(capacity * sizeof *a->data);
if (a->data == NULL) {
return false;
}
a->count = 0;
a->capacity = capacity;
return true;
}
static void array_destroy(IntArray *a)
{
free(a->data);
a->data = NULL; /* the habit that prevents bugs 2 and 3 */
a->count = a->capacity = 0;
}
static bool array_push(IntArray *a, int value)
{
if (a->count == a->capacity) {
size_t bigger_capacity = a->capacity * 2;
int *bigger = realloc(a->data, bigger_capacity * sizeof *bigger);
if (bigger == NULL) {
return false;
}
a->data = bigger;
a->capacity = bigger_capacity;
}
a->data[a->count++] = value;
return true;
}
int main(void)
{
printf("built with BUG=%d\n\n", BUG);
IntArray a;
if (!array_init(&a, 4)) {
return EXIT_FAILURE;
}
for (int i = 1; i <= 6; i++) {
array_push(&a, i * 10);
}
printf("array holds %zu of %zu: ", a.count, a.capacity);
for (size_t i = 0; i < a.count; i++) printf("%d ", a.data[i]);
putchar('\n');
#if BUG == 1
/* LEAK: allocate and forget. */
char *forgotten = malloc(64);
strcpy(forgotten, "nobody will free me");
printf("leaked: %s\n", forgotten);
/* no free */
#elif BUG == 2
/* USE AFTER FREE. */
free(a.data);
printf("after free, a.data[0] = %d\n", a.data[0]);
a.data = NULL;
#elif BUG == 3
/* DOUBLE FREE. */
free(a.data);
free(a.data);
a.data = NULL;
#elif BUG == 4
/* HEAP OVERRUN: one element past the end. */
printf("writing a.data[%zu] with capacity %zu\n", a.capacity, a.capacity);
a.data[a.capacity] = 999;
#elif BUG == 5
/* INVALID FREE: not the pointer malloc returned. */
free(a.data + 1);
a.data = NULL;
#endif
array_destroy(&a);
puts("finished");
return EXIT_SUCCESS;
}Run each one
for b in 0 1 2 3 4 5; do
gcc -std=c17 -Wall -Wextra -g -fsanitize=address -DBUG=$b -o bug$b bugs.c
done
./bug0 # clean
./bug1 # leak
./bug2 # use after free
./bug3 # double free
./bug4 # overrun
./bug5 # invalid freeWhat each reports
BUG=1 — leak. The program finishes normally, then:
==1234==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 64 byte(s) in 1 object(s) allocated from:
#1 0x... in main bugs.c:76
SUMMARY: AddressSanitizer: 64 byte(s) leaked in 1 allocation(s).Note that the output appears only at exit and the exit status becomes non-zero — which is how a leak fails a build in week 35's continuous integration.
BUG=2 — use after free. Three traces: used at line 82, freed at line 81, allocated at line 22. Without the sanitizer this program prints 10 quite happily, because the block has not been reused yet.
BUG=3 — double free. attempting double-free on 0x..., with the free site and the original allocation both named. Without the sanitizer, glibc prints free(): double free detected in tcache 2 and aborts — useful, but with no indication of where.
BUG=4 — overrun. The most instructive one:
==1234==ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 4 at 0x602000000030 thread T0
#0 0x... in main bugs.c:95
0x602000000030 is located 0 bytes to the right of 32-byte region
allocated by thread T0 here:
#1 0x... in array_push bugs.c:44"0 bytes to the right of a 32-byte region" is an off-by-one stated in the clearest possible terms. Compare with the unsanitized build, which prints finished and exits successfully while having corrupted the allocator's metadata.
BUG=5 — invalid free. attempting free on address which was not malloc()-ed, plus the allocation site of the real block.
The same bugs under Valgrind
gcc -std=c17 -Wall -Wextra -g -DBUG=2 -o bug2_plain bugs.c
valgrind --leak-check=full ./bug2_plain==9876== Invalid read of size 4
==9876== at 0x...: main (bugs.c:82)
==9876== Address 0x4a4b040 is 0 bytes inside a block of size 32 free'd
==9876== at 0x...: free
==9876== by 0x...: main (bugs.c:81)
==9876== Block was alloc'd at
==9876== by 0x...: array_push (bugs.c:44)The same three facts in different words — and obtained without rebuilding with a special flag, which is what makes Valgrind valuable on binaries you did not compile.
One Valgrind catches that AddressSanitizer does not
int *p = malloc(4 * sizeof *p);
printf("%d\n", p[0]); /* reading uninitialized heap memory */
free(p);==9876== Conditional jump or move depends on uninitialised value(s)
==9876== Uninitialised value was created by a heap allocation
==9876== at 0x...: mallocAddressSanitizer reports nothing here — the memory is legitimately owned, merely unwritten. This is why the two tools are complements rather than alternatives, and why calloc is worth preferring when you cannot guarantee every byte gets written.
6Habits that prevent rather than detect
| Habit | Prevents |
|---|---|
Set the pointer to NULL right after free | Use after free, double free |
Write the free immediately after writing the malloc | Leaks |
One exit path per function, via goto cleanup | Leaks on error paths — week 27 |
Paired create/destroy functions | Ambiguous ownership, double free |
Never free a pointer you did not receive from malloc | Invalid free |
calloc when every byte will not be written | Uninitialized reads |
Build with -fsanitize=address,undefined by default | All of the above, detected on the day you write them |
7Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
Early return before the free | Leak on the error path only | Single cleanup path — week 27. |
| Using a pointer after freeing it | Stale data, or corruption | Assign NULL immediately after free. |
| Two owners of one block | Double free | Decide ownership once; document it. |
| Freeing an interior pointer | Immediate abort | Keep the original; walk a copy. |
| Assuming no crash means no bug | Corruption survives into production | Run under a sanitizer before believing it works. |
| Testing only the happy path | Error-path leaks never exercised | Test failure injection — week 35. |
Using a pointer into a block after realloc | Dangling — the block may have moved | Recompute from the new base pointer. |
| Shipping a release build never run under Valgrind | Leaks found by users | Make it part of the release checklist. |
8Check yourself
Why does a memory bug's symptom usually appear far from its cause?
Because the damaging operation is legal at the machine level and produces no immediate failure. An overrun quietly modifies a neighbouring object or the allocator's metadata; a use-after-free reads a block that has not yet been reused. The failure surfaces only when something else touches the corrupted state, which can be much later and in unrelated code.
What does setting a pointer to NULL after free buy you?
It converts two silent bugs into immediate, obvious ones. A later use dereferences NULL and crashes at the guilty line instead of reading a reused block; a later free becomes free(NULL), which is defined to do nothing. It costs one assignment.
When would you reach for Valgrind rather than AddressSanitizer?
When you cannot rebuild the binary, and when you need to detect reads of uninitialized memory — which AddressSanitizer does not catch, because the memory is legitimately owned. Valgrind's --track-origins=yes also reports where an uninitialized value came from. The trade-off is a slowdown of 10–50× instead of about 2×.
An AddressSanitizer report says "4 bytes to the right of a 32-byte region". What does that tell you?
That the program accessed memory just past the end of a 32-byte allocation — an off-by-one or a loop bound that is too large. The report also names where that block was allocated, so the array and the faulty access are both identified without reading any code.
Why is a leak in a short-lived command-line tool less urgent than one in a server?
Because the operating system reclaims the whole address space when a process exits, so a tool that runs for a second and leaks a kilobyte causes no harm. A server runs for weeks and repeats the leaking path millions of times, so the same defect grows without bound until allocation fails. The bug is identical; the consequence is not.
9Where this leads
Week 21 turns to structures, which let you group related data into one object — and which immediately raise a harder version of this week's question, because a structure can own allocations of its own. The create/destroy pattern from week 19 becomes the standard answer, and week 33 makes it an opaque handle where the caller cannot see, or corrupt, the internals at all.