Skip to content

kernel::dynobj

Runtime-created kernel objects, for the radio blobs.

FlintOS’s primitives are compile-time sized. Queue<T, N> fixes length and type at build time, which is exactly right for a static RTOS and is what gives it bounded latency and no fragmentation.

Espressif’s blobs do not work that way. They create and destroy objects at runtime, in sizes they choose, through wifi_osi_funcs_t: queue_create(len, item_size) copies bytes and does not know a type.

This is not an adapter over the static primitives — it is a second model standing beside them. The static ones keep their semantics and their tests unchanged, and nothing here is meant for kernel or application use.

The blocking machinery, all of it. [crate::queue]’s waiter lists are keyed by an arbitrary usize, not by a queue type, so a dynamic object passes its own address and gets the scheduler integration, the timeout handling, the refusal to block in interrupt context and — most valuable — the existing race tests, which were hard won.

What is not reused is storage: these live in [crate::heap], which exists for the radio and is confined to it.

Dynamic objects are where an RTOS loses its timing guarantees. Every operation here is O(1) apart from waking waiters, which is the same cost the static queues already pay. Nothing allocates on the send or receive path; allocation happens at create and free at delete, and both are documented as unsuitable for a hot path.

struct DynQueue { /* private fields */ }

A byte-copying queue whose length and item size are chosen at runtime.

The blob’s queue_create(len, item_size). Items are opaque bytes: the queue never knows a type, so it cannot drop one, which is why [DynQueue::delete] is safe to call with items still in it.

Implements: Send

struct EventGroup { /* private fields */ }

Bits that tasks can wait on, any or all.

Twenty-four bits, matching what the blob expects; the top eight of a u32 are reserved and rejected rather than silently masked off.

Implements: Default

struct RecursiveMutex { /* private fields */ }

A mutex that the same task may lock more than once.

A second type, deliberately. [crate::mutex] refuses re-entry and logs an error, for a stated reason: nothing in its guard and unlock bookkeeping supports nested ownership. That decision stands. The blob needs recursion, so it gets its own type rather than the existing one being weakened.

No priority inheritance here, which the static mutex does have. Adding it would mean this type reaching into the scheduler’s boost bookkeeping, and the blob’s own locks are short and uncontended between its tasks. Worth revisiting if a priority inversion is ever actually observed.

Implements: Default

struct Semaphore { /* private fields */ }

A counting semaphore. Nothing like it exists in the static model.

A binary semaphore is this with a maximum of one, which is what the blob asks for most often.

struct SpinlockHandle { /* private fields */ }

An opaque spinlock, created and destroyed at runtime.

Wraps [crate::smp::Spinlock] so the blob gets a handle it can pass around. The wrapped type’s interrupt-masking order is load-bearing and is preserved exactly: it masks on the calling core for the whole critical section. The other order — take the lock, then mask — deadlocks when a handler on the same core wants the lock, which is precisely the case the radio hits.

Reentrancy still panics rather than hanging, as it does for every other user of Spinlock. That is deliberate: a hang reports nothing.

Implements: Default

enum DeleteError {
NoSuchTask,
StillRunning,
NotDeletable,
}

Why a delete was refused.

Implements: Clone, Copy, Debug, Eq, PartialEq, StructuralPartialEq

fn current_task() -> u32

The id of the task that is running now.

fn delete_self() -> !

End the calling task. Never returns.

A task cannot free the stack it is executing on: the moment the heap reissued those bytes, the next interrupt on this core would write its frame into somebody else’s allocation. So this does the half that is safe from here — leave the ready set, stop being a queue waiter — and hands the rest to [reap_deleted], which runs on the idle task’s stack.

The task stays in TaskState::Deleting with its stack intact until then. Nothing schedules it: every transition back into the ready set matches on the blocked states by name, and this is not one of them.

request_switch marks a switch pending; it does not perform one. Until the scheduler acts, this core is still executing here, on a stack that must stay valid. Parking is what keeps that true — returning would run the caller’s epilogue on a task the scheduler has already written off.

fn delete_task(id: u32) -> Result<(), DeleteError>

Delete a task and return its stack to the heap.

Deleting the calling task never returns — see [delete_self].

A task running on the other core. It cannot be made to switch away synchronously, and freeing the stack under it would corrupt it mid-instruction. [DeleteError::StillRunning].

Pool-backed tasks. Nothing to reclaim; see [DeleteError::NotDeletable].

Removing the task from every queue waiter list. A deleted task left listed gets unblocked by a later wake_one_*, by which point the slot may belong to a different task — which then returns from a wait it never entered, far from the delete that caused it.

References: DeleteError

fn reap_deleted() -> usize

Free the stacks and slots of tasks that have deleted themselves.

Called from the idle loop, which is the one context guaranteed not to be running on a dying task’s stack: idle runs on the boot stack and is only reached when nothing else is ready.

Returns how many were reaped, which is what the tests assert on.

A task is only freed once it is TaskState::Deleting and is not any core’s current task. The second condition is what covers the other core: while it is still executing the dying task — or saving its context on the way out — current_per_core still names it, and this skips it for now. Idle runs often; there is no hurry.

fn spawn_task(name: &'static str, entry: fn(..), priority: hal::types::Priority, stack_size: usize) -> Option<u32>

Create a task whose stack is returned when it is deleted.

The static [crate::spawn::sys_spawn] bump-allocates from the linker’s task_stacks region and never reclaims it — correct for a static RTOS, which creates its tasks once. The blobs create and delete tasks throughout a session, so these take heap stacks instead and the pool is left alone.

Returns the task id, or None if there is no TCB slot or no heap.

References: Priority

fn spawn_task_on(name: &'static str, entry: fn(..), priority: hal::types::Priority, stack_size: usize, affinity: crate::scheduler::Affinity) -> Option<u32>

Create a heap-stacked task pinned to a core.

[spawn_task] with the affinity spelled out. It exists because the blobs ask for it: _task_create_pinned_to_core is a separate entry from _task_create precisely because the radio’s own task must run on the core whose interrupt matrix its handlers were routed through, and answering that request with Affinity::Any would be a wrong answer rather than a missing one — the task would work, until the tick migrated it and its interrupts stopped arriving.

References: Affinity, Priority

fn yield_from_isr()

Ask for a reschedule from an interrupt handler.

Does not switch here — the switch happens on the way out of the handler, which is the only point at which it is safe.

const EVENT_BITS_MASK: u32;

The usable bits. The blob’s own API reserves the top byte for control.

const FOREVER: u32;

Wait forever. Matches the convention the static queues already use.

const HIGHEST_PRIORITY: u8;

The lowest-numbered — that is, highest — priority a task may be given.

FlintOS numbers priorities so that smaller is more urgent. The blob assumes the opposite in places, so the adapter has to invert; this constant and [LOWEST_PRIORITY] are what it inverts between.

const LOWEST_PRIORITY: u8;

The highest-numbered — least urgent — priority. Background(15).