Memory Management Mastery

The deep track and the heart of C-lingua: the stack vs the heap, manual allocation, ownership and lifetimes, RAII vs defer, custom allocators and arenas, the classic memory bugs, and how seven systems languages each tame (or expose) memory.

The Stack and the Heap

Two regions, two lifetimes: the cheap automatic stack and the flexible, expensive heap.

Every running program owns a slab of address space carved into regions. Two of them matter for day-to-day memory work: the stack and the heap. Understanding the difference is the single most important idea in this entire track, because every language below is really just a different strategy for deciding which region a value lives in and who is responsible for it.

The stack is a contiguous region that grows and shrinks as functions are called and return. When you call a function, the runtime pushes a stack frame containing its parameters, return address, and local variables; when the function returns, that frame is popped. Allocation is essentially free - it is one adjustment of the stack pointer register. Lifetimes are automatic: a local lives exactly as long as its enclosing scope. The catch is that the stack is small (often 1-8 MiB by default) and a frame's size must be known when the function is compiled, so you cannot put a runtime-sized array there safely, and you must never return a pointer into a frame that is about to be popped.

The heap is a large pool of memory you carve up explicitly at runtime. You ask an allocator for n bytes, it finds a free block and hands you a pointer, and that block stays valid until you give it back. The heap is where you put data whose size you only learn at runtime, or whose lifetime must outlive the function that created it. The price is bookkeeping: every heap allocation is a promise that someone, someday, will free it exactly once.

#include <stdlib.h>

void demo(int n) {
    int on_stack[4];          // automatic: freed when demo() returns
    int *on_heap = malloc(n * sizeof(int)); // manual: yours to free
    // ... use both ...
    free(on_heap);            // forget this line and you leak
}                            // on_stack vanishes here, automatically

A classic, fatal mistake is confusing the two - returning the address of a stack local:

int *broken(void) {
    int x = 42;
    return &x;   // BUG: x's frame is popped on return; pointer dangles
}

The pointer survives, but the storage it points at is gone. Read that compiler warning. Most of this track is about the heap, because the heap is where the bugs, the costs, and the interesting design choices live.

Manual Allocation: malloc and free

The raw contract - request bytes, receive a pointer, return it exactly once.

The C heap API is tiny and brutally honest. malloc(size) returns a pointer to at least size uninitialized bytes, or NULL if it cannot. calloc(n, size) allocates n * size bytes and zeroes them. realloc(ptr, newsize) resizes a block, possibly moving it (so you must use the returned pointer, not the old one). free(ptr) returns a block to the allocator. That is the entire contract, and every rule that follows is a corollary of it.

#include <stdlib.h>
#include <string.h>

char *dup_upper(const char *s) {
    size_t n = strlen(s);
    char *out = malloc(n + 1);     // +1 for the NUL terminator
    if (out == NULL) return NULL;  // ALWAYS check: malloc can fail
    for (size_t i = 0; i < n; i++)
        out[i] = (s[i] >= 'a' && s[i] <= 'z') ? s[i] - 32 : s[i];
    out[n] = '\0';
    return out;                     // caller now owns this block
}

int main(void) {
    char *up = dup_upper("hello");
    if (up) {
        puts(up);
        free(up);                   // exactly once, by whoever owns it
    }
    return 0;
}

Three rules you violate at your peril:

  1. Check the return value. malloc returns NULL on failure. Dereferencing NULL is undefined behavior, usually a crash.
  2. malloc does not zero memory. Reading an uninitialized block gives you garbage. Use calloc when you need zeros, or write every byte before you read it.
  3. Free exactly once, and never use after free. free(p) does not change p; the pointer still holds the now-invalid address. Defensive code sets p = NULL; right after freeing, because free(NULL) is a guaranteed no-op while free-ing a stale pointer is not.

Note also that free needs no size - the allocator records the size of each block in hidden bookkeeping next to (or in a table beside) the data it hands you. That is why you must pass back exactly the pointer malloc gave you, not one you computed by arithmetic into the middle of the block. Everything fancier in this track - arenas, smart pointers, defer - exists to make this raw contract harder to break.

Ownership and Lifetimes

Who frees this, and until when is it valid? The two questions behind every API.

