module 01 · memory models · Part II · the languages

Chapter 08 — C: the explicit model

The 30-second version. C has no protective memory model — it has a memory contract: a value is raw bytes at an address, you own every byte, you free every heap byte yourself, and the language promises nothing in exchange. Pointers are typed addresses; sizeof and alignment rules let you compute any layout on paper; malloc hides a small receipt next to your data; and "undefined behavior" is not a crash — it is a license for the optimizer to assume you never make mistakes. Everything other languages wrap around memory (headers, tags, GC, bounds checks) is machinery C leaves out on purpose.

The contract: four blunt answers

Part II of this module asks every language the same four questions. C's answers are the shortest in the book:

QuestionC's answer
What is a value?Raw bytes at an address. The type is a compile-time interpretation; nothing at runtime records it.
Who owns it?You. Ownership is a convention between programmers, invisible to the compiler.
Who frees it?You. Stack objects pop with the frame (Chapter 04); statics live until exit; heap blocks wait for free().
What safety is guaranteed?None. The standard promises nothing; safety is the programmer's job.

Chapter 06 showed a Python int as a 28-byte box; a C int is 4 bytes, nothing else — every byte of that cost is opt-in here.

A pointer is a typed address: 8 raw bytes on x86-64/ARM64 (Chapter 07) holding a virtual address (Chapter 03), plus a type that says how to interpret the bytes there — and how big one "step" is:

int a[4] = {10, 20, 30, 40};
int *p = a;            // "a" decays to &a[0]: p = address + the type int
p + 1;                 // the ADDRESS advances sizeof(int) == 4 bytes, not 1
*(p + 2);              // 30
&a[3] - &a[0];         // 3 — pointer difference counts ELEMENTS, never bytes

Pointer arithmetic is scaled by sizeof(*p): char *p; p+1 moves 1 byte, struct rec *q; q+1 moves the whole struct. That is why indexing works for any type: a[i] is defined by the standard as *((a)+(i)) — address plus i elements.

Array decay. In almost every expression, an array's name becomes a pointer to its first element. Exactly three contexts refuse to decay (C11 6.3.2.1): operand of sizeof, operand of unary &, and a string literal initializing a char array:

int a[4];              // an ARRAY: 16 contiguous bytes in main's frame
sizeof(a);             // 16 — the whole object (4 * sizeof(int), x86-64)
sizeof(a) / sizeof(a[0]);   // 4 — the "count elements" idiom

void f(int b[100]);    // the [100] is DOCUMENTATION ONLY: b is int *
sizeof(b);             // 8 inside f — a pointer, not an array

The size is amputated at the call boundary, by rule — which is why every C API that takes a buffer also takes a length (read, memcpy, strncpy).

One program, six neighborhoods

Chapter 03 built the virtual address space; here it is from inside a running C program. Six neighborhoods, each with its own lifetime, permissions, and growth direction:

   a running C program, x86-64 Linux, non-PIE (ASLR shifts heap/stack/libs every run)

0x0000000000400000  +---------------------+
                    | ELF headers   r--   |
0x0000000000401000  | .text         r-x   |   the code: read + execute
0x0000000000402000  | .rodata       r--   |   string literals, const tables
0x0000000000403000  | .data.rel.ro  r--   |   RELRO hardening (Chapter 21)
0x0000000000404000  | .data         rw-   |   initialized globals and statics
0x0000000000405000  | .bss          rw-   |   zeroed globals/statics: 0 file bytes
                    +---------------------+
                    | heap          rw-   |   grows UP via brk (Chapter 05)
                    |  malloc < 128 KiB   |   glibc 2.35 default M_MMAP_THRESHOLD
                    |          ...        |
                    +---------------------+
0x00007f..........  | shared libraries    |   libc and friends: the mmap region
                    | large mallocs       |   >= 128 KiB: fresh mmap, page-aligned
                    |          ...        |
0x00007ffd........  | stack         rw-   |   grows DOWN: frames (Chapter 04)
0x00007ffffffff000  +---------------------+   argv/envp at the very top
0x0000000000000000  | the NULL page       |   unmapped on purpose (Chapter 03)

Where each declaration lands:

