Per-thread stack isolation (full-lockdown, part 2 of 2)¶
Status: shipped mechanism, arch-generalized. Design record, not a phasing plan. Second half of
full lockdown — read kernel-ram-isolation.md §1 first for the two-knob
split (TK_CONFIG_UNPRIV_THREADS vs TK_CONFIG_ENABLE_MPU_ISOLATION); this doc doesn’t repeat it.
Companion: kernel-owned-sync.md.
Goal: an unprivileged thread must not be able to read or write another thread’s stack —
call frames, saved registers, locals. Confinement alone (TK_CONFIG_UNPRIV_THREADS) does not give
you this: on the SRD/PMP-denylist arches (§5) the unprivileged map still grants all of RAM,
sub-region-carved only for the kernel singleton and MSP, so every thread’s stack is one shared RW
grant peers can reach into.
Non-goal: isolating private globals between threads — a thread can still touch another
thread’s static data. Stacks hold the live working state; that is the high-value target.
1. The mechanism: a per-thread stack region, reprogrammed every switch¶
One MPU/PMP slot is dedicated to “the currently-running unprivileged thread’s own stack,” and the
kernel reprograms it on every context switch — both the cooperative yield path and the
preemptive tick path, unconditionally when TK_FEAT_MPU_GUARD is on:
/* kernel/tk_kernel.c — cooperative switch (:344-350) and preempt-select (:990-996), identical shape */
#if TK_FEAT_MPU_GUARD
arch_stack_guard_set(tcb->stack_mem);
arch_thread_region_set(tcb->stack_mem, (uint32_t)tcb->stack_cells,
arch_context_is_unprivileged(&tcb->context));
#endif
arch_thread_region_set() grants exactly [stack_mem, stack_mem + stack_cells) RW to the
incoming thread when it is unprivileged, and disables the slot (or a no-op, on v8-M — §5) for a
privileged thread, whose stack the background/whitelist already covers appropriately. Two other
threads’ stacks sit in the same denied zone as everything else not currently granted — reachable by
neither the previous thread (region reprogrammed away) nor a peer (never granted at all).
Overflow for free on the denylist arches: because the region is sized to the exact stack
bounds, its lower edge doubles as the overflow limit — a full-descending overflow steps below the
granted region and faults at the offending instruction, same class of fault as isolation
(TK_STACK_FAULT_PROTECTION) vs. overflow (TK_STACK_FAULT_MPU) is disambiguated by the arch’s
fault decode, not by this mechanism.
2. Declaring an isolatable stack — TK_THREAD_STACK¶
/* kernel/tk_kernel.h:69-75 */
#if TK_FEAT_ISOLATION
#define TK_THREAD_STACK(name, size) \
static tk_stack_cell_t name[(size)] __attribute__((aligned(size), section(".thread_stacks")))
#else
#define TK_THREAD_STACK(name, size) \
static tk_stack_cell_t name[(size)]
#endif
size in addressable cells (tk_stack_cell_t, ADR 0001 — kernel/tk_types.h), not bytes; on
every arch currently in the tree a cell is a byte, so this is presently the same number either way,
but the type keeps the macro honest for a future word-addressed target. Under full lockdown the
stack is placed in the .thread_stacks linker zone and aligned to its own size; without it,
TK_THREAD_STACK is a plain array — the same call site compiles either way. A stack declared any
other way (a bare array, a malloc) lands in the ordinary shared grant and is not isolated —
reachable by every peer, exactly as if the feature were off.
3. Worked example: ARMv7-M power-of-two stacks (nRF52832 / PCA10040)¶
Arch-specific. Skip to §5 for the general contract.
ARMv7-M MPU regions must be power-of-two sized and naturally aligned to cover a stack exactly with one region:
/* arch/arm_v7m/kernel_arch.c:408-426 */
void arch_thread_region_set(const void* stack_mem, uint32_t stack_size, int unprivileged) {
#if TK_FEAT_ISOLATION
uint32_t base = (uint32_t)(uintptr_t)stack_mem;
MPU_RNR = STACK_REGION;
if (unprivileged && stack_size >= 32u && (stack_size & (stack_size-1u)) == 0u &&
(base & (stack_size-1u)) == 0u) {
uint32_t log2 = __builtin_ctz(stack_size);
MPU_RBAR = base; MPU_RASR = MPU_RASR_ENABLE | ((log2-1u)<<1) | MPU_RASR_AP_RW | MPU_RASR_XN;
} else {
MPU_RASR = 0u; /* non-conforming stack: not isolated, degrades to shared grant */
}
#endif
}
A non-power-of-two or misaligned stack simply doesn’t get isolated — arch_stack_isolatable()
(arch/arm_v7m/kernel_arch.c:429-441) reports whether a given (stack_mem, stack_size) pair
would be isolatable (power-of-two, aligned, and inside the .thread_stacks zone bounds
_sstacks/_estacks), so the kernel can check before trusting isolation — see §6 for whether
that check is actually wired up today.
The zone itself is carved the same way as kernel-ram-isolation.md §4’s .privileged_bss:
boards/nrf52832_pca10040/nrf52832_pca10040.ld:79-89 reserves an 8 KB-aligned .thread_stacks
region between .bss and the MSP reservation, and arch_mpu_init()’s SRD mask denies it in region
1 the same way it denies the kernel-RAM zone (both zones OR into one SRD mask,
arch/arm_v7m/kernel_arch.c:371-375).
4. Region budget (ARMv7-M)¶
After full lockdown, region use on the 8-region ARMv7-M MPU: 0 flash, 1 shared RAM (SRD-carved), 2
peripherals, 3 = per-thread stack (reprogrammed every switch), 7 = the 32-byte overflow guard
(kept for privileged threads, which get no isolation region — the background covers all RAM for
them, so only the guard band gives them overflow detection). Slots 4–6 are free. This is unchanged
from the pre-reshape design and still accurate against arch/arm_v7m/kernel_arch.c’s GUARD_REGION 7u / STACK_REGION 3u defines.
5. The general contract, and multi-arch generalization¶
Strip the power-of-two/SRD specifics and the contract is: on every switch, grant the incoming unprivileged thread’s own stack and nothing else in the stacks zone; a privileged thread or a non-conforming stack falls back to whatever the background/whitelist already grants it.
Arch |
Mechanism |
Alignment constraint |
Needs the |
|---|---|---|---|
arm_v7m |
MPU region + SRD, reprogrammed per switch (§3) |
power-of-two, naturally aligned |
yes |
arm_v8m |
No per-switch region call at all. |
no — no |
|
risc_v (M-mode) |
PMP |
power-of-two, naturally aligned (NAPOT encoding) |
yes |
risc_v (S-mode/SBI) |
Sv32 MMU: the whole |
4 KiB page granularity, no power-of-two requirement |
yes (zone-level only, not per-thread) |
arm_v6m |
none (no MPU) |
— |
n/a |
The v8-M and RISC-V S-mode rows both make arch_thread_region_set() a no-op, but for opposite
reasons — v8-M because the restore asm already handles it more precisely; RISC-V S-mode because
per-thread isolation isn’t built yet there. Don’t conflate “no-op” with “unimplemented” without
checking which case a given arch is in.
6. Root status: the guard rail is wired (fail-closed at creation)¶
The pre-reshape design’s phase “4.3” was a fail-closed guard rail: check every unprivileged
thread’s stack with arch_stack_isolatable() and reject a non-conforming one, so it is loud instead
of silently degrading to “shared and unisolated.”
arch_stack_isolatable() is implemented on every ARCH_HAS_MPU arch (arm_v7m, arm_v8m,
risc_v) and defaulted to “always isolatable” where there’s no MPU (arch/arch_defaults.h:74). Each
denylist-arch impl checks the full condition — power-of-two + naturally aligned and inside the
.thread_stacks zone (arm_v7m kernel_arch.c:437, risc_v :665); arm_v8m needs only 32-byte
alignment (its base/limit region isolates any extent, no zone). It is now wired: create_core
(kernel/tk_kernel.c:902, review D2) calls it under #if TK_FEAT_ISOLATION for every created
thread and returns NULL + TK_DET(TK_ERR_PARAM) for a non-conforming stack — so a bad stack is
rejected at the point of the offending tk_thread_create*/tk_thread_spawn* call, not silently run
unisolated. The check is placed at creation rather than a tk_kernel_start() sweep (the original 4.3
site) so the failure names the specific offending call. No TK_CONFIG_UNPRIV_REQUIRE_ISOLATABLE knob
is needed — under full lockdown it is unconditionally fail-closed. Proven by
examples/isolation_reject (§7 item 3).
7. Validation shape¶
Inter-thread isolation: an unprivileged thread writes into a peer’s stack buffer → fault, faulting address inside the peer’s stack zone; the same thread writing its own stack works. Proven by
examples/isolation(HW, nRF52).Overflow still caught: on the denylist arches (§5), the per-thread region’s lower edge is the overflow trip; on v8-M, PSPLIM is (see
kernel-ram-isolation.md— out of scope here).Fail-closed on non-conforming input: a mis-aligned / out-of-zone stack is rejected at creation (
NULL+TK_ERR_PARAM), not run un-isolated — the loud replacement for the old silent degrade (§6). Proven byexamples/isolation_reject(runtime on qemu RISC-V; builds on nRF52) alongside a conformingTK_THREAD_STACKthread that is accepted and runs.