# 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`](per-thread-stack-isolation.md) (the other half of full lockdown) and [`kernel-owned-sync.md`](kernel-owned-sync.md) (the sync-object corollary). Indexed from [`../../safety-and-security.md`](../../safety-and-security.md) §2 (pending migration) and builds on the layering [`../../decisions/0002-multi-instance-kernel.md`](../../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 | `kernel/tk_config.h` | Buys | Needs | |---|---|---|---| | **Confinement** | `TK_CONFIG_UNPRIV_THREADS` | App threads run unprivileged; kernel calls that touch privileged state route through a syscall trap (`TK_ARCH_HAS_SYSCALL`); the derived `TK_FEAT_UNPRIV` gates it | `ARCH_HAS_UNPRIV` (arch provides a user/privileged split + trap) | | **Full lockdown** | `TK_CONFIG_ENABLE_MPU_ISOLATION` | Kernel-RAM (`.privileged_bss`) + per-thread stack (`.thread_stacks`) MPU/PMP-denied to unprivileged code; the derived `TK_FEAT_ISOLATION` gates it | `TK_FEAT_UNPRIV` already on, `ARCH_HAS_MPU`, **and** board linker regions carving the two zones | `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: ```c /* 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`](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: ```c /* 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. ```c /* 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: 1. `TK_PRIVILEGED_DATA` places the kernel singleton + sync pools somewhere `arch_mpu_init()` denies to unprivileged code and the privileged background (or an explicit privileged region) still reaches. 2. `arch_mpu_init()` programs that map once, before `tk_kernel_start()`. 3. 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 in `kernel/` and `arch/{arm_v7m,arm_v8m,risc_v}/` today. - **Board linker zones:** `boards/nrf52832_pca10040/nrf52832_pca10040.ld` and `boards/qemu_riscv32_virt/virt.ld` both 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.c` no longer externs board-provided `tk_mpu_*` C constants; it reads the board's *linker symbols* (`__mpu_flash_base` etc., `nrf52832_pca10040.ld`) directly via macros that keep the `tk_mpu_*` names, and `_sprivileged`/`_eprivileged` → `_spriv`/`_epriv`. So `TK_CONFIG_UNPRIV_THREADS=1` (and `+ENABLE_MPU_ISOLATION`) **link for `nrf52832_pca10040`** with 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/isolation`](../../../examples/isolation/main.c) is HW-verified on nRF52 (a peer-stack write faults; §7 item 1 of the stack-isolation doc); [`examples/isolation_reject`](../../../examples/isolation_reject/main.c) proves the fail-closed create-time guard (runtime on qemu RISC-V), and [`examples/identity`](../../../examples/identity/main.c) proves 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 | `ARCH_HAS_MPU`/`UNPRIV` | Mechanism | Needs `TK_FEAT_ISOLATION` + linker zones? | |---|---|---|---| | **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** — `arch/arm_v8m/kernel_arch.c` never reads `TK_FEAT_ISOLATION`; full-lockdown-equivalent protection is what `TK_CONFIG_UNPRIV_THREADS` alone gives you here, because the singleton needs no placed section to be denied — it's simply not on the whitelist | | **risc_v** (RV32, M-mode) | yes/yes | PMP, NAPOT entries — denylist: `pmp1`/`pmp2` deny the kernel-RAM/thread-stacks zones, `pmp3` grants the rest, `pmp0` re-grants the running thread's own stack per switch (`arch/risc_v/kernel_arch.c:589-613`) | **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 `.user_text` + `.thread_stacks` pages; kernel RAM stays `U=0` (`arch/risc_v/kernel_arch.c:498-572`) | **yes** (different mechanism, same zones) | | **arm_v6m** (M0/M0+) | no/no | none — `arch/arm_v6m/kernel_arch.h` declares neither flag; the entire safety tier compiles to the central no-ops in `arch_defaults.h` | 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. --- ## 8. Related - [`per-thread-stack-isolation.md`](per-thread-stack-isolation.md) — the other half of full lockdown (peer-stack isolation), same two-knob split, same per-arch table. - [`kernel-owned-sync.md`](kernel-owned-sync.md) — why sync objects need this same `TK_PRIVILEGED_DATA` placement, not just the singleton. - [`../../decisions/0001-foreign-architecture-policy.md`](../../decisions/0001-foreign-architecture-policy.md) — the `void*`/cell-count stack contract this mechanism's stack sizes (`stack_cells`) ride on. - [`../../safety-and-security.md`](../../safety-and-security.md) §2.3–2.4 (pending migration into root `docs/`) — the L1/L2/L3 protection ladder this is the L3 layer of, and the residual-risk list (shared globals, interrupt-masking on TriCore, CPU hogging) that full lockdown does **not** close.