const char *msg = "hi";          /* "hi\0" = 3 bytes in .rodata; msg itself wherever declared */
static const int tbl[] = {1, 2}; /* .rodata (gcc 13/clang put const globals there;
                                    the C standard guarantees no location) */
int counter = 1;                 /* .data — a nonzero initializer costs file bytes */
static int visits;               /* .bss — 0 file bytes; the loader maps zeroed pages */
void f(void) {
    static int calls;            /* .bss too: local SCOPE, program-long LIFETIME */
    char buf[8];                 /* stack: f's frame; vanishes at return */
    char *p = malloc(100);       /* the 100 bytes: heap; the pointer p: stack */
}

Three details worth burning in:

malloc's hidden receipt

Chapter 05 built a free-list allocator from scratch; Chapter 19 tours the real ones. The fact to internalize: malloc's bookkeeping lives next to your data, not in a side table. The signature forces it:

void free(void *p);      // takes the pointer VALUE and nothing else

free() receives an address and no size. There is no global index to look it up in. The only place the block's size can live is memory the allocator controls — the bytes next to the block. glibc 2.35 on x86-64 puts the size word 8 bytes below your pointer:

   glibc 2.35 chunk, x86-64 (sizes in bytes)

  lower addresses
  +0x00  +----------------------------+  <- chunk start (16-aligned)
         | prev_size        (8)       |     meaningful only if PREVIOUS chunk is free;
         |                            |     otherwise it is the previous chunk's tail
  +0x08  +----------------------------+
         | size             (8)       |     <- free() reads THIS word, at p - 8
         |   low 3 bits = flags P/M/A |     sizes are multiples of 16: bits are free
  +0x10  +----------------------------+  <- p: the value malloc returned (16-aligned)
         | user data ...              |     malloc(0) still gets 24 usable bytes here
         | next chunk's prev_size (8) |     <- overlaps your LAST 8 usable bytes
         +----------------------------+  <- next chunk's size word
  higher addresses                       usable bytes = chunk size - 8

The failure modes, in order of how often they ruin a week:

char *p = malloc(64);   // chunk 80 B (64 + 8 header, rounded to 16); usable 72
p[72] = 'x';            // 1 byte past usable: the NEXT chunk's size word.
                        // Corruption with a delay: nothing dies until that chunk
                        // is freed or split (Chapters 05, 19, 21).
free(p);                // reads p-8, checks alignment and neighbors, pushes the
                        // chunk to tcache/bins. Your DATA is untouched, not zeroed.
free(p);                // glibc 2.35: abort "free(): double free detected in tcache 2"
free(NULL);             // specified no-op since C89: the one pointer you may free blind

Layout arithmetic: alignment and padding

