Manual Memory Management — Middle Level¶
Topic: Manual Memory Management Focus: How the allocator actually works, what each failure mode does under the hood, and ownership conventions in real APIs.
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- Core Concepts
- Mental Models
- Code Examples
- Coding Patterns
- Pros & Cons
- Use Cases
- Best Practices
- Edge Cases & Pitfalls
- Summary
Introduction¶
At the junior level, malloc and free were a black box governed by a contract. Now we open the box. Understanding what the allocator stores, how a block is laid out, and what actually happens during a use-after-free is what separates "I follow the rules" from "I understand why the rules exist." It also explains the otherwise baffling crashes: why a double-free corrupts unrelated data, why writing one byte past a buffer can hijack control flow, why a leak in C is invisible until the machine dies.
This tier also introduces the central discipline of manual memory: ownership conventions. The hardest question in C is not "how do I free this?" but "whose job is it to free this?" — and how an API communicates that answer.
Prerequisites¶
- The
malloc/calloc/realloc/freecontract from the junior tier. - Comfort reading C with
struct, pointers, and pointer arithmetic. - A rough idea of virtual memory: your process sees a flat address space; the OS maps it to physical RAM in pages (typically 4 KiB).
Glossary¶
| Term | Meaning |
|---|---|
| Chunk / block | A unit of heap memory the allocator hands out, usually larger than you asked for. |
| Header / metadata | Bookkeeping bytes the allocator stores alongside (often just before) your block. |
| Alignment | The requirement that an address be a multiple of some power of two (e.g. 8 or 16). |
| Free list | The allocator's internal list of available blocks. |
| Fragmentation | Wasted heap space: many small free gaps that can't satisfy a large request. |
| Ownership | The convention naming exactly one party responsible for freeing a block. |
| Transfer of ownership | Handing the free-responsibility to another function or component. |
| Heap spraying | An exploit technique that fills the heap with attacker-controlled data. |
Core Concepts¶
What the allocator stores¶
When you call malloc(24), the allocator does not hand you exactly 24 bytes from nowhere. It:
- Rounds the size up for alignment and minimum block size. On a 64-bit system, blocks are typically aligned to 16 bytes, and there's a minimum size (often 32 bytes) because each block needs room for bookkeeping when free.
- Stores a header — commonly in the bytes immediately before the pointer it returns. A typical glibc header holds the chunk size and a few status flags (e.g. "is the previous chunk in use?").
- Returns a pointer to the usable region, just after the header.
header usable region you get a pointer to
┌─────────┐ ┌──────────────────────────────────┐
... ── │ size+ │ │ your 24 bytes (rounded to 32) │ ── next chunk ...
│ flags │ │ │
└─────────┘ └──────────────────────────────────┘
^
malloc returns this address
Two consequences fall out of this picture:
free(p)knows the size without you telling it. It reads the header just beforep. That's whyfreetakes only a pointer.- Writing one byte before your block (
p[-1]) or past its end corrupts the allocator's metadata, not just your data. This is why overflows are catastrophic, not merely wrong.
Alignment¶
The CPU requires (or strongly prefers) that certain types live at aligned addresses. A double or a pointer typically must sit at an 8- or 16-byte boundary. malloc guarantees its returned pointer is suitably aligned for any type — that's why it over-allocates and rounds up. When you write your own allocator, honoring alignment is non-negotiable; a misaligned access is a crash on some architectures and a silent slowdown on others.
The failure modes, under the hood¶
Now the bugs make sense:
-
Use-after-free. After
free(p), the allocator may putp's chunk on a free list, writing free-list pointers into the body of your old block. If you then read it, you see allocator internals; if you write it, you corrupt the free list. Worse, the allocator may hand the same chunk to a differentmalloc, so two parts of your program now alias the same memory. This is the #1 exploit primitive in modern attacks. -
Double-free. Calling
free(p)twice puts the same chunk on the free list twice. A later allocation can return it while it's also still queued, letting an attacker arrange formallocto return an arbitrary address. glibc has hardening (tcache double-free detection) but it is not a guarantee. -
Buffer overflow. Writing past your block tramples the next chunk's header. The next
freeormallocthen operates on corrupt metadata — the foundation of decades of exploits. -
Memory leak. Nothing crashes. The chunk stays marked "in use" forever. The process's resident memory climbs until the OOM killer (Linux) terminates you or the allocator fails. In a long-running server, a 40-byte leak per request is a guaranteed outage on a timer.
-
Mismatched alloc/free. In C++, memory from
newmust be released withdelete,new[]withdelete[], andmallocwithfree. Mixing them is undefined behavior, becausenew[]may store an element count in a header thatdelete(singular) doesn't know about. -
Invalid free. Calling
freeon a pointer that didn't come frommalloc(a stack address, an interior pointer, an already-freed pointer) reads garbage as a header and corrupts the heap.
Ownership conventions¶
The contract says "free each block once." The hard part is deciding who. APIs answer this with conventions:
- Caller allocates, caller frees. The function fills a buffer the caller provides. Common and safe:
- Callee allocates, caller frees (ownership transfer). The function
mallocs and returns; the caller mustfree. This must be documented: - Callee allocates, callee frees (opaque handle). The library owns the memory; you get a handle and call a matching destructor:
The cardinal rule: every API that returns or accepts a pointer must document its ownership. "Who frees this?" should never require reading the implementation.
Mental Models¶
- The header is the allocator's memory of you. Your block is sandwiched in metadata. Step outside your lines and you erase the allocator's notes.
- Freed memory is reused, not destroyed. Think of a freed chunk as a hotel room immediately resold. Use-after-free is two guests in one room.
- Ownership is a baton in a relay race. At every moment exactly one runner holds it. Drop it (leak) or have two runners think they hold it (double-free) and the race ends in a crash.
Code Examples¶
Ownership transfer, documented¶
// OWNERSHIP: returns a heap-allocated string; CALLER must free().
// Returns NULL on allocation failure.
char *join_path(const char *dir, const char *file) {
size_t n = strlen(dir) + 1 + strlen(file) + 1; // +1 sep, +1 NUL
char *out = malloc(n);
if (!out) return NULL;
snprintf(out, n, "%s/%s", dir, file);
return out;
}
void caller(void) {
char *p = join_path("/etc", "hosts");
if (!p) return;
// ... use p ...
free(p); // caller honors the transfer
}
The interior-pointer trap (invalid free)¶
char *buf = malloc(100);
char *cursor = buf + 10; // interior pointer
// ... work via cursor ...
free(cursor); // BUG: UB — must free `buf`, the original
free reads the header just before cursor, which is in the middle of your data, not a real header. Heap corruption.
Double-free across two owners¶
void process(char *data) {
// ... uses data ...
free(data); // process thinks it owns data
}
void caller(void) {
char *d = malloc(64);
process(d);
free(d); // BUG: double-free — process already freed it
}
This is an ownership bug, not a typo. The fix is a documented convention: either process borrows (never frees) or it consumes (caller never frees).
Coding Patterns¶
Single-exit cleanup (the C goto idiom)¶
C has no destructors, so resource cleanup on every error path is verbose and bug-prone. The idiomatic answer is a cleanup ladder:
int load(const char *path, Result *out) {
int rc = -1;
char *buf = NULL;
FILE *f = NULL;
f = fopen(path, "rb");
if (!f) goto done;
buf = malloc(SIZE);
if (!buf) goto done;
if (read_into(f, buf) != 0) goto done;
out->data = buf;
buf = NULL; // ownership transferred to out — don't free below
rc = 0;
done:
free(buf); // free(NULL) is a safe no-op
if (f) fclose(f);
return rc;
}
Two things make this robust: free(NULL) is a guaranteed no-op (so the ladder is uniform), and setting buf = NULL after transfer prevents a double-free.
Pros & Cons¶
Pros
- Zero hidden cost. No GC threads, no write barriers, no pauses.
- Cache-friendly layouts. You decide exactly where data lives, enabling tight, contiguous structures.
- Deterministic teardown. Resources release at a known point, not "eventually."
Cons
- Ownership is a manual proof. Nothing checks your reasoning; mistakes compile cleanly.
- Fragmentation. Long-running programs can waste significant memory to free gaps.
- Metadata is fragile. One overflow corrupts the allocator, turning a local bug into global chaos.
Use Cases¶
- Library APIs where you must define and document ownership across a boundary.
- Parsers and protocol handlers that build and tear down trees of allocations per message.
- Systems where GC is banned: kernels, drivers, real-time control loops, microcontrollers.
Best Practices¶
- Document ownership at every pointer-passing boundary. A one-line comment ("CALLER frees") prevents a class of bugs.
- Free in reverse construction order for dependent resources.
- Use the
goto-cleanup or single-exit pattern so every error path runs the same teardown. - Null after free, and rely on
free(NULL)being safe to keep cleanup uniform. - Never free an interior pointer. Keep the original allocation pointer until teardown.
- In C++, match the allocator:
new↔delete,new[]↔delete[],malloc↔free. Better: stop using rawnew/delete(next tier).
Edge Cases & Pitfalls¶
realloc(p, 0)is implementation-defined: it may freepand returnNULL, or return a minimal block. Don't rely on either; avoid it.reallocshrinking can still move the block. Always reassign from the return value.- Zero-size
malloc(0)may returnNULLor a unique freeable pointer — both are conforming. Don't treatNULLfrommalloc(0)as failure. - Integer overflow in size math:
malloc(count * size)can overflow and under-allocate, then you write past the (tiny) block. Usecalloc(count, size), which checks the multiplication. - Freeing memory you don't own (a string literal, a stack array, library-owned memory) corrupts the heap.
- Aliasing after transfer: keeping a copy of a pointer you handed off leads to use-after-free when the new owner frees it.
Summary¶
- The allocator stores a header (size + flags) next to your block and rounds up for alignment; that's why
freeneeds only a pointer and why overflows corrupt allocator metadata. - Each failure mode has a concrete mechanism: freed chunks go on free lists and get reused (use-after-free, double-free), overflows trample neighboring headers, leaks keep chunks marked in-use forever.
- The defining discipline is ownership: exactly one party frees each block, and APIs must document who.
- Patterns like the
goto-cleanup ladder, null-after-free, andcallocfor size math tame manual memory in plain C. - The next tier compares how C, C++ (RAII), and Rust (compile-time ownership) each attack this problem from a design standpoint.
In this topic
- junior
- middle
- senior
- professional