malloc/free give you a mechanism but no policy. The policy questions are ownership (who is responsible for freeing a block?) and lifetime (over what span of time is a pointer valid?). Get these right on paper and the bugs in the next lesson mostly evaporate; get them wrong and no language feature will save you.

Ownership answers the question "when this pointer is passed around, who must eventually call free?" There is usually exactly one owner. A function might borrow a pointer (read or write through it without freeing) or take ownership (promising to free it, or to pass ownership on). Languages and codebases encode this in conventions - names like take/borrow, comments like // caller owns the result, or the type system itself (a unique_ptr in C++, a moved value in Rust). The single most common cause of leaks and double-frees is two pieces of code that each think they own the same block.

Lifetime answers "how long does the pointed-to storage remain valid?" A stack local's lifetime ends when its scope exits. A heap block's lifetime ends when it is freed. A dangling pointer is one that outlives the thing it points at - the storage is gone but the pointer still exists, an accident waiting to be dereferenced.

// Ownership documented by convention. Different functions, different rules:

size_t count_words(const char *text);   // BORROWS text: does not free it
char  *read_file(const char *path);     // TRANSFERS ownership to caller
void   list_push(List *l, char *owned); // TAKES ownership of `owned`

void use(void) {
    char *body = read_file("in.txt");   // we now own `body`
    if (!body) return;
    size_t n = count_words(body);       // borrow: still ours afterward
    printf("%zu words\n", n);
    free(body);                          // we free, because we own
}

Lifetimes also have a hierarchy. If a struct owns pointers to other heap blocks, freeing the struct without first freeing what it points to leaks the children; freeing the children but reusing the struct leaves dangling members. A well-designed module pairs every constructor with a destructor that walks the whole ownership tree:

typedef struct { char **lines; size_t len; } Doc;

void doc_free(Doc *d) {
    for (size_t i = 0; i < d->len; i++)
        free(d->lines[i]);   // free children first
    free(d->lines);          // then the array of pointers
    free(d);                 // then the struct itself
}

Every memory-safe design - RAII, defer, garbage collection, borrow checking - is ultimately a way to make ownership and lifetime automatic, checked, or at least obvious, instead of living only in your head and in comments.

RAII versus defer

Two answers to 'how do I never forget to free' - destructors vs scheduled cleanup.

Manual free calls are fragile because cleanup is spatially separated from acquisition and easily skipped on an early return or an exception. The two dominant solutions tie cleanup to scope automatically, but in opposite ways: C++'s RAII attaches cleanup to a type, while Zig/Hare/Odin's defer attaches cleanup to a statement.

RAII - Resource Acquisition Is Initialization. In C++, an object's destructor runs deterministically when the object goes out of scope (or when its owning container is destroyed). You wrap a resource in a type whose constructor acquires it and whose destructor releases it; thereafter the compiler guarantees release on every exit path, including exceptions. The standard unique_ptr is RAII for heap memory:

#include <memory>
#include <vector>

void process() {
    auto buf = std::make_unique<int[]>(1024); // acquired
    std::vector<int> v(100);                   // also RAII-managed
    if (buf[0] == 0) return;                    // destructors still run
    use(buf.get(), v);
}   // ~unique_ptr frees buf; ~vector frees v - automatically, every path

The cleanup logic lives once, in the type, and applies everywhere the type is used. The cost is that the rule is invisible at the use site - you must know that vector and unique_ptr own heap memory - and that copying such types needs careful move/copy semantics to avoid double-frees.

defer. Zig, Hare, and Odin take the opposite tack: there are no destructors, but a defer <statement> schedules that statement to run when the current scope exits, in reverse order of registration. Cleanup is written right next to acquisition, but it is explicit and local - you can see every release in the function body.

const std = @import("std");

fn process(alloc: std.mem.Allocator) !void {
    const buf = try alloc.alloc(u8, 1024);
    defer alloc.free(buf);          // runs on EVERY exit from this scope

    const tmp = try alloc.alloc(u8, 64);
    defer alloc.free(tmp);          // runs FIRST (reverse order)

    if (buf[0] == 0) return;        // both defers still fire
    doWork(buf, tmp);
}

