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

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:

/* 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:

  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 is HW-verified on nRF52 (a peer-stack write faults; §7 item 1 of the stack-isolation doc); examples/isolation_reject proves the fail-closed create-time guard (runtime on qemu RISC-V), and examples/identity 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.