armviz

How a virtual address becomes a physical one

Every memory access a program makes goes through the same small machine. The program names a virtual address; the MMU turns it into a physical one; only then does anything move. There is no fast path. Code fetches are translated too.

The mechanism is a chain of lookup tables, and the only interesting part is that the chain's shape is not fixed. It comes out of TCR_EL1 at the moment the MMU is switched on, and it changes with two fields.

Three registers set the shape

TTBR0_EL1 is the base address of the first table. Its name says "translation table base, lower half" — there is a TTBR1_EL1 for the upper half of the address space, and the two are configured separately.

TCR_EL1 is the interesting one. It says how big an address is and how big a granule is, and from those two numbers the entire level structure follows. Everything else in the register — the cacheability and shareability fields — affects performance and coherence rather than which entry gets read.

SCTLR_EL1 has the enable bit, M. Translation is off at reset, so nothing above happens until software writes it.

The address is a stack of indices

Take a 48-bit virtual address with 4 KiB granules. It splits like this:

63                     48 47        39 38        30 29        21 20        12 11         0
+------------------------+-------------+------------+------------+------------+-------------+
|        ignored         |  L0 index   |  L1 index  | L2 index  |  L3 index   | page offset |
|        T0SZ bits       |   9 bits    |   9 bits   |  9 bits   |   9 bits    |   12 bits   |
+------------------------+-------------+------------+------------+------------+-------------+

Reading it left to right: the top 16 bits are ignored (that is what T0SZ = 16 means), then four 9-bit indices, then a 12-bit offset within the page.

The processor uses those indices as an address. The level 0 index selects an entry in the table TTBR0_EL1 points at. That entry is another address — a pointer to the level 1 table — and the level 1 index selects an entry in that. Four levels down, the entry is not a pointer any more. It is a leaf: a block or a page descriptor, with a physical address in it. The walk is over.

So the hardware does four dependent memory reads before it can move your byte. None of them are cached in a way you can see, and none of them are skippable without permission.

Nine bits, not nine-and-a-bit

The reason each index is nine bits is the reason it is not twelve. A table has 512 entries of eight bytes, and 512 × 8 = 4096, which is exactly one granule. So one index consumes exactly one granule of address space, and the index width has to track the granule size:

GranuleGranule bitsEntries per tableIndex width
4 KiB125129
16 KiB14204811
64 KiB16819213

This is the first thing that makes the arithmetic feel wrong: 16 KiB granules give eleven-bit indices, not twelve. The table is bigger, so the index is wider, so it eats more of the address. Doubling the granule size costs you bits.

Where T0SZ and TG0 bite

TG0 selects the granule. T0SZ says how many top bits of the address are ignored, which is the same as saying how many significant bits remain: 64 - T0SZ.

Change T0SZ and the level structure changes with it, because the indices have to tile exactly the bits that remain. These three are all 4 KiB granules with 48-bit physical addresses, and they are the same architecture:

T0SZ=16  4 levels, root L0
        L0:47:39  L1:38:30  L2:29:21  L3:20:12

T0SZ=22  4 levels, root L0
        L0:41:39   <- only 3 bits
        L1:38:30  L2:29:21  L3:20:12

T0SZ=34  2 levels, root L2
        L2:29:21  L3:20:12

Two things to notice. At T0SZ=22 the root index is only three bits wide, because the address is 42 bits and the three lower levels consume 27 of them — 15 left for the root. The root table is smaller than the others, not narrower per entry. And at T0SZ=34 there is no level 0 at all: the root is level 2, and the walk is two reads instead of four.

That is why the rule levels = (va bits - 4) / stride is integer division, and why the root level is 4 - levels rather than always 0.

The level count is not always four

AArch64 allows five levels. With 5-level paging, TCR_EL1.T0SZ can go down to 10, giving 54 significant bits, and the walk starts at a level numbered minus one:

T0SZ=10  5 levels, root L-1
        L-1:53:48  L0:47:39  L1:38:30  L2:29:21  L3:20:12

L−1 exists because the levels are numbered relative to the last level, which is always level 3. That is the invariant: level 3 is always the last one, always indexed by bits 20:12 under a 4 KiB granule, and everything above it counts downward from there.

What a descriptor says once you find one

The leaf entry is 64 bits and the low two decide its shape:

  • 0b11 — another table, and bits 47:12 are the next address in the walk. This is also a page descriptor, at level 3.
  • 0b01 — a block, with a physical address and a size set by which level it was found at.

The shape bit is 0b01 for a block, not 0b00. 0b00 is invalid, which is convenient: a zeroed table entry is an invalid descriptor rather than a plausible-looking block mapping address zero.

A block's physical address is bits 47:12, shifted up by addrLo for the level — which is to say the field holds PA >> addrLo, not the address itself. At level 3 addrLo is 12, so nothing shifts. At level 1 with a 4 KiB granule addrLo is 30, so the field holds the address shifted right by 30, and a block there covers 1 GiB.

Two more fields matter enough to call out:

AP at bits 7:6 controls access at EL0 — the low bit is read permission and the high bit is write permission, with the high bit set meaning read-only. So 01 means read-only and 11 means read-write, which is the opposite of how the letters read.

AttrIndx selects one of sixteen attribute bytes in MAIR_EL1, which is where Normal versus Device memory, and cacheability, actually get decided. It sits at bits 4:2 on a leaf but at 5:3 on a table descriptor, which is the kind of detail worth opening the descriptor explorer for rather than recalling.

The faults

Translation can fail, and the four reasons are worth separating because they mean different things:

  • Invalid — the entry had bit 0 clear, or the walk ran off the end of the tables. A null pointer dereference shows up here.
  • Translation fault — the descriptor is a valid pointer but the next table is not accessible. Usually a permission problem on a table descriptor, not on the data you asked for.
  • Permission fault — the descriptor was found and fine, and then refused the access. AP said no, or PXN/UXN said no to executing from it.
  • Access flag fault — the mapping is fine but AF was never set, so the hardware refuses until software acknowledges the access for the first time.

The distinction between the last two is the one people get wrong when reading a fault: "no entry" and "an entry that says no" are different events, and only the second one means your page tables are working.

Try it

The address translation page has the same thing interactively: change the granule or address size and watch the split change, paste a descriptor and see which bits mean what, and follow a single address through four tables one entry at a time.

The walk the browser shows is checked against QEMU's own translation implementation — a real AArch64 CPU with the MMU on, reading through the same mappings — so the indices and the leaf fields above are not just internally consistent. All three granule sizes are checked separately, because each one produces a different level structure and agreeing on one of them says nothing about the other two.