Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 satp names, 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, unmap and set_flags refuse a range that reaches past it with InvalidArgument, 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 entriesFromWhatShared
0..=2550x0user space, up to USER_AREA_END (0x40_0000_0000, 256 GiB)no
256..=3830xffff_ffc0_0000_0000physmap, PHYSMAP_SIZE (128 GiB), from physical 0, filled to the end of RAMyes
384..=5090xffff_ffe0_0000_0000empty
5100xffff_ffff_8000_0000per-process kernel data (PROCESS_AREA)no
5110xffff_ffff_c000_0000the kernel area (KERNEL_AREA)yes

Inside the kernel area:

AddressWhat
0xffff_ffff_f000_0000PLIC window (KERNEL_PLIC_BASE), up to 64 MiB
0xffff_ffff_f400_0000DMA register window (KERNEL_DMA_REGS): KERNEL_DMA_PAGES (16) pages, one per DMA device
0xffff_ffff_ffc0_0000hart 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_0000kernel code and constants (512 KiB)
0xffff_ffff_ffd8_0000kernel data (1 MiB)

root entriessharing0xffff_ffff_ffff_ffffkernel area: image, kernel and trapstacks, PLIC and DMA register windows511shared0xffff_ffff_c000_0000per-process kernel data510one per address space0xffff_ffff_8000_0000empty384 - 5090xffff_ffe0_0000_0000physmap: physical 0 to the end ofRAM, 1 GiB leaves256 - 383shared0xffff_ffc0_0000_0000not canonical: every access faults0x40_0000_0000user space, 256 GiB0 - 255one per address space0x0 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 entriesFromWhatShared
0..=5110x0user space, up to USER_AREA_END (0x8000_0000, 2 GiB)no
512..=10190x8000_0000physmap, PHYSMAP_SIZE (2032 MiB), identityyes
1020..=10210xff00_0000PLIC window, up to 8 MiB less the DMA windowyes
1021, last 64 KiB0xff7f_0000DMA register window: 16 pagesyes
10220xff80_0000per-process kernel data (PROCESS_AREA)no
10230xffc0_0000kernel 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.

root entriessharing0xffff_ffffkernel area: image, kernel and trapstacks1023shared0xffc0_0000per-process kernel data1022one per address space0xff80_0000DMA register window1021, 64 KiBshared0xff7f_0000PLIC window1020 - 1021shared0xff00_0000physmap: RAM at its own address, 4MiB leaves512 - 1019shared0x8000_0000user space, 2 GiB0 - 511one per address space0x0 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.

RangeWhatPlaced by
0x0..0x1_0000free; page 0 is user spacenobody
0x1_0000..0x1FF0_0000the program image: the link rangethe program’s linker
0x1FF0_0000..0x1FF4_0000the loader stub, in a launched process (at most 256 KiB)the launcher
0x2000_0000..0x4000_0000the 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 overlapthe loader
0x4000_0000..0x4040_0000the message area (4 MiB): where the kernel maps a lend or transfer the process receives, every one inside itthe kernel
0x6000_0000..0x7000_0000the map_anon area (256 MiB): where map_anon, map_device and dma_alloc place pages, every run inside itthe kernel
0x7000_0000..0x7FF0_0000the 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 tookthe runtime
0x7FF0_0000the startup block, in a launched processthe launcher
0x7FF8_0000..0x8000_0000the 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 herethe 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.

widths0x7fff_fffffirst thread’s stack (up to 128 pageslaunched; boot reserves top 32 pages)Sv32: the end of userspace; Sv39: user spacegoes on to 0x40_0000_00000x7ff8_0000startup block (launched)0x7ff0_0000thread-stack area, 255 MiB: spawnedstacks, each above an unmapped guardpage (the runtime)0x7000_0000map_anon area, 256 MiB0x6000_0000free0x4040_0000message area, 4 MiB0x4000_0000bundle, init only, read-only (at most512 MiB)0x2000_0000free0x1ff4_0000loader stub (launched)0x1ff0_0000program image (link range)0x1_0000free, page 0 included0x0 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.

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)
levels23
entries per table1024512
VPN bits per level109
entry width4 bytes8 bytes
leaf sizes4 KiB, 4 MiB4 KiB, 2 MiB, 1 GiB
satp modebit 318 << 60
satp root PPNbits 0-21bits 0-43
canonical addressesallbits 63-38 all equal
USER_AREA_END0x8000_00000x40_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.

BitNameHow Redoubt uses it
0V validset on every live mapping and every pointer to a next-level table
1-3R, W, Xa 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
4U useron every user-half leaf of a process; on no kernel-half entry
5G globalon 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-7A, Dset when the entry is made, because not every hart updates them in hardware; Redoubt never reads them
8S lentsoftware bit: see below
9Psoftware 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:

VSMeaning
10an ordinary mapping
01the lender’s entry while the page is lent: no access, and the entry keeps the frame, so it is the loan’s only record
11the 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
00empty, 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 every satp change, 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_THREAD are 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. The sum-clear probe loads from a readable user page, so it shows SUM is clear, not MXR: a set MXR would 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_anon places 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, SUM clear. 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 with SUM clear 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.