Zig adds errdefer, which runs only if the scope exits via an error - perfect for rolling back a half-built object while keeping it on the success path.

The trade-off in one line: RAII is automatic and invisible (great for deep ownership trees, but the cleanup is hidden in types); defer is explicit and local (great for auditing, but you must remember to write the defer, and it only handles scope-bound lifetimes, not objects stashed into long-lived containers). Neither helps a value that must outlive its creating scope - for that you still need ownership transfer, which is exactly why the next lesson is about owning regions of memory rather than individual objects.

Custom Allocators and Arenas

Stop freeing one object at a time - allocate from a region, free the whole region at once.

malloc/free per object is general but slow and bug-prone: every allocation hits a global, thread-synchronized free list, and every object needs its own matching free. The fix that systems programmers reach for constantly is the custom allocator, and the simplest, most powerful one is the arena (also called a region, bump, or linear allocator).

An arena grabs one big block up front and bumps a pointer to satisfy each request. Allocation is a pointer add and a bounds check - far faster than malloc. The trick is that you never free individual objects; instead you free (or reset) the entire arena at once when the whole batch is no longer needed. This collapses N frees into 1, eliminates use-after-free between arenas, and makes leaks structurally impossible within an arena's lifetime.

typedef struct { char *base; size_t cap, used; } Arena;

Arena arena_new(size_t cap) {
    return (Arena){ .base = malloc(cap), .cap = cap, .used = 0 };
}

void *arena_alloc(Arena *a, size_t n) {
    n = (n + 15) & ~(size_t)15;          // round up for alignment
    if (a->used + n > a->cap) return NULL;
    void *p = a->base + a->used;
    a->used += n;                         // the whole allocator: a bump
    return p;
}

void arena_reset(Arena *a) { a->used = 0; }      // "free" everything, O(1)
void arena_free(Arena *a)  { free(a->base); }    // give the block back

Arenas shine when many objects share a lifetime: all the nodes of a parse tree, all the temporaries of one request, all the entities of one game frame. You allocate freely, never tracking individual frees, then reset once at the end of the phase. This is why modern systems languages put allocators first:

  • Zig has no global allocator at all. Any function that allocates takes a std.mem.Allocator parameter, so the caller picks the strategy. std.heap.ArenaAllocator wraps any backing allocator and frees everything in deinit:
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();              // frees ALL arena allocations at once
const a = arena.allocator();
const xs = try a.alloc(u32, 1000); // no individual free needed
  • Odin bakes an allocator into its implicit context. new, make, and free use context.allocator by default, but you can swap in an arena for a scope and every nested call inherits it. Odin even ships context.temp_allocator, a growing arena you clear with free_all:
arena: virtual.Arena
_ = virtual.arena_init_growing(&arena)
context.allocator = virtual.arena_allocator(&arena)
defer virtual.arena_destroy(&arena)   // one call frees the whole region

The big idea: lifetime is a property of a region, not of an object. Group objects by when they die, give each group an arena, and most manual free calls - and the bugs that come with them - simply disappear.

The Classic Memory Bugs

Use-after-free, double-free, leaks, buffer overflow, dangling pointers - and how to spot them.

Manual memory gives you five recurring failure modes. Each is a violation of the ownership/lifetime rules from earlier; learning to see them is half the battle, and tools (AddressSanitizer, Valgrind) catch the rest.

1. Memory leak - you allocate but never free, so the block is lost until the process dies. Long-running servers die slowly of this.

char *p = malloc(100);
p = malloc(200);   // LEAK: the first 100 bytes are now unreachable
free(p);           // frees only the second block

2. Use-after-free (dangling pointer) - you keep using a pointer after the storage is freed. The block may have been reused for something else, so reads return garbage and writes corrupt unrelated data - a favorite of exploit writers.

char *p = malloc(16);
free(p);
strcpy(p, "oops");  // USE-AFTER-FREE: p points to reclaimed memory

3. Double-free - you free the same block twice. The second call corrupts the allocator's internal free list, often crashing later in unrelated code.

free(p);
free(p);            // DOUBLE-FREE: undefined behavior, heap corruption

4. Buffer overflow (out-of-bounds access) - you read or write past the end (or before the start) of a block. C does no bounds checking, so a one-byte overrun silently smashes the next block's bookkeeping or an adjacent variable.