Chapter 01 derived alignment from the memory system; Chapter 06 padded objects in Java and C++. C is where you do the arithmetic yourself — and the compiler never reorders your fields (declaration order is semantic; contrast Go's compiler, Chapter 11). Three rules compute any struct on x86-64 Linux:

  1. Each member lands at the next offset that is a multiple of its alignment (char 1, short 2, int 4, long/pointer 8, long double 16).
  2. The struct's alignment is the strictest member's.
  3. Total size rounds up to a multiple of the struct's alignment — so array element [1] is still aligned.
struct rec {        /* layout A: the order you thought of the fields in */
    char  kind;     /* offset 0x00 */
    int   len;      /* offset 0x04 — 3 pad bytes precede it (int needs 4) */
    char *data;     /* offset 0x08 — already 8-aligned: no gap */
    char  flag;     /* offset 0x10 */
};                  /* sizeof == 24: 3 pad + 7 tail pad; alignof == 8 */
   layout A (declaration order)         layout B (sorted by size, descending)
   +0x00 | kind  |1B                     +0x00 | data  |8B
   +0x01 | pad   |3B                     +0x08 | len   |4B
   +0x04 | len   |4B                     +0x0c | kind  |1B
   +0x08 | data  |8B                     +0x0d | flag  |1B
   +0x10 | flag  |1B                     +0x0e | pad   |2B
   +0x11 | pad   |7B                     +0x10 (end)       sizeof == 16
   +0x18 (end)               sizeof == 24

Layout B is the same four fields — char *data; int len; char kind; char flag; — with sizeof == 16. Identical semantics, 24 versus 16 bytes: a third less memory and a third fewer bytes per cache line in an array of a million (Chapter 02). Nothing was "optimized" — two declarations moved. offsetof and _Alignas pin layouts when wire formats demand it; __attribute__((packed)) removes padding at the price of unaligned access — slow on x86-64, trapping on some ARM profiles.

The optimizer's license: undefined behavior and aliasing

C's safety answer was "nothing is guaranteed." The fine print: with undefined behavior, the standard imposes no requirements on the result — and compilers read that as permission to assume the UB never happens. At -O0 your bug crashes; at -O2 the absence of your bug becomes an optimization fact. The classic:

/* "detect overflow by watching for wraparound" — a famous broken idiom */
int add_overflows(int a, int b) {
    int sum = a + b;      /* signed overflow is UB (C11 6.5/5) */
    if (sum < a)          /* "if it wrapped, sum drops below a" */
        return 1;
    return 0;
}

Since signed overflow is UB, the optimizer reasons: assume a + b never wraps; then a + b < a simplifies to b < 0. The addition and comparison both vanish. gcc 13, x86-64, -O2, emits exactly this:

        add_overflows:
                xorl  %eax, %eax
                testl %esi, %esi       ; inspects b ONLY — the add is gone
                setl  %al
                ret

add_overflows(INT_MAX - 1, 2) returns 0. Your check was deleted, legally:

int add_overflows_fixed(int a, int b) {
    return b > INT_MAX - a;            /* arithmetic that cannot overflow */
}
/* or, gcc/clang: a checked operation with no UB at all */
int sum;
if (__builtin_add_overflow(a, b, &sum)) return 1;

Strict aliasing is the same license aimed at types. C11 6.5/7 lists the types that may access an object's bytes: its own type, qualified/signed/unsigned variants, and char. Anything else is UB:

float f = 1.0f;
uint32_t bits = *(uint32_t *)&f;   /* UB: compiler may assume these objects are
                                      unrelated and REORDER the store to f past
                                      this load */

The two defined ways to pun:

union { float f; uint32_t u; } pun = { .f = 1.0f };
uint32_t a = pun.u;                /* C99 TC3+: write one member, read another —
                                      allowed; the VALUE is implementation-defined
                                      (the IEEE-754 bits everywhere you've heard of) */

uint32_t b;
memcpy(&b, &f, sizeof b);          /* always defined: memcpy copies as bytes, and
                                      gcc/clang compile it to one 4-byte mov */

There is no performance argument for the cast. The folklore: Linux builds with -fno-strict-aliasing (in KBUILD_CFLAGS for decades) because kernel macros pun types as a way of life and auditing every one was judged riskier than opting out; PostgreSQL does the same. The flag does not make punning legal — it only stops the optimizer from exploiting the illegality.

The ragged edges: VLAs, alloca, and the C11 repairs

Chapter 04 gave every function a fixed frame built by the prologue and destroyed by the epilogue. C99's variable-length array punches a hole in that:

void f(size_t n) {
    int buf[n];     /* VLA: size known only at run time */
    ...
}                   /* reclaimed when the frame pops — see below */

gcc on x86-64 computes n * 4, rounds to 16, and subtracts from rsp — the frame becomes variable-sized. Three problems:

  1. Failure is silent. A huge n moves rsp past the stack guard page (Chapter 03): no NULL to check, no error code. The fault lands somewhere unrelated, or worse, in another mapping. Pure UB.
  2. Worst-case depth becomes uncomputable. Frame-size audits go blind; the kernel's budget check is CONFIG_FRAME_WARN, 2048 bytes by default on 64-bit.
  3. Recursion multiplies both.

The Linux kernel finished purging VLAs in v4.20 (late 2018, Kees Cook's series) and builds with -Wvla so they cannot return — near-zero measured cost, total auditability win. C11 made VLAs optional (__STDC_NO_VLA__ == 1 means absent; MSVC never had them).

alloca() is the same idea without the array syntax: extend the current frame, return a pointer into it. Never standardized (a BSD/SVID tradition every major libc implements), freed not by any call but by the function's return — Chapter 07's epilogue (leave, or mov rsp, rbp; pop rbp; ARM64's restoring ldp) resets rsp in one instruction and every alloca evaporates. In a loop it accumulates until return, and there is no free() you could call. alloca(0) is UB.

The C11 repairs for the legitimate uses:

/* 1. aligned_alloc: your alignment, freed by plain free() */
void *line = aligned_alloc(64, 4096);  /* 64 = cache line (Chapter 02). C11 says size
                                          "should" be a multiple of alignment (DR 460
                                          ambiguity); glibc 2.35 tolerates any size,
                                          some BSD libcs return NULL. Round up
                                          yourself, or use posix_memalign(). */

/* 2. flexible array member: header + payload in ONE allocation */
struct msg {              /* C11 */
    uint64_t tag;         /* offset 0,  8 bytes */
    uint32_t len;         /* offset 8,  4 bytes */
                           /* 4 bytes pad: struct alignment is 8 */
    unsigned char data[]; /* offset 16, 0 declared bytes: the FAM */
};                        /* sizeof == 16, offsetof(data) == 16 */

struct msg *m = malloc(sizeof *m + n);   /* one malloc, one free(m) */

The pre-C99 idiom it replaces:

struct msg_old {
    uint64_t tag;
    uint32_t len;
    unsigned char data[1];   /* the 1-byte hack: sizeof lies (13 -> padded to 16),
                               every allocation site hand-does the -1 */
};
struct msg_old *m = malloc(sizeof *m + n - 1);   /* forget the -1: off-by-one into
                                                    the next chunk's header */

The hack's whole cost is that arithmetic: sizeof no longer reports the header size and the -1 is a per-site ritual. The FAM says: zero declared size, offsetof is the truth, one malloc, one free, no arithmetic to get wrong.

What insiders know

See it yourself

The lab: make the six neighborhoods personal. The program prints where its own objects live, then dumps its own mappings — it opens /proc/self/maps itself (cat /proc/self/maps in a shell shows cat's map: "self" resolves per opener):

/* map.c — x86-64 Linux */
#include <stdio.h>
#include <stdlib.h>

static const char *lit  = "a shared literal";  /* the bytes: .rodata */
static const int   tbl[3] = {1, 2, 3};         /* .rodata */
static int answer  = 42;                       /* .data */
static int zeros;                             /* .bss */

int main(void) {
    int stack_var = 0;                 /* main's frame */
    char *small = malloc(64);          /* heap via brk */
    char *big   = malloc(1u << 20);    /* 1 MiB >= 128 KiB: via mmap */

    printf("literal    %p\n", (void *)lit);
    printf("const tbl  %p\n", (void *)tbl);
    printf("init'd 42  %p\n", (void *)&answer);
    printf("zeroed     %p\n", (void *)&zeros);
    printf("stack      %p\n", (void *)&stack_var);
    printf("heap 64 B  %p\n", (void *)small);
    printf("mmap 1MiB  %p\n", (void *)big);

    FILE *f = fopen("/proc/self/maps", "r");
    char line[256];
    while (fgets(line, sizeof line, f)) fputs(line, stdout);
}
$ gcc -no-pie -O0 -g -o map map.c && ./map  # -no-pie: textbook addresses;
literal    0x402010                          # default PIE gives 0x55.. + ASLR
const tbl  0x402020
init'd 42  0x404028
zeroed     0x404038
stack      0x7ffc7a2b1c94
heap 64 B  0x4062a0
mmap 1MiB  0x7f539c000010
00400000-00401000 r--p 00000000  /tmp/map
00401000-00402000 r-xp 00001000  /tmp/map   <- .text: the code
00402000-00403000 r--p 00002000  /tmp/map   <- .rodata: literal, tbl
00403000-00404000 r--p 00003000  /tmp/map   <- .data.rel.ro
00404000-00405000 rw-p 00004000  /tmp/map   <- .data: answer (0x404028)
00405000-00406000 rw-p 00000000  00:00 0    <- .bss: zeros, no file backing
00406000-00427000 rw-p 00000000  00:00 0 [heap]  <- small (0x4062a0)
...
7f539c000000-7f539e010000 rw-p  00:00 0     <- big: the 1 MiB mmap
7ffc7a29c000-7ffc7a2bd000 rw-p [stack]

What to notice: every printed address falls inside the matching line — find each one. The permission column explains the segfault before the write: .rodata is r--. The .bss line has no file backing. Run it twice: stack and mmap addresses move (ASLR, Chapter 03); the 0x40… ones do not (non-PIE). nm -n map adds the symbol view: letter codes R/D/B for rodata/data/bss.

Now break it, with two tools:

/* asan.c */
#include <stdlib.h>
int main(void) {
    char *p = malloc(16);
    p[20] = 'x';     /* 4 bytes past the end */
    return 0;
}
$ gcc -fsanitize=address -g -O1 -o asan asan.c && ./asan
=================================================================
==2718==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6020000000d4
WRITE of size 1 at 0x6020000000d4 thread T0
    #0 0x... in main /tmp/asan.c:4
0x6020000000d4 is located 4 bytes after 16-byte region [0x6020000000c0,0x6020000000d0)
allocated by thread T0 here:
    #0 0x... in malloc
    #1 0x... in main /tmp/asan.c:3
SUMMARY: AddressSanitizer: heap-buffer-overflow /tmp/asan.c:4

The report gives what you need: where ("4 bytes after a 16-byte region") and who allocated it (the exact line). ASan wraps malloc with poisoned redzones (at least 16 bytes on each side) plus a shadow map — 1 shadow byte per 8 memory bytes, encoding how many leading bytes are addressable. Freed blocks are poisoned and quarantined too, so use-after-free trips the same machinery. Cost: roughly 2x runtime, 2–3x memory — a debug-build tool (Chapter 21 covers the production relatives).

$ valgrind --leak-check=full ./map    # no recompile; ~20-50x slowdown
==31337== HEAP SUMMARY:
==31337==     in use at exit: 1,048,600 bytes in 2 blocks
==31337==   definitely lost: 1,048,600 bytes in 2 blocks

valgrind runs the binary in an instrumented VM, watching every load and store; no rebuild needed, and unlike ASan it also flags reads of uninitialized memory.

How to use this knowledge

Connections

Forward links — where the rest of the program picks this chapter up:

Quiz

  1. free() takes only a pointer — no size, no handle. Explain why that signature forces allocator metadata to live adjacent to every heap block, and what that implies about a one-byte overflow past the end of an allocation.
  1. What is array decay? Name the three expression contexts in which an array name does not decay, and explain why nearly every C API that accepts a buffer must also accept a length.
  1. Predict sizeof and every field offset, on x86-64 Linux, for both structs:
   struct s1 { char a; char *b; char c; };
   struct s2 { char *b; char a; char c; };
  1. Predict the output and where each object's bytes live (x86-64 Linux):
   char str[] = "hello";
   char *p    = "hello";
   printf("%zu %zu\n", sizeof(str), sizeof(p));
   str[0] = 'H';   /* A */
   p[0]   = 'H';   /* B */

What happens at A and at B, and why?

  1. Diagnose the bug: why is this broken, what exactly happens to the returned pointer, and what are two correct fixes?
   char *make(void) {
       char buf[32];
       snprintf(buf, sizeof buf, "%d", 42);
       return buf;
   }
  1. Diagnose: at -O2 (gcc 13, x86-64) this function's guard is deleted — the compiler treats it as equivalent to return b < 0 ? -1 : a + b;. Which rule of the standard licenses that, and what is a correct overflow guard for two size_t values?
   int check(int a, int b) {
       int s = a + b;
       if (s < a) return -1;
       return s;
   }
  1. A program runs flawlessly in development but corrupts data in production. The bug is a use-after-free: dev runs mostly touch fresh heap pages while production recycles used chunks. Name two tools or knobs that reproduce the corruption deterministically in dev, and what each does to freed memory.
  1. Cross-chapter: alloca() "never fails and never leaks," yet many style guides ban it. Tie its reclamation to the exact function epilogue from Chapters 04 and 07, explain why a huge alloca (or VLA) size produces no error at all, and name the kernel version and year in which Linux finished removing VLAs.
Answers
  1. The signature makes memory itself the parameter block. With only a pointer value, free() must recover size, boundaries, and status without any lookup table — none exists. The only storage the allocator controls is the memory around the block, so the size word sits at p − 8 (glibc 2.35, x86-64) and free-list links live inside free chunks' user areas. A one-byte overflow past the usable area lands in the next chunk's size field: nothing fails immediately; the corruption detonates when that chunk is later freed, split, or consolidated — the mechanism Chapters 05, 19, and 21 exploit.
  1. Decay is the implicit conversion of an array name to a pointer to its first element. The exceptions: (1) operand of sizeof — the whole array is measured; (2) operand of unary & — the result is a pointer to the array; (3) a string literal initializing a char array — the bytes are copied in. APIs need lengths because the callee receives 8 bytes of address with the size deleted at the call boundary, so the caller must ship the count (read(fd, buf, count)).
  1. s1: a@0, pad 7, b@8, c@16, pad 7 — sizeof 24. b needs 8-byte alignment, so 7 bytes pad after the 1-byte a; c lands at 16; the total rounds up to the struct's alignment (8), adding 7 tail bytes. s2: b@0, a@8, c@9, pad 6 — sizeof 16. Descending size order collects the two chars into one gap. Same fields, same alignment, 8 bytes less — the whole game is where the padding pools.
  1. 6 8. str is a 6-byte array (5 chars + NUL) in main's stack frame, initialized by copying the literal; sizeof sees the whole array. p is an 8-byte pointer; the 6 bytes it aims at live in .rodata. At A the write hits main's writable stack frame — fine. At B the write targets a read-only mapping — SIGSEGV, because the page is r-- (Chapter 03). Formally B is UB; on Linux the fault is the outcome. Extra credit: identical literals can merge into one .rodata object, so a "successful" B would corrupt every user of that string.
  1. Dangling pointer into a dead frame. buf lives in make's stack frame; the epilogue restores rsp/rbp (Chapter 07) and the 32 bytes become memory below the stack pointer, reusable by the next call — often printf itself. The address is valid mapped memory whose contents can change at any moment: UB that "works in demos." Fixes: (1) malloc/strdup and hand ownership to the caller; (2) invert the contract — the caller supplies the buffer and size, keeping the memory in a frame that outlives the callee.
  1. Signed overflow is UB (C11 6.5/5), and UB licenses assumptions. The optimizer may assume a + b never wraps; then a + b < a simplifies to b < 0, the check reduces to a sign test, and the addition runs unchecked — check(INT_MAX-1, 2) returns a wrapped value with no −1. For sizes the correct guard cannot overflow: if (b > SIZE_MAX - a) fail;, or __builtin_add_overflow(a, b, &s) — arrange arithmetic whose operands cannot wrap in the first place.
  1. (a) MALLOC_PERTURB_=165 (glibc): freed blocks are filled with 0xa5 and fresh allocations with its complement 0x5a — the stale read now hits recognizable poison, and the uninitialized read hits garbage instead of lucky zeros. (b) AddressSanitizer (-fsanitize=address): free() moves the block into a poisoned quarantine (shadow bytes mark it dead), so the next access reports heap-use-after-free with both the free and allocation stacks. (c) valgrind also catches it as an invalid read, no rebuild required. Any two of the three earn the point; the shared principle is that only the allocator can make freed memory detectably dead.
  1. The epilogue is the deallocator. x86-64: leave or mov rsp, rbp; pop rbp; ARM64: the restoring ldp. One register write resets the stack pointer and every alloca/VLA in the frame evaporates — no free list, no metadata, no free() that could release it early ("never leaks" = "waits for return"). A huge alloca/VLA simply subtracts a huge value from rsp: the stack pointer can jump past the guard page (Chapter 03), so later accesses fault at unrelated addresses — no NULL, no error code, pure UB. The Linux kernel completed its VLA purge in v4.20, late 2018, and builds with -Wvla to keep them out (CONFIG_FRAME_WARN, 2048 B default on 64-bit, needs statically auditable frames).