Research note: Embassy and RIOT¶
Kind: Read-only source analysis, 2026-10-07. Embassy from a sparse shallow clone of embassy-rs/embassy at commit b3e27baf6c20 (embassy-executor, embassy-time, embassy-time-driver, embassy-time-queue-utils, embassy-sync); paths relative to the repository root. RIOT from RIOT-OS/RIOT at commit f9e38576567b, read from the git objects (git show HEAD:<path>) because the sparse checkout was empty; paths relative to the repository root as stored in git. Line numbers drift; paths and symbols are the durable reference. Synthesized in R-001.
Part A: Embassy¶
A1 Execution model¶
The run queue is an intrusive lock-free stack (
cordyceps::TransferStack<TaskHeader>): enqueue setstask.next = old headand atomically swaps the head; dequeue takes the whole batch withtake_alland iterates it (reverse order, justified for fairness) (embassy-executor/src/raw/run_queue.rs:45-58, 99-111). Link storage is therun_queue_itemfield inside everyTaskHeader(raw/mod.rs:109-120, run_queue.rs:18, 39-42); targets without pointer atomics fall back to a critical-section-guarded stack (run_queue.rs:195-235).A waker is (pointer to TaskHeader, static VTABLE); clone copies, drop is a no-op, so waking needs no allocation (raw/waker.rs:5-21).
wake_taskdoesstate.run_enqueue: atomically sets RUN_QUEUED and only if it was clear pushes to the owning executor and calls the pender (raw/mod.rs:630-639, 462-469).task_from_wakerrecovers the task by comparing vtable addresses so wait queues can store a one-word task pointer (waker.rs:34-49; turbo variant waker_turbo.rs).State bits:
STATE_SPAWNEDandSTATE_RUN_QUEUED(raw/state_atomics.rs:25-27; the ARM variant packs AtomicBools into one u32, state_atomics_arm.rs:14-25, 68-74; critical-section fallback state_critical_section.rs). Spawn is a CAS0 -> SPAWNED|RUN_QUEUED(state_atomics.rs:42-46); lifecycle diagram at mod.rs:70-108. There is no TIMER_QUEUED bit in this version: timer-queue membership lives inTaskHeader.timer_queue_item(mod.rs:116-117) and “in queue” is encoded aswaker.is_some()(embassy-time-queue-utils/src/queue_integrated.rs:21-22, 146-149).Task storage:
TaskStorage<F> { raw: TaskHeader, future: UninitCell<F> }isrepr(C)so the header is at offset 0 (mod.rs:218-224);TaskPool<F, N>is[TaskStorage<F>; N]; spawn claims the first free slot or returnsSpawnError::Busy(mod.rs:383-400, spawner.rs:60-67). The#[task]macro emits astaticsized at compile time (src/lib.rs:151-193; README.md:5-6); the future is built in place via a closure (mod.rs:250-256, 330). OnPoll::Readythe future is dropped,poll_fnreplaced bypoll_exited, thendespawn(mod.rs:276-291).Poll loop:
SyncExecutor::pollisrun_queue.dequeue_all(|p| (task.poll_fn)(p))(mod.rs:487-506). The thread-mode Cortex-M executor isloop { poll(); wfe }with pendersev(src/platform/cortex_m.rs:107-117, 16-21).InterruptExecutor: the pender pends an NVIC IRQ (STIR on v7, cortex_m.rs:23-47), the ISR callsexecutor.poll()(179-182),start()unmasks the IRQ and returns aSendSpawner(207-223). Preemptive priority levels are several executors on software interrupts at different NVIC priorities; a higher-priority ISR executor preempts a lower one mid-poll, while tasks inside one executor stay cooperative (cortex_m.rs:134-147; README.md:12). The pender may be called from any context and must never callpollre-entrantly (src/pender.rs:10-28, mod.rs:530-535).Optional
scheduler-priorityandscheduler-deadlinesort each dequeued batch into aSortedList(run_queue.rs:127-176; Cargo.toml:210-215; src/metadata.rs:60-94), still with no preemption within an executor.The “context switch” cost is one indirect call
poll_fn(p)(mod.rs:498); the waker ismem::forget-ed to avoid a virtual drop (299-301). Stack: one per executor (the thread executor on the main stack, interrupt executors on the exception stack); future state is in the staticTaskStorage, not on any stack.
A2 Time¶
Driver trait:
fn now(&self) -> u64; fn schedule_wake(&self, at: u64, waker: &Waker)(embassy-time-driver/src/lib.rs:118-139).now()must be monotonic and effectively never overflow; 16- and 32-bit hardware must be extended to 64 bits in software (lib.rs:120-128). Binding is by the linker symbols_embassy_time_nowand_embassy_time_schedule_wakefromtime_driver_impl!, exactly one driver per image (lib.rs:141-177).TICK_HZis a compile-time const chosen bytick-hz-*features (lib.rs:112-115, src/tick.rs:3-40).InstantandDurationareu64ticks (embassy-time/src/instant.rs:9-10, duration.rs:10-11); ms and µs conversion uses GCD-reduced constant multiply and divide (duration.rs:31-37, 56-66; src/lib.rs:43-45);Instant + Durationischecked_addand panics on overflow,try_*variants exist (instant.rs:178-211, timer.rs:162-174). No wrap handling at all: 64 bits at 1 MHz is about 584,000 years (embassy-time/README.md:11-13).Timer queue API:
schedule_wake(at, waker) -> bool(“re-arm the alarm”) andnext_expiration(now) -> u64(wakes expired, returns the minimum) (embassy-time-queue-utils/src/lib.rs:37-48). The integrated queue is an intrusive singly linked list through the per-taskTimerQueueItem; re-scheduling an already queued task only lowersexpires_at(queue_integrated.rs:35-37, 76-108, 114-132). The generic queue is aheapless::Vec<(at, Waker)>, default 64 slots; when full it pops and wakes a victim (queue_generic.rs:38-40, 70-77, 103-120).One hardware alarm multiplexing: the recommended driver loop is
if queue.schedule_wake(..) { loop { next = queue.next_expiration(now); if set_alarm(next) break } }under a critical section (embassy-time-driver/src/lib.rs:73-84). The CMSDK driver shows it concretely: a 32-bit free-running “timekeeper” whose overflow IRQ bumpsperiods(embassy-time/src/driver_cmsdk/mod.rs:197-199), one alarm timer,trigger_alarmre-arms from the queue (227-243),set_alarmreturns false if the target already passed (245-260). No per-tick interrupt: only overflow and next-deadline IRQs.Timer::atandafterstoreexpires_at;pollreturns Ready only ifyielded_once && expired, otherwiseschedule_wake(expires_at)and Pending, so it always yields at least once (timer.rs:128-165, 304-315).block_forbusy-waits onInstant::now()(delay.rs:14-17).Ticker is drift-free:
next()doesexpires_at += durationfrom the previous expiry, not fromnow(timer.rs:359-375, 470-481; README.md:34). Consequence: if several periods are missed it fires back to back until caught up (no skip);reset*methods re-anchor to now (402-460).
A3 Synchronization¶
RawMutex { const INIT; fn lock<R>(&self, f) -> R }only guarantees no concurrent access and is reentrant-tolerant (embassy-sync/src/blocking_mutex/raw.rs:17-38).CriticalSectionRawMutexiscritical_section::with(the portable CS crate, 59-65);NoopRawMutexisf()and is!Syncso it cannot cross executors (120-138);ThreadModeRawMutexassertsICSR.VECTACTIVE == 0(167-198);PanicRawMutexdetects misuse (88-110).Async
Mutex:locked: boolplus oneWakerRegistrationbehind a blocking mutex (mutex.rs:19-47);lockregisters the waker if locked (77-96); guard drop clears and wakes (203-215). Not fair or FIFO: a single waker slot; registering a second waiter wakes the first so they re-register in turn (waitqueue/waker_registration.rs:21-43). No ownership or priority concept.Channel<M,T,N>:Deque<T,N>plusreceiver_wakerandsenders_waker(both single-slot) (channel.rs:687-691); send wakes the receiver (773-786), receive wakes senders when it frees a full queue (731-744). MPMC in API but multi-waiter contention resolves via the wake-the-other-waiter trick.Signal:enum State { None, Waiting(Waker), Signaled(T) };signaloverwrites and wakes (signal.rs:44-49, 77-84, 92-112), a latest-value notification for one consumer.PubSubChannel:Deque<(T, remaining_readers)>, u64 message ids,MultiWakerRegistration<SUBS>and<PUBS>(pubsub/mod.rs:304-319); slow subscribers getLagged(n)(364-369);publish_immediatedrops the oldest (354-362); an entry is freed when the last reader consumed it (383-387).Wait queues:
WakerRegistrationsingle slot (waker_registration.rs:10-50);AtomicWakerlock-free 2-bit state machine WAITING, REGISTERING, WAKING with wake hand-off to an in-flight register (waitqueue/atomic_waker.rs:62-77, 146-268), aliasingCriticalSectionWakerwhere no 32-bit CAS exists (282-283);MultiWakerRegistration<N>is a heapless Vec that wakes everyone when full (multi_waker.rs:9-51).zerocopy_channel: SPSC over a caller-provided&mut [T];SendSlothands out&mut Tinto the ring andsend_done()commits (zerocopy_channel.rs:39-66, 125-172, 193-217).Pipe: byteRingBuffer<N>with read and writeWakerRegistration, implementsembedded_io_async(pipe.rs:216-218, 273-275, 550-574).
Embassy: ideas to borrow¶
Static task pools with compile-time sizing and
Busyon exhaustion, no heap (raw/mod.rs:383-400, lib.rs:151-193).Waker-style completion: a one-word task pointer plus “enqueue only if RUN_QUEUED was clear” makes wakes idempotent and ISR-safe (mod.rs:630-639; state_atomics_arm.rs:68-74); intrusive run-queue links inside the TCB (run_queue.rs:18).
A batch-dequeue run queue (take all then iterate) for O(1) ISR enqueue (run_queue.rs:99-111).
One-alarm multiplexing via
schedule_wake -> boolplusnext_expiration(now), with the “set alarm, verify not in past, retry” loop (embassy-time-driver/src/lib.rs:73-84; driver_cmsdk/mod.rs:227-260); a 64-bit tick timebase so wrap never matters (lib.rs:120-128).Ticker semantics
expires_at += period(timer.rs:470-481).The
RawMutexabstraction selecting a critical section, a thread-mode assert, or a no-op lock per data-sharing scope, plus the portablecritical_sectioncrate (blocking_mutex/raw.rs:30-38, 59-65, 167-198).Lagged-aware broadcast with per-message reader counts (pubsub/mod.rs:306, 364-369); the zero-copy slot channel (zerocopy_channel.rs).
Embassy: weaknesses and limitations¶
No preemption inside an executor; a long poll blocks all peers; priorities only via separate interrupt executors on single-core bare metal (cortex_m.rs:134-147; Cargo.toml:160-165).
No priority inheritance or ownership in the async
Mutex; single-slot wakers cause waiter thrashing (mutex.rs:19-23; waker_registration.rs:30-38).No isolation: everything is privileged, with
unsafetask and pointer casts (mod.rs:218-224);SpawnTokendrop panics (spawner.rs:51-56);Instantarithmetic panics on overflow (instant.rs:202-211).Timer queue capacity: the generic queue silently wakes a victim when full (queue_generic.rs:70-77); the integrated queue requires Embassy wakers only (waker.rs:34-38).
Debuggability of async state machines relies on an optional
tracefeature (lib.rs:7-14); Ticker catch-up bursts after long stalls (timer.rs:472-475).
Part B: RIOT¶
B1 Scheduler¶
sched_runqueues[SCHED_PRIO_LEVELS](circular singly linkedclist, O(1) rpush, lpop, lpoprpush, core/lib/include/clist.h:21-40) plusrunqueue_bitcacheu32 (core/sched.c:76-77); 16 levels by default, maximum 32 (core/include/sched.h:188-189, sched.c:60). Bit order flips on CLZ availability so selection is oneclzorctz(sched.c:84-116).sched_run: with no idle thread and an empty bitcache it loopssched_arch_idle()(sched.c:149-158); clearssched_context_switch_request, selects the highest-priority queue and its headsched_runqueues[nextrq].next->next(160-164), marks RUNNING, updatessched_active_pidandthread, TLS, the MPU stack guard (177-219).Priority 0 is highest (“nice” values, sched.h:22-39);
THREAD_PRIORITY_MIN = SCHED_PRIO_LEVELS-1 = IDLE(core/lib/include/thread_config.h:118-131). Equal priorities are semi-cooperative (sched.h:36-39);thread_yielddoessched_runq_advance, which isclist_lpoprpush(core/thread.c:151-162, sched.h:318-321). Optionalsched_round_robin: a ztimer (10 ms on ZTIMER_MSEC) advances the queue and yields, driven bysched_runq_callbackon runqueue changes (sys/sched_round_robin/sched_round_robin.c:41-107; sys/include/sched_round_robin.h:42-71; hooks in sched.c:166-168, 237-242, 256-258).States: STOPPED, ZOMBIE, SLEEPING, MUTEX, RECEIVE, SEND, REPLY_BLOCKED, FLAG_BLOCKED_ANY and ALL, MBOX_BLOCKED, COND_BLOCKED, RUNNING, PENDING (sched.h:159-172);
STATUS_ON_RUNQUEUE == STATUS_RUNNING, sostatus >= RUNNINGmeans runnable (sched.h:180).sched_set_statuspushes or pops the runqueue when crossing that threshold and clears the bit when a queue empties (sched.c:227-276).Switch request:
sched_switch(other_prio)yields immediately viathread_yield_higher()in thread context, or setssched_context_switch_request = 1in ISR context (sched.c:278-302, sched.h:210-233). On Cortex-M the switch is PendSV (SCB_ICSR_PENDSVSET, cpu/cortexm_common/thread_arch.c:549) whose handler callssched_runwith IRQs disabled and skips the switch if the same thread wins (thread_arch.c:318-343).IRQ API:
irq_disable()returns the old PRIMASK,irq_restore(state)(core/lib/include/irq.h:46-72; cpu/cortexm_common/include/irq_arch.h:52-83);irq_is_in()isIPSR & 0xFF(irq_arch.h:119-121). Blocking from an ISR is refused, not asserted everywhere:thread_sleepreturns early (thread.c:109-113),ztimer_sleepasserts (sys/ztimer/util.c:45), mutex_blockasserts an active thread (core/mutex.c:62-64),msg_sendredirects tomsg_send_int(core/msg.c:65-67).Idle: the
core_idle_threadmodule runspm_set_lowest()in a loop at THREAD_PRIORITY_IDLE (core/lib/init.c:92-98, 116-120); otherwisesched_arch_idle()(pm_set_lowest()or WFI, then briefly re-enable IRQs) runs inside the scheduler (thread_arch.c:553-566; sched.h:268-281).MAXTHREADSdefaults to 32 (sched.h:94-96);sched_threads[KERNEL_PID_LAST+1]is a staticthread_t*array (sched.c:57), PIDsKERNEL_PID_FIRST=1 .. FIRST+MAXTHREADS-1(sched.h:105-115);thread_createlinearly scans for a free PID else-EOVERFLOW(thread.c:313-326), places the TCB at the top of the caller’s stack (259-272), paints the stack forthread_measure_stack_free(298-308, 207-234).thread_tfields: sp, status, priority, pid, flags, rq_entry, wait_data, msg_waiters, msg_queue (cib), msg_array (core/include/thread.h:167-208).
B2 IPC and synchronization¶
msg_tis sender_pid, u16 type, union {u32 value, pointer} (core/include/msg.h:194-200)._msg_send: if the target is not RECEIVE_BLOCKED, try to copy into the target’s per-threadcibring (msg.c:43-61, 105-122); no queue and blocking leads to SEND_BLOCKED, priority-sorted intotarget->msg_waiters(134-154);msg_try_sendreturns 0 instead (124-129); if the target is receive-blocked, copy directly intotarget->wait_dataand make it PENDING (159-170)._msg_receivepops the ring or blocks RECEIVE_BLOCKED; a blocked sender’s message is pulled in and the sender released (360-444).msg_send_receiveandmsg_replyrendezvous via REPLY_BLOCKED (260-326).msg_init_queuesets the per-thread array (504-510); docs: synchronous by default, asynchronous only with a queue,try_senddrops (msg.h:87-110).thread_flags_tis u16 in the TCB (core/include/thread_flags.h:118, thread.h:175).thread_flags_setORs the mask and_thread_flags_wakecompares against the mask parked inwait_datafor ANY or ALL, sets PENDING andsched_context_switch_request(core/thread_flags.c:25-51, 143-157);wait_any,wait_all,wait_oneblock in STATUS_FLAG_BLOCKED_* and atomically clear what they return (53-62, 87-135). PredefinedTHREAD_FLAG_MSG_WAITING (1<<15)andTHREAD_FLAG_TIMEOUT (1<<14)(thread_flags.h:93-99); message queueing and blocked sends set MSG_WAITING viathread_flags_set_internalso one wait covers messages, timeouts, and events (msg.c:57-59, 149-151).sys/eventbuilds on this:event_postpushes a sender-allocated clist node and setsTHREAD_FLAG_EVENT(sys/event/event.c:34-49),event_wait_multiloops pop thenthread_flags_wait_any(85-110), timeouts via THREAD_FLAG_TIMEOUT (113-129, 162-177; sys/include/event.h:33, 45).Mutex:
mutex_t.queueis NULL,MUTEX_LOCKED ((list_node_t*)-1), or the head of a priority-sorted waiter list (core/include/mutex.h:36-72, 130; core/mutex.doc.md:17-24); waiters are inserted bythread_add_to_list(mutex.c:67-74, thread.c:187-205); unlock wakes the head, the highest priority (204-210). Priority inheritance is opt-in (core_mutex_priority_inheritance): raise the owner on block, restore on unlock (mutex.c:76-85, 216-224); the default is none (mutex.doc.md:5-9).mutex_cancel:mutex_cancel_t {mutex, thread, cancelled}(mutex.h:103-107, 167-169),mutex_lock_cancelablereturns-ECANCELED(mutex.c:135-182),mutex_cancelremoves the thread from the waiter list and wakes it, ISR-safe (263-293), used forztimer_mutex_lock_timeout(util.c:221-243).mutex_unlock_and_sleep(233-261).cond_waitunlocks, blocks COND_BLOCKED on a sorted list, relocks on return (core/cond.c:32-48); signal and broadcast thensched_switch(min_prio)(50-75).rmutex_t {mutex, refcount, owner}with an atomic owner read on relock (core/lib/include/rmutex.h:34-56, core/lib/rmutex.c:40-120).mbox: a cib ring ofmsg_tplus readers and writers lists; put hands directly to a waiting reader, else queues or blocks the writer (core/mbox.c:57-124).
B3 ztimer¶
Clock tree:
ztimer_clock_t {list, ops, last, adjust_set, adjust_sleep, adjust_clock_start, users, max_value, lower_last, checkpoint, block_pm_mode}(sys/include/ztimer.h:365-388),ztimer_ops_t {set, now, cancel, start, stop}(335-360). A convert clock ownslowerand alower_entrytimer whose callback isztimer_handler(self)(sys/ztimer/convert.c:52-68); frac scales set and now withfrac_scale(convert_frac.c:48-71, 108-116), shift uses>>and<<(convert_shift.c:32-75), muldiv64 uses 64-bit ceiling division (convert_muldiv64.c:34-74).ZTIMER_USECis the base timer directly at 1 MHz, shift for 250 or 500 kHz, else frac;ZTIMER_MSECprefers an lptimer or RTT, else frac on the base timer (sys/ztimer/init.c:117-146, 212-247; design text ztimer.h:90-124).Extension: a clock with
max_value < UINT32_MAXarms at mostmax_value/2and keeps intermediate callbacks even with no timers (core.c:398-422, 434-457);_ztimer_now_extendcheckpointscheckpoint += (lower_now - lower_last) mod max_value(core.c:263-283, ztimer.h:161-177, 680-696);ztimer_extendis always pulled in (Makefile.dep:23-26).Ondemand:
ztimer_acquireandreleasecount users and callops->startandstop(core.c:53-113);ztimer_setacquires and subtractsadjust_clock_start(163-174);ztimer_handlerreleases per fired timer and stops re-arming when nobody needs the clock (474-492);periph_timerstops the hardware right after init (periph_timer.c:83-97, 131-135); converters forward start and stop downwards (convert.c:38-50). Without ondemand,pm_blockandpm_unblockbracket the first and last timer (core.c:222-228, 361-367).Timer list: entries store the delta to the predecessor,
list.offsetis the absolute base (ztimer.h:127-134)._add_entry_to_listwalks summing deltas and rebases the successor (core.c:216-253);_ztimer_update_head_offsetfoldsnow - list.offsetinto the head entries (286-327); removal gives the offset back to the successor (329-370).ztimer_handlerpops offset-0 entries, fires callbacks, re-checks for expiries that happened during callbacks, then re-arms and yields if not in an ISR (424-502).Wrap:
ztimer_now_tis u32 and only differences are meaningful (ztimer.h:304-308, 575-577); the backend set is always relative and clamped to amintick count (periph_timer.c:44-48;CONFIG_ZTIMER_USEC_MIN 10, sys/include/ztimer/config.h:76-77) to avoid underflow (ztimer.h:180-188).Periodic:
ztimer_periodic_t.last; reset computestarget = last + interval; offset = target - now; if (offset > interval) offset = 0andlast = target(periodic.c:29-41), drift-free with an immediate catch-up tick when late.ztimer_periodic_wakeupinstead resets*last_wakeup = nowon a miss, dropping phase (util.c:65-82).Sleep and flags:
ztimer_sleepuses a stackMUTEX_INIT_LOCKEDunlocked by the callback and subtractsadjust_sleep(util.c:36-63);ztimer_set_timeout_flagsets THREAD_FLAG_TIMEOUT (177-191);ztimer_set_msgandztimer_msg_receive_timeout(102-130);ztimer_mutex_lock_timeoutviamutex_cancel(221-243).Adjust:
adjust_setsubtracted in everyztimer_set(core.c:186-192),adjust_sleepin sleep,adjust_clock_starton cold start;ztimer_overhead_setandsleepmeasureafter - pre - base(overhead.c:38-63);ztimer_auto_adjusttakes the minimum over 100 samples at init (init.c:272-291, 386-405); defaults 0 (config.h:158-184). xtimer remains as a legacy API withztimer_xtimer_compat(Makefile.dep:14-20); ztimer’s relative-offset list plus clock chain replaces xtimer’s single 64-bit clock.
RIOT: ideas to borrow¶
thread_flags: a 16-bit per-TCB word with ANY, ALL, ONE waits, and reserved bits so IPC and timers compose into one wait (thread_flags.c:25-51; msg.c:57-59; util.c:177-191).
The ztimer clock tree: a hardware clock plus converter layers, each a full clock (ztimer.h:90-124; convert.c:52-68); a relative-offset list that uses the full 32-bit range (core.c:216-253); max_value/2 extension with checkpointing (core.c:398-422, 263-283).
Overhead compensation
adjust_set,adjust_sleep,adjust_clock_startwith boot-time auto-calibration (core.c:186-192; init.c:279-291).Ondemand clocks with user counting so timers themselves gate low power (core.c:53-113, 163-174).
mutex_cancelas the generic timeout primitive for any blocking lock (mutex.c:263-293; util.c:221-243).A bitcache runqueue with CLZ-ordered bits,
STATUS_ON_RUNQUEUEthreshold encoding, TCB-in-stack placement, and stack painting (sched.c:84-116, 262-276; thread.c:259-308).Sender-allocated event queues that never drop (event.c:34-49; event.h:33).
RIOT: weaknesses¶
No priority inheritance by default; an opt-in module only, and only for
mutex_t(mutex.doc.md:5-9; mutex.c:76-85).A static
MAXTHREADSarray and linear PID scan (sched.h:94-96; thread.c:313-326); a 32-level cap tied to a u32 bitcache (sched.c:60).msg semantics are easy to misuse: blocking sends can deadlock without a receiver queue,
msg_try_sendsilently drops, and queue sizing is per thread (msg.h:87-110; msg.c:124-129);msg_send_receiveabuses the reply buffer for sending (msg.c:275-279).Same-priority threads are cooperative unless
sched_round_robin(itself a ztimer client) is enabled (sched.h:36-39; sched_round_robin.c:28-30).32-bit
ztimer_now_tdemands wrap discipline from users and continuous clock activity for comparisons (ztimer.h:575-600); ondemand forgets time while the clock is off.Several ISR-misuse paths return silently instead of asserting (thread.c:109-113, 66-70).