Porting the tk kernel to a new CPU (arch/<cpu>)¶
Audience: anyone adding a CPU port or reviewing an existing one.
Scope: the contract between the portable kernel (kernel/) and a CPU port
(arch/<cpu>/kernel_arch.{h,c}). Field-by-field reference: docs/architecture/arch-interface.md.
The byte-width contract this doc leans on throughout is docs/decisions/0001-foreign-architecture-policy.md
(ADR 0001). The HAL/MCAL contract is separate — docs/architecture/hal-architecture.md. Board-level
sequencing (where this port slots into a board’s bring-up) is docs/guides/board-bringup.md.
A port is one header + one source file selected by TK_ARCH in the board fragment
(make/board/<board>.mk, via kernel/tk_arch.h’s front door). It owns three mandatory
things and nothing else: context switching, the interrupt-state primitive, and the timer
tick. It may additionally opt into SMP, an MPU/stack-guard, unprivileged threads, and
context-reclaim/lazy-materialization (§1b) — but it must not know about peripherals,
devices, or the application.
Existing ports to copy from, by switching mechanism:
Deferred switch via PendSV —
arm_v6m,arm_v7m,arm_v8m(a board tick callstk_kernel_tick_preempt(); the actual register save/restore happens later, in PendSV).Trap-frame switch (the timer trap saves the full frame itself) —
risc_v(CLINT →mtvec),c2000(CPU-timer ISR →switch.asm);tricoredeclares this shape too but itskernel_arch.chas not been grafted into the tree yet (see §7’s status table).Cooperative host sim —
pthread(a mutex+condvar baton;ARCH_CONTEXT_SWITCH_SUPPORTED1,ARCH_PREEMPTION_SUPPORTED0).No switching —
flat(run-to-completion, forhost_test).Tier 0 only, no kernel —
msp430(declares nothing beyond the IRQ pair; a HAL/MCAL superloop links it and never pulls in the scheduler).
1. Capability macros (in kernel_arch.h)¶
Macro |
Meaning |
|---|---|
|
|
|
|
|
Smallest thread stack the port can run, in cells (§5) — it must hold the saved context frame plus the C dispatch chain that runs on the thread’s own stack during a switch/preemption. |
The two capability flags are independent on purpose. The kernel default preemption
mode is TK_PREEMPTIVE, but it is inert where ARCH_PREEMPTION_SUPPORTED == 0
(nothing calls the tick), so threads there switch only at yield/sleep/block. Make
the macro honest; do not set it to 1 hoping the kernel will preempt for you.
Status of the nine in-tree ports (arch/*):
port |
CONTEXT_SWITCH |
PREEMPTION |
mechanism |
status at root |
|---|---|---|---|---|
|
1 |
1 |
board tick → PendSV (deferred) |
complete; SMP real when built |
|
1 |
1 |
board tick → PendSV (deferred) |
complete; real MPU + unprivileged threads |
|
1 |
1 |
board tick → PendSV (deferred) |
complete; PMSAv8 MPU + unprivileged + PSPLIM hw stack-limit; SMP complete on multi-core M33 |
|
1 |
1 |
CLINT mtimer → |
complete; PMP MPU + U-mode unprivileged; SMP real multi-hart |
|
1 |
1 |
CPU-timer ISR → inline switch, |
implemented ( |
|
1 |
1 |
STM compare IRQ → inline switch (trap-frame) |
declared only — |
|
1 |
0 |
cooperative baton (mutex+condvar) |
complete; |
|
0 |
0 |
run-to-completion |
complete; no |
|
0 |
0 |
— Tier 0 only, no kernel on this target |
complete by design; no board paired yet |
1b. Optional tiers — SMP, MPU, unprivileged, context-reclaim¶
Beyond the three mandatory macros above, a port may declare any of these; each is backed by
a central no-op default in arch/arch_defaults.h, included at the end of every
kernel_arch.h. A port #defines ONLY the capability flag for a tier it actually
implements and supplies the real primitives; every tier it leaves undeclared compiles out to
the no-op group instead — so e.g. arm_v6m (no MPU, no unprivileged split) is ~80 lines
instead of hand-copying ~20 stub functions. This is the “stub tax” arch_defaults.h exists
to kill.
Tier |
Capability macro |
Undeclared default |
Real in-tree today |
|---|---|---|---|
2 — SMP |
|
1 core; no-op spinlock/IPI (IRQ masking already serializes) |
|
3a — MPU / stack-guard |
|
software canary only; |
|
3b — unprivileged / syscall |
|
every thread stays privileged; syscall-return-set is a no-op |
|
3c — context reclaim / lazy materialize |
|
no default needed — the kernel guards the call on the macro, so absence compiles the call out entirely |
|
3d — supervisor fence |
|
no default needed — the kernel guards the call on |
|
property — stack direction |
|
|
|
property — timebase owner |
|
|
|
A port does not declare an isolation capability directly. tk_config.h DERIVES TK_ARCH_ISOLATION_CAPABLE
= defined(ARCH_HAS_MPU) && defined(ARCH_HAS_UNPRIV) — a real HW isolation boundary needs both a
memory-protection unit (to fence kernel RAM + per-thread stacks) and the unprivileged split (a thread the
fence acts against). It reads 1 on arm_v7m/arm_v8m/risc_v, 0 on arm_v6m (M0+), c2000, and
pthread. Apps test it (and the degrading TK_ISOLATION_LEVEL ladder / runtime tk_kernel_isolation())
for an honest per-arch answer to “is my island/thread boundary HW-backed here?” (review QC3-2/3); a port
gets it for free by declaring 3a + 3b. The top rung, TK_ISOLATION_INSTANCE (the C3-B supervisor fence),
is DERIVED separately from TK_ARCH_SUPERVISOR_FENCE_CAPABLE = defined(ARCH_HAS_SUPERVISOR_FENCE) (tier
3d) — not from 3a+3b: fencing the kernel between islands needs no unprivileged split, so a port may
declare 3d without 3b. An app requesting TK_CONFIG_ENABLE_INSTANCE_ISOLATION on an arch that lacks 3d
fails loud at compile time (tools/check_fence_matrix.sh guards the matrix). See kernel/tk_config.h.
Full field-by-field prototypes for each tier: docs/architecture/arch-interface.md.
2. Required types + functions¶
struct arch_context { /* opaque to the kernel; e.g. one saved stack pointer as the FIRST word */ };
typedef <uint> arch_irq_state_t; /* saved interrupt-mask state */
static inline void arch_context_clear(struct arch_context*); /* mark unused */
void arch_context_init(struct arch_context*, void* stack_mem, size_t stack_cells,
void* (*fn)(void*), void* arg); /* build first frame */
void arch_context_switch(struct arch_context* from, struct arch_context* to);
void arch_context_start(struct arch_context* to); /* launch first thread */
void arch_idle(void); /* idle-thread body: wait for irq */
void arch_timer_init(void); /* arm the 1 ms tick */
uint32_t arch_timer_now_ms(void); /* only consumed when ARCH_OWNS_TIMEBASE */
/* Interrupt state — TWO distinct pairs (see §3) */
static inline void arch_irq_disable(void); /* flat mask: kernel cooperative yield */
static inline void arch_irq_enable(void);
static inline arch_irq_state_t arch_irq_save(void); /* nesting-safe: HAL/DET */
static inline void arch_irq_restore(arch_irq_state_t);
stack_mem/stack_cells are a void* region and a count of addressable cells
(tk_stack_cell_t = unsigned char, kernel/tk_types.h) — never uint8_t*/bytes. This is
ADR 0001’s CHAR_BIT-neutral stack contract: a cell is one sizeof-unit, 8 bits on every
ARM/RISC-V/host port in the tree and 16 bits on C2000 (CHAR_BIT == 16 there, no uint8_t
exists). ARCH_MIN_STACK_SIZE is a cell count too, and tk_thread_create() (kernel/tk_kernel.c)
rejects a stack smaller than it.
arch_timer_init() is entirely board-owned — the port declares it but must not hardcode a
rate. What “board-owned” means varies by port in the tree today: the ARM ports document it
explicitly (arm_v7m’s header: “arch_timer_init() is provided by the BOARD (tick source is
board-specific: RTC1 on nRF52, where SysTick can’t survive sleep)”), while risc_v’s
arch_timer_init reads a board .mk compile define (TK_RISCV_MTIME_HZ) rather than any HAL
call. Either way, the arch never assumes a number. The tick period is 1 ms.
arch_idle() is the kernel idle-thread body (called from tk_kernel.c); it should park the
core until the next interrupt (e.g. wfi / WFE, or a pause). It is required for any
ARCH_CONTEXT_SWITCH_SUPPORTED == 1 port (the flat no-switch port omits it). Note that
arch_sched_init() and arch_kernel_tick() are port-internal board glue (PendSV priority
setup, the tick-ISR core) shared by all three deferred-switch ARM ports
(arm_v6m/arm_v7m/arm_v8m) and called by each board’s own tick driver — they are not
part of this kernel-facing contract, so a new port need not provide them by name.
3. The interrupt-state contract (get this right)¶
There are two masking pairs and they are not interchangeable:
arch_irq_disable()/arch_irq_enable()— a flat mask the kernel uses around its own scheduler bookkeeping.enable()turns interrupts on unconditionally; it is the cooperative-yield mask, not save/restore. Only the kernel uses it, and only where it knows interrupts should end up on.arch_irq_save()/arch_irq_restore()— nesting-safe:save()returns the prior mask and disables;restore()puts that exact state back (re-enabling only if it was enabled). The HAL and the DET/error module use these so they are correct when called from any context, including nested critical sections.
A new port must provide both pairs even if one is a no-op — true of every single-core build
in the tree, including flat/pthread (no hardware interrupts at all) and tricore/msp430
(single-core by default; tricore gains a real spinlock only once built TK_SMP_CORES>1).
4. Which preemption hook to call¶
The scheduler decision lives in the kernel and is shared by yield and preemption so
they always agree. The kernel-provided entry points a port calls are the Tier-B contract
in kernel/tk_kernel_port.h (never included by application code). A preemptive port wires
its timer to one of two hooks depending on how it switches:
Deferred-switch ports (the switch happens later, in a separate lowest-priority handler) call
tk_kernel_tick_preempt()from the timer ISR. It runs the scheduler and requests a switch; the actual register save/restore happens in the deferred handler. →arm_v6m/arm_v7m/arm_v8m: the board tick callstk_kernel_tick_preempt()(usually via the friendly wrapper below), which pends PendSV; PendSV does the switch and tail-chains after the ISR.Trap-frame ports (the timer trap itself saves the full frame) call
tk_kernel_preempt_select(current_sp)from the trap, which returns the next thread’s stack pointer to restore in place. →risc_v(mtvectrap) andc2000(switch.asm) both do this;tricoreis declared to when its body lands.
The friendly integration entry point a board’s 1 ms timer callback should actually call
is tk_tick() (also in tk_kernel_port.h): it advances the kernel’s millisecond base
(tk_tick_advance()) and drives preemption via tk_kernel_tick_preempt() — this is what
the ARM boards wire into their tick ISR. It is pre-launch-safe (a no-op before the first
thread runs and during the launch-commit window), so an SDK that already runs its own timer
before tk_kernel_start() may call it unconditionally.
Both tk_kernel_tick_preempt() and tk_kernel_preempt_select() funnel into the same internal
tk_kernel_tick() (wake due sleepers, refresh the quantum, pick the next thread) — flat
and pthread aside, every real port agrees here. In TK_COOPERATIVE mode it still wakes
sleepers but never forces a switch.
SMP adds a third hook, only compiled when ARCH_CORE_COUNT > 1:
tk_kernel_switch_select(current_sp) — the cooperative-yield counterpart for a trap-frame
SMP port (records the outgoing SP, picks next, releases the kernel lock, returns the next SP;
the lock is never held across the resume). A select-in-trap port whose restore must also
reprogram per-thread hardware state the SP alone doesn’t carry (PSPLIM, CONTROL.nPRIV, an
MPU region) calls tk_kernel_current_context() right after *_select to fetch it.
5. Stacks and the trap frame¶
The preemptive trap frame is large and lives on the interrupted thread’s own stack, and its size and unit vary by port:
risc_vpushes a full 128-byte integer frame (cells = 8-bit bytes) — the reason itsARCH_MIN_STACK_SIZEfloor is 512 cells.c2000saves a smaller register set, but its cells are 16-bit words (CHAR_BIT == 16,ARCH_STACK_GROWS_UP 1) —ARCH_MIN_STACK_SIZE(256) is a word count, not a byte count. Confusing the two on a word-addressed target is exactly the class of bug ADR 0001 exists to prevent.tricoreis the outlier shape: the register context lives in hardware Context Save Areas (CSAs), not on the C stack at all, so itsARCH_MIN_STACK_SIZE(512, 8-bit cells) is pure C call-chain headroom, not a register-frame floor. This is why it is the one port withARCH_HAS_CONTEXT_RECLAIM(§1b) — freeing a dead thread means returning its CSA chain, not just its stack memory.
Therefore, for any port:
Size every thread stack ≥
ARCH_MIN_STACK_SIZE, in that port’s cells (the kernel enforces this intk_thread_create()); size it generously for threads with deep call chains or FPU use.Honour the CPU’s stack alignment in
arch_context_init()(e.g. 8-byte on Cortex-M, 16-byte on RISC-V).Under-sizing manifests as the trap’s stores running off the end of the stack and overwriting adjacent memory/code — on QEMU this shows up as illegal-instruction loops, not a clean fault.
6. CPU-architecture vs board-specific¶
arch/<cpu> is the CPU port; anything board-specific belongs in boards/<board>.
Two current seams to be aware of when adding a second board of an existing CPU:
RISC-V CLINT/PLIC addresses are presently hardcoded in
arch/risc_v(legacy SiFive layout, base0x02000000) for the QEMUvirtmachine. A second RISC-V board would move these to a per-board constant the port reads (onlyTK_RISCV_MTIME_HZ, the timebase frequency, is already externalized to the board.mk).Timer source and rate are never hardcoded in the arch — see §2. Keep it that way when adding a second board of an existing CPU:
arch_timer_init()stays a board-provided function, even if two boards of the same CPU pick the source differently.arch_trap_init()(RISC-V) setsmtvecwithout arming the timer, so a HAL-only superloop (no kernel) can take interrupts without a kernel tick calling into a scheduler that isn’t running.
7. New-port checklist¶
ARCH_CONTEXT_SWITCH_SUPPORTED,ARCH_PREEMPTION_SUPPORTED,ARCH_MIN_STACK_SIZE(§1).struct arch_context+arch_context_clear; decide the stack-cell width andARCH_STACK_GROWS_UPif it isn’t 0 (§1b, §5).Both interrupt pairs (§3) — no-ops if single-threaded.
arch_context_init(build the first frame instack_cells, honour alignment),arch_context_switch,arch_context_start.arch_timer_init— board-provided rate, never hardcoded (§2); wire the timer to the right preempt hook (§4), normally viatk_tick()— or skip it entirely for a cooperative port (ARCH_PREEMPTION_SUPPORTED 0).Add
make/arch/<cpu>.mk(toolchain + flags) and amake/board/<board>.mkfragment — the two currently-missing pieces forc2000/tricore/msp430at root (§1’s status table).Verify: build and run the cross-board example apps against the new board —
examples/threads(make BOARD=<board> run/flash) for the single-core path, and, if the port declaresARCH_HAS_SMP,examples/threads_smpwithSMP=2(qemu_riscv32_virtandrp2040_picoare the two in-tree boards that already prove this path). There is no standalone multi-arch build harness at root yet — the example apps are it.