Memory layout
Every address space has two halves. The lower half is user space, the process’s own. The upper half is the kernel’s, the same in every address space and unreachable from user mode: it holds the physmap (all of RAM at a fixed offset), the kernel image and stacks, the windows onto the interrupt controller and DMA devices, and one area of per-process kernel data. This page gives every fixed address, for Sv39 (rv64) and Sv32 (rv32), the conventions user space follows, and what each page-table entry bit means to Redoubt.
Purpose
The kernel, the loader, the loader stub and every launcher must agree on where things are. The
kernel-half constants live in one crate, redoubt-layout (libs/layout), which the loader uses
to build the map and the kernel uses to rely on it. The end of user space (USER_AREA_END) and
PAGE_SIZE (4096, on both widths) are redoubt-sys’s, because programs need them. The page-table
code is the paging crate (libs/paging), the only code that reads or writes a page-table entry.
What the mapping calls do with this layout is memory; the rules they keep are
R11 (memory) and R19 (kernel W^X). This page owns one rule, R24: the
kernel never reaches user memory through a user mapping.
The direct physical map
Status: built · tested: bench:kernel-wx, bench:kernel-half-attack
The loader maps all of RAM once, in the kernel half, at
virt = PHYSMAP_BASE + (phys - PHYSMAP_PHYS_BASE)
Code computes it with physmap_virt(phys), never PHYSMAP_BASE + phys, because the offset
differs by width. On Sv39 the map starts at physical 0 (PHYSMAP_PHYS_BASE = 0), so it also
covers the physical addresses below RAM, where the devices sit. On Sv32 it starts at RAM
(0x8000_0000 on QEMU virt), which is also the start of the kernel half, so the map is the
identity there.
The window is PHYSMAP_SIZE long (128 GiB on Sv39, 2032 MiB on Sv32). The loader refuses a
machine whose RAM does not fit in it (R17 (fail closed)): the kernel hands out
frames lowest first, so RAM past the window would stop the kernel the first time a process
allocated a frame there.
Every physmap entry is readable, global and supervisor-only: no U bit and never X. All are
writable but the kernel’s read-only frames. The map is built from the largest leaves (1 GiB on
Sv39, 4 MiB on Sv32), except around those frames: for every kernel page that is not writable
(code and constants) the loader splits the superpages above it down to 4 KiB and clears W on
that frame’s alias. At boot, before any
process runs, the kernel walks its own mappings and stops unless no executable kernel page is
writable and the physmap alias of each is neither writable nor executable (verify_kernel_wx,
R19). It prints the verdict on every boot.
Nothing unmaps the physmap. So the kernel reaches any frame at any time, without switching
satp and without temporary mappings:
- page tables are walked and edited in software, from the root that
satpnames, through the physmap (kernel/src/arch/riscv/physmap.rs); another process’s tables are edited the same way; - kernel objects (budgets, handle-table pages) live in frames no process maps, reached through
the physmap (
kernel/src/kframe.rs); - a system call reads or writes a user record by walking the caller’s tables to the frame,
checking the entry is a user mapping with the permission needed and not lent, and copying
through the physmap (
user_frame). The kernel never dereferences a user pointer.
The split by root entry
Status: built · tested (5)
- bench:map-fixed-attack
- bench:loader-rejects-kernel-address
- bench:loader-rejects-kernel-entry
- host:redoubt-sys::map_fixed_range_check_refuses_rv32_wraparound
- bench:kernel-half-attack
The root table’s lower half is user space; its upper half is the kernel’s. The kernel’s root
entries are made once, by the loader, and never change afterwards. Creating an address space
allocates a root and copies every kernel-half entry into it from the current root, except the
one for per-process kernel data, which each address space fills with its own pages
(MemoryMapping::allocate). Sharing the kernel is therefore sharing those entries: the
physmap’s leaves and the tables under the kernel area.
The kernel maps two things after boot: the interrupt controller (PLIC) and the DMA register
window (devices). The loader creates the tables under both before any user root
copies the kernel’s entries (reserve_tables), so those leaves appear in every address space
and the kernel maps them without allocating. Those are the only kernel-half mappings made after
boot.
The walls on the boundary:
- Mapping calls stop at
USER_AREA_END.map_fixed,process_map,unmapandset_flagsrefuse a range that reaches past it withInvalidArgument, before they look at a page table. A demand-paging fault above it ends the process. - Boot images stay below it. The loader refuses a program whose segment or entry point is
outside
[PAGE_SIZE, USER_AREA_END), and the kernel image outside the kernel area (R16 (image confinement)). - No kernel-half entry has
U. A user-mode load, store or fetch there faults, and the fault handler ends the process.
The two address maps
Status: built · partly tested: the constants’ separation is checked at compile time and the PLIC’s size at boot, not attacked by a case · tested: bench:kernel-wx, bench:legacy-gone, bench:kernel-half-attack
Addresses are from libs/layout/src/lib.rs, the kernel’s link.x and link64.x, and
kernel/src/arch/riscv/process.rs. Compile-time assertions keep the DMA window clear of the
physmap, the per-process area and the kernel stacks; at boot the kernel stops if the PLIC the
device tree reports would run into the DMA window.
Sv39 (rv64)
Root entries are 1 GiB each, 512 of them. Addresses from bit 38 up must all equal bit 38; any other address is not canonical and every access to it faults.
| Root entries | From | What | Shared |
|---|---|---|---|
| 0..=255 | 0x0 | user space, up to USER_AREA_END (0x40_0000_0000, 256 GiB) | no |
| 256..=383 | 0xffff_ffc0_0000_0000 | physmap, PHYSMAP_SIZE (128 GiB), from physical 0, filled to the end of RAM | yes |
| 384..=509 | 0xffff_ffe0_0000_0000 | empty | |
| 510 | 0xffff_ffff_8000_0000 | per-process kernel data (PROCESS_AREA) | no |
| 511 | 0xffff_ffff_c000_0000 | the kernel area (KERNEL_AREA) | yes |
Inside the kernel area:
| Address | What |
|---|---|
0xffff_ffff_f000_0000 | PLIC window (KERNEL_PLIC_BASE), up to 64 MiB |
0xffff_ffff_f400_0000 | DMA register window (KERNEL_DMA_REGS): KERNEL_DMA_PAGES (16) pages, one per DMA device |
0xffff_ffff_ffc0_0000 | hart stacks (HART_STACKS): MAX_HARTS (8) slots of 18 pages, each an unmapped guard page, the kernel stack (8 pages), a guard page and the trap stack (8 pages), by boot index; slot 0 is the boot hart’s (KERNEL_STACK_TOP, TRAP_STACK_TOP), which the loader maps, and the kernel maps a slot when it starts its hart |
0xffff_ffff_ffd0_0000 | kernel code and constants (512 KiB) |
0xffff_ffff_ffd8_0000 | kernel data (1 MiB) |
Figure: the Sv39 address map. Everything above the non-canonical gap is supervisor-only.
Sv32 (rv32)
Root entries are 4 MiB each, 1024 of them. Every 32-bit address is canonical.
| Root entries | From | What | Shared |
|---|---|---|---|
| 0..=511 | 0x0 | user space, up to USER_AREA_END (0x8000_0000, 2 GiB) | no |
| 512..=1019 | 0x8000_0000 | physmap, PHYSMAP_SIZE (2032 MiB), identity | yes |
| 1020..=1021 | 0xff00_0000 | PLIC window, up to 8 MiB less the DMA window | yes |
| 1021, last 64 KiB | 0xff7f_0000 | DMA register window: 16 pages | yes |
| 1022 | 0xff80_0000 | per-process kernel data (PROCESS_AREA) | no |
| 1023 | 0xffc0_0000 | kernel area: hart stacks at 0xffc0_0000 (HART_STACKS: MAX_HARTS slots of kernel and trap stack with guard pages, slot 0 the boot hart’s, as on Sv39), code at 0xffd0_0000, data at 0xffd8_0000 (1 MiB) | yes |
A QEMU virt PLIC is 6 MiB, which is why the PLIC window takes two root entries.
Figure: the Sv32 address map. Everything from 0x8000_0000 up is supervisor-only.
Per-process kernel data
PROCESS_AREA holds the current process’s header: one page on both widths, the process’s own,
charged to its budget, with no U bit. It holds the process’s bookkeeping and thread masks, and
the table from TID to each thread’s IPC page (MAX_THREADS + 1
entries of 4 bytes). The kernel asserts that this fits in the page.
A thread’s saved registers, 32 machine words, are the last bytes of its IPC page, the page a
thread costs its budget (objects), which the kernel reaches through
the physmap. So a process pays for the threads it has, not for MAX_THREADS. The trap handler finds
the running thread’s context through the hart’s block, reached by sscratch and mapped in every
address space: it holds the address of the running thread’s saved context, the hart’s current PID
and thread, and the hart’s own saved context for when it runs the kernel’s thread (PID 1, which
has no budget and no IPC pages). Switching thread on a hart writes that hart’s block, never
another’s.
The rest of the per-process entry is never mapped. A new thread’s return address is
EXIT_THREAD, an address there (0xffff_ffff_8080_3000 on Sv39, 0xff80_3000 on Sv32): a thread
that returns from its entry function fetches there, takes an instruction page fault, and the
kernel ends the thread as if it had called thread_exit (processes). A jump to
any other address there is an ordinary fault.
User space
Regions
Status: built · partly tested: the message area is a convention of the kernel that no case attacks as an address · tested (6)
- bench:map-fixed-attack
- bench:map-fixed-tables
- bench:map-anon-search-bound
- bench:boot-stack-reservation
- bench:bundle-mapped
- bench:thread-stack-guard
User space uses the same addresses on both widths, all below 2 GiB. On Sv39 the rest, up to
256 GiB, is free for map_fixed and process_map and nothing is placed there by default.
| Range | What | Placed by |
|---|---|---|
0x0..0x1_0000 | free; page 0 is user space | nobody |
0x1_0000..0x1FF0_0000 | the program image: the link range | the program’s linker |
0x1FF0_0000..0x1FF4_0000 | the loader stub, in a launched process (at most 256 KiB) | the launcher |
0x2000_0000..0x4000_0000 | the bundle (BUNDLE_AT), only in init’s address space or the tester’s in its place: the whole verified initrd, mapped read-only by the loader and kept so by init, which is trusted (a program it starts holds only copies), charged to root; at most 512 MiB, and the loader refuses a larger one or one that does not start on a page. map_fixed over it is refused as an overlap | the loader |
0x4000_0000..0x4040_0000 | the message area (4 MiB): where the kernel maps a lend or transfer the process receives, every one inside it | the kernel |
0x6000_0000..0x7000_0000 | the map_anon area (256 MiB): where map_anon, map_device and dma_alloc place pages, every run inside it | the kernel |
0x7000_0000..0x7FF0_0000 | the thread-stack area (255 MiB, redoubt_rt::thread::STACKS_BASE): where the runtime’s spawn places every spawned thread’s stack by map_fixed, slot after slot from the bottom, each stack’s pages above one page the runtime leaves unmapped as its guard, so an overflow faults there and the kernel ends the process at once (redoubt-rt); the kernel places nothing here (map_anon’s area ends below it), so a guard stays unmapped under the process’s own map_anon calls, and a map_fixed of the program’s own in the area is refused to the next spawn whose slot it took | the runtime |
0x7FF0_0000 | the startup block, in a launched process | the launcher |
0x7FF8_0000..0x8000_0000 | the first thread’s stack: the loader reserves 32 pages (0x7FFE_0000..0x8000_0000) for a boot process, backing only the top one before first touch; a launcher maps and backs all of a child’s chosen 1 to 128 pages here | the loader for a boot process; a launcher places its own |
The kernel searches its two areas for the first free run of pages, starting at its last
choice; map_anon’s choice is memory’s. A process started by the loader gets its
image, its stack and the bundle; its first thread starts with sp 16 bytes below
0x8000_0000, the bundle’s address in a0 and its length in a1
(boot). On Sv32 the stack top is also the
end of user space.
Figure: user space, the same on both widths. Launched processes get the stub and a startup block; init gets the bundle instead.
Page 0
Status: built · tested: bench:map-fixed-attack, host:redoubt-sys::map_fixed_range_check_refuses_rv32_wraparound
User space starts at address 0 on both widths. No page is reserved at the bottom: map_fixed,
process_map, unmap and set_flags accept page 0 like any other page. A mapped page 0
affects only the process that maps it, or the child a parent maps it into with process_map,
because the kernel never dereferences a user pointer (see Why). Program images do not start
there: the loader and the stub refuse a segment on page 0.
The loader stub and the link range
Status: built · tested: bench:stub-launch, host:stub::plan_refuses_a_segment_touching_page_zero, host:stub::plan_refuses_a_segment_reaching_into_the_stub_region
A launched process runs the loader stub first. Its launcher maps the stub’s
flat binary read-only and executable at STUB_ENTRY (0x1FF0_0000, the same on both widths) and
starts the child’s first thread there. The stub’s link.x fixes that origin, limits the stub to
256 KiB, and fails the build if _start is not its first byte or if the stub has any writable
data. STUB_ENTRY is a constant of the stub crate, shared by the stub and every launcher, not
a kernel constant: process_map’s destination is the launcher’s choice.
Programs link at 0x1_0000. The stub refuses, before it maps anything, a segment that touches
page 0, that ends past STUB_ENTRY, or that overlaps the stub’s region, the startup block or the
image bytes it came from. The child then exits with the stub’s bad-image code, and nothing else
is affected. That leaves just under 512 MiB of link range, and MAX_IMAGE_LEN (0x1FF0_0000)
caps the image the startup block names at the same size.
Launcher placement
Status: built · tested: bench:stub-launch
A launcher places the startup block and the child’s stack outside the link range. Every launcher
takes the placement from one definition beside STUB_ENTRY in the stub crate
(stub/src/lib.rs): the stack top at STACK_TOP (0x8000_0000), the
startup block at STARTUP_AT (0x7FF0_0000) and the copy of the ELF image at IMAGE_AT
(0x4000_0000). The client launcher accepts at most MAX_STACK_PAGES (128) stack pages, so even
its lowest stack page is above the startup block with unmapped pages between them. The stub cannot
see the stack. With the stack outside the link range, no segment of an honest image can meet it;
a hostile segment that names the stack’s pages is still refused by map_fixed, which never
replaces a mapping (R11). The stub does not check for a gap between a
segment and the stack; see Residual risks.
Sv32 and Sv39 compared
Status: built · tested (4)
- bench:wx
- bench:touch-beyond-ram
- bench:stub-launch
- bench:map-fixed-tables
The two modes share the low ten entry bits, and the physical page number starts at bit 10 in
both. So one usize-sized Pte type and one flag set (PteFlags) serve both widths, and the
kernel’s page-table code is written in terms of LEVELS, vpn() and leaf_size() and is the
same source for both. Only these differ, as cfg(target_pointer_width) constants in paging:
| Sv32 (rv32) | Sv39 (rv64) | |
|---|---|---|
| levels | 2 | 3 |
| entries per table | 1024 | 512 |
| VPN bits per level | 10 | 9 |
| entry width | 4 bytes | 8 bytes |
| leaf sizes | 4 KiB, 4 MiB | 4 KiB, 2 MiB, 1 GiB |
satp mode | bit 31 | 8 << 60 |
satp root PPN | bits 0-21 | bits 0-43 |
| canonical addresses | all | bits 63-38 all equal |
USER_AREA_END | 0x8000_0000 | 0x40_0000_0000 |
User mappings are always 4 KiB leaves; only the physmap uses superpages. On Sv39 the table below a level-1 entry is named by its address, not by its index, because an index recurs in every gigabyte: counting missing tables by index would undercount a range that crosses one (R22 (range cost)). Sv32 has one level below the root, so the question does not arise.
satp and page-table entries
satp
Status: built · partly tested: QEMU’s TLB is not tagged by ASID, so a missing flush shows only through the checked build’s audit; one hart is argued from the code · tested: bench:asid-reuse-stale, bench:pid-reuse-authority, bench:smp-evict, bench:uaf-lent-page, host:paging::satp_round_trips_on_both_layouts, host:paging::each_flush_covers_what_the_spec_says
satp holds the mode, the root table’s physical page number and the ASID (SatpLayout::make), and
a process’s ASID is its PID. MAX_PROCESS_COUNT is below 2 to the power of ASID_BITS, 9 in
Sv32 and 16 in Sv39, and PID 0 is never a process, so every PID fits the field with no table
between them. A compile-time assert holds it on each width, and the boot refuses a hart whose
field is narrower, found by writing ones to it. The kernel is PID 1; the loader numbers boot
processes from 2 and names each one’s PID in a field of its own in the handoff record
(boot), and the kernel writes each one’s ASID when it takes them over. Each hart’s
record of the PID it runs is in its per-hart block, read as current_pid and set whenever that
hart switches address space (set_current_pid).
The kernel runs in whichever address space was current when it trapped, because every address
space maps the kernel half. The kernel half’s shared leaves are global (G), so no ASID flush
drops them. Switching process writes satp and flushes nothing: a process’s cached translations
carry its ASID and wait for its next turn. A change to a leaf flushes that address in that
process’s ASID, running or not; a change to a table pointer flushes the process’s whole ASID; a
change to a kernel-half leaf flushes that address in every ASID. A PID’s ASID is flushed whole
when the PID is given out, before it first runs, and a dying process’s space is left before any
of its frames is freed. A user access that faults although the current space’s leaf allows it
met an entry this hart cached before another hart changed it: the kernel flushes that address in
the ASID and runs the instruction again, once. A fault the kernel takes itself is never retried
so: the kernel reaches no user mapping (R24), and its own fault is a
kernel failure. A checked build logs every page-table write and stops if one is
unflushed when it returns to user mode. On QEMU every sfence.vma and every satp change
empties the whole TLB, so the cases check the kernel’s flushes through the checked build’s log,
not through a stale translation; the TLB’s reuse across switches shows only on hardware
(the FPGA platform). The loader enters the kernel through satp
too: it points stvec at the kernel’s entry and writes satp; the next fetch, from the loader’s
own unmapped address, faults, and the trap lands in the kernel with the arguments still in
registers.
Entry bits
Status: built · tested: bench:wx, bench:map-fixed-attack, bench:kernel-wx
packet-beta title a page-table entry, Sv32's 32 bits. Sv39 carries the PPN on to bit 53 and keeps bits 54 to 63 zero 0: "V" 1: "R" 2: "W" 3: "X" 4: "U" 5: "G" 6: "A" 7: "D" 8: "S" 9: "P" 10-31: "PPN"
Figure: a page-table entry, the same low ten bits on both widths.
| Bit | Name | How Redoubt uses it |
|---|---|---|
| 0 | V valid | set on every live mapping and every pointer to a next-level table |
| 1-3 | R, W, X | a leaf has at least one. Never W with X, never W without R (R11): Pte::leaf refuses both, and a mapping call’s flags are checked before that |
| 4 | U user | on every user-half leaf of a process; on no kernel-half entry |
| 5 | G global | on the kernel’s shared leaves: the physmap, the kernel image, the stacks, the PLIC and the DMA register window; never in the user half or on the per-process entry |
| 6-7 | A, D | set when the entry is made, because not every hart updates them in hardware; Redoubt never reads them |
| 8 | S lent | software bit: see below |
| 9 | P | software bit, never set |
A non-leaf entry is V alone. An entry with permissions and no V is a reservation: the
page is backed with a zeroed frame on first touch. A permission fault on a valid page ends the
process; it never counts as a page that wants backing.
The lent bit
Status: built · tested (5)
- bench:return-lent-unmapped
- bench:lender-touches-lent
- bench:move-borrowed-page
- bench:map-fixed-attack
- bench:uaf-lent-page
A lend is recorded in the page tables themselves, with the software bit S:
V | S | Meaning |
|---|---|---|
| 1 | 0 | an ordinary mapping |
| 0 | 1 | the lender’s entry while the page is lent: no access, and the entry keeps the frame, so it is the loan’s only record |
| 1 | 1 | the borrower’s alias: normal loads and stores, but no call may unmap it, change its flags, pass it on, or use it as a system-call record |
| 0 | 0 | empty, or a reservation |
Lending clears V and sets S on the caller’s entries; the pages are mapped into the server,
with S, only when it takes the call. A reply clears the server’s entries and restores V on the
caller’s, checking first that both still name the same frame. A transfer is mapped into its
receiver without S: the page is simply the receiver’s. Only the end of the loan changes a
lender’s entry: a reply restores it, and a transfer or an abandoned call removes it. No new
mapping, reservation or unmap overwrites it (I9 (pages W^X, zeroed, lends unmapped);
R3 (lends and abandoned calls)).
Security properties
R24 (SUM and MXR clear)
Status: built · partly tested: no probe covers MXR (Residual risks); the firmware already enters S-mode with both bits clear, so no case shows the kernel’s own clear doing work · tested: bench:sum-clear
The kernel clears sstatus.SUM and sstatus.MXR at entry, in _start before any Rust runs,
and never sets either. So S-mode cannot load or store through a user mapping, and a stray kernel
dereference of a user address faults. MXR is included because with it set, kernel loads could
read pages that are execute-only. The kernel reaches user memory only by walking the caller’s
tables and copying through the physmap, so no path needs either bit. A boot assertion checks
both are clear. A test build (sum-probe) loads the first caller’s ecall straight through its
user address, and the load faults as a kernel failure.
Residual risks
- The physmap has a writable alias of user code. Every user page, code included, is also mapped writable in the physmap. Only the kernel can use that mapping, but a kernel bug that writes through a wrong address can change any process’s code. The kernel’s own code has no writable alias (R19).
- The kernel can address all RAM. A kernel arbitrary-write bug is not limited to the pages the current process maps. On Sv39 the physmap also covers the physical range below RAM, where device registers sit, as ordinary kernel read-write memory; the kernel never uses those addresses, but a stray write through them reaches a device.
- A flush acts on this hart only. Each change flushes its address or its ASID on the hart that makes it. Another hart that ran the process flushes that ASID before it runs the process again, and one running it now is shot down first, at every removal from the process and at its destruction (memory).
- QEMU cannot show a missing flush. Its TLB is not tagged by ASID, and it empties it on every
sfence.vma, whatever its operands, and everysatpchange, so the bench finds a missing flush only through the checked build’s audit. - The firmware must delegate instruction page faults to S-mode. Kernel entry from the loader
and a thread’s return to
EXIT_THREADare both instruction page faults the kernel must take. The vendored RustSBI delegates them; with a firmware that did not, the boot would never reach the kernel. The firmware is in the TCB (boot). - No probe covers
MXR. Thesum-clearprobe loads from a readable user page, so it showsSUMis clear, notMXR: a setMXRwould let a kernel load read an execute-only page, and only the boot assertion would notice (R24). - No guard gap between a segment and the stack. The stub checks segments against the stub, the startup block and the image, not against the stack, and the kernel refuses only an overlap. A launcher that puts the stack inside the link range can get a child whose data ends right at its stack’s bottom, so a stack overflow writes the data instead of faulting. The launcher convention above prevents it; nothing enforces it.
- No ASLR. Every address on this page is fixed, and
map_anonplaces pages deterministically. A memory-safety bug in a program is easier to exploit, within that program’s own process. Address randomisation is beyond M6.
Why
- A direct physical map, not a recursive window. Mapping the page tables into themselves
works with two levels but not three. With every frame at a fixed address, a table walk is a
plain software walk, creating a process needs no map-fill-unmap dance, and the kernel edits
another process’s tables without switching
satp. The cost is that all RAM is kernel-addressable (Residual risks). - Split by root entry. Sharing the kernel half is copying some root entries. Because the loader makes every kernel-half table before the first user root exists, and the kernel never adds a root entry, no kernel mapping ever has to be pushed into existing address spaces.
- Page 0 in user space,
SUMclear. The null-page attack needs the kernel to dereference a pointer that user space controls. The Redoubt kernel never does: it walks the caller’s tables and copies through the physmap, and withSUMclear any direct supervisor access to a user page faults. So a mapped page 0 can harm only its own process, and refusing it would be a rule with no wall behind it. - The stub at a fixed address, high in the link range. Every launcher and the stub agree without a kernel constant, the kernel stays ignorant of ELF, and programs keep just under 512 MiB to link into, so large programs need no special base.
- The same user addresses on both widths. One convention serves rv32 and rv64 programs and their launchers. Sv39’s extra user space stays free for programs that ask for it by address.
- The lent bit in the entry. The lender’s own entry is the record of the loan, so the kernel keeps no separate table of loans that could disagree with the page tables, and a teardown finds every lent-out frame by walking them.