05 · Memory Management Deep Dive¶
Level 2 introduced malloc and free.
This module goes one level deeper: where in memory your variables
actually live, what realloc really does under the hood, and how to
systematically catch the bugs — leaks, use-after-free, double-free — that
don't always crash the moment they happen. These are the exact bugs that
show up hardest to debug in Module 10's
multi-threaded server, where a bad free can corrupt state used by a
completely different thread.
Four regions, one address space¶
A running C program's memory is divided into regions with different lifetimes:
// layout.c -- where different kinds of variables actually live
#include <stdio.h>
#include <stdlib.h>
int global_var = 100; // data segment (initialized global)
static int static_var; // BSS segment (zero-initialized)
void show_addresses(void) {
int local_var = 1; // stack
int *heap_var = malloc(sizeof(int));
*heap_var = 1;
printf("global_var (data): %p\n", (void *)&global_var);
printf("static_var (bss): %p\n", (void *)&static_var);
printf("local_var (stack): %p\n", (void *)&local_var);
printf("heap_var (heap): %p\n", (void *)heap_var);
free(heap_var);
}
int main(void) {
show_addresses();
return 0;
}
global_var (data): 0x1007dc000
static_var (bss): 0x1007dc004
local_var (stack): 0x16f62aa5c
heap_var (heap): 0x100e9d940
(The exact addresses will differ on every run — modern OSes randomize
layout per-process for security. What stays constant is the grouping:
global_var and static_var sit next to each other in the low, static
part of the address space; local_var lives on the stack, which grows and
shrinks automatically with function calls; heap_var lives in a
completely separate region that only malloc/free manage.)
| Region | Lifetime | Managed by |
|---|---|---|
| Data / BSS | Program start to program exit | Compiler/linker (fixed size, decided at compile time) |
| Stack | Function call to function return | Compiler-generated code (automatic, cannot outlive the function) |
| Heap | malloc to matching free |
You, entirely by hand |
The stack is fast (allocating a local variable is just moving a pointer)
but strictly scoped — a pointer to a local variable is garbage the instant
the function returns. The heap is slower (malloc has real bookkeeping
work to do) but lives until you explicitly free it, which is what makes
it the only option for data that needs to outlive the function that created
it, or whose size isn't known until runtime.
realloc: grow (or shrink) an existing block safely¶
realloc resizes a previously malloc'd block, copying the existing
contents if it has to move the block to find enough contiguous space:
// realloc_demo.c -- the correct pattern for growing a heap buffer with realloc
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t count = 4;
int *arr = malloc(count * sizeof(int));
for (size_t i = 0; i < count; i++) arr[i] = (int)(i + 1);
printf("before grow: ");
for (size_t i = 0; i < count; i++) printf("%d ", arr[i]);
printf("\n");
size_t new_count = 8;
int *tmp = realloc(arr, new_count * sizeof(int));
if (tmp == NULL) {
fprintf(stderr, "realloc failed, original block still valid\n");
free(arr);
return 1;
}
arr = tmp; // only overwrite arr once realloc succeeded
for (size_t i = count; i < new_count; i++) arr[i] = (int)(i + 1);
count = new_count;
printf("after grow: ");
for (size_t i = 0; i < count; i++) printf("%d ", arr[i]);
printf("\n");
free(arr);
return 0;
}
The critical detail is the temporary variable: int *tmp = realloc(arr,
...) instead of arr = realloc(arr, ...). If realloc fails, it returns
NULL and leaves the original block untouched — overwriting arr
directly with that NULL would both lose the only pointer to the still-valid
original allocation (an instant leak) and discard your data. Assigning to a
temporary first, checking it, and only then updating arr is the only
version of this pattern that handles allocation failure correctly.
Use-after-free: the bug that only sometimes crashes¶
// uaf.c -- use-after-free, caught by AddressSanitizer
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof(int));
*p = 42;
printf("before free: %d\n", *p);
free(p);
printf("after free: %d\n", *p); // BUG: reads freed memory
return 0;
}
Compiled and run normally, this often prints 42 twice and looks
completely fine — the memory hasn't been reused by anything else yet, so
reading it "works," even though it's undefined behavior. Compiled with
AddressSanitizer, the same bug is caught immediately:
before free: 42
==22402==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000d0 at pc 0x000102860928 bp 0x00016d59ea00 sp 0x00016d59e9f8
READ of size 4 at 0x6020000000d0 thread T0
#0 0x000102860924 in main uaf.c:11
#1 0x000181d37dfc in start+0x1b4c (dyld:arm64e+0x1fdfc)
0x6020000000d0 is located 0 bytes inside of 4-byte region [0x6020000000d0,0x6020000000d4)
freed by thread T0 here:
#0 0x0001030cd258 in free+0x7c
#1 0x0001028608d8 in main uaf.c:10
previously allocated by thread T0 here:
#0 0x0001030cd164 in malloc+0x78
#1 0x000102860804 in main uaf.c:6
SUMMARY: AddressSanitizer: heap-use-after-free uaf.c:11 in main
ASan poisons freed memory so any later access is caught the instant it happens, and it prints the allocation site, the free site, and the offending access site — three stack traces that pin down the bug exactly, instead of a crash somewhere unrelated minutes later once the freed memory happens to get reused for something else. That "crashes somewhere unrelated, much later" behavior is precisely why use-after-free bugs are miserable to find without a tool like this: the symptom and the cause can be arbitrarily far apart in the program's execution.
Habit that prevents most of these: set a pointer to NULL immediately
after freeing it (free(p); p = NULL;). Dereferencing a NULL pointer
still crashes, but it crashes immediately and obviously at the exact
location of the bug, instead of silently reading whatever now occupies that
freed memory.
Leaks: memory that's never freed¶
A leak is simpler than a use-after-free — no crash at all, just memory
that's allocated and never released, so the process's memory footprint
grows for as long as it keeps running. On Linux, valgrind --leak-check=full
./program is the standard tool (it runs your program in an instrumented
virtual machine and reports every block still allocated at exit, with the
call stack that allocated it). On macOS, the leaks command-line tool
(built into Xcode's command line tools) and Instruments' Leaks template do
the equivalent job. AddressSanitizer's leak detector (ASAN_OPTIONS=detect_leaks=1)
covers the same ground on Linux, but is not available on macOS —
know which tool your platform actually supports before you rely on it in
CI.
The fix is always the same regardless of which tool finds it: for every
malloc/calloc/realloc in the program, there must be a reachable path
to exactly one matching free. A long-running server (like the one in
Module 10) that leaks even a handful of bytes per request will eventually
exhaust its memory — which is why memory-checking tools are run against
test suites in CI for exactly this kind of code, not just when something
visibly breaks.
Double-free: freeing the same block twice¶
Calling free twice on the same pointer (without an intervening
malloc reusing that address) corrupts the allocator's internal
bookkeeping — the fix is the same discipline as use-after-free: set the
pointer to NULL right after freeing it, since free(NULL) is explicitly
defined by the C standard to do nothing, making accidental double-frees
harmless as long as you followed that habit:
free(p);
p = NULL;
// ... much later, in a code path that doesn't know p was already freed ...
free(p); // free(NULL) -- safe no-op, not a crash
Cheat sheet¶
| Bug | Symptom | Tool that catches it |
|---|---|---|
| Use-after-free | Sometimes silent, sometimes a crash later | ASan (-fsanitize=address), valgrind |
| Double-free | Allocator corruption, may crash immediately or later | ASan, valgrind |
| Buffer overflow (heap) | Silent corruption of adjacent data | ASan, valgrind |
| Buffer overflow (stack) | Crash, or a classic security vulnerability | ASan, -fstack-protector |
| Memory leak | No crash, just growing memory use | valgrind (Linux), leaks/Instruments (macOS) |
| Uninitialized read | Nondeterministic behavior | -fsanitize=undefined, valgrind's memcheck |
| Function | What it does | Failure return |
|---|---|---|
malloc(n) |
Allocates n uninitialized bytes |
NULL |
calloc(n, size) |
Allocates n * size bytes, zeroed |
NULL |
realloc(p, n) |
Resizes block p to n bytes, may move it |
NULL (original block untouched) |
free(p) |
Releases the block; free(NULL) is a defined no-op |
— |
How It Actually Works¶
The four memory regions correspond to concrete sections the linker builds
into the executable file and instructions the OS loader follows when
starting the process, not just an abstract mental model. global_var
(initialized to a non-zero value) lands in the ELF/Mach-O binary's .data
section, with its initial value literally stored as bytes in the
executable file on disk — the loader just maps that section into memory
read-write. static_var (implicitly zero) lands in .bss, which is
special: the executable file does not store any bytes for it at all
(storing a million zero bytes on disk would be wasteful), it just records
"reserve this many bytes and zero them" — the loader allocates and zeroes
that memory at startup, which is also why every uninitialized static
or global variable in C is guaranteed to start at zero, unlike an
uninitialized local. The stack and heap, by contrast, have no
representation in the file at all — they're regions the OS sets up fresh
for each running process, which is exactly why their addresses vary
between runs (address space layout randomization deliberately places them
differently each time, specifically to make memory-corruption exploits
harder to write reliably).
realloc's "may move the block" behavior is a direct consequence of how
the heap allocator's internal free list is organized: growing a block in
place is only possible if the memory immediately following it is currently
free and large enough — information the allocator tracks via headers
adjacent to each block, as covered in
Level 2, Module 2. When that's not the
case, realloc internally does the equivalent of malloc(newsize) (find a
new large-enough free region elsewhere), memcpy (copy the old block's
entire contents there, byte for byte), then free(oldptr) (return the
original block to the free list) — three real operations, any of which can
fail, which is exactly why checking the return value into a temporary
before overwriting the original pointer is the only version of this
pattern that can survive a failed malloc step without losing the still-
valid original allocation.
AddressSanitizer's ability to pinpoint a use-after-free precisely (down to
the exact allocation, free, and re-access call stacks) works because ASan
doesn't let free'd memory get reused immediately — it holds a
configurable quarantine of recently-freed blocks aside, poisoning their
shadow-memory bytes (the same shadow-memory mechanism used for
buffer-overflow detection) so any subsequent access to that address is
flagged immediately rather than silently succeeding against still-present
old data. This is precisely why the un-instrumented build of the same
uaf.c "often prints 42 twice" — a normal allocator has no such
quarantine and will happily hand that exact address back out to the very
next malloc call, meaning the bug's visible symptom depends entirely on
whether anything else happened to reuse that memory before you read it
again.
Exercise¶
Take uaf.c above and fix it two ways, confirming each with
-fsanitize=address,undefined: first, simply move the free(p) call to
after the last use of p. Second — as a separate version — keep the
original order but add p = NULL; immediately after free(p), and add an
if (p != NULL) guard around the later access, so the bug becomes
structurally impossible instead of just reordered. Then write a small
program that deliberately leaks a malloc'd block inside a loop that runs
1000 times, and run it under leaks (macOS) or valgrind --leak-check=full
(Linux) to see the tool report exactly how many bytes leaked and where the
allocation happened.