Kernel-owned synchronization objects¶
Status: shipped design. Every semaphore, mutex, and condition variable in the tree is
kernel-owned by construction — there is no struct-based sync API to migrate away from; this
document describes what ships, not a plan. Depends on the TK_PRIVILEGED_DATA placement
established in kernel-ram-isolation.md; the confined caller reaching
it is the syscall boundary described there and in
per-thread-stack-isolation.md.
Goal: an unprivileged thread must not be able to corrupt a sync object it shares with another thread, nor use the sync API to make the kernel touch arbitrary memory.
1. Two holes a struct-based, app-owned design would have¶
If struct tk_semaphore/tk_mutex lived in application RAM (the app declares struct tk_semaphore s; tk_sem_init(&s, 0); and passes &s to every call), two holes follow directly from confinement
(kernel-ram-isolation.md §1):
Field corruption.
ssits in the shared unprivileged-RW grant, so any thread — not just the ones the object is meant to coordinate — can writes.count = 999or scribble a wait-queue pointer, breaking synchronization for every legitimate user.Forged-pointer confused deputy. A syscall dispatcher that resolves the object from an app-supplied pointer (
(struct tk_semaphore*)frame[1]) and then operates on it privileged is a confused deputy: a hostile thread passes a pointer into.privileged_bss(or anywhere), and the kernel dereferences it with privilege — an arbitrary-kernel-write primitive that defeats the whole lockdown fromkernel-ram-isolation.md, regardless of how tight that lockdown otherwise is.
The root cause of both is the same: trusting an app-owned address. The shipped fix is that the kernel owns every sync object and hands the app only a validated handle — never a pointer.
2. The shape: kernel-owned pools + opaque integer handles¶
kernel/tk_sync.c:20-25 holds three fixed pools, each TK_PRIVILEGED_DATA (placed in
.privileged_bss under full lockdown, ordinary .bss otherwise — same macro, same file, as
kernel-ram-isolation.md §2):
static struct tk_semaphore g_sem_pool[TK_CONFIG_MAX_SEMAPHORES] TK_PRIVILEGED_DATA;
static unsigned char g_sem_used[TK_CONFIG_MAX_SEMAPHORES] TK_PRIVILEGED_DATA;
static struct tk_mutex g_mutex_pool[TK_CONFIG_MAX_MUTEXES] TK_PRIVILEGED_DATA;
static unsigned char g_mutex_used[TK_CONFIG_MAX_MUTEXES] TK_PRIVILEGED_DATA;
static struct tk_cond g_cond_pool[TK_CONFIG_MAX_CONDS] TK_PRIVILEGED_DATA;
static unsigned char g_cond_used[TK_CONFIG_MAX_CONDS] TK_PRIVILEGED_DATA;
Three object kinds ship today — semaphore, mutex, and condition variable (kernel/tk_sem.h,
kernel/tk_mutex.h, kernel/tk_cond.h). Condition variables did not exist when this mechanism was
first designed; they were added on top of the identical pool/handle/resolve shape with no changes
to the model — the strongest evidence that the shape generalizes.
tk_sem_create() / tk_mutex_create() / tk_cond_create() allocate a free slot and return an
opaque handle: typedef int16_t tk_sem_t; etc. (kernel/tk_sem.h:11, kernel/tk_mutex.h:10,
kernel/tk_cond.h:16) — a small integer, not a pointer. [setup]-only (create before
tk_kernel_start); there is no destroy, so pool exhaustion is a permanent-until-reset condition,
reported once via TK_DET (§6) and TK_SEM_INVALID/TK_MUTEX_INVALID/TK_COND_INVALID (0).
Why an integer index, not an opaque pointer¶
A pointer-shaped handle (FreeRTOS-MPU style) still needs bounds-checking against the pool address
range before use — a forged pointer that happens to land inside the pool would otherwise pass. An
index is bounded by construction: sem_resolve rejects anything outside [0, POOL_SIZE)
without any address arithmetic, and an index cannot alias anything outside its own pool no matter
what value a hostile caller picks.
Handle encoding: 1-based, 0 = invalid¶
/* kernel/tk_sync.c:28-34 */
struct tk_semaphore* tk_sem_resolve(tk_sem_t h) {
if ((h > 0) && ((uint16_t)(h - 1) < TK_CONFIG_MAX_SEMAPHORES) && (g_sem_used[h - 1] != 0u)) {
return &g_sem_pool[h - 1];
}
TK_DET(TK_MOD_SYNC, 0u, TK_ERR_PARAM);
return NULL;
}
A handle is index + 1; 0 is invalid. This is what lets a zero-initialized handle (a global
tk_sem_t s; never assigned, or a struct field left at its default) fail h > 0 and resolve to
NULL rather than silently aliasing slot 0 — no separate sentinel value needed. tk_mutex_resolve
and tk_cond_resolve (kernel/tk_sync.c:35-48) are the same three-part check: positive, in-range,
marked used.
3. The syscall boundary: the kernel resolves, never dereferences an app pointer¶
Every blocking op has a direct-call body used when confinement is off, and — under
TK_FEAT_UNPRIV — a syscall-routed variant that carries the handle, never a pointer, across the
trap:
/* kernel/tk_sync.c:132-140 */
int tk_sem_pend_timeout(tk_sem_t s, uint32_t timeout_ms) {
#if TK_FEAT_UNPRIV
return (int)tk_syscall(TK_SYS_SEM_PEND, (uint16_t)s, timeout_ms);
#else
struct tk_semaphore* obj = tk_sem_resolve(s);
return (obj != NULL) ? tk_sem_pend_timeout_impl(obj, timeout_ms) : 1;
#endif
}
tk_sync_svc_dispatch() (kernel/tk_sync.c:308-330, reached from tk_svc_dispatch —
kernel-ram-isolation.md §3) resolves the handle on the privileged side, from the stacked
syscall frame, exactly as the direct path does:
case TK_SYS_SEM_PEND: {
struct tk_semaphore* o = tk_sem_resolve((tk_sem_t)frame[1]);
frame[0] = (o) ? (uint32_t)tk_sem_pend_timeout_impl(o, frame[2]) : 1u;
break;
}
The kernel never receives, stores, or dereferences an app-supplied address for a sync object — only
a small integer it bounds-checks itself, on both the syscall path and the direct path. That
symmetry (kernel/tk_kernel_internal.h:107-111 — TK_SYS_SEM_PEND, TK_SYS_SEM_POST, TK_SYS_MUTEX_LOCK, TK_SYS_MUTEX_UNLOCK, ..., TK_SYS_COND_WAIT, TK_SYS_COND_SIGNAL, TK_SYS_COND_BROADCAST) is what closes hole #2 from §1 without a special case for the unprivileged
build — the resolver runs identically either way; only the call site (direct vs. tk_syscall)
differs.
tk_cond_wait_timeout packs two handles (condition variable + mutex) into one syscall argument
word (kernel/tk_sync.c:276, ((uint32_t)(uint16_t)c << 16) | (uint16_t)m) since a syscall carries
only two register-width args — both still resolved server-side before use, same guarantee.
4. What this closes, concretely¶
Field corruption (§1.1): closed by placement, not validation — the pools live in
.privileged_bss, which is exactly the regionkernel-ram-isolation.mddenies to unprivileged code. An unprivileged write anywhere in the pool’s address range faults; this reuses that mechanism’s proof, it doesn’t need its own.Confused deputy (§1.2): closed by
sem_resolve/mutex_resolve/cond_resolverejecting any handle that isn’t a currently-used slot index. A forged handle —0x7FFF, or a small in-range but never-created index — is just an out-of-range or unused integer:TK_DET+NULL, never a wild pointer dereferenced with privilege.
5. Semantics unchanged by the ownership model¶
FIFO wait queues; post/unlock/signal wake the longest waiter and yield to it immediately if it
outranks the caller (kernel/tk_sync.c:150-152,233-234,265-269 — the post-wake priority check);
timeout variants (*_timeout, 0 = forever) return 0 acquired / 1 timed out; mutex priority
inheritance is single-level, non-transitive, default protocol
(kernel/tk_sync.c:180-184); recursive and error-check mutex types exist alongside the default
normal type (kernel/tk_mutex.h:18-22); pools are fixed-size with no destroy — allocation is a
one-shot setup-time operation in practice. None of this is new; the handle indirection sits
underneath it, invisible to a caller who already used the pre-handle API shape.
tk_cond_wait/_timeout (kernel/tk_sync.c:247-286) atomically release the held mutex and block —
the release happens under the same kernel lock that resolves the wait, so there is no window where
the mutex looks unlocked to another thread before the waiter is actually queued.
6. TK_DET — currently a placeholder at root¶
Every rejection path in this document (sem_resolve failing, pool exhaustion on create) reports
through TK_DET(TK_MOD_SYNC, 0u, TK_ERR_PARAM). At root today, TK_DET is a no-op placeholder:
/* kernel/tk_det.h */
#define TK_DET(mod, id, err) ((void)0)
The module/error-id constants (TK_MOD_SYNC, TK_ERR_PARAM, …) exist so call sites compile and
read correctly, but nothing observes a rejected handle yet — a forged or exhausted handle is
silently rejected (the caller still gets NULL/TK_SEM_INVALID/a timeout-shaped 1), not
reported. The richer hal/tk_det.h + hal/tk_error.h backend (hook + per-module/status counters)
referenced by earlier design notes has not been ported into the reshaped hal/ yet — the design
described in this document (resolve, reject, report) is otherwise complete; only the “report” leg’s
backend is a stub.