-
Notifications
You must be signed in to change notification settings - Fork 4
Programming Model
mipOS lets firmware be written as a set of sequential tasks instead of one large superloop full of manually maintained state machines. The programmer writes the activity as ordinary C control flow; the kernel preserves the task context when that activity has to wait.
This page is grounded in the current implementation under mipos/ and the
scheduler smoke test under tests/. Function names and snippets intentionally
use the source-level API rather than pseudo-RTOS terminology.
In a classic superloop, every activity must remember its own position:
for (;;) {
poll_uart_state_machine();
poll_button_state_machine();
poll_storage_state_machine();
poll_network_state_machine();
}That works while the product is tiny, but each feature becomes a manually encoded state machine. In mipOS, the same activities can become independent sequential tasks:
static int console_task(task_param_t p)
{
(void)p;
for (;;) {
read_command();
execute_command();
mipos_schedule();
}
}The important difference is not performance; it is shape. The call stack holds where the task is, so the application code can keep reading like the operation it represents.
A task is an ordinary C function running on its own application-provided stack. It keeps local variables across waits because the BSP saves and restores the CPU context and stack pointer when switching tasks.
A task gives other tasks a chance to run when it:
- calls
mipos_schedule; - calls a sleep/wait primitive such as
mipos_tm_wkafterormipos_tm_msleep; - waits for a signal, queue item, mutex, or timer;
- returns from its entry point.
There is no preemptive time slicing between tasks. A task that computes for a long time without yielding will delay the rest of the system, so long-running work should be split with explicit scheduling points.
The core creation API is mipos_t_create. The caller supplies the task name,
entry function, parameter, stack memory, stack size, and flags.
static char worker_stack[1024];
static int worker(task_param_t param)
{
for (;;) {
do_one_step(param);
mipos_tm_msleep(10);
}
}
mipos_t_create("worker",
worker,
user_context,
worker_stack,
sizeof(worker_stack),
MIPOS_NO_RT);The implementation in mipos/mipos_task.c shows the intended ownership model:
the kernel finds a free descriptor, records the entry point, then stores the
caller-provided stack pointer and size.
if (!(p_task->entry_point)) {
_mipos_t_create(name, p_task, entry_point, param, flags);
p_task->task_stack = task_stack;
p_task->stack_size = stack_size;
mipos_leave_cs();
return id;
}The root task is started by mipos_start. Most examples follow the same basic
pattern: initialize board or simulator services, start mipOS with a root task,
then let the root task create the rest of the application.
static char root_stack[64 * 1024];
int main(void)
{
mipos_start(root_task, 0, root_stack, sizeof(root_stack));
return 2;
}mipos_start is the kernel entry point. Its startup sequence is visible in
mipos/mipos_kernel.c:
scheduler_init();
mipos_bsp_setup_reset_and_wd();
mipos_bsp_setup_clk();
mipos_bsp_exception_vector();
mipos_bsp_create_hw_rtc_timer();
mipos_bsp_enable_irq_mask();
mipos_t_create(
"root", root_task, root_task_param, task_stack, stack_size, MIPOS_NO_RT);After that, execution stays inside the scheduler loop. The scheduler repeatedly walks the fixed task table, skips invalid or non-runnable descriptors, services software timers, and dispatches the next ready non-real-time task.
The task table is static and bounded. That is deliberate: small targets should make task capacity visible at compile time instead of discovering memory pressure through dynamic allocation.
The dispatcher has two paths:
- first run: switch to the application-provided task stack and call the entry point;
- resume: restore the saved task register state after a previous wait/yield.
The first-dispatch path in mipos/mipos_kernel.c computes the top of the stack
from the caller's buffer, adjusts alignment on 64-bit simulator builds, then
uses the BSP stack-switch primitive:
top_of_the_stack = (void*)(&p_task->task_stack[p_task->stack_size]);
top_of_the_stack =
(void*)((mipos_reg_t)top_of_the_stack - sizeof(void*));
mipos_replace_sp(saved_stack_pointer, top_of_the_stack);
p_task->task_ret_value = p_task->entry_point(p_task->param);On 64-bit simulator builds this is routed through a dedicated helper instead of using the older direct stack macro. That split exists because x64 ABIs have more strict stack alignment and callee-saved register rules than small 8/32-bit MCU ports.
The scheduler smoke test in tests/mipos_scheduler_smoke.c demonstrates the
basic cooperative contract. The root task creates two workers. Each worker
appends its tag and then calls mipos_schedule.
static int worker_task(task_param_t param)
{
const char tag = (char)(mipos_reg_t)param;
for (int i = 0; i < 3; ++i) {
append_tag(tag);
mipos_schedule();
}
return tag;
}The expected sequence is interleaved, for example ABABAB, because every worker
yields after each step. If one task never yielded, the system would still be
cooperative, but responsiveness would be poor.
The test makes that behavior explicit:
if (first_b >= last_a) {
fail("workers did not interleave");
}This is the useful mental model for application code: a task owns the CPU until it reaches a kernel scheduling point.
Most blocking operations eventually use the same internal pattern. The macro
_mipos_t_suspend in mipos/mipos_kernel.h saves the current task context,
marks the task as waiting for a signal, stores the process stack pointer, and
jumps back to the scheduler context.
if (mipos_save_context(mipos_kernel_env.task_context_ptr->reg_state)) {
_mipos_t_reset_signal_and_run(_SIGNUM);
} else {
mipos_kernel_env.task_context_ptr->process_stack_pointer =
(mipos_reg_t)mipos_get_sp();
_mipos_t_set_wait_for_signal(_SIGNUM);
mipos_context_switch_to(mipos_kernel_env.scheduler_registers_state);
}The exact mechanics behind mipos_save_context, mipos_get_sp, and
mipos_context_switch_to belong to the BSP. For example, the simulator may use
setjmp/longjmp plus small assembly helpers, while STM8 and QEMU ARM provide
architecture-specific stack and interrupt primitives.
Time-based waits are expressed as blocking calls:
-
mipos_tm_wkafter(ticks)yields until a scheduler tick count expires; -
mipos_tm_msleep(ms)sleeps for milliseconds; -
mipos_tm_usleep(us)sleeps for microseconds when the port supports the requested resolution; -
mipos_get_rtc_counterreads the RTC counter.
The RTC path is intentionally small. The BSP periodically calls
mipos_update_rtc, and the scheduler wakes tasks whose rtc_timeout has
expired.
void mipos_update_rtc(uint32_t quantum)
{
mipos_kernel_env.rtc_counter += quantum;
mipos_kick_watchdog();
}Bare-metal ports feed this from a hardware timer, SysTick, or an epoch hook. The STM8 port calls it from the TIM4 interrupt; the QEMU ARM ports call it from the emulated timer path. On the simulator, time comes from host facilities.
Queues are fixed-size FIFO buffers backed by caller-provided storage. They are useful for producer/consumer handoff without dynamic allocation.
static mipos_queue_t queue;
static mipos_q_item_t queue_pool[8];
mipos_q_init(&queue, queue_pool, 8);
mipos_q_send(&queue, 42);
mipos_q_item_t item;
if (mipos_q_receive(&queue, &item, TRUE) == 0) {
consume(item);
}mipos/mipos_queue.c shows the storage model directly:
queue->head = 0;
queue->tail = 0;
queue->item = queue_pool;
queue->queue_limit = queue_pool_length;
queue->queue_count = 0;A blocking receive suspends the current task on MIPOS_SIGQUE when the queue is
empty. A send wakes the suspended task by notifying the same signal.
if (queue->queue_count == 0 && queue->suspended_tid == 0) {
queue->suspended_tid = mipos_kernel_env.current_task;
if (bl) {
_mipos_t_suspend(MIPOS_SIGQUE);
}
}mipos_q_receive can block. RT-style polled tasks should avoid blocking mode
and use queue count or message-pending checks instead.
The mutex implementation is intentionally small. A task waiting for a locked mutex cooperatively yields until the mutex becomes available.
static mipos_mtx_t lock = MU_INIT;
mipos_mu_lock(&lock);
update_shared_state();
mipos_mu_unlock(&lock);The implementation in mipos/mipos_mutex.h is a small macro rather than a
separate scheduler object:
while (1) {
if ((_MTX)->state == MUTEX_UNLOCKED) {
break;
}
mipos_tm_wkafter(0);
}
(_MTX)->state |= MUTEX_LOCKED;Because mipOS is cooperative, task-shared state is stable until the current task blocks or yields. Mutexes are still useful when an update spans calls that may schedule, or when the ownership rule is clearer than relying on convention.
Signals are the lightest notification mechanism. A task can suspend until a
signal mask is satisfied, while another task resumes or notifies it. The public
API exposes this through helpers such as mipos_t_waitfor_signal,
mipos_t_resume, and the lower-level signal notification path in the kernel.
Signals are also used internally:
-
MIPOS_SIGWKPresumes a suspended task; -
MIPOS_SIGQUEwakes queue receivers; -
MIPOS_SIGALMsupports scheduler-tick waits; -
MIPOS_SIGTMRsupports RTC-based sleeps.
Use signals for events, queues for data transfer, and mutexes for ownership. That separation keeps embedded code easier to inspect.
mipOS is soft real time. It is predictable when tasks cooperate, but it does not guarantee hard deadlines if a task holds the CPU too long. Interrupt handlers should stay short and defer work to tasks where possible.
The concurrency model is deliberately simple:
- task-shared data changes only at explicit scheduling points;
- interrupt-shared data needs BSP critical sections;
- long computations should yield periodically;
- blocking calls are safe only where the task is allowed to suspend.
The kernel has a small real-time task path, but the current examples and tests
primarily exercise cooperative non-real-time tasks. Treat the non-real-time path
as the documented default unless a port or application explicitly uses
MIPOS_RT.
The application decides stack sizes and storage pools. This is less automatic than a large RTOS, but it is a good fit for small devices because resource use is explicit and easy to audit in code and linker maps.
Typical application-owned resources include:
- root and worker task stacks;
- queue backing arrays;
- fixed-size memory pool arenas;
- malloc heap arena when enabled;
- disk images or block-driver context;
- network adapter context for lwIP experiments.
A useful review rule is: if an object can grow, the owning application or example should make its storage visible in source. That is why examples declare root stacks, queue pools, filesystem buffers, and network contexts explicitly.