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 names | a virtual address | a physical address | a physical address |
| Same address, other program | somewhere else | the same place | the same place |
| Unit that carries permission | a page (usually 4 KiB) | a region (eight or so) | none |
fork | yes (copy-on-write) | no | no |
| Writing past the end | a page fault only once it leaves what is mapped; within the same page, silence | a fault, if it leaves the region | nothing happens at all |
| Where you meet it | PCs, servers, phones | Cortex-M parts, RTOSes | 8-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.
| Scheme | Levels | Virtual address | Page sizes available |
|---|---|---|---|
| Sv39 | 3 | 39 bits | 4 KiB · 2 MiB · 1 GiB |
| Sv48 | 4 | 48 bits | 4 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
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.
- A ring buffer. One page was mapped twice, back to back. So writing twelve bytes eight before the end never has to ask “have I reached the end?” — the four that overflow simply appear at the start. It is an old trick in networking and audio code, where that question comes up constantly.
- A guard page. Take the permissions away from the last page and an overrun stops there. Instead of quietly wrecking its neighbour, the program dies on the spot. This is what lies at the end of a stack.
- W^X. Not one of this program’s mappings is both writable and executable. Code runs but cannot be changed; data changes but cannot be run.
★ 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.
| What | Why | What instead |
|---|---|---|
no fork | there is no way to give a child “the same addresses, different memory” | vfork — share the memory, and the parent waits |
| no copy-on-write | nothing can intercept a write | copy up front, or share deliberately |
| addresses are not fixed | a program is loaded wherever there is room | position-independent formats such as FDPIC |
| code costs no RAM | it executes straight out of flash (XIP) | only the data is copied into RAM |
| fragmentation is fatal | scattered physical pages cannot be stitched together | arenas 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.
| Scheme | What it is | How it shows up in C |
|---|---|---|
| Harvard architecture | code 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 spaces | which space is written into the type. The extension of ISO/IEC TR 18037 | const __flash char msg[] = "hi"; — a different instruction is issued to read it; AVR requires the const |
| bank switching | too 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 address | near, far and huge were keywords the compiler added to the grammar. Not library functions — they went on declarations, like const |
| word-addressed DSPs | the smallest addressable unit is not 8 bits | on 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
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.
| Way | What it does | What it costs |
|---|---|---|
| a non-cacheable region | use the MPU to keep that buffer out of the cache | every access to that buffer gets slower |
| clean and invalidate | write the cache’s contents down to memory before DMA reads, and throw the cached copy away after DMA writes | it works in line-sized units, which is fussy for small buffers |
| TCM | use dedicated memory that never goes through the cache | it 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.
- MTE (memory tagging extension, ARMv8.5): memory is divided into 16-byte granules, each granule gets a 4-bit tag, and the pointer carries the same 4 bits in its topmost byte. If the two disagree the hardware catches it. Reuse freed memory and the tags no longer match, so the program dies right there.
- CHERI (capability hardware enhanced RISC instructions)/Morello: the pointer grows to 128 bits and carries, besides the address, its bounds and permissions and a validity tag that cannot be forged. Permissions can only be narrowed, never widened.
★ 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
mallocgave 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
forksimply 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
farand__flash, and you write which space a thing belongs to into its declaration, the way you writeconst. - 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.