Kernel-RAM isolation (full-lockdown, part 1 of 2)¶
Status: shipped mechanism, arch-generalized. Design record, not a phasing plan — the code is
the source of truth; this doc explains what it does and why. Companion to
per-thread-stack-isolation.md (the other half of full lockdown)
and kernel-owned-sync.md (the sync-object corollary). Indexed from
../../safety-and-security.md §2 (pending migration) and builds on
the layering ../../decisions/0002-multi-instance-kernel.md
assumes (kernel depends only on arch).
Goal: an unprivileged application thread must not be able to read or write kernel state — the TCB array, scheduler bookkeeping, sync-object pools. Corrupting a TCB hijacks the scheduler or forges another thread’s identity; corrupting scheduler state breaks every thread sharing the core.
1. Two knobs, not one — read this first¶
The pre-reshape design (and earlier drafts of this doc) treated “unprivileged threads” and “kernel RAM is locked down” as one step. The reshaped kernel splits them into two independent config knobs, because they turned out to have different hardware requirements once the mechanism was ported past the one board it was designed against:
Knob |
|
Buys |
Needs |
|---|---|---|---|
Confinement |
|
App threads run unprivileged; kernel calls that touch privileged state route through a syscall trap ( |
|
Full lockdown |
|
Kernel-RAM ( |
|
kernel/tk_config.h:87-92 states the OFF case for the second knob plainly: “OFF = reduced
isolation: unpriv confinement via the SCS-unmapped map, no custom sections.” That reduced mode is
real and useful on its own — an unprivileged thread already cannot reconfigure the core (the System
Control Space at 0xE000_xxxx is granted to nobody) even with ENABLE_MPU_ISOLATION off; it can
just still read/write the kernel singleton and peer stacks, because region 1 grants all of RAM.
TK_CONFIG_ENABLE_MPU_ISOLATION is deliberately not forced on by either the hardened or
full profile (profiles/tk_profile_hardened.h:20-22, profiles/tk_profile_full.h:20-21): both
comment “needs board linker regions — per-board opt-in on top of this.” Confinement is a
framework-level guarantee any ARCH_HAS_UNPRIV target gets from one -D; full lockdown is a
per-board integration step (§6, §7).
Why the split, concretely (§7 makes this load-bearing): on ARMv8-M the whitelist MPU model gives you full-lockdown-equivalent protection for free the moment confinement is on — no second knob, no linker zones, needed at all. Collapsing both knobs into one, as the earlier single-flag design did, would have forced v8-M boards to carry zones they don’t need. The split is what let the mechanism generalize instead of staying ARMv7-M-shaped.
A static assert keeps the two consistent: full lockdown implies confinement implies the guard
(kernel/tk_config.h:194, TK_CONFIG_ENABLE_MPU_ISOLATION == 0 || TK_FEAT_MPU_GUARD != 0).
2. What “full lockdown” protects and why it’s needed¶
Confinement alone (knob 1) still leaves a hole: the unprivileged map on the SRD-carving arches
(§7) grants all of RAM RW so app globals stay reachable, and the kernel singleton is ordinary
.bss sitting inside that grant. Full lockdown (knob 2) closes it by moving the singleton into a
placed, MPU/PMP-denied section:
/* kernel/tk_kernel_internal.h */
#if TK_FEAT_ISOLATION
#define TK_PRIVILEGED_DATA __attribute__((section(".privileged_bss")))
#else
#define TK_PRIVILEGED_DATA
#endif
struct tk_kernel (the TCB array + scheduler state) and the sync-object pools
(kernel/tk_sync.c:20-25 — see kernel-owned-sync.md) are tagged
TK_PRIVILEGED_DATA. With the knob off, the macro is empty and everything sits in plain .bss —
zero cost, and the exact same C source compiles for both configurations.
3. The blocker this mechanism forces: no unprivileged path may touch kernel state directly¶
Once the kernel singleton is deny-mapped, any code that runs unprivileged yet dereferences it faults instantly. This is why sleep and exit are syscalls, not direct calls, in the shipped kernel:
/* kernel/tk_kernel.c */
void tk_thread_sleep_ms(uint32_t ms) {
#if TK_FEAT_UNPRIV
(void)tk_syscall(TK_SYS_SLEEP, ms, 0u); /* trap: the switch touches the SCS (privileged) */
#else
tk_thread_sleep_ms_impl(ms);
#endif
}
void tk_thread_exit(void* retval) {
#if TK_FEAT_UNPRIV
(void)tk_syscall(TK_SYS_EXIT, (uintptr_t)retval, 0u);
...
join/detach follow the same shape (TK_SYS_JOIN_WAIT/_REAP/TK_SYS_DETACH,
kernel/tk_kernel.c:778-783,814-848). tk_svc_dispatch (kernel/tk_kernel.c:814) is the single
privileged router both ARM SVC #1 and RISC-V ecall (from U-mode, mcause 8) land on
(kernel/tk_kernel_internal.h:104-148 defines tk_syscall() per arch: ARM svc 1, TriCore
syscall 1, RISC-V ecall). An unprivileged thread never reaches struct tk_kernel on its own
stack frame — every mutation happens inside the trap, privileged.
The idle thread (and any thread tk_thread_set_privileged() opts back to Supervisor,
kernel/tk_kernel.c:618-619) runs privileged and reaches the singleton directly — this is
“Option A” from the earlier design (per-thread privilege via arch_context_set_privilege), and it
is what shipped: struct arch_context carries an npriv field on every ARCH_HAS_UNPRIV arch
(arch/arm_v7m/kernel_arch.h:53-56, arch/risc_v/kernel_arch.c:466-473 via the frame’s saved
privilege bits), applied on every switch in the PendSV/SVC restore path
(arch/arm_v7m/kernel_arch.c:126-134,169-178) or carried for free in the RISC-V trap frame’s
mstatus/sstatus “previous privilege” field.
4. Worked example: ARMv7-M sub-region-disable (nRF52832 / PCA10040)¶
The rest of this document (§5, §7) is arch-neutral. This section is a concrete instance — read it as “one way to implement §2,” not as the mechanism itself. Skip to §5 for the general contract.
ARMv7-M MPU regions ≥ 256 B split into 8 independently-disableable sub-regions (SRD, RASR
bits [15:8]). A disabled sub-region falls through to the next-lower-priority region — with
PRIVDEFENA set, that is the privileged-only background. arch/arm_v7m/kernel_arch.c uses this to
punch privileged-only holes in the blanket RAM grant rather than spending a whole region:
boards/nrf52832_pca10040/nrf52832_pca10040.ld — RAM 64 KB, region 1, 8 KB sub-regions
sub 0 0x20000000 .privileged_bss (8 KB, 8 KB-aligned) → SRD-denied → privileged-only
sub 1-4 .data/.bss/app → granted RW (unpriv)
sub 5-6 .thread_stacks (see per-thread-stack-isolation.md)
sub 7 0x2000E000 MSP (handler stack, top 8 KB, reserved) → SRD-denied → privileged-only
0x20010000
arch_mpu_init() computes the SRD mask from the zone symbols’ addresses
(srd_for_zone(), arch/arm_v7m/kernel_arch.c:338-346), not from fixed sub-region numbers — the
zones only need to be sub-region-aligned, not positioned at known offsets. That is what lets full
lockdown drop into an SDK-generated linker script that appends .privileged_bss wherever it likes,
rather than requiring the kernel to own the whole linker script.
/* arch/arm_v7m/kernel_arch.c:370-378 */
const uint32_t subregion = tk_mpu_ram_size / 8u;
uint32_t srd = srd_for_zone(&_sprivileged, &_eprivileged, tk_mpu_ram_base, subregion)
| srd_for_zone(&_sstacks, &_estacks, tk_mpu_ram_base, subregion);
mpu_program(1u, tk_mpu_ram_base, MPU_RASR_ENABLE | MPU_SIZE_FIELD(tk_mpu_ram_size)
| MPU_RASR_AP_RW | MPU_RASR_XN | (srd << 8));
The handler-mode (MSP) stack gets the same treatment — the linker reserves the top 8 KB sub-region
(_smsp, nrf52832_pca10040.ld:94-97, with ASSERT(_end <= _smsp, ...) so app RAM can never grow
into it) and region 1’s SRD denies it too. An unprivileged write to either zone or the MSP faults
MemManage, CFSR DACCVIOL, MMFAR inside the zone.
Sizing guard: TK_CONFIG_ENABLE_MPU_ISOLATION == 0 || TK_FEAT_MPU_GUARD != 0
(kernel/tk_config.h:194) is the only static assert on this path today; there is no
sizeof(struct tk_kernel) <= 0x2000 build-time check yet — a singleton or sync-pool config that
overflows one 8 KB sub-region degrades silently rather than failing the build. Worth adding if the
pools grow (TK_CONFIG_MAX_SEMAPHORES/_MUTEXES/_CONDS, kernel/tk_config.h:41-49).
5. The general contract (arch-neutral)¶
Strip the SRD arithmetic away and the contract every ARCH_HAS_MPU port satisfies is:
TK_PRIVILEGED_DATAplaces the kernel singleton + sync pools somewherearch_mpu_init()denies to unprivileged code and the privileged background (or an explicit privileged region) still reaches.arch_mpu_init()programs that map once, beforetk_kernel_start().No code that can run unprivileged may dereference the singleton outside a syscall trap (§3).
Nothing in this contract mentions sub-regions, NAPOT, or 8 KB — those are §4’s implementation choice on one arch family. §7 shows two arches that satisfy the same three points with a structurally different mechanism.
6. Root status: what’s wired today vs. what a board still owes¶
Verified against the current tree (not the pre-reshape design):
Mechanism (kernel + arch): complete and arch-generalized (§7) —
TK_PRIVILEGED_DATA,arch_mpu_init()/arch_thread_region_set(), the syscall-routed sleep/exit/join/detach, and the per-arch MPU/PMP programming all exist inkernel/andarch/{arm_v7m,arm_v8m,risc_v}/today.Board linker zones:
boards/nrf52832_pca10040/nrf52832_pca10040.ldandboards/qemu_riscv32_virt/virt.ldboth already carve.privileged_bss+.thread_stacks(NOLOAD, correctly aligned) — the two boards currently in the root tree are full-lockdown-ready at the linker level.The
tk_mpu_*descriptor gap is closed (review D1).arch/arm_v7m/kernel_arch.cno longer externs board-providedtk_mpu_*C constants; it reads the board’s linker symbols (__mpu_flash_baseetc.,nrf52832_pca10040.ld) directly via macros that keep thetk_mpu_*names, and_sprivileged/_eprivileged→_spriv/_epriv. SoTK_CONFIG_UNPRIV_THREADS=1(and+ENABLE_MPU_ISOLATION) link fornrf52832_pca10040with no board-glue file — no ~10-line descriptor is owed. The RISC-V path never had the gap (its PMP setup reads only linker symbols).Exercised at root, on hardware.
examples/isolationis HW-verified on nRF52 (a peer-stack write faults; §7 item 1 of the stack-isolation doc);examples/isolation_rejectproves the fail-closed create-time guard (runtime on qemu RISC-V), andexamples/identityproves the unprivileged getter path (HW on nRF52 + RISC-V). Full lockdown on ARMv7-M is implemented, linker-ready, and proven.
7. Multi-arch generalization¶
The mechanism is not ARMv7-M/nRF52-specific; it is implemented once per arch family behind the same
ARCH_HAS_MPU / ARCH_HAS_UNPRIV contract (arch/arch_defaults.h no-ops it out entirely for an
arch that declares neither):
Arch |
|
Mechanism |
Needs |
|---|---|---|---|
arm_v7m (M3/M4/M7) |
yes/yes |
PMSAv7 MPU, region+SRD (§4) — denylist: grant all RAM, then deny two SRD-carved zones |
yes |
arm_v8m (M33-class) |
yes/yes |
PMSAv8 MPU, base/LIMIT regions (no sub-regions) — whitelist: grant only code + the running thread’s own stack; everything else (incl. kernel RAM) is covered by no region, so it faults by default |
no — |
risc_v (RV32, M-mode) |
yes/yes |
PMP, NAPOT entries — denylist: |
yes |
risc_v (RV32, S-mode/SBI) |
yes/yes |
Sv32 MMU paging instead of PMP (M-mode-only) — one identity table, U-mode granted only |
yes (different mechanism, same zones) |
arm_v6m (M0/M0+) |
no/no |
none — |
n/a — software canary only |
The v8-M row is the sharpest illustration of §1’s point: full-lockdown behavior and the
TK_CONFIG_ENABLE_MPU_ISOLATION knob are not the same thing. The knob exists because the
SRD/PMP-denylist arches need extra linker zones to get there; a whitelist arch reaches the same end
state through TK_CONFIG_UNPRIV_THREADS alone.