AhuraRTOS is a small, multi-platform preemptive real-time operating system - an architecture-independent core behind a thin port layer, a single public header, and an explicit application/kernel boundary. No editable kernel files. No hidden config.
Ports available today - ARM Cortex-M
Planned - same port interface, no core changes
A functional, self-testing kernel whose core knows nothing about the CPU it runs on. Every Cortex-M from M0 to M85 works today; further architectures plug into the same port interface.
O(1) list-based scheduling with one FIFO ready list per priority, a ready bitmap for O(1) next-task lookup, and round-robin among equal priorities over a configurable time slice. A scheduler lock defers preemption without masking a single interrupt.
The core is portable C. A port supplies only what the CPU decides - context switch, tick, critical section, atomics, low-power hooks - so a new architecture is a new port, not a kernel change. ARM Cortex-M ships today: ARMv6-M through ARMv8.1-M, M0 to M85, in three shared port files. RISC-V and Xtensa/ESP32 are next (see Roadmap).
Mutexes with priority inheritance, semaphores, queues, events, and lightweight per-task notifications - all with timeout_ms waits: try-once, timed, or forever.
A deferrable, Zephyr-style work queue running on dedicated kernel service tasks.
A coalescing first-fit allocator over a static array, comparable to FreeRTOS heap_4, compiled out entirely when unused.
Secure, non-secure, and disabled modes, with application callbacks for secure-context banking. Compiles and links, but has not been run on a TrustZone-enabled part.
Per-task core affinity across shared ready lists. Builds in the CI matrix, but has never run on real multi-core silicon.
A standalone module that exercises every enabled feature and reports PASS/FAIL over printf - validate a board with zero application code.
ahura.h plus one application-owned os_config.h, copied from a template. The kernel ships no configuration of its own.
Each group below is one OS_CONFIG_<FEATURE>_ENABLE away from being compiled out entirely - code, RAM and API surface, not just skipped at runtime. Everything is declared in a single header, ahura.h. For the exhaustive reference - every parameter, every status code, every constraint - see the kernel reference.
Preemptive and priority-based, with 31 levels, one FIFO ready list per priority and round-robin between equals over a time slice you set (OS_CONFIG_TIME_SLICE_TICKS, or 0 to turn rotation off). A task's stack and name belong to its handle, so creating one only says what it does. The kernel's own service tasks refuse pause and delete.
/* file scope: handle + stack, initialized at compile time */
OS_TASK_DEFINE(worker, 512U);
os_task_create(&worker,
OS_TASK_CONFIG(worker_entry, NULL, OS_TASK_PRIO_2));
os_task_start(&worker);
os_task_priority_set(&worker, OS_TASK_PRIO_5);
os_task_pause(&worker);
os_task_delete(&worker);
Two preemption barriers, and they are not interchangeable. A critical section masks interrupts, so it stops everything - the tick, the drivers, every latency budget that depends on them. A scheduler lock stops only the scheduler: interrupts keep running and keep waking tasks, they just do not get the CPU until the outermost unlock, which then takes the switch it deferred. Guard task-to-task data with the lock; anything an ISR also touches still needs the critical section.
os_critical_enter(); /* task <-> ISR, and across cores */
os_critical_exit();
os_kernel_lock(); /* task <-> task, interrupts stay live */
rebuild_shared_state();
os_kernel_unlock(); /* deferred switch happens here */
Blocking delays yield the CPU once the scheduler runs and fall back to a precise busy-wait before it does. Microsecond waits are cycle-counted. None of them return a status - a delay either waits, or the request was one the platform cannot express and an assertion says so.
os_delay_ms(500U);
os_delay_us(200U); /* busy-wait, cycle-accurate */
os_delay_s(1U);
os_delay_ms(OS_WAIT_FOREVER); /* park this task */
uint32_t now = os_tick_get();
Inheritance is always on, the way FreeRTOS and Zephyr do it: a lower-priority owner is boosted to the blocking waiter's priority until it releases. Stays correct when one task holds several contended mutexes at once.
static os_mutex_t lock;
os_mutex_init(&lock);
os_mutex_lock(&lock, 100U); /* ms, or OS_WAIT_FOREVER */
os_mutex_try_lock(&lock); /* never blocks */
os_mutex_unlock(&lock);
The same queue with a geometry not known until run time - item size and capacity from a config word, a probed device, a parsed header. Only os_queue_init_dynamic needs the heap, so it is the one call that disappears with OS_CONFIG_ALLOC_ENABLE; everything else behaves identically. The queue object is still yours, only its buffer comes from the heap, which is why a failed init leaves nothing to clean up.
OS_QUEUE_DEFINE_DYNAMIC(log_q);
/* geometry decided at run time */
os_queue_init_dynamic(&log_q, sizeof(entry_t), n);
os_queue_send(&log_q, &entry, 10U); /* same API */
os_queue_receive(&log_q, &out, OS_WAIT_FOREVER);
os_queue_cleanup(&log_q); /* returns the buffer to the heap */
Fixed-size items copied between tasks, or from an ISR to a task. No heap involved: the buffer is an array the macro declares, so this is the whole queue API on a build with OS_CONFIG_ALLOC_ENABLE at 0. Neither compile-time form takes an item size or a capacity - both are read off the array and so cannot disagree with it.
/* usable where it stands, nothing to call */
OS_QUEUE_DEFINE_STATIC(sensor_q, sample_t, 8);
/* your own buffer - a linker section, DMA RAM */
OS_QUEUE_DEFINE_BUFFER(rx_q, dma_area);
os_queue_send(&sensor_q, &sample, 10U);
os_queue_receive(&sensor_q, &out, OS_WAIT_FOREVER);
size_t used = os_queue_count_get(&sensor_q);
size_t room = os_queue_free_get(&sensor_q); /* back-pressure */
os_queue_cleanup(&sensor_q); /* empties, keeps the array */
Initial and maximum count, with the same timeout_ms convention as everything else: try once, wait a while, or wait forever. Giving is ISR-safe.
static os_semaphore_t sem;
os_semaphore_init(&sem, 0U, 4U); /* initial, max */
os_semaphore_give(&sem); /* ISR-safe */
os_semaphore_take(&sem, OS_WAIT_FOREVER);
Wait on a set of bits, for all of them or any of them, optionally clearing what matched on the way out. The classic fan-in primitive when several producers must each report done.
static os_event_t evt;
uint32_t matched;
os_event_init(&evt);
os_event_set_bits(&evt, 0x1U); /* ISR-safe */
os_event_wait_bits(&evt, 0x7U,
true, /* wait for all */
true, /* clear on exit */
&matched, 500U);
A single-value mailbox built into every task's own control block, so one task or an ISR can signal a specific task without allocating an object for it. The lightest signal the kernel has.
os_notify_give(&worker, 42U); /* ISR-safe */
uint32_t value;
os_notify_wait(OS_WAIT_FOREVER, &value);
/* NULL when only the wake-up matters */
os_notify_wait(OS_WAIT_FOREVER, NULL);
One-shot or periodic, with callbacks running on a dedicated kernel task rather than in the tick interrupt. Periods reload in the tick, so a periodic timer does not drift with callback latency.
static os_timer_t t;
os_timer_init(&t, OS_TICKS_FROM_MS(250U),
OS_TIMER_MODE_PERIODIC, on_expiry, NULL);
os_timer_start(&t); /* or resume a pause */
os_timer_restart(&t); /* always a full period */
os_timer_pause(&t); /* keeps the time left */
os_timer_stop(&t);
os_timer_delete(&t);
Hand a function to the kernel to run later, from an ISR if you like. There is no work object to declare or keep alive: the handler and its payload are copied into a kernel slot, so a local buffer may go out of scope the moment the call returns.
static void handler(void *data, size_t len);
my_payload_t payload = { ... }; /* an ordinary local */
os_work_submit(handler, &payload, sizeof(payload), 100U);
os_work_submit(handler, NULL, 0U, 0U); /* no payload */
A full operation set on a 32-bit word, every one returning the value from before it ran. Lock-free where the core has exclusive load/store; a critical section where it does not - the API and its behaviour are identical either way.
static os_atomic_t counter = OS_ATOMIC_INIT(0);
os_atomic_inc(&counter); /* returns the OLD value */
os_atomic_add(&counter, 5);
os_atomic_or(&flags, 0x4);
os_atomic_cas(&counter, 10, 20);
os_atomic_set_bit(&flags, 3U);
A coalescing first-fit allocator over a static array, comparable to FreeRTOS heap_4. Nothing is taken from the linker heap, and the whole thing compiles out when unused.
void *p = os_mem_alloc(64U);
os_mem_free(p);
size_t free_now = os_mem_free_get();
size_t worst_ever = os_mem_watermark_get();
On every switch away from a task the kernel checks its stack pointer is still inside its own stack and that a guard word at the bottom is intact, then parks the core on a hit. ARMv8-M mainline traps this in hardware; on every other core this is the only detection there is.
/* you define it; no kernel default, so a missing
one is a link error rather than a silent detector */
void os_stack_overflow_cb(const char *task_name)
{
/* write it somewhere: UART, retained RAM, bkpt */
}
Stack watermarking pattern-fills each stack at creation and reports the worst-case headroom a task has ever had. CPU sampling counts how many ticks interrupted the idle task versus anything else. Both opt-in, both close to free.
size_t min_free;
os_task_stack_watermark_get(task, &min_free);
uint32_t percent = os_cpu_usage_get(); /* since last call */
Log calls format into a ring buffer and a dedicated task drains it, so a logging task is never blocked on the transport. Assertions call your hook and then park the core - the kernel ships no default, so forgetting it is a link error rather than an unexplained halt.
OS_LOG_INFO("sensor = %d", value);
OS_LOG_ERROR("i2c timeout on 0x%02X", addr);
uint32_t lost = os_log_dropped_get();
OS_ASSERT(index < count);
Secure, non-secure or disabled, chosen per build on ARMv8-M. In non-secure mode the context switch banks each task's secure state through two callbacks you provide - the kernel ships no defaults, so forgetting them is a link error rather than a task switched without its secure state. Treat this as unproven: the code compiles and the callbacks are wired, but none of it has been exercised on a part with the Security Extension enabled.
#define OS_CONFIG_TRUSTZONE OS_CONFIG_TRUSTZONE_NON_SECURE
void os_arch_tz_context_save_cb(uint32_t task_id);
void os_arch_tz_context_restore_cb(uint32_t task_id);
SMP scheduling gives each task a core-affinity mask over shared ready lists; it compiles and is exercised in CI, but has never run on real multi-core silicon. Tickless idle suppresses the tick across a known-idle window - implemented on the ARMv8-M port, but not yet wired into the idle task, so today it changes nothing at run time.
OS_TASK_CONFIG(entry, NULL, prio,
OS_TASK_CORE(0) | OS_TASK_CORE(2));
void os_tickless_pre_sleep_cb(void); /* gate clocks */
void os_tickless_post_sleep_cb(void); /* restore them */
A preemptive kernel is mostly three mechanisms: something that decides who runs, something that switches to them, and something that measures time. Here is what each one is in AhuraRTOS.
PendSV round trip.
SVC entirely to the application. The hardware stacks half the frame; the port stacks the rest (r4-r11, EXC_RETURN, and the FPU registers only when that task has actually used the FPU).
SysTick drives one counter. Each tick advances delayed tasks, software timers and the work queue, then pends PendSV only if that tick actually made someone runnable - a quiescent tick costs a bitmap check instead of a full switch. Only finite sleepers sit in the delay list, so the cost is proportional to tasks that are sleeping, not to tasks that exist.
os_config.h, copied from a template, visible to both the application and the kernel library so their structure sizes can never disagree. Every option is a plain define, and an incomplete file is a compile error rather than a silently disabled feature. The kernel itself ships no editable configuration.
AhuraRTOS takes over one exception - PendSV - and asks the application for one thing: a periodic call to os_tick_handler(). It claims no SVC_Handler, no SysTick_Handler, no HAL and no vendor headers. That is the whole integration contract, and it is why the same kernel drops onto an STM32, an nRF52 and an LPC without changing anything but a config file.
Run it from the root of your project - the directory holding CMakeLists.txt and the .ioc. It fetches the kernel, copies the three application-owned files, adds the CMake block, routes the tick and wires up os_init() / os_start(). It prints the exact diff first and asks before writing anything.
irm https://raw.githubusercontent.com/AhuraRTOS/AhuraRTOS/main/tools/install_stm32.py | python -
curl -fsSL https://raw.githubusercontent.com/AhuraRTOS/AhuraRTOS/main/tools/install_stm32.py | python3 -
Python 3.8+ and nothing else - no pip install, and nothing saved into your project but the integration itself. It never opens the .ioc: that file is what CubeMX generates from, and everything the script needs is in the generated sources. Every C edit goes inside a CubeMX USER CODE section, so regeneration keeps it, and your os_config.h, os_cb.c and os_main.c are never overwritten once they exist.
Running it twice is free. It checks what is already in place and fills in only what is missing - which also makes it the repair when CubeMX regenerates over the integration. --dry-run shows the diff and stops, --uninstall takes it all back out.
The procedure itself: fetch the kernel, copy the three application-owned files, add it to the build, route the tick, keep PendSV_Handler free, boot it. CMake and non-CMake both - Keil, MPLAB X, SEGGER or a hand-written Makefile need only the right source and include lists, and the installation page gives them.
The same steps on a NUCLEO-H503RB, verified end to end - and what the one command above does for you. ST tooling differs from every other vendor's in exactly two places: it generates its own PendSV_Handler, and its HAL takes SysTick. Exact menu paths, the CMakeLists.txt block, and what survives regeneration.
Full configuration options, the integration contract, per-vendor notes, task-priority rules and every module's API live in the kernel reference - the authoritative reference.
The kernel ships a self-test suite that exercises every enabled feature and reports PASS/FAIL over printf, finishing with a cycle-accurate benchmark table. It needs no application code and no board support beyond a working printf, which makes it the fastest way to confirm a new target is correctly integrated - before anything is built on top of it.
In os_config.h. The test task replaces the default application task, so the suite runs alone and os_main() is never called.
#define OS_CONFIG_TEST_ENABLE 1U
The kernel ships no stub for os_test(), not even a weak one, so forgetting this is a link error rather than a test build that silently tests nothing.
add_subdirectory(AhuraRTOS/kernel/test)
target_link_libraries(my_firmware os_test)
With OS_CONFIG_LOG_ENABLE also on, the suite defines os_log_output_cb itself so it can inspect what the kernel emitted. Your os_cb.c steps aside - the current template already guards it.
#if (OS_CONFIG_LOG_ENABLE == 1U) && \
(OS_CONFIG_TEST_ENABLE == 0U)
os_config.h as a configure dependency, since a header is not otherwise a configure-time dependency and the build would go on linking the previous choice. Flipping the define then is the whole procedure.
set_property(DIRECTORY APPEND PROPERTY
CMAKE_CONFIGURE_DEPENDS ${OS_CONFIG_DIR}/os_config.h)
file(READ ${OS_CONFIG_DIR}/os_config.h _os_cfg)
if(_os_cfg MATCHES "#define[ \t]+OS_CONFIG_TEST_ENABLE[ \t]+1")
add_subdirectory(AhuraRTOS/kernel/test)
set(AHURA_TEST_LIB os_test)
endif()
target_link_libraries(my_firmware ahura_kernel ${AHURA_TEST_LIB})
.rodata behind those PASS/FAIL strings: roughly 100 KB at -Os, against about 20 KB for a small application. On a 128 KB part that means run it from a Release (-Os) build - measured on a NUCLEO-H503RB, the suite lands at 130 KB of 128 KB available at -O0 and does not link, while -Os fits at 99%. The linker says so plainly rather than producing a broken image, and the suite already drops its extended stress tests (~15 KB) in unoptimized builds for the same reason. Parts with 256 KB or more take either. Once the port is verified, set the switch back to 0 and the suite leaves the image entirely.
Seeing nothing on the terminal? That is almost never the kernel. newlib buffers stdout, and on a target with no tty it buffers fully - the output sits in a 1 KB buffer that a kernel which never exits may never flush. Call setvbuf(stdout, NULL, _IONBF, 0); in main() before os_init(); that also avoids the malloc newlib would otherwise make for the buffer.
The kernel, the examples and the documentation all live in one repository. A plain git clone gives you everything - nothing to initialise, nothing to keep in step.
AhuraRTOS/
├── doc/ ← installation, vendor notes,
│ kernel reference, self-test
├── kernel/ ← core, arch ports, templates,
│ self-test suite
├── examples/ ← one runnable main per feature
├── tools/ ← one-command CubeMX installer
├── LICENSE
└── README.md ← project overview
The kernel exposes a single public header, ahura.h, and expects one application-owned config file, os_config.h. Everything the application needs to touch lives outside the kernel tree - the kernel itself ships no configuration of its own, keeping the application/kernel boundary explicit and easy to reason about.
Phase 1 is underway. Everything past it is planned, not promised.
Core kernel, the architecture port layer, examples, and a minimal portable HAL - with STM32 as the bring-up target.
Ports for further instruction sets (RISC-V, Xtensa/ESP32), modular driver interfaces, consistent cross-platform APIs. Vendor families built on Cortex-M - NXP, TI, Nordic - already work through the ARM ports today.
Configuration and build tooling, optional modules (filesystem, additional IPC), community-driven extensions.
The kernel is functional and self-testing across the Cortex-M range. APIs may still change.