char buf[8];
strcpy(buf, "this is far too long"); // OVERFLOW: writes past buf[7]

5. Uninitialized read - you read a malloc'd block before writing it, getting whatever bytes were left behind. Nondeterministic and maddening.

int *a = malloc(4 * sizeof(int));
int s = a[0] + a[1];  // UNINITIALIZED: garbage values

Defensive habits that prevent most of these:

free(p);
p = NULL;           // future use-of/free-of p is now a safe no-op or crash
                    // free(NULL) is a no-op; deref NULL crashes loudly

char dst[8];
snprintf(dst, sizeof(dst), "%s", src);  // bounded copy, never overflows

The defensive p = NULL after free neuters both use-after-free (you dereference NULL and crash immediately, instead of corrupting silently) and double-free (free(NULL) is defined as a no-op). Always build with -fsanitize=address during development: ASan instruments every allocation and access and reports the exact line of a use-after-free, overflow, or leak. The bugs are old and well understood - which is precisely why each language in the final lesson is, at heart, a different bet on how to make them unrepresentable.

Seven Memory Models, Side by Side

The same allocate-use-free task in C, C++, HolyC, Zig, Hare, Odin, and Forth.

Here is the same tiny task - allocate a buffer, use it, release it - in all seven of C-lingua's systems languages. Read them together: the shape of each snippet is the language's whole philosophy of memory in miniature.

C - manual, naked. You call malloc, you check for NULL, you call free. Nothing helps you; nothing gets in your way. Every other model here is a reaction to this one.

#include <stdlib.h>
int *xs = malloc(64 * sizeof(int));
if (xs) { xs[0] = 1; free(xs); }   // you own every step

C++ - RAII and smart pointers (but manual is still there). Idiomatic C++ never writes raw new/delete; it wraps memory in unique_ptr (single owner, freed by destructor) or shared_ptr (reference-counted, freed when the last owner dies). The raw form still exists for interop and special cases.

#include <memory>
auto xs = std::make_unique<int[]>(64);  // RAII: freed at scope exit
xs[0] = 1;
// int *raw = new int[64]; delete[] raw; // the manual escape hatch

