SPEC-002 - Interrupt, Exception, and Critical-Section Model¶
Implementation and release scope: Project status records the implemented subset; roadmap defines release gates. Acceptance of this design does not establish implementation, verification, or support. Reconciled 2026-10-14.
Status: Accepted 2026-10-07 by the project owner, with the defaults of §15. Specification work item 2 of the roadmap (07 §3).
Requirements: docs/requirements/KRN-IRQ.md (KRN-IRQ-001 to KRN-IRQ-036; v0.1 identifiers restated, new ones from 017).
Builds on: SPEC-001 (contexts, misuse behavior, annotations); 03 §1.2, §2.4 (per-CPU state), §10 (faults); v0.1 §3.7, §6, §4.5.
Toolchain constraints applied: document 09 §5 and §8 (naked functions, .S trap entry on RISC-V, signal handlers on AVR, PRIMASK and BASEPRI vocabulary on Cortex-M).
1. Scope and terms¶
Term |
Meaning in this specification |
|---|---|
Interrupt |
An asynchronous hardware event that transfers control to a handler |
Exception |
A synchronous event raised by the executing instruction: fault, trap, system call |
ISR |
The handler that runs in interrupt context for one interrupt source |
Kernel-aware interrupt |
An interrupt whose handler may call ISR-safe kernel API; the kernel may mask it |
Kernel-independent interrupt |
An interrupt the kernel never masks; its handler may not call the kernel |
Kernel masking level |
The interrupt priority boundary between the two classes, |
Critical section |
A region with kernel-aware interrupts masked on the current CPU, entered with |
Scheduler lock |
A region in which the current thread cannot be preempted by another thread; interrupts still run |
Preemption point |
A place where the kernel may switch the running thread |
Reschedule pending |
Per-CPU flag: a scheduling decision is owed at the next preemption point |
Outermost exit |
Return from the only active interrupt on this CPU, back to thread context |
2. Per-CPU context state¶
The per-CPU state of 03 §2.4 gains the fields this specification needs:
cpu[n]:
current_thread
irq_nesting_depth 0 in thread context; N inside N nested kernel-aware interrupts
sched_lock_depth scheduler lock nesting
reschedule_pending bool
irq_lock_depth checked builds only: critical-section nesting, for LIFO and misuse checks
irq_lock_enter_cycles optional statistics: start of the current outermost critical section
irq_lock_max_cycles optional statistics: longest critical section observed
isr_stack base and size of the dedicated interrupt stack, where one exists
2.1 State machine¶
kernel start
PREKERNEL ---------------> THREAD (depth 0)
| ^
kernel-aware IRQ | | outermost exit: if reschedule_pending && sched_lock_depth == 0
entry: depth 1 | | then switch threads before returning
v |
ISR (depth 1) <----+
| | nested exit: depth-1, never switches
nested IRQ: depth+1 | |
v |
ISR (depth 2..N) --+
Kernel-independent interrupts are outside this machine: they do not touch cpu[n] and may
preempt any state, including a critical section.
emb_context() (SPEC-001 §5.1) is derived from this state: PREKERNEL before start, ISR when irq_nesting_depth > 0, else THREAD. On architectures with a hardware exception-active register (Cortex-M IPSR) the port may read that instead of the counter; the result must be identical.
3. Interrupt classes¶
3.1 Kernel-aware interrupts¶
Priority at or below the kernel masking level.
Masked by
emb_irq_lock()and by the kernel’s own critical sections.May call any function annotated
@ctx thread isr(ISR-safe), and nothing else.Enter and exit through the kernel’s interrupt prologue and epilogue (hardware-provided or
EMB_ISRmacro, §6).May wake threads; the wake sets
reschedule_pending, and the switch happens at the outermost exit.
3.2 Kernel-independent interrupts¶
Priority above the kernel masking level.
Never masked by the kernel, so their latency is the hardware’s latency plus nothing from EmbLinkRTOS.
May not call any kernel function, read kernel state, or touch kernel objects. They communicate with the rest of the system through lock-free single-producer structures of their own and, when they need the kernel, by pending a kernel-aware software interrupt with
emb_arch_irq_pend_soft(), whose handler does the kernel work.Exist only where the architecture provides interrupt priority masking (Cortex-M Armv7-M and Armv8-M with
BASEPRI; RISC-V with a CLIC or an interrupt controller that supports threshold masking). On Armv6-M (PRIMASK only), AVR (one global enable), and the native port, the class is unavailable andCONFIG_EMB_IRQ_ZERO_LATENCYis rejected at configuration time.
3.3 Configuration¶
CONFIG_EMB_IRQ_ZERO_LATENCY bool enable the kernel-independent class (architecture-gated)
CONFIG_EMB_IRQ_KERNEL_LEVEL int highest generic priority that may call the kernel (§8)
CONFIG_EMB_IRQ_NESTING bool allow kernel-aware interrupts to nest (architecture-gated)
CONFIG_EMB_IRQ_MAX_NESTING int depth the interrupt stack is sized for; exceeding it is a fault in checked builds
CONFIG_EMB_IRQ_LOCK_STATS bool measure critical-section durations with the cycle counter
CONFIG_EMB_IRQ_DYNAMIC bool runtime `emb_irq_connect()`; otherwise the vector table is generated and static
4. Critical sections¶
4.1 Primitives¶
/* emb_irq_key_t is supplied by the port: uint8_t on AVR, unsigned int elsewhere. */
emb_irq_key_t emb_irq_lock(void); /* @ctx thread isr prekernel @blocks no @time O(1) */
void emb_irq_unlock(emb_irq_key_t key); /* @ctx thread isr prekernel @blocks no @time O(1) */
bool emb_irq_is_locked(void); /* @ctx thread isr prekernel @blocks no @time O(1) */
emb_irq_lock() masks kernel-aware interrupts on the current CPU and returns the previous mask state. emb_irq_unlock(key) restores exactly that state. Nesting is therefore natural and costs nothing: the inner unlock restores a state in which interrupts were already masked. Unlocks must be in reverse order of locks (LIFO); checked builds verify it with irq_lock_depth.
The primitives are static inline __attribute__((always_inline)) wrappers over emb_arch_irq_lock() and emb_arch_irq_unlock() with a "memory" clobber, so they are both a hardware mask and a compiler barrier (09 §5). They never allocate, never block, never yield.
Architecture |
|
Key |
|---|---|---|
Cortex-M Armv7-M, Armv8-M, zero-latency on |
|
previous |
Cortex-M Armv7-M, Armv8-M, zero-latency off |
|
previous |
Cortex-M Armv6-M |
|
previous |
RISC-V |
|
previous |
AVR |
|
previous |
native |
simulator lock on the current virtual CPU |
previous lock state |
4.2 Rules inside a critical section¶
Bounded and short: the kernel’s own critical sections have a per-architecture cycle budget (§10) that is measured, not assumed.
No blocking, no thread-only API, no scheduler lock operations that would reschedule, no loops without declared admitted-resource bounds; complete kernel costs include scans, inheritance hooks and expiry batches (KRN-RT-002, KRN-IRQ-014; timing-and-evidence).
An ISR-safe kernel call made inside a critical section preserves the caller’s mask: the kernel never unmasks interrupts behind a caller’s back (KRN-IRQ-033). Kernel internals therefore use the same key-based primitive and restore what they found.
A critical section never spans a preemption point. If a thread blocks while a critical section it opened is still active, that is misuse (SPEC-001 §5.3) and a fault in checked builds.
4.3 Multiprocessor¶
emb_irq_lock() protects against interrupts and preemption on the current CPU only. Data shared across CPUs is protected with a spinlock that also masks:
emb_status_t emb_spin_lock(emb_spinlock_t *lock, emb_irq_key_t *out_key); /* masks, then spins */
void emb_spin_unlock(emb_spinlock_t *lock, emb_irq_key_t key);
On uniprocessor builds the spinlock compiles to the critical section alone (KRN-SMP-002). Lock ordering rules belong to the SMP specification; this specification only fixes that a spinlock is never held across a blocking call and is always acquired with interrupts masked.
4.4 Scoped form¶
No scoped macro is provided in C (a for-trick macro evaluates its argument once but hides control flow and violates the header rules of SPEC-001 §8). The C++ wrappers provide an RAII guard.
5. Scheduler lock¶
void emb_sched_lock(void); /* @ctx thread @blocks no @time O(1) */
void emb_sched_unlock(void); /* @ctx thread @blocks no @time O(1), plus one reschedule at depth 0 */
unsigned emb_sched_lock_depth(void); /* @ctx thread isr @blocks no */
Thread context only. In an ISR the scheduler is implicitly non-preemptive until the outermost exit, so a scheduler lock there is misuse.
Nestable by depth counter (KRN-SCH-017). Interrupts are not masked (KRN-SCH-019); kernel-aware ISRs still run and may make threads READY, which sets
reschedule_pending(KRN-SCH-016).The unlock that brings the depth to zero performs the pending reschedule immediately, in the unlocking thread’s context (KRN-SCH-018).
Blocking with a nonzero timeout while locked is misuse (SPEC-001 §5.3). Yield while locked is a no-op that keeps
reschedule_pendingset.A scheduler lock is a latency hazard for every higher-priority thread: it has the same cycle budget discipline as a critical section (§10) and emits the trace points
sched_lockandsched_unlockwith duration when tracing is on.It is not a synchronization primitive: it protects nothing against interrupts, DMA, other CPUs, or kernel-independent handlers (v0.1 §4.5).
6. Preemption points and reschedule on exit¶
6.1 The only places a thread switch may occur¶
Point |
Condition |
|---|---|
P1 Outermost exit of a kernel-aware interrupt to thread context |
|
P2 Inside a thread-context kernel operation that changes schedulability: block, wake, yield, priority change, budget action, thread exit, |
no caller-owned critical section and |
P3 Kernel start |
the first thread is launched |
Nowhere else. In particular: never inside a nested interrupt (KRN-SCH-021, KRN-IRQ-013), never while a caller-owned critical section is active, never at a tick that has not changed any thread’s state (KRN-SCH-004). Application code that makes no kernel call is preempted only through P1.
6.2 How an ISR causes a switch¶
An ISR-safe operation makes a thread READY whose effective priority exceeds the current thread’s, or otherwise changes the scheduling decision.
The kernel sets
cpu.reschedule_pendingand callsemb_arch_reschedule_pend().At the outermost exit, the architecture’s epilogue sees the flag (or the pended switch exception runs), clears it, and performs the context switch: save the interrupted thread’s remaining context, select the highest eligible thread, restore it, return into it.
Architecture |
Mechanism for P1 |
|---|---|
Cortex-M |
|
RISC-V |
The trap entry (a |
AVR |
No hardware nesting or pend mechanism. Kernel-aware ISRs are declared with |
native |
The simulator stops the running host thread at a safe point, runs the handler on an interrupt context, and applies P1 on return. |
6.3 Thread-context switches (P2)¶
The public preemption contract excludes caller-owned critical sections. Internally M1 selects and calls the port with its own kernel critical section held; the port restores the incoming context’s mask, and checked builds save/restore per-context lock depth (A10). A thread-context kernel operation that changes schedulability ends with embk_sched_reschedule_if_needed(), which, when not locked and not in a critical section, calls emb_arch_switch_to(next). On Cortex-M this too is done by pending PendSV and letting it run at return from the SVC or at the next instruction boundary with interrupts enabled, so there is exactly one switch path per architecture; on RISC-V and AVR it is a direct call into the switch routine.
7. Nesting and stacks¶
7.1 Nesting¶
Kernel-aware interrupts nest where the architecture permits and
CONFIG_EMB_IRQ_NESTING=y: Cortex-M always (NVIC preemption), RISC-V only when the trap handler re-enablesMIEafter savingmepc,mcause, andmstatusand the interrupt controller provides preemption levels, AVR never (kernel-aware handlers run withIclear).irq_nesting_depthis observable (emb_irq_nesting_depth(), KRN-IRQ-012) and bounded byCONFIG_EMB_IRQ_MAX_NESTING; exceeding the bound is a kernel fault in checked builds.Only the outermost exit may switch threads (§6).
7.2 Interrupt stacks¶
Architecture |
Where interrupt frames live |
Accounting |
|---|---|---|
Cortex-M |
Dedicated main stack ( |
|
RISC-V |
Dedicated per-CPU interrupt stack, entered by swapping |
same |
AVR |
On the interrupted thread’s stack (no alternative on this core) |
every thread stack budget includes |
native |
Host-provided |
n/a |
Interrupt stack overflow is detected in checked builds with a fill pattern checked at the outermost exit, and by MSPLIM on Armv8-M where the toolchain exposes it (09 §10 gap 1).
8. Interrupt management API¶
8.1 Priority numbering¶
Generic interrupt priorities follow the thread convention: a larger number is more urgent. 0 is the least urgent kernel-aware level. The architecture port maps generic levels to hardware encodings (NVIC’s inverted numbering and its implemented priority bits, PLIC priorities, CLIC levels); the hardware description stores generic levels and the generator emits encoded values.
0 .. CONFIG_EMB_IRQ_KERNEL_LEVEL kernel-aware
CONFIG_EMB_IRQ_KERNEL_LEVEL+1 .. MAX kernel-independent (only with CONFIG_EMB_IRQ_ZERO_LATENCY)
EMB_IRQ_LEVEL_KERNEL_MAX alias of CONFIG_EMB_IRQ_KERNEL_LEVEL
EMB_IRQ_LEVEL_MAX highest level the hardware implements
A configuration whose kernel level is the hardware maximum has no kernel-independent class; the configuration system reports that as information, not an error.
8.2 Functions¶
typedef uint16_t emb_irq_t; /* interrupt number from the generated SoC table */
emb_status_t emb_irq_enable(emb_irq_t irq); /* @ctx thread isr prekernel */
emb_status_t emb_irq_disable(emb_irq_t irq); /* @ctx thread isr prekernel */
bool emb_irq_is_enabled(emb_irq_t irq); /* @ctx thread isr prekernel */
emb_status_t emb_irq_set_level(emb_irq_t irq, uint8_t level); /* @ctx thread prekernel; EMB_EINVAL above EMB_IRQ_LEVEL_MAX */
emb_status_t emb_irq_pend(emb_irq_t irq); /* @ctx thread isr; software-trigger where supported */
emb_status_t emb_irq_clear_pending(emb_irq_t irq); /* @ctx thread isr */
emb_status_t emb_irq_connect(emb_irq_t irq, emb_isr_fn_t fn, void *arg); /* only with CONFIG_EMB_IRQ_DYNAMIC; @ctx thread prekernel */
All are @blocks no, @time O(1). The default is a generated static vector table: the hardware description binds each interrupt to its handler symbol at build time, so no RAM table and no runtime connect exist in the tiny and base profiles unless CONFIG_EMB_IRQ_DYNAMIC is selected (then a RAM table of (fn, arg) pairs is indexed by the first-level handler).
8.3 Writing a handler¶
EMB_ISR(USART2_IRQn) /* kernel-aware: may call @ctx thread isr functions */
{
uint8_t byte = USART2->RDR;
(void)emb_notify_set(g_rx_thread, RX_BIT); /* sets reschedule_pending if needed */
}
EMB_ISR_RAW(TIM1_CC_IRQn) /* kernel-independent or raw: no kernel calls allowed */
{
capture_buf[idx++ & MASK] = TIM1->CCR1;
}
EMB_ISR(name) expands per architecture to the correct declaration (plain function on Cortex-M, naked full-context vector on AVR, the dispatcher entry on RISC-V and native) and is the only portable way to declare a kernel-aware handler. EMB_ISR_RAW declares a handler that the kernel treats as kernel-independent in every respect except masking on architectures without priority masking; calling the kernel from it is undefined, and checked builds detect it on architectures where emb_context() can tell (Cortex-M, RISC-V, native).
9. Exceptions and faults¶
Event |
Route |
|---|---|
Synchronous fault: hard fault, bus or memory fault, usage fault, MPU or PMP violation, illegal instruction, misaligned access, divide by zero where trapped |
|
System call trap ( |
Not a fault: the trap entry routes it to the system-call dispatcher of the isolated profile (03 §8.1) |
Kernel-aware interrupt |
§6 |
Kernel-independent interrupt |
handler only; the kernel is not involved |
Debug monitor, breakpoint |
Passed to the debug agent where one is configured; otherwise a kernel fault |
Non-maskable interrupt |
Treated as kernel-independent: it may not call the kernel; the port documents its stack and nesting behavior |
The fault entry runs on the interrupt stack with kernel-aware interrupts masked, writes the crash record before any recovery action (FLT-001), and never returns into the faulting instruction unless a registered recoverable-fault hook says so (for example an MPU fault used for stack-guard growth detection in a diagnostic mode).
10. Latency: definitions, budgets, and measurement¶
10.1 Metrics¶
Metric |
From |
To |
|---|---|---|
Interrupt latency |
interrupt asserted at the controller |
first instruction of the handler body |
Kernel masking latency |
the longest time kernel-aware interrupts are masked by the kernel itself (its critical sections) plus the architecture’s own entry cost |
|
ISR overhead |
handler body end |
thread resumes when no switch is needed |
ISR-to-thread latency |
an ISR wakes a higher-priority thread |
that thread’s first instruction |
Scheduler-lock latency |
longest scheduler lock held by the kernel |
10.2 Budgets¶
Each architecture port publishes, per reference board and configuration, the measured worst case of every metric with the metadata of 05 §4.4. The kernel’s own critical sections and scheduler locks have a design budget stated in the port’s documentation; CONFIG_EMB_IRQ_LOCK_STATS records the longest observed and checked builds fault when a declared budget is exceeded in CI stress runs. No number appears in this specification: the budgets are evidence, produced by the benchmark pipeline, not promises written ahead of measurement (KRN-RT-007).
10.3 Instrumentation¶
Cycle counters per architecture (DWT_CYCCNT on Cortex-M3 and up, mcycle on RISC-V, a free-running timer on Cortex-M0+ and AVR), trace points irq_enter, irq_exit, irq_lock, irq_unlock, sched_lock, sched_unlock, resched_pend, switch, and a GPIO toggle hook for oscilloscope confirmation on HIL.
11. Timer interrupt¶
The system timer (tick or tickless comparator) is a kernel-aware interrupt. Its default level is the highest kernel-aware level, so timeout and deadline expiry are delayed only by critical sections and by kernel-independent interrupts, not by other kernel-aware handlers; its body is bounded (move expired timeouts to READY, set reschedule_pending, program the next comparator). The level is configurable for boards where another kernel-aware interrupt must win.
12. Checked-build diagnostics¶
Condition |
Detection |
|---|---|
Thread-only API from an ISR |
|
Blocking with nonzero timeout inside a critical section or with the scheduler locked |
|
|
depth and key consistency check |
Scheduler lock operations from an ISR |
context check |
Nesting beyond |
counter check at interrupt entry |
Interrupt stack overflow |
fill-pattern check at outermost exit; |
Kernel call from an |
context check on architectures that can distinguish; documented as undetectable on AVR |
Critical section longer than the declared budget |
|
CONFIG_EMB_FAULT_WHERE selects whether the function-name location is included; tiny defaults it off because strings cost AVR SRAM (A11). The full retained crash record is planned; M1 reports through its fault/board path.
Each raises a kernel fault with the class from SPEC-001 §5.3 and the location, recorded in the crash record.
13. Per-architecture summary¶
Cortex-M Armv7-M / Armv8-M |
Cortex-M Armv6-M |
RISC-V |
AVR |
native |
|
|---|---|---|---|---|---|
Masking primitive |
|
|
|
|
simulator |
Kernel-independent class |
yes with |
no |
with CLIC or threshold-capable controller |
no |
no |
Nesting |
hardware |
hardware |
optional, software re-enable |
no |
simulated |
Interrupt stack |
|
|
|
thread stack plus reserve |
host |
Switch path (P1) |
PendSV, |
PendSV, |
trap epilogue in |
|
gate |
Handler declaration |
plain C function |
plain C function |
|
|
|
Reschedule pend |
|
same |
flag, optional |
flag |
flag |
Timer |
|
|
|
Timer1 compare |
virtual clock |
Fault entry |
HardFault, MemManage, BusFault, UsageFault |
HardFault |
trap with |
none (reset only) |
signals |
14. Reference model¶
The per-CPU context state machine of §2 and the preemption-point rules of §6 are the first module of the executable reference model (ADR-013): a small state machine with operations irq_enter, irq_exit, irq_lock, irq_unlock, sched_lock, sched_unlock, make_ready, block, yield, and an oracle that says whether a switch may occur after each operation. Work item 4 (wait and wake protocol) builds on it. The model is written when work item 4 starts, so that both are explored together.
15. Decisions taken at acceptance (2026-10-07)¶
The timer interrupt runs at the highest kernel-aware level by default; the level is configurable per board.
AVR keeps both
EMB_ISR(full-context, may call the kernel) andEMB_ISR_RAW(plainsignalhandler, may not); the undetectable-misuse case on AVR is documented.RISC-V kernel-aware interrupt nesting is a configuration option, off by default, enabled for CLIC-equipped parts.
emb_irq_set_level()is allowed from thread context at runtime as well as pre-kernel.