All documentation
How it works
Paging and memory
docs/paging.mdView on GitHubFour-level paging with 4 KiB pages, a higher-half kernel and no user space
yet. This page covers the whole path: the bootstrap map the assembly entry
builds, the real page table that replaces it, how physical memory becomes a
free list, the allocators layered on top, and the rules that keep them from
deadlocking. The code lives in src/cpp/mm/, with the PTE encoding in
arch/*/pte.h behind hal/pte.h.
The address space
| Range | Contents |
|---|---|
0x0000000000000000 – 0x00007FFFFFFFFFFF |
User half. Mapped only by the x86 bootstrap table, and only until the real table takes over; on arm64 TTBR0 walks are disabled outright after boot |
0xFFFF800000000000 + phys |
KernelSpaceBase. The bootstrap linear map, and the permanent home of every MMIO mapping |
0xFFFF800001000000 |
Kernel image (x86-64; arm64 links at …40200000, matching the QEMU virt load address). Split into text RX / rodata RO+NX / data RW+NX late in boot |
| just above the image | The TmpMap window: 512 slots, one L1 table, the only way the kernel touches an arbitrary physical page -- 448 shared under a lock, and one for each CPU for copying a frame |
| next huge-page boundary | PageArray, one 32-byte descriptor per 4 KiB frame, mapped in 2 MiB pages |
after PageArray |
The VaAllocator arena the page allocator hands out |
Two page tables
BuiltinPageTable is the bootstrap linear map: phys ↔
phys + KernelSpaceBase, arithmetic rather than a walk. On x86 it is the C++
copy of what boot64.asm built — four 1 GiB PDPT entries of 2 MiB pages,
covering the low 4 GiB — installed in Main2 before GRUB's tables are
overwritten. On arm64 the equivalent is built in arch/arm64/builtin_pt.cpp.
MapHighRam() extends it once the firmware memory map is known: one 1 GiB
block per GiB of RAM above 4 GiB, skipping blocks with no usable RAM in
them. On x86 that needs CPUID PDPE1GB (everything since Nehalem and
Barcelona has it; QEMU's default CPU model does not advertise it, so the
fallback is a path that actually gets exercised); on arm64 a 1 GiB L1 block
is always available at a 4 KiB granule. 512 GiB is where one PDPT/L1 table
runs out, and that is the kernel's ceiling.
The low 4 GiB deliberately stays at 2 MiB granularity even after the extension: below that line the MTRRs describe MMIO holes with memory types other than write-back, and a large page spanning two MTRR types is left to the implementation — in practice the most conservative type wins, which would make a whole GiB of RAM uncached.
Why this matters: the bootstrap map's extent is the hard ceiling on the
RAM the kernel can use. Building the free list threads a next-pointer
through the free pages themselves, which means writing to every one of them
through this map. RAM above GetMappedLimit() is real, reported by the
firmware, and unusable — so GetFreePages prints both numbers, and
meminfo shows them, rather than letting the difference be silent.
PageTable is the real thing: a 4-level table built page by page, with
Root loaded into CR3 (x86) or TTBR1 (arm64) partway through Main2. From
that point the linear map is gone and every access to an arbitrary physical
page goes through TmpMap.
TmpMap: the window onto physical memory
512 consecutive VAs sharing one L1 table. The first 448 are shared, guarded
by TmpMapLock: TmpMapPage(phys) finds a free slot, writes the leaf PTE,
invalidates the local TLB, takes a reference on the page and returns the
VA; TmpUnmapPage reverses it. TmpMapRange takes a run of adjacent slots
for a structure that crosses a page boundary (ACPI tables, mostly).
The last 64 are a slot for each CPU, and serve one thing: a copy into or
out of a frame -- a page handed out by address and mapped nowhere, what a
guest's memory is made of (kcore::frame) -- which is every read and write
of a guest's memory but its devices' rings, which are mapped through
Mm::MapPages while a queue is up (kcore::frame::Mapped). MapFrameSlot(phys) maps this CPU's slot and
UnmapFrameSlot clears it, with interrupts off from one to the other,
which makes the slot the copy's alone: no lock, no slot to search for, no
reference (the frame is the caller's for the call), no memory-map lookup
(a frame is RAM), and one TLB invalidation instead of two -- the slot was
not present, and no TLB keeps anything of an entry that was not
(Hal::PteMadeValid is x86's compiler barrier and arm64's dsb ishst; isb). The frame is mapped only for the length of its copy, and nowhere
the moment it is done. Through the shared slots, their lock and their two
flushes had cost a busy guest's vCPU as much as the copy itself.
Two details that are easy to get wrong:
- RAM is mapped write-back, everything else uncached —
TmpMapserves both free pages and MMIO, andMemoryMap::IsUsableRamis what tells them apart. - The slots are shared, and unmapping invalidates only the local TLB. So
VirtToPhys— a four-deep walk, each level mapped and unmapped in turn — disables preemption for the whole walk: a migration mid-walk would leave a stale mapping on the original CPU that a later slot reuse would mistranslate.
From firmware map to free list
PageTable::Setup() runs in this order, and each step depends on the one
before:
HighestPhyAddris the top of usable RAM, clamped to the bootstrap map.PageArrayis sized (onePageper frame — 512 MiB on a 64 GiB box) and given a physically contiguous, huge-page-aligned home byReservePageArray, clear of the kernel image and of every reserved region. That home is then added to the memory map as reserved, which is the entire mechanism that keeps the free list off it.GetFreePages()walks the usable regions and threads a singly linked list through the pages themselves. It skips reserved runs boundary to boundary rather than testing each page (a real server has twenty-odd reserved regions and sixteen million pages), skips the kernel image, and sorts pages whose identity address is shadowed by the kernel's own VA window onto a second list —ExcludedPages— becauseSetupstill reaches its allocations through the bootstrap map.- The kernel image, the
TmpMapslots and its L1 table get real 4 KiB mappings;PageArraygets 2 MiB ones (512 times fewer leaf entries, and a TLB that can actually cover it). - Every descriptor is initialised through the bootstrap map, with its list
links pointed at the address the descriptor will answer to once
PageArrayis live — "self-pointing means not on the free list" is the testAllocContiguousPagesuses.
SetupFreePagesList() then runs DrainEarlyFreeList twice: once for the
main list, once for the excluded one, which is safe by then because all page
access goes through TmpMap. The drain holds a single TmpMap slot across
the whole walk instead of using the general pair — one TLB invalidation and
one lock round trip per page instead of two of each.
Nothing is zeroed on the way to the list: AllocPageNoLock zeroes every page
it hands out, so a page sitting on the free list is not expected to be clean.
Even so, building the list touches every page twice, about 0.6 µs each, so a
64 GiB machine spends roughly ten seconds of its boot here — hence the
mm: … MiB onto the free list progress lines, one per 8 GiB, so a long boot
does not look like a hang.
Page descriptors and refcounting
Page is 32 bytes: a list entry, an atomic refcount, a physical address.
The refcounting is explicit and the easiest thing here to get wrong:
| Call | Effect on the refcount |
|---|---|
MapPage(va, page) |
+1 |
GetPage(phys) |
+1 — the caller must Put() |
UnmapPage(va) |
net 0 (it does a GetPage and a Put internally), returns the Page* |
FreePage(page) |
back onto the free list; does not touch the refcount |
To undo a MapPage: UnmapPage + Put. To free a mapped page:
UnmapPage + FreePage + Put.
The range forms — MapPages (array of Page*), MapContiguousPages (a run
of descriptors), MapPhysPages (physical addresses of pages that already
exist) — share one walk down L4/L3/L2 and one temp mapping of the L1 table,
so an n-page block costs 4 temp mappings instead of 4n. All three are
all-or-nothing, and all of them invalidate only the local TLB: the caller
shoots down the other CPUs once for the whole range.
The allocators
Three layers, each built on the one below.
PageTable::AllocPage / AllocContiguousPages(n ≤ 128) — frames off
the free list. A contiguous run is found by walking the free list and, for
each candidate, checking that the next n-1 descriptors in PageArray are
physically consecutive and still free — a self-pointing list entry is what
marks a descriptor as allocated. The walk is deliberately unbounded (a cap
would turn a slow allocation into a spurious OOM, and plain new panics on
that) but says so in the log once it has examined more than a thousand
entries, because it runs with the page-table lock held.
PageAllocatorImpl — eight FixedPageAllocator buckets, for 1, 2, 4 …
128 pages. The VA arena starts where PageArray ends and spans 70% of the
free page count; each bucket gets an eighth of it and tracks it with a
VaAllocator bitmap (mm/va_allocator.h), whose blocks are aligned to their
own size. The
Mm:: surface on top of it (mm/new.h):
| API | Allocates frames? | Tracked VA? | Frees frames on release? |
|---|---|---|---|
Mm::Alloc / Mm::Free |
yes | yes (via the pools) | yes |
Mm::AllocMapPages / UnmapFreePages |
yes, contiguous, returns the physical address too | yes | yes |
Mm::MapPages / UnmapPages |
no — the caller supplies the physical addresses | yes | no |
AllocatorImpl + Pool — power-of-two size classes for small
allocations, one Pool per class, each carving 4 KiB pages into blocks with
a small per-block header (a list link and the caller's tag, which is what
makes a leak report name its owner). An 8-byte header goes on every request,
so the smallest class actually reached is 16 bytes and the largest is 2 KiB;
a request of 2040 bytes or more goes straight to the page allocator. Free
tells the two apart by alignment — a page-aligned pointer came from the page
allocator — and checks a magic word otherwise. A free block carries a poison
tag, swapped for the owner's and back by compare-and-swap, which is how a
double free is caught — on two CPUs at once too — and a block handed out
twice, or written into while free.
What the heap keeps between a free and the next allocation, so that neither
costs what the first one did (heapbench, below, measures each):
- A cache per CPU in front of the pools. A few free blocks of each size,
under a lock only that CPU takes, traded with the pool half a cache at a
time — so allocations of one size on several CPUs do not all queue on the
pool's lock. Used once the CPU can say which it is (
Hal::IrqChipReady). - An empty page per pool. The largest class's block has a page to itself, so without it every allocation and free of 1 to 2 KiB was a page mapped and unmapped.
- Freed blocks of the page allocator, still mapped — a bounded list per
size, a thousandth of RAM at most and never more than 512 KiB a size. A
block that comes back costs no map and, on x86, no TLB shootdown of every
other CPU.
Mm::Alloczeroes a kept block, as fresh pages always are;Mm::AllocUninit, what Rust's global allocator calls, does not —GlobalAlloc::allocpromises no contents, andalloc_zeroedzeroes on the Rust side. DMA buffers (UnmapFreePages) are never kept.
AllocatorImpl::Trim gives all of it back; an allocation that finds no page
or no VA trims the page allocator's lists and tries once more before refusing.
operator new sits on top of Mm::Alloc, and its contract is unusual:
plain new T(...) panics on OOM and never returns nullptr. The
replaceable plain form is implicitly potentially-throwing, so the compiler
runs the constructor on the result unchecked and may delete a caller's null
test — a null check after plain new is dead code. An allocation that wants
to observe OOM must use new (Mm::NoThrow) T(...) or
Mm::TAlloc<T, Tag>(), both of which are checked; from Stdlib:: code the
tag is spelled in full, new (Kernel::Mm::NoThrow) T[...], as in
lib/vector.h. The reasoning is in mm/new.h. There is no way to make the
plain form nullable instead: a noexcept redeclaration of it does not
compile, and the toolchain's clang parses -fcheck-new without implementing
it.
MMIO, cacheability and W^X
MapMmioRegion(phys, size, policy) builds 4 KiB leaf entries at
phys + KernelSpaceBase, allocating table pages as it goes. Two policies:
MmioUncached for device registers (every load and store reaches the
device, in program order) and MmioWriteCombining for memory-like device
RAM with no read side effects — a framebuffer — where stores may be
buffered, merged and reordered, which is what makes drawing affordable. The
write-combining writer must call Hal::WcFlush() when the pixels have to be
visible, and must never use it for registers; on a CPU without PAT the
policy silently degrades to uncached.
Every MMIO mapping is non-executable: nothing is ever fetched from a device, and on real hardware a speculative fetch from device memory can trigger read side effects.
These mappings live outside the VaAllocator, are permanent, and invalidate
only the local TLB — so MapMmioRegion callers must run on the BSP before
the APs start. On arm64 a request that falls inside the boot device GiB is
answered from the premapped window instead (Hal::MmioPremappedVa).
Never map DMA or device memory at phys + KernelSpaceBase by hand: go
through Mm::MapPages / Mm::AllocMapPages so the VA is tracked.
W^X has two halves. Every mapping the kernel makes at runtime is
non-executable from birth: MapRangeLocked sets NX on each leaf it writes,
so the heap, task stacks, DMA buffers, the temp-map window and PageArray
are RW+NX, and so is MMIO (MapMmioRegion). Nothing walks that back except
the module loader, which asks for each segment's permissions by name once
the segment is written (SetRangeProtection), so a module's text is RX and
nothing is ever writable and executable at once. The tables above the leaves
stay permissive: on both architectures an NX bit on an L4/L3/L2 entry takes
execute permission away from the whole subtree and no leaf can give it back.
The other half is the kernel image, which is the one executable mapping the
page table is built with — the kernel runs out of it from the root switch
onwards. Late in boot ProtectRange walks its 4 KiB leaves and splits it
into text RX, rodata RO+NX, data RW+NX on both architectures.
So self-modifying code, executing from a heap buffer, or writing through a
pointer into .rodata faults instead of silently working. wxprobe=text
deliberately writes to .text and wxprobe=heap deliberately calls into a
page the allocator just handed out, to prove both halves are on;
scripts/wx-test.sh runs both boots and fails if either survives.
On x86-64 the NX bit means nothing until EFER.NXE is set, and bit 63 is a
reserved bit until it is — a PTE carrying it faults on the first touch. So
Hal::EnableWxSupport() runs before any page table is built on the BSP, and
on each AP before it loads the kernel's (ApMain2). arm64 needs no enable:
PXN/UXN are always honored.
TLB shootdown, and the deadlock it can cause
Every mapping change invalidates only the issuing CPU's TLB.
CpuTable::InvalidateTlbAll/Address/Range fan the invalidation out to the
others as an IPI task and wait for every one to acknowledge — except on
arm64, where TLBI broadcasts in hardware and Hal::TlbShootdownNeedsIpi()
answers false.
This is the origin of the single most important rule in the codebase:
never allocate or free with a spinlock held. Mm::Free/Mm::Alloc can
reach the page allocator, which shoots down every other CPU and waits for
all of them to answer. A CPU spinning on the lock you hold has interrupts
off and can never answer, so the two wait for each other forever and the
machine stops dead with no panic and nothing to reset it. Collect what needs
releasing into a local list under the lock, unlock, then release. Better
still, do not allocate on a datapath at all — network frames come from a
pool built once at boot for exactly this reason.
SendTlbIPI gives up after ten seconds and panics naming the CPUs that never
answered, so a new instance of this bug is a report rather than a dead
machine.
Looking at it from the shell
meminfo— the firmware memory map, and how much of it the kernel actually uses (RAM reported vs. RAM reachable).memusage— free and total page counts.heapbench(a module,insmod /heapbench.ko; Modules) — what an allocation and a free cost, size by size, on one CPU and on all.memcheck—CheckFreeListwalks every descriptor and reports any page that is on the free list but must not be: inside a reserved region, inside the kernel image, or outside usable RAM. That invariant is what the whole carve-out scheme rests on, and its violation is silent until something overwritesPageArrayor an ACPI table.