HolyC - TempleOS C dialect. Terry Davis's HolyC is C with capital-cased builtins. Memory comes from MAlloc/CAlloc and is released with Free. It allocates from the current task's heap by default (memory can be tied to a task's lifetime), and like C it does no checking - the model is essentially classic C with a different spelling. (We highlight HolyC as C.)

U8 *xs = MAlloc(64 * sizeof(U8));   // task-heap allocation
xs[0] = 1;
Free(xs);                           // release it yourself

Zig - explicit allocators, defer. There is no hidden allocator and no global malloc; allocation goes through an Allocator you are handed. alloc can fail, so it returns an error union you try, and defer guarantees the matching free.

const xs = try allocator.alloc(i32, 64);
defer allocator.free(xs);          // explicit, scope-bound cleanup
xs[0] = 1;

Hare - manual alloc/free, defer. Hare keeps C's manual model but makes alloc and free keywords, not library functions. alloc(init) allocates and initializes in one step (slice sizes are computed automatically - alloc([0...], 64), not 64*size(int)), and defer pairs cleanup with creation.

let xs: []int = alloc([0...], 64);  // keyword, auto-sized, initialized
defer free(xs);                     // manual ownership, defer'd release
xs[0] = 1;

Odin - context allocator, defer. Odin threads an implicit context through every call; new, make, and free/delete use context.allocator. Swap that allocator (or use context.temp_allocator, a growing arena cleared by free_all) and all nested code follows, no parameter passing required.

xs := make([]int, 64)              // uses context.allocator
defer delete(xs)                   // delete frees slices/maps/dyn arrays
xs[0] = 1

Forth - raw memory, no allocator at all. Forth exposes the bare dictionary. HERE is the address of the next free byte; ALLOT advances it to carve out space; @ fetches and ! stores a cell. There is no free for ALLOTed space - you reclaim it by negative ALLOT or by forgetting words. It is memory management stripped to the metal.

HERE              \ address of the start of our buffer
256 CELLS ALLOT   \ reserve 256 cells; HERE moves past them
42 OVER !         \ store 42 into the first cell ( ! = store )
DUP @ .           \ fetch ( @ ) the first cell and print it -> 42

Line them up by who decides the strategy and who is forced to clean up:

  • C / HolyC: you do everything, by hand, with no checks. (HolyC ties allocations to a task heap.)
  • C++: a type (the destructor) cleans up for you; ownership is encoded in unique_ptr/shared_ptr.
  • Zig: the caller chooses the allocator and passes it in; defer cleans up; allocation failure is in the type system.
  • Hare: like C but alloc/free are keywords with auto-sizing, plus defer.
  • Odin: an implicit context carries the allocator so deep call trees share a strategy; defer cleans up.
  • Forth: there is no allocator - you move HERE with ALLOT and address raw cells with @/!.

Notice the spectrum: from Forth's total exposure of raw memory, through C's manual contract, to Zig/Hare/Odin's explicit but ergonomic allocators, to C++'s automatic destructor-driven RAII. None of them adds a garbage collector - that is the whole point of a systems language. Mastering memory means being able to read any of these and instantly answer the two questions from the ownership lesson: who frees this, and until when is it valid?

Choosing a Strategy

Match the lifetime to the tool - and know which footguns each choice leaves loaded.

You now have the whole toolbox. Mastery is choosing the right tool for a given lifetime pattern rather than reflexively reaching for malloc/free everywhere. Here is the decision process the best systems programmers run, almost unconsciously.

Start by classifying the lifetime. Ask how long the value must live, then pick the cheapest mechanism that covers it:

  • Lives within one function call? Put it on the stack. Zero allocation cost, zero cleanup code, impossible to leak. Reach for the heap only when the size is unknown at compile time or the value must outlive the call.
  • Lives for one phase/request/frame, alongside many siblings? Use an arena. Allocate freely, reset once. This is the highest-leverage technique in the track - it converts a swarm of free calls (and their bugs) into a single reset.
  • Lives for an unpredictable span, with one clear owner? Use scope-bound cleanup - RAII (unique_ptr in C++) or defer (Zig/Hare/Odin) - so release is automatic on every exit path.
  • Shared by several owners with no single 'last user'? Use reference counting (shared_ptr), accepting its atomic-increment cost and its blind spot for reference cycles.
  • Truly long-lived, process-wide? A one-time allocation that is never freed is fine - leaking on purpose at the top of main is a legitimate, simplifying choice.

Then make ownership explicit at every boundary. A function signature or a comment should answer "does this borrow or take?" for every pointer it accepts or returns. Most leaks and double-frees are two pieces of code disagreeing about who owns a block; writing the ownership down - in a type, a name, or a comment - prevents the disagreement.

Know the footgun each choice leaves loaded:

  • Raw malloc/free (C, HolyC): leaks, double-free, use-after-free - all on you. Pair every malloc with its free at design time and set pointers to NULL after freeing.
  • RAII (C++): hidden cleanup and subtle copy/move semantics; a careless copy can double-free, a cycle of shared_ptr leaks forever.
  • defer (Zig/Hare/Odin): you must remember to write it, and it only covers scope-bound lifetimes - a value handed off to a long-lived container needs explicit ownership transfer instead.
  • Arenas: cheap and safe within a region, but a pointer that escapes its arena becomes a dangling pointer the instant the arena resets. Never let an arena allocation outlive its arena.
  • Forth HERE/ALLOT: no safety net whatsoever; you are the allocator.

Finally, let the machine check you. Build with AddressSanitizer (-fsanitize=address) and run under Valgrind; in Zig use the GeneralPurposeAllocator in debug mode, which detects leaks, double-frees, and use-after-free for you. These tools turn the silent, nondeterministic bugs of the previous lesson into a precise line number.

The through-line of this entire track: memory is about lifetimes, and lifetimes are a design decision, not an afterthought. Decide when each value dies before you write the code that creates it, choose the cheapest tool that honors that lifetime, and write the ownership down. Do that, and the heap stops being a minefield and becomes exactly what it is - a flexible, fast, and fully controllable resource. That control is the whole reason these seven languages exist.