C / DYNAMIC MEMORY
Memory leaks and finding them with sanitizers
Spot the pointer-losing patterns that leak heap blocks, and use -fsanitize=address to get the size, count and allocation stack of every unreachable block.
What you will learn
- A leak is a live block with no pointer left to it, not just memory you forgot to free
- Build with gcc -g -fsanitize=address to get a stack trace per leaked allocation
- Tell direct from indirect leaks to find the one owner that dropped a whole structure
- Verify a fix by re-running until the report is empty and the exit status is 0
Understanding Memory leaks and finding them with sanitizers
A leak is not simply memory you forgot to free; it is a live allocation whose last pointer you no longer have. The allocator still records the block as in use, but free needs the exact address malloc returned, so losing that address makes the block unreleasable for the rest of the process. Three moves lose it: assigning a new pointer over the old one, leaving a function before freeing a local pointer, and overwriting a struct field that owned the block. Nothing crashes when this happens, which is why leaks pass every test that only checks output.
That silence is why leak detection is a tool's job. Link with -fsanitize=address and, just before the process exits, LeakSanitizer stops the program, walks its own table of allocations that are still live, and conservatively scans the roots (globals, every thread's stack, registers, thread-local storage) for any word that looks like a pointer into each block. A block no root can reach is a direct leak; a block reachable only from another leaked block is an indirect leak. This reachability rule explains a result that surprises people: a never-freed global cache is not reported, while an identical block whose only pointer was a local that went out of scope is.
Read the report as an allocation record, not a bug location: each entry gives a size, an object count and the stack trace of the malloc call, while the mistake almost always sits on some path after that call. The object count is the most useful number, because it separates a one-time leak of a startup buffer from a per-iteration leak that grows the resident set until the OOM killer intervenes. Both deserve fixing, since a report full of harmless exit-time entries is a report nobody reads, and ASan makes that enforceable by exiting with a nonzero status while any leak remains.
<stdio.h>
<stdlib.h>
<string.h>
static long live_blocks = 0;
static void *xmalloc(size_t n)
{
void *p = malloc(n);
if (p == NULL) { perror("malloc"); exit(EXIT_FAILURE); }
live_blocks++;
return p;
}
static void xfree(void *p)
{
if (p != NULL) live_blocks--;
free(p);
}
static char *dup_string(const char *s)
{
size_t n = strlen(s) + 1;
char *copy = xmalloc(n);
memcpy(copy, s, n);
return copy;
}
int main(void)
{
char *name = dup_string("ada");
printf("name = %s, live blocks = %ld\n", name, live_blocks);
name = dup_string("grace"); /* the address of the "ada" block is gone */
printf("name = %s, live blocks = %ld\n", name, live_blocks);
xfree(name);
printf("after xfree, live blocks = %ld\n", live_blocks);
return 0;
}
A memory leak is a still-live allocation that no reachable pointer refers to any more, which is exactly the condition a leak checker tests by scanning roots at exit.
Worked examples
The early-return leak and the single-release fix
Two allocations are stranded by an error path, and the same function is rewritten so one release point covers every path.
<stdio.h>
<stdlib.h>
static int live = 0;
static void *acquire(size_t n)
{
void *p = malloc(n);
if (p == NULL) { perror("malloc"); exit(EXIT_FAILURE); }
live++;
return p;
}
static void release(void *p)
{
if (p != NULL) live--;
free(p);
}
static char *load_leaky(int ok)
{
char *header = acquire(16);
char *body = acquire(64);
if (!ok)
return NULL; /* header and body are both stranded */
release(header);
return body;
}
static char *load_clean(int ok)
{
char *header = acquire(16);
char *body = acquire(64);
char *result = NULL;
if (ok) {
result = body; /* ownership moves to the caller */
body = NULL;
}
release(header);
release(body); /* release(NULL) changes nothing */
return result;
}
int main(void)
{
int before;
before = live;
release(load_leaky(0));
printf("load_leaky(0) stranded %d block(s)\n", live - before);
before = live;
release(load_clean(0));
printf("load_clean(0) stranded %d block(s)\n", live - before);
before = live;
release(load_clean(1));
printf("load_clean(1) stranded %d block(s)\n", live - before);
printf("live blocks at exit: %d\n", live);
return 0;
}
Example explained
Line 1if (!ok) return NULL; leaves load_leaky without touching header or body, so both addresses die with the frame.
Line 2load_clean sets body = NULL once ownership moves out, which makes the single release(body) at the bottom correct on the failure path and a no-op on the success path.
Line 3The exit total of 2 is what LeakSanitizer would print as two Direct leak entries whose traces point at the two acquire calls inside load_leaky.
Why a never-freed global is not reported
Two identical 32-byte blocks are never freed, yet only the one with no surviving pointer counts as a leak.
<stdio.h>
<stdlib.h>
<string.h>
char *cache; /* a global: LeakSanitizer scans it as a root */
static void fill_cache(void)
{
cache = malloc(32);
if (cache != NULL)
strcpy(cache, "cached");
}
static void drop_a_block(void)
{
char *tmp = malloc(32);
if (tmp == NULL)
return;
strcpy(tmp, "temporary");
printf("%s\n", tmp);
} /* tmp dies here; nothing points at the block */
int main(void)
{
fill_cache();
drop_a_block();
if (cache != NULL)
printf("%s\n", cache);
return 0;
}
Example explained
Line 1Neither malloc has a matching free, so "did you call free" cannot be the rule the checker applies.
Line 2cache is a global, so the root scan finds a pointer into the first block and LeakSanitizer stays quiet about it.
Line 3Compile with gcc -g -fsanitize=address ex.c && ./a.out: stdout stays the two lines above, and stderr gains "Direct leak of 32 byte(s) in 1 object(s)" with drop_a_block's malloc at the top of the trace.
Line 4Valgrind draws the same line with different words, calling one block definitely lost and the other still reachable.
Direct versus indirect leaks in a linked list
Dropping the head pointer of a three-node list leaks one block directly and two indirectly.
<stdio.h>
<stdlib.h>
struct node {
int v;
struct node *next;
};
static struct node *push(struct node *head, int v)
{
struct node *n = malloc(sizeof *n);
if (n == NULL) { perror("malloc"); exit(EXIT_FAILURE); }
n->v = v;
n->next = head;
return n;
}
static int length(const struct node *n)
{
int k = 0;
for (; n != NULL; n = n->next)
k++;
return k;
}
static void destroy(struct node *n)
{
while (n != NULL) {
struct node *next = n->next;
free(n);
n = next;
}
}
int main(void)
{
struct node *a = NULL;
struct node *b = NULL;
int i;
for (i = 0; i < 3; i++)
a = push(a, i);
printf("list a length = %d\n", length(a));
a = NULL; /* the only pointer to the chain is gone */
printf("list a length after losing the head = %d\n", length(a));
for (i = 0; i < 3; i++)
b = push(b, i);
destroy(b);
b = NULL;
printf("list b length after destroy = %d\n", length(b));
return 0;
}
Example explained
Line 1a = NULL does not touch the heap: three nodes are still allocated, and now no root reaches any of them.
Line 2LeakSanitizer reports the former head as "Direct leak of 16 byte(s) in 1 object(s)" on a typical 64-bit build, and the two nodes behind it as "Indirect leak of 32 byte(s) in 2 object(s)".
Line 3An indirect entry is a block reachable only from another leaked block, so chasing the direct leak first, by walking the chain the way destroy does, clears all three at once.
Line 4free(a) on its own would not have helped: it releases one node and loses that node's next field along with it.
Important notes
LeakSanitizer only runs at normal termination, so a program stopped by Ctrl-C, a signal, abort() or _exit() prints nothing; give long-running code a clean shutdown path, or call __lsan_do_recoverable_leak_check() from <sanitizer/lsan_interface.h> at a moment you pick.
An ASan build is roughly twice as slow and uses several times the memory, and it only sees allocations that pass through malloc, so a pool that carves objects out of one big block hides its internal leaks and appears as a single leaked chunk.
Common mistakes
Reusing one pointer variable for a second allocation, as in p = malloc(...) twice with no free between: the first address is gone for good, and the leak report points at the first, innocent-looking malloc line rather than the assignment that lost it.
Freeing a struct while its members are still allocated: free(p) releases the struct and turns p->name into a block nothing points at, reported on its own line and growing with every object the program processes.
Treating the report's stack trace as the location of the bug; it is the allocation site, so hunting for a mistake there wastes time when the dropped pointer is in a caller or on an error path further down.
Try it yourself
Change, predict, then run
Take the early-return example, add a third acquire() call to load_leaky, then rewrite that function with one cleanup section at the bottom so the program prints 0 stranded blocks for both ok = 0 and ok = 1 and 0 live blocks at exit.
Open the C workspaceCheck your understanding
A program allocates 1 KB and keeps the only pointer in a global variable, then allocates another 1 KB inside a helper whose local pointer goes out of scope. Neither block is ever freed. Built with -fsanitize=address, what does the report at exit contain?
- Two leaks, because free was never called on either block
- Nothing, because the process is about to exit and the kernel reclaims the whole address space
- One leak, the block whose only pointer was the dead local, because nothing reachable still points at it
- One leak, the global's block, because memory reached from globals is never released automatically
Show answer
LeakSanitizer classifies by reachability at exit rather than by whether free ran: the root scan finds the pointer sitting in the global and stays quiet about that block, while the helper's block has no pointer anywhere and is reported with its malloc call site. The first option is tempting because both blocks really are un-freed, but un-freed and unreachable are different conditions, which is why valgrind labels the global's block still reachable instead of definitely lost.