Proven C Book←↑→

Appendix P — The machinery that governs memory

malloc hands back one address. An address of what?

This book has touched memory many times on the way here: the regions a program sits in (chapter 5), the ladder from registers down to main memory (chapter 12), the layout the operating system hands you (chapter 88), and the insides of an allocator (chapter 89). But all of those were the C side, or the result. The thing that enforces the regions, that translates an address at the bottom of the ladder, that keeps the layout honest — the machinery — never appeared head-on.

This appendix opens that machinery. And, by this book’s rule, measures on this machine what it claims.

First, one word. When this appendix says address translation, it means that “the address the program named” and “the number actually stamped on the memory cell” are different, and something in between swaps one for the other. On a machine with translation, p = 0x1000 is not “cell 1000” but “wherever has agreed, for now, to look like 1000 to this program”. Whether a machine has that one thing is what divides the three kinds below.

Platform note. What this appendix rests on, and where it stops

The authority for the concepts is each architecture’s specification — page tables in the privileged spec of each ISA (instruction set architecture), the MPU in Arm’s PMSA, named address spaces in ISO/IEC TR 18037 and each compiler’s manual.

The measurements come from one machine. It is x86-64 Linux and its page is 4 KiB. Four of the examples use POSIX (mmap, fork, /proc); the fifth is built with an AVR cross compiler. Standard C alone cannot show you any of this — and that it cannot is half of this appendix’s point.

The four POSIX examples were also cross-compiled for aarch64 and run under an emulator (qemu user mode). All four build and run to the end, and the conclusions of the promise and its arrival (promise) and of the page size, double mapping, guard page and permission count (layout) were the same. But this emulator hands system calls to the host’s x86-64 kernel, so kernel-side numbers such as the cost of first touch or the shared/private count after fork are no evidence about ARM.

On an Android phone (real ARM hardware), firsttouch and layout reached the same conclusions — about 1,140 ns for a first touch against about 39 ns for writing again, and the double mapping, the guard page and even the grant of a request for “writable and executable” were the same. cow printed 0 MB because the phone’s clang removed the 512 MB fill (the demonstration has been fixed), and the fixed version has not yet been run again on the phone. promise could not run with a phone’s memory, short of 8 GB.

Three kinds of machine#

There are broadly three ways to govern memory. This table is the skeleton of the appendix; the sections that follow fill in its cells.

Address translation
(MMU, memory management unit)
Protection only (MPU)Bare addresses
What a pointer namesa virtual addressa physical addressa physical address
Same address, other programsomewhere elsethe same placethe same place
Unit that carries permissiona page (usually 4 KiB)a region (eight or so)none
forkyes (copy-on-write)nono
Writing past the enda page fault only once it leaves what is mapped; within the same page, silencea fault, if it leaves the regionnothing happens at all
Where you meet itPCs, servers, phonesCortex-M parts, RTOSes8-bit MCUs, early boot

Table 105.1 — Three ways to govern memory

★ The last two rows are the heart of the table. chapters 54 said the worst outcome is that nothing happens; here that appears as a property of the hardware. On a bare-address machine, running past the end of an array quietly changes the variable next door. There is nothing there to catch it.

But do not misread the other column: having an MMU does not mean overruns get caught. Its mesh is a page (4 KiB) wide, so treading on the variable next door within the same page happens just as quietly on a machine with translation. An example further on is the proof — it overruns 8192 bytes without incident before it reaches a page whose permissions were taken away. What an MMU casts is a net woven at page size, not a fence around a variable.

The shape of translation — what an MMU actually does#

On a machine with translation, the value of a pointer is a virtual address. A separate table turns it into a physical one, and that table is not one layer but several: the address is cut into pieces and one piece indexes each layer on the way down.

SchemeLevelsVirtual addressPage sizes available
Sv39339 bits4 KiB · 2 MiB · 1 GiB
Sv48448 bits4 KiB · 2 MiB · 1 GiB · 512 GiB

Table 105.2 — Page-table levels and page sizes — the two RISC-V schemes

Three levels means one translation costs three reads of memory. What cuts that cost is the TLB (translation lookaside buffer), which keeps recent translations; what happens when you outrun it has already been measured in Appendix O, and is not measured again here. The reason for large pages (2 MiB) also falls out of this table: covering the same span with one five-hundred-and-twelfth of the entries uses that much less of the TLB.

Each entry carries not only an address but permissions. Read, write and execute are granted per page, which is why a program’s code gets read and execute while its data gets read and write. The example below walks the mappings currently in force in this program and counts them.

Three consequences#

One — an allocation is a promise#

It is easy to think that asking for 8 GiB brings 8 GiB. Measured, it does not.

examples-en/apx-memory/promise/promise.c

/* Allocation is a promise: borrowing 8 GiB does not fetch pages until they are touched.

   VmSize is the map and VmRSS is what has arrived. Their separation is the character
   of a machine with address translation. */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

static long field_kb(const char *key)
{
    FILE *f = fopen("/proc/self/status", "r");
    char line[256], pat[64];
    long v = -1;
    if (!f)
        return -1;
    snprintf(pat, sizeof pat, "%s %%ld", key);
    while (fgets(line, sizeof line, f))
        if (sscanf(line, pat, &v) == 1)
            break;
    fclose(f);
    return v;
}

static void show(const char *key, const char *when)
{
    long vsz = field_kb("VmSize:") / 1024, rss = field_kb("VmRSS:") / 1024;
    printf("  %-24s  map %6ld MB   real %6ld MB\n", when, vsz, rss);
    printf("#DATA %s %ld %ld\n", key, vsz, rss);
}

/* Make the buffer memory that can be seen from outside. Held only by a local pointer, the
   compiler treats it as memory nobody reads and removes the malloc and the per-page writes
   entirely --- Clang 22 did, and the 8 GiB promise vanished. It survived only by chance
   under this machine's GCC 14 (a phone run exposed it). Stored once in a volatile global,
   outside calls (fopen) must be assumed able to see that memory. */
static char *volatile held;

int main(void)
{
    const size_t GIB = 1024u * 1024u * 1024u;
    const size_t want = 8 * GIB;

    printf("== a promise of %zu MB ==\n\n", want / 1024 / 1024);
    printf("  %-24s  %-13s %s\n", "", "address space", "memory that arrived");
    printf("#DATA-BEGIN\n");
    show("before", "before the request");

    char *p = malloc(want);
    held = p;
    if (!p) {
        printf("#DATA-END\n");
        puts("\n  * this machine refused the promise; the point below still holds.");
        return 0;
    }
    show("malloc", "right after malloc");

    /* one byte per page: one touch brings one page in */
    for (size_t i = 0; i < want; i += 4096)
        p[i] = 1;
    show("touched", "after touching it all");

    free(p);
    show("freed", "after free");
    printf("#DATA-END\n");

    puts("\n  * malloc drew a map; it did not fetch memory. The memory arrived one page");
    puts("    at a time, as the program touched it.");
    puts("  * a block this large gets a mapping of its own, so free hands the whole map");
    puts("    back. A small block does not: it returns to the heap, and the address");
    puts("    space stays as wide as it was.");
    return 0;
}

Output

== a promise of 8192 MB ==

                            address space memory that arrived
  before the request        map      2 MB   real      1 MB
  right after malloc        map   8194 MB   real      1 MB
  after touching it all     map   8194 MB   real   8193 MB
  after free                map      2 MB   real      1 MB

  * malloc drew a map; it did not fetch memory. The memory arrived one page
    at a time, as the program touched it.
  * a block this large gets a mapping of its own, so free hands the whole map
    back. A small block does not: it returns to the heap, and the address
    space stays as wide as it was.

A width was drawn in the address-space layout; the memory did not come. Right after asking for 8 GiB the memory actually present is 1 MB. This is lazy allocation, and several familiar idioms stand on it — reserving a generous array and touching only as much as you use, above all.

A common misconception. Asking for a lot of memory makes a program slow by that much

Asking is nearly free. The bill comes when you touch it. So the number to watch when you care about speed is not the size you asked for but the number of pages touched.

Two — the bill for that promise comes later#

Not free, then: deferred. The invoice arrives at the first touch.

examples-en/apx-memory/firsttouch/firsttouch.c

/* The bill for a promise arrives later: compare a page's first and second touch.
   The first touch faults, allocates and zeroes a page, and updates the page table. */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <sys/mman.h>

static double ns(void)
{
    struct timespec t;
    clock_gettime(CLOCK_MONOTONIC, &t);
    return (double)t.tv_sec * 1e9 + (double)t.tv_nsec;
}

static int cmp_d(const void *a, const void *b)
{
    double x = *(const double *)a, y = *(const double *)b;
    return x < y ? -1 : x > y;
}

int main(void)
{
    const size_t PAGE = 4096, PAGES = 64u * 1024;      /* 256 MiB */
    const int R = 5;
    double first[5], again[5];

    printf("== what a page costs the first time it is touched ==\n\n");
    printf("#DATA-BEGIN\n");

    for (int r = 0; r < R; r++) {
        char *p = mmap(NULL, PAGE * PAGES, PROT_READ | PROT_WRITE,
                       MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
        if (p == MAP_FAILED) {
            puts("mmap refused; this machine cannot run the experiment.");
            return 0;
        }
        double t0 = ns();
        for (size_t i = 0; i < PAGES; i++) p[i * PAGE] = 1;
        double t1 = ns();
        for (size_t i = 0; i < PAGES; i++) p[i * PAGE] = 2;
        double t2 = ns();
        first[r] = (t1 - t0) / (double)PAGES;
        again[r] = (t2 - t1) / (double)PAGES;
        munmap(p, PAGE * PAGES);
    }
    qsort(first, R, sizeof *first, cmp_d);
    qsort(again, R, sizeof *again, cmp_d);

    printf("  %-16s %10.1f ns per page\n", "first touch", first[R / 2]);
    printf("  %-16s %10.1f ns per page\n", "touched again", again[R / 2]);
    printf("  %-16s %10.1f x\n", "factor", first[R / 2] / again[R / 2]);
    printf("#DATA first %.1f\n", first[R / 2]);
    printf("#DATA again %.1f\n", again[R / 2]);
    printf("#DATA factor %.1f\n", first[R / 2] / again[R / 2]);
    printf("#DATA-END\n");

    puts("\n  * the second write is a write. The first one is a fault, a page, a zeroing");
    puts("    and an entry in the page table -- the bill for the memory that was");
    puts("    promised earlier and not delivered until now.");
    return 0;
}

Output

== what a page costs the first time it is touched ==

  first touch          1143.7 ns per page
  touched again           9.4 ns per page
  factor                122.2 x

  * the second write is a write. The first one is a fault, a page, a zeroing
    and an entry in the page table -- the bill for the memory that was
    promised earlier and not delivered until now.

The same write to the same line differs by more than a hundredfold between the first time and the second. The first time there is no translation, so a fault is taken, the kernel finds a page, fills it with zeros, records it in the table, and returns. The second time is a write.

★ This is why programs that measure time touch their pages before measuring. Otherwise you measure page faults instead of the thing you meant to measure. The examples in Appendix O warm up for exactly this reason.

Three — the copy that copies nothing#

With translation you can arrange for two parties to share one thing until one of them changes it, and only then give that one its own copy. That is copy-on-write.

examples-en/apx-memory/cow/cow.c

/* See copy-on-write: fork does not copy 512 MiB.
   Count Private_Dirty rather than RSS, because RSS includes shared pages. */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

/* smaps_rollup exists from kernel 4.14. If it cannot be read, add up the per-region smaps.
   If neither can be read, return -1 --- dividing that -1 by 1024 used to print "0 MB". */
static long roll_kb(const char *key)
{
    char line[256], pat[64];
    long v, sum = -1;
    snprintf(pat, sizeof pat, "%s %%ld", key);
    FILE *f = fopen("/proc/self/smaps_rollup", "r");
    if (f) {
        while (fgets(line, sizeof line, f))
            if (sscanf(line, pat, &v) == 1) { sum = v; break; }
        fclose(f);
        if (sum >= 0)
            return sum;
    }
    f = fopen("/proc/self/smaps", "r");
    if (!f)
        return -1;
    while (fgets(line, sizeof line, f))
        if (sscanf(line, pat, &v) == 1)
            sum = (sum < 0 ? 0 : sum) + v;
    fclose(f);
    return sum;
}

static void show(const char *key, const char *who)
{
    long sh_kb = roll_kb("Shared_Dirty:"), pr_kb = roll_kb("Private_Dirty:");
    if (sh_kb < 0 || pr_kb < 0) {
        printf("  %-28s (unavailable: this system does not let the program read /proc/self/smaps)\n", who);
        printf("#DATA %s -1 -1\n", key);
        return;
    }
    long sh = sh_kb / 1024, pr = pr_kb / 1024;
    printf("  %-28s shared %5ld MB   private %5ld MB\n", who, sh, pr);
    printf("#DATA %s %ld %ld\n", key, sh, pr);
}

/* Make the buffer memory that can be seen from outside. Held only by a local pointer, the
   compiler treats it as memory nobody reads and removes the malloc, the fill and the writes
   entirely --- Clang 22 did, and GCC 16 removed the parent's fill. It survived only by chance
   under this machine's GCC 14 (a phone run exposed it). Stored once in a volatile global,
   outside calls (fopen, fork) must be assumed able to see that memory. */
static char *volatile held;

int main(void)
{
    const size_t N = 512u * 1024 * 1024;
    char *p = malloc(N);
    held = p;
    long sum = 0;

    if (!p) { puts("not enough memory for the experiment."); return 0; }
    memset(p, 7, N);

    printf("== what fork copies ==\n\n");
    printf("#DATA-BEGIN\n");
    show("parent0", "parent, 512 MB filled");
    fflush(stdout);                     /* keep the child from inheriting buffered parent output */

    pid_t kid = fork();
    if (kid == 0) {
        show("child0", "  child, just forked");
        for (size_t i = 0; i < N; i += 4096) sum += p[i];
        show("child_read", "  child, read every page");
        for (size_t i = 0; i < N; i += 4096) p[i] = 9;
        show("child_wrote", "  child, wrote every page");
        (void)sum;
        fflush(stdout);
        _exit(0);
    }
    wait(NULL);
    show("parent1", "parent, after the child");
    printf("#DATA-END\n");

    puts("\n  * forking cost nothing, and reading cost nothing. Writing cost everything.");
    puts("  * the pages were shared until the moment one side changed them: that is the");
    puts("    whole of copy-on-write, and it needs a machine that can translate.");
    free(p);
    return 0;
}

Output

== what fork copies ==

  parent, 512 MB filled        shared     0 MB   private   512 MB
    child, just forked         shared   512 MB   private     0 MB
    child, read every page     shared   512 MB   private     0 MB
    child, wrote every page    shared     0 MB   private   512 MB
  parent, after the child      shared     0 MB   private   512 MB

  * forking cost nothing, and reading cost nothing. Writing cost everything.
  * the pages were shared until the moment one side changed them: that is the
    whole of copy-on-write, and it needs a machine that can translate.

512 MB were filled, then the process split, and the child’s own pages number zero. Reading all of them keeps it at zero. Only writing all of them makes it 512 MB. That is why fork is cheap — and it is a trick no machine without translation can play, as the next section shows.

Using the layout, not just obeying it#

An MMU is known as the thing that stops you, but it is also a thing you put to work. The same memory can sit at two addresses at once, and an address can have no memory behind it at all.

examples-en/apx-memory/layout/layout.c

/* A map is both a guard and a tool. Demonstrate a double-mapped ring, a guard page,
   and the permission combinations in this process. */
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <setjmp.h>
#include <unistd.h>
#include <sys/mman.h>
#if defined(__ANDROID__)
/* At the older Android API level Termux targets, Bionic hides the memfd_create declaration
   (it appears from API 30). The kernel has the system call, so call it by number. */
#include <sys/syscall.h>
#define memfd_create(name, flags) ((int)syscall(__NR_memfd_create, (name), (flags)))
#endif
#if defined(__ANDROID__)
/* At the older Android API level Termux targets, Bionic hides the memfd_create declaration
   (it appears from API 30). The kernel has the system call, so call it by number. */
#include <sys/syscall.h>
#define memfd_create(name, flags) ((int)syscall(__NR_memfd_create, (name), (flags)))
#endif

static sigjmp_buf back;

static void on_segv(int sig)
{
    (void)sig;
    siglongjmp(back, 1);
}

static void ring_of_one_page(size_t pg)
{
    int fd = memfd_create("ring", 0);
    if (fd < 0 || ftruncate(fd, (long)pg) != 0) { puts("  (memfd unavailable)"); return; }

    /* 먼저 두 쪽 넓이의 자리를 잡아 두고, 그 위에 같은 기억을 두 번 덮어 사상한다. */
    char *base = mmap(NULL, pg * 2, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    char *a = mmap(base,      pg, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, fd, 0);
    char *b = mmap(base + pg, pg, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, fd, 0);
    if (a == MAP_FAILED || b == MAP_FAILED) { puts("  (mapping refused)"); return; }

    strcpy(a, "hello");
    printf("  two windows %zu bytes apart; wrote at the first, read \"%s\" at the second\n",
           (size_t)(b - a), b);

    /* 끝에서 8 바이트 앞에 12 바이트를 쓴다 --- 감기 검사 없이 그냥 이어 쓴다. */
    memcpy(a + pg - 8, "ABCDEFGHIJKL", 12);
    printf("  wrote 12 bytes 8 before the end; the last 4 came out at the start: \"%.4s\"\n", a);
    printf("#DATA ring %.4s\n", a);
    close(fd);
}

static void guard_page(size_t pg)
{
    struct sigaction sa;
    char *p = mmap(NULL, pg * 3, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) { puts("  (mapping refused)"); return; }
    mprotect(p + pg * 2, pg, PROT_NONE);            /* 마지막 쪽의 권한을 거둔다 */

    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_segv;
    sigaction(SIGSEGV, &sa, NULL);

    if (sigsetjmp(back, 1) == 0) {
        for (size_t i = 0; i < pg * 3; i++) p[i] = 1;
        puts("  the overrun ran past the guard -- nothing stopped it");
        printf("#DATA guard none\n");
    } else {
        printf("  the overrun stopped at the guard page, %zu bytes in\n", pg * 2);
        printf("#DATA guard stopped\n");
    }
    signal(SIGSEGV, SIG_DFL);
}

static void permissions_of_this_program(void)
{
    FILE *f = fopen("/proc/self/maps", "r");
    char line[512], perm[8];
    long r = 0, x = 0, w = 0, none = 0, wx = 0, total = 0;
    if (!f) return;
    while (fgets(line, sizeof line, f)) {
        if (sscanf(line, "%*s %7s", perm) != 1) continue;
        total++;
        if (perm[0] == 'r' && perm[2] == 'x') x++;
        else if (perm[1] == 'w') w++;
        else if (perm[0] == 'r') r++;
        if (perm[0] == '-' && perm[1] == '-' && perm[2] == '-') none++;
        if (perm[1] == 'w' && perm[2] == 'x') wx++;
    }
    fclose(f);
    printf("  %ld regions: %ld read-only, %ld readable+writable, %ld executable, %ld with no access\n",
           total, r, w, x, none);
    printf("  regions that are writable AND executable: %ld\n", wx);
    printf("#DATA regions %ld\n", total);
    printf("#DATA wx %ld\n", wx);
}

int main(void)
{
    size_t pg = (size_t)sysconf(_SC_PAGESIZE);
    void *both;

    printf("== the page size of this machine: %zu bytes ==\n\n", pg);
    printf("#DATA-BEGIN\n");
    printf("#DATA pagesize %zu\n", pg);

    puts("-- one page of memory, mapped twice in a row --");
    ring_of_one_page(pg);

    puts("\n-- a page with its permissions taken away --");
    guard_page(pg);

    puts("\n-- the permissions this program is running under --");
    permissions_of_this_program();

    both = mmap(NULL, pg, PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    printf("  asking for one that is both writable and executable: %s\n",
           both == MAP_FAILED ? "refused" : "granted");
    printf("#DATA wxrequest %s\n", both == MAP_FAILED ? "refused" : "granted");
    printf("#DATA-END\n");

    puts("\n  * the same memory can sit at two addresses, and an address can have no");
    puts("    memory at all. What a pointer names is a place on a map.");
    return 0;
}

Output

== the page size of this machine: 4096 bytes ==

-- one page of memory, mapped twice in a row --
  two windows 4096 bytes apart; wrote at the first, read "hello" at the second
  wrote 12 bytes 8 before the end; the last 4 came out at the start: "IJKL"

-- a page with its permissions taken away --
  the overrun stopped at the guard page, 8192 bytes in

-- the permissions this program is running under --
  27 regions: 10 read-only, 12 readable+writable, 4 executable, 1 with no access
  regions that are writable AND executable: 0
  asking for one that is both writable and executable: granted

  * the same memory can sit at two addresses, and an address can have no
    memory at all. What a pointer names is a place on a map.

Three things came out at once.

★ Be honest about that last line. Among the mappings currently in force this machine has no write-plus-execute, but asking for a new one is granted. So W^X is not a law the hardware enforces; it is an agreement among linker, loader and kernel policy. It differs from machine to machine, and a JIT compiler opens that door deliberately.

Machines that do not translate#

Small machines have no MMU. They have only an MPU, or not even that.

An MPU (memory protection unit) does not change addresses. It draws regions over physical addresses and attaches permissions to them. That is the PMSA used by the Cortex-M family; the number of regions is usually eight, and on ARMv7-M each region must be a power-of-two size from 32 bytes to 4 GB. RISC-V has a relative — PMP (physical memory protection), which puts a policy on 16 or 64 physical regions, configurable only from the highest privilege (machine mode).

What changes when translation is missing? The way you write C changes.

WhatWhyWhat instead
no forkthere is no way to give a child “the same addresses, different memory”vfork — share the memory, and the parent waits
no copy-on-writenothing can intercept a writecopy up front, or share deliberately
addresses are not fixeda program is loaded wherever there is roomposition-independent formats such as FDPIC
code costs no RAMit executes straight out of flash (XIP)only the data is copied into RAM
fragmentation is fatalscattered physical pages cannot be stitched togetherarenas and pools — chapter 94

Table 105.3 — What changes on a machine without translation

★ The last row ties back into the rest of the book. Arenas and pools are so common in embedded work not out of taste but because there is nothing there to stitch with. A machine with translation can make physically scattered pages look adjacent in virtual addresses; a machine without it cannot, and once memory is scattered that is the end of it.

When there is more than one address space#

Here is the least familiar part of this appendix. Everything so far has spoken as if there were one address space. Many machines do not have one.

SchemeWhat it isHow it shows up in C
Harvard architecturecode and data live in separate address spaces (AVR and others)the same number 0x100 is a place in flash and a place in RAM
named address spaceswhich space is written into the type. The extension of ISO/IEC TR 18037const __flash char msg[] = "hi"; — a different instruction is issued to read it; AVR requires the const
bank switchingtoo few address lines, so a window is opened in the address space and the bank (a slab of memory) shown through it is swapped“which bank is in the window now” becomes program state
segments (x86 real mode)two 16-bit halves overlapped into a 20-bit addressnear, far and huge were keywords the compiler added to the grammar. Not library functions — they went on declarations, like const
word-addressed DSPsthe smallest addressable unit is not 8 bitson the TI C55x a char is 16 bits — a world where sizeof(char) and sizeof(int) are both 1

Table 105.4 — Machines with more than one address space

Look at the second row a little longer. In avr-gcc, __flash points at read-only data placed in flash, and following such a pointer issues a flash-specific instruction rather than an ordinary load.

What matters here is what kind of thing __flash was added as. Not a macro, not a library function, not a #pragma. It is a type qualifier — in GCC’s own words, “address space identifiers may be used exactly like any other C type qualifier (e.g. const or volatile)”. So it goes where those go.

So it is built, for real, with an AVR cross compiler. This example is not a program that runs: compiling it is the experiment. It is built three times and what came out is printed as it came.

examples-en/apx-memory/named_space/named_space.c

/* See a named address space: AVR `__flash`.

   This file does not run on this machine (x86-64). Compiling it is the experiment:
   run.sh builds it three times with an AVR cross compiler and shows the results.

   Three things to see:
     1. `const __flash` is a type qualifier in the same position as `const`.
     2. It is not standard C. Strict `-std=c23` rejects it; `-std=gnu23` accepts it.
     3. The same subscript `x[i]` emits different instructions for different spaces,
        and the data lands in different regions. */

const char         ram[] = "hi";      /* placed in RAM (.rodata)              */
const __flash char rom[] = "hi";      /* placed in flash (.progmem.data)      */

char from_ram(int i) { return ram[i]; }
char from_rom(int i) { return rom[i]; }

/* Mix pointers from different spaces: one points to RAM, the other to flash,
   yet the assignment silently drops the qualifier. */
const char         *p;
const __flash char *q;
void mix(void) { p = q; }

Output

== compiler ==
avr-gcc (GCC) 16.1.0

== 1. strict ISO mode: -std=c23 ==
  named_space.c:13:14: error: expected ';' before 'char'
  -> __flash is a GNU extension, so the strict mode does not have the word.

== 2. GNU mode: -std=gnu23 -Wall -Wextra -Wpedantic ==
  compiled, and said nothing --- not even about mixing the two spaces
  in 'p = q' (see the source). The qualifier is dropped in silence.

== 3. the same subscript, two instructions ==
  from_ram:
    subi r24,lo8(-(ram))
    sbci r25,hi8(-(ram))
    movw r30,r24
    ld r24,Z
    ret
  from_rom:
    subi r24,lo8(-(rom))
    sbci r25,hi8(-(rom))
    movw r30,r24
    lpm r24,Z
    ret
  where each array landed:
    rom  -> .progmem.data
    ram  -> .rodata

  * ld reads data memory; lpm reads program memory. The C text was the same
    'x[i]' in both functions -- the type chose the instruction.

Three things showed up, and one of them was not what I expected.

First, it attaches to a declaration. const __flash char rom[] stands next to const. It qualifies not a value but the type.

Second, it is nevertheless not a word of standard C. Built with -std=c23 — strict ISO mode — the file does not compile at all; the parser asks for a ; before char, because that dialect simply has no such word. -std=gnu23 is what makes it stand. The one who added the word to the language was the compiler, not the standard, and this one line says so.

Third, the compiler picks the instruction from the type. In C, from_ram and from_rom are the same x[i]; what came out was ld (read data memory) in one and lpm (read program memory) in the other, with the arrays landing in .rodata and .progmem.data respectively. One type qualifier decided both where the data goes and which instruction reads it.

A common misconception. Mixing pointers from different address spaces is caught by the compiler

★ This is where the measurement contradicted me. p = q — putting a pointer into flash into a pointer into RAM — passed without a word, even under -Wall -Wextra -Wpedantic (avr-gcc 16.1.0). The compiler that complains when an assignment drops a const is silent here. Follow the p you get, and an ld goes out and reads the RAM cell with that number — not the letter sitting in flash. This is exactly the accident behind the first row of this table — in a Harvard machine the same 0x100 names two places. What prevents it is the programmer, not the compiler.

__memx goes one step further: it uses a 24-bit pointer and encodes which space in the high byte, so that one pointer can reach either — at the price of asking “which space?” on every dereference.

★ Why this matters. chapters 38 said a pointer is not “an integer holding an address” but a thing that points at an object. If that sounded like pedantry, here is the ground for it: the same integer value can name two places on a machine. That is why the standard is written in terms of objects and not addresses.

History belongs in the same place. The far of 16-bit DOS was not a function or a macro either; it was a keyword the compiler added to the language. “This pointer carries a a plain pointer (near) held only a 16-bit offset, so which byte that value meant depended on what happened to be in the segment register at the time. To reach data in another segment you had to write far into the declaration, so that the pointer carried the segment as well.

So both eras did the same thing. When a machine has more than one address space, that fact climbs up into the grammar — because without somewhere in the language to tell the compiler which space you mean, the program cannot be written at all. Standard C has no such place, so each compiler makes one: far then, address-space qualifiers such as __flash now.

Caches, DMA, and scratchpads#

Separately from translation, memory acquires copies. That is the cache (chapter 12). While only the program touches memory this stays invisible; it surfaces the moment another device touches the same memory.

On a cached microcontroller such as the Cortex-M7, using DMA (direct memory access) goes wrong in both directions: DMA puts fresh data in RAM while the CPU reads a stale copy from cache, and the CPU writes data to send that is still sitting in cache while DMA reads stale RAM. There are three ways out.

WayWhat it doesWhat it costs
a non-cacheable regionuse the MPU to keep that buffer out of the cacheevery access to that buffer gets slower
clean and invalidatewrite the cache’s contents down to memory before DMA reads, and throw the cached copy away after DMA writesit works in line-sized units, which is fussy for small buffers
TCMuse dedicated memory that never goes through the cacheit is small, and it differs from part to part

Table 105.5 — When DMA and the cache disagree

TCM (tightly coupled memory) is not a cache but a place in the address space. A cache fills itself; TCM is filled by the programmer’s decision about what belongs there. The same idea carries several names — scratchpad, local store. “Fast memory you place by hand instead of getting automatically” has always been one branch of embedded and accelerator design.

Pointers are growing flesh#

One last current, happening now: hardware is loading more into pointers.

★ One of this book’s arguments closes here. When chapters 38 said a pointer is not an integer, it may have sounded like the standard being difficult. That difficulty is now descending into silicon. Code that treats pointers as integers really does break on these machines — because what was stored in the topmost byte got erased, or because a narrowed capability was cast to an integer in the hope of widening it again.

Recap

  • The address malloc gave you is usually not the number of a memory cell but a place in an address space. Writing C differs between machines that have something to turn that place into real memory and machines that do not.
  • With address translation, an allocation is a promise and the bill comes at first touch — measured here at more than a hundredfold per page.
  • Copy-on-write needs a device that can intercept a write. That is why fork simply does not exist on machines without translation.
  • An MMU does not only forbid. It puts one piece of memory at two addresses at once to make a ring buffer, and posts an address with no memory behind it as a guard.
  • Small machines have only an MPU, or nothing. With nothing, running past the end is silent.
  • The assumption of a single address space breaks often in embedded work. When it does, the fact climbs into the grammar — the compiler adds keywords such as far and __flash, and you write which space a thing belongs to into its declaration, the way you write const.
  • Caches surface when another device touches the same memory: non-cacheable regions, clean and invalidate, TCM.
  • Tags and bounds are being loaded into pointers. “A pointer is not an integer” is moving from something the standard says to something the hardware says.