I built a preemptive real-time operating system from scratch in C and ARM assembly, running on a Raspberry Pi Compute Module 4 (BCM2711, Cortex-A72). The OS runs entirely bare-metal and uses a priority-based preemptive scheduler, IRQ-driven context switching, hardware drivers, and synchronization primitives.
See the code on GitHub.
I organized rpitos into the five tiers below, each one built with modularity in mind and on top of the tier beneath it.
Base Kernel: The base kernel (scheduler, tasks, heap) manages CPU time and memory.
Drivers: responsible for interfacing with hardware. The RTOS has drivers for serial communication, memory management, interrupt registration and handling, and internal timing, watchdog, and reset behavior.
Kernel objects: common synchronization primitives (mutex, semaphore, queue, software timer). These are the only things allowed to actually block a task, and they're built entirely out of the scheduler's own primitives.
Libraries: Generic libraries that sit on top of everything and only ever touch the RTOS through its published API, the same as application code would.
Multicore support: Code to enable the multicore functionality of the RPi 4 is layered in sideways. rpitos uses a bound-multiprocessing (BMP) model, where each task is pinned to the core it was created on and never migrates. This means every core owns a completely independent scheduler instance, ready lists, and tick source. Most of the kernel has no idea multicore exists at all as each core runs as its own isolated system. Synchronization primitives are able to span cores and do so by gating with spinlocks.
I didn't want a bad firmware flash to be able to brick the board, so the RTOS and the bootloader cooperate through a small piece of shared state on eMMC instead of just trusting that a freshly flashed app works. The app lives in one of two A/B slots; every time a new slot gets flashed, the bootloader marks it "on trial" before jumping to it. If that app can prove it's alive, the trial (it calls wdt_meta_confirm_slot()) after it's been healthy for a configured amount of time. The flag clears and that slot becomes the new default. If it can't, because it crashed, hung, or never got that far, the watchdog resets the board, the reset count in the shared metadata climbs, and once that count passes a configured tolerance the bootloader stops trusting the new slot and falls back to the last one that confirmed itself.
An RTOS with no display is hard to introspect by staring at a UART console, so I built a companion desktop app: a Rust/egui GUI that connects to a running board over its own dedicated telemetry UART channel. It decodes a small framed wire protocol into live scheduler and task state.
The client renders scheduler dynamics and per-core status in real time, tracks which tasks are currently blocked and on what, and includes its own DFU panel that drives the exact same firmware-update protocol the bootloader implements, so I can push a full update from the host GUI instead of a separate command-line tool.
The telemetry link is TX-only and one-way: the device streams framed packets out over a dedicated PL011 channel (UART_CHANNEL_TELEMETRY, TXD5 on GPIO12/ALT4, no RX pin) at 921600 baud, and nothing on the device ever blocks waiting for the host to acknowledge anything — there's no ACK/NACK in the protocol at all. Every packet uses the same frame, defined in source/telemetry/telemetry.h and built by telemetry_send_framed() in source/telemetry/telemetry_frame.c: [0xA5 magic][type:1][seq:1][len_msb:1][len_lsb:1][payload:len][crc32:4 LE][0x5A trailer], with the CRC32 covering everything from the magic byte through the payload. seq wraps at 256 per packet type, which is what lets the host notice a dropped or corrupted frame without any retransmit scheme.
What the device actually decides to broadcast is deliberately raw. It's a ~10 Hz heartbeat counter, one PKT_TASK_CREATED per task (id, core, priority, name) fired from task_create(), one PKT_TICK_STATE per core per scheduler tick (queued into a per-core ring buffer by telemetry_report_tick_state() and drained by telemetry_publisher_task, so the tick handler itself never touches the UART), plus block/unblock, sync-object-created, and mutex-ownership-changed events as they happen. The full packet layout table and the reasoning for exactly these hook points live in source/telemetry/docs.md. Notably absent from the wire: per-task CPU time, ready/blocked lists, or anything that looks like the timing diagram above — the device only ever reports raw ids, states, and counters as they change.
All of that reconstruction is the client's job. The telemetry-protocol crate (client/telemetry-protocol/src/packet.rs) mirrors the device-side enums byte-for-byte and decodes each validated frame into a typed Packet, and telemetry-gui (scheduler_dynamics.rs, time_breakdown.rs, blocked_tracker.rs, sync_view.rs) turns that raw event stream into the ready/blocked lists, per-task timing breakdown, and sync-ownership graph shown above — every bit of derived state in the GUI is something the firmware never had to compute.
Non-blocking delay library
The delay library provides a pure NOP busy-wait delay function as well as a timed delay that uses the scheduler tick count. Neither yields the CPU to another task.
| Function | Signature | Purpose |
|---|---|---|
| delay_init | StatusCode delay_init(volatile uint64_t *p_tick_count); | Link the delay library to the scheduler's shared tick counter. |
| delay_cycles | void delay_cycles(uint64_t cycles); | Busy-wait for the given number of CPU cycles. |
| delay_ms | void delay_ms(uint64_t ticks); | Busy-wait for the given number of scheduler ticks (not wall-clock ms). |
The single StatusCode enum that almost every function in the kernel returns.
A priority-inheritance mutex implementation.
Each Mutex carries its own Spinlock since a waiter can live on a different core than the owner. mutex_lock() is managed by the scheduler systick. If inheritance is enabled and the caller is higher priority than the current owner, the owner's priority is boosted for as long as it holds the mutex.
struct Mutex {
MutexState state; // locked or unlocked
TaskControlBlock *mutex_owner; // task currently holding it
List mutex_blocked_list; // tasks waiting
uint8_t inheritance_enabled; // priority inheritance on/off
TaskPriorityLevel inherited_priority; // highest priority among current waiters
Spinlock lock; // cross-core lock on this struct
#ifdef RTOS_TELEMETRY
uint16_t sync_id; // telemetry builds only
#endif
};
What mutex_lock() and mutex_unlock() do on every call:
A worked example of the boost across two cores, Task A holding the mutex on core 0 while a higher-priority Task B blocks on core 1:
mutex_unlock() finds the next owner and the second-highest waiting priority in a single pass over the blocked list:
while (iter != NULL) {
if (iter->owner->priority > best->owner->priority) {
if (best->owner->priority > second_best) second_best = best->owner->priority;
best = iter;
} else {
if (iter->owner->priority > second_best) second_best = iter->owner->priority;
}
iter = iter->next;
}
| Function | Signature | Purpose |
|---|---|---|
| mutex_init | void mutex_init(Mutex *mtx, const char *name); | Initialize a mutex to the unlocked state. |
| mutex_set_inheritance | StatusCode mutex_set_inheritance(Mutex *mtx, uint8_t enable); | Enable or disable priority inheritance for this mutex. |
| mutex_lock | StatusCode mutex_lock(Mutex *mtx, int64_t timeout_ms); | Lock the mutex, blocking up to timeout_ms; boosts the owner's priority if inheritance is enabled and the caller is higher priority. |
| mutex_unlock | void mutex_unlock(Mutex *mtx); | Unlock the mutex, restoring the owner's original priority and waking the next waiter. |
A counting semaphore for signaling between tasks and interrupt handlers.
semaphore_take() blocks by adding the task to a FIFO list and blocking until something wakes it - either semaphore_give() or a timeout from the scheduler tick. semaphore_give() hands its count directly to the next waiting task instead of incrementing the counter and letting it race to claim it. give() also locks the waiting task's own core, not the caller's core, so it's safe to call from a task or ISR on one core to wake a task sitting on another core's ready list.
struct Semaphore {
uint32_t max_count; // ceiling
uint32_t count; // current value
List semaphore_blocked_list; // FIFO wait list
Spinlock lock; // cross-core lock on this struct
#ifdef RTOS_TELEMETRY
uint16_t sync_id; // telemetry builds only
#endif
};
semaphore_take() can also be called with a timeout parameter so that the caller is guaranteed to return after a certain amount of time. If the timeout expires without the semaphore being acquired, then semaphore_give() will return E_TIMED_OUT
| Function | Signature | Purpose |
|---|---|---|
| semaphore_init | void semaphore_init(Semaphore *smph, uint32_t max_count, uint32_t initial_count, const char *name); | Initialize a counting semaphore with the given max and starting count. |
| semaphore_init_with_parent | void semaphore_init_with_parent(Semaphore *smph, uint32_t max_count, uint32_t initial_count, const char *name, uint16_t parent_sync_id); | (RTOS_TELEMETRY builds) Same as semaphore_init(), plus a parent-sync link used internally by queue. |
| semaphore_take | StatusCode semaphore_take(Semaphore *smph, int64_t timeout_ms); | Take one count, blocking up to timeout_ms if none are available. |
| semaphore_give | StatusCode semaphore_give(Semaphore *smph); | Give one count back, waking the longest-waiting blocked task if any. |
A fixed-capacity message queue built on top of two semaphores.
queue_init() allocates its backing buffer with one heap_malloc() call, then sets up two semaphores: space_available starts at full capacity, data_available starts at 0 — the standard producer/consumer pattern. queue_send()/queue_recv() copy an item in or out byte-by-byte (no libc, no memcpy in this freestanding build) and have no wait-list logic of their own; both blocking paths go through semaphore.
struct Queue {
uint8_t *buf; // backing buffer, capacity * item_size bytes
uint32_t head; // write index
uint32_t tail; // read index
uint32_t capacity; // max number of items in the queue
uint32_t item_size; // bytes per item
Semaphore space_available; // gates queue_send
Semaphore data_available; // gates queue_recv
Spinlock lock; // guards the byte-copy step
#ifdef RTOS_TELEMETRY
uint16_t sync_id; // telemetry builds only
#endif
};
queue_init() takes item_size as a parameter and allocates capacity * item_size bytes for the backing buffer. queue_send()/queue_recv() don't know or care what type is being queued — src and dst are void *, and both copy exactly item_size bytes to or from the buffer at head/tail's offset. The same Queue works for single bytes or large structs; only the numbers passed to queue_init() change.
| Function | Signature | Purpose |
|---|---|---|
| queue_init | StatusCode queue_init(Queue *q, uint32_t capacity, uint32_t item_size, const char *name); | Initialize a fixed-capacity message queue, allocating its backing buffer from the kernel heap. |
| queue_send | StatusCode queue_send(Queue *q, const void *src, int64_t timeout_ms); | Copy one item into the queue, blocking up to timeout_ms if it's full. |
| queue_recv | StatusCode queue_recv(Queue *q, void *dst, int64_t timeout_ms); | Copy one item out of the queue, blocking up to timeout_ms if it's empty. |
Tick-driven timers built on top of the scheduler. All software timers live in a software timer handler task. Software timers can be configured to have one-shot or repeating callbacks.
Each SoftwareTimer sits in one of two lists: a blocked list sorted by expiry_tick, and an active list. software_timer_tick() runs in on the scheduler systick and moves expired timers onto the active list and signals a semaphore. The callback itself runs later, off the tick, on a dedicated sw_timer service task. Periodic timers re-arm relative to their scheduled expiry (expiry_tick += period), not to when they actually ran, to prevent drift.
struct SoftwareTimer {
uint64_t expiry_tick; // scheduler tick this timer next fires at
uint64_t period; // ticks between fires (periodic) or until fire (one-shot)
TimerMode timer_mode; // one-shot or periodic
TimerCallback callback; // runs when it fires
TimerListId list_id; // which list this node is currently in
SoftwareTimer *next; // intrusive singly-linked list link
};
| Function | Signature | Purpose |
|---|---|---|
| software_timer_init | StatusCode software_timer_init(); | Initialize the software timer subsystem's internal lists. |
| software_timer_start | StatusCode software_timer_start(); | Start the service task that runs expired timers' callbacks. |
| software_timer_create | StatusCode software_timer_create(SoftwareTimer *software_timer, uint64_t period, TimerCallback callback_function, TimerMode timer_mode); | Configure a timer with its period, callback, and one-shot/periodic mode. |
| software_timer_stop | StatusCode software_timer_stop(SoftwareTimer *software_timer); | Cancel a timer, removing it from whichever internal list it's in. |
| software_timer_reset | StatusCode software_timer_reset(SoftwareTimer *software_timer); | (Re-)arm a timer to expire after its period from now. |
| software_timer_tick | void software_timer_tick(uint64_t now_tick); | Move every expired timer from blocked to active and signal the service task once per timer. |
A cross-core lock using Lamport's Bakery algorithm.
Each core draws a ticket one higher than the max ticket any other core currently holds, then waits until every other core whose (ticket, core_id) pair sorts before its own has passed through.
| Function | Signature | Purpose |
|---|---|---|
| spinlock_init | void spinlock_init(Spinlock *lock); | Initialize a spinlock to the unlocked state. |
| spinlock_acquire | void spinlock_acquire(Spinlock *lock); | Acquire the lock, spinning until it's this core's turn. |
| spinlock_release | void spinlock_release(Spinlock *lock); | Release the lock. |
UART driver with RX calls that automatically switch between blocking and task-based transmission.
uart_tx() operations all check uart_task_started before every write. Until a channel's TX task has been started, they fall through to raw blocking implementation, which happens during early boot, before the scheduler or task exists. Once uart_channel_task_start() has been called for a channel, the same calls instead enqueue into a ring buffer and wake a dedicated uart_tx_task with a semaphore. The UART buffer + task mechanism is guarded by a spinlock and can be accessed from any core
The UART buffer and task mechanism keeps uart calls lightweight which is important for an RTOS. Additionally, it prevents jumbled/stomped UART messages when multiple cores & tasks try to write through the same UART peripheral.
kmain()
uart_channel_init(...)
uart_print("booting...") -> raw PIO, straight out
scheduler_init(...)
uart_channel_task_start(...) -> creates uart_tx_task
scheduler_start()
...
uart_print("running") -> same call, now enqueues
+ wakes uart_tx_task
Six BCM2711 UART instances exist; one is unsupported and the rest are fixed hardware facts, not caller-configurable:
| Channel | Base |
|---|---|
| UART_CHANNEL_0 | UART0_BASE (0xFE201000) |
| UART_CHANNEL_1 | unsupported (mini-UART) |
| UART_CHANNEL_2 | UART2_BASE (0xFE201400) |
| UART_CHANNEL_3 | UART3_BASE (0xFE201600) |
| UART_CHANNEL_4 | UART4_BASE (0xFE201800) |
| UART_CHANNEL_5 | UART5_BASE (0xFE201A00) |
Each channel gets its own independent UartConfig, so pins, baud rate, and mode can differ freely from channel to channel.
typedef struct {
uint8_t tx_pin; // GPIO pin for TX
uint8_t rx_pin; // GPIO pin for RX, or UART_PIN_NONE for TX-only
GPIOFunc alt_func; // ALT function tx_pin/rx_pin get muxed to
UartBaudrate baudrate; // UART_BAUDRATE_115200 or UART_BAUDRATE_921600
UartMode mode; // UART_MODE_BLOCKING or UART_MODE_BUFFERED_TASK
bool is_dma_enabled; // only meaningful when mode == UART_MODE_BUFFERED_TASK
uint8_t dma_channel; // which DMA channel to use if is_dma_enabled
uint16_t task_stack_words; // TX drain task's stack size
uint8_t task_priority; // TX drain task's priority
} UartConfig;
Configuring the print channel for buffered, DMA-paced TX:
static UartConfig print_uart_cfg = {
.tx_pin = 14, .rx_pin = 15,
.alt_func = GPIO_FUNC_ALT0,
.baudrate = UART_BAUDRATE_115200,
.mode = UART_MODE_BUFFERED_TASK,
.is_dma_enabled = true,
.dma_channel = UART_DEFAULT_DMA_CHANNEL,
.task_stack_words = 256,
.task_priority = TASK_PRIORITY_2,
}; // static — uart_channel_init() stores this pointer, not a copy
uart_channel_init(UART_CHANNEL_PRINT, &print_uart_cfg);
uart_channel_task_start(UART_CHANNEL_PRINT);
| Function | Signature | Purpose |
|---|---|---|
| uart_channel_init | StatusCode uart_channel_init(uint8_t channel, UartConfig *config); | Configure a channel's GPIO pins, baud rate, and line control. |
| uart_channel_deinit | void uart_channel_deinit(uint8_t channel); | Drain the TX FIFO, disable the UART, and release its GPIO pins. |
| uart_channel_drain | void uart_channel_drain(uint8_t channel); | Block until the TX FIFO has fully drained. |
| uart_channel_task_start | StatusCode uart_channel_task_start(uint8_t channel); | Start a channel's ring-buffer TX task (and, for the print channel, enable RX interrupts). |
| Function | Signature | Purpose |
|---|---|---|
| uart_channel_tx_raw | void uart_channel_tx_raw(uint8_t channel, uint8_t byte); | Transmit a single raw byte, blocking until the TX FIFO has room. |
| uart_channel_print | void uart_channel_print(uint8_t channel, const char *str); | Write a NUL-terminated string. |
| uart_channel_printf | void uart_channel_printf(uint8_t channel, const char *fmt, ...); | Write a printf-style string. Supports %c %s %d %u %x %X %% with zero-padding and width. |
| uart_channel_send_byte | void uart_channel_send_byte(uint8_t channel, uint8_t byte); | Queue a byte for transmission via the ring buffer (full mode only). |
| Function | Signature | Purpose |
|---|---|---|
| uart_channel_rx | uint8_t uart_channel_rx(uint8_t channel); | Read one byte, blocking until the RX FIFO is non-empty. |
| uart_channel_rx_nonblocking | StatusCode uart_channel_rx_nonblocking(uint8_t channel, uint8_t *out); | Read one byte if available, without blocking (E_EMPTY otherwise). |
| uart_channel_rx_timed | StatusCode uart_channel_rx_timed(uint8_t channel, uint8_t *out, uint32_t timeout_ms); | Read one byte, blocking up to timeout_ms (E_TIMED_OUT otherwise). |
| uart_rx_irq_enable | void uart_rx_irq_enable(UartRxHandler handler); | Register an additional app-specific RX byte callback. |
| uart_rx_irq_handler | void uart_rx_irq_handler(void); | RX interrupt handler: drains the RX FIFO, feeds the DFU-trigger watch, calls the registered handler. |
| Function | Signature | Purpose |
|---|---|---|
| uart_dma_irq_handler | void uart_dma_irq_handler(void); | TX-complete handler for the UART DMA channel; called from _irq_handler. |
| uart_fault_report | void uart_fault_report(uint32_t kind, uint32_t pc, uint32_t addr, uint32_t status); | Print a fixed-format fault report from a context where the normal printf path may not be safe. |
Direct register-level control of the BCM2711 GPIO controller — function select, pull, and pin I/O.
| Function | Signature | Purpose |
|---|---|---|
| gpio_set_function | void gpio_set_function(uint32_t pin, GPIOFunc funct); | Set a pin's alternate function (input, output, or ALT0-ALT5). |
| gpio_set_pull | void gpio_set_pull(uint8_t pin, GPIOPull pull); | Set a pin's internal pull-up/pull-down/none. |
| gpio_on | void gpio_on(uint32_t pin); | Drive a pin high. |
| gpio_off | void gpio_off(uint32_t pin); | Drive a pin low. |
| gpio_read | uint8_t gpio_read(uint32_t pin); | Read a pin's current input level. |
CRC32 computed with the ARMv8 hardware crc32b/h/w instructions.
| Function | Signature | Purpose |
|---|---|---|
| crc32_init | StatusCode crc32_init(); | Check whether the CPU supports the ARMv8 CRC32 instructions (via ID_ISAR5). |
| crc32_start | void crc32_start(CRC32 *ctx); | Reset a CRC32 context to its initial value. |
| crc32_update | void crc32_update(CRC32 *ctx, const uint8_t *data, size_t len); | Fold len bytes of data into a running CRC32. |
| crc32_update_byte | void crc32_update_byte(CRC32 *ctx, uint8_t byte); | Fold a single byte into a running CRC32. |
| crc32_finish | uint32_t crc32_finish(CRC32 *ctx); | Finalize a CRC32 context and return the checksum. |
| crc32_verify | int crc32_verify(CRC32 *ctx, uint32_t expected); | Finalize a CRC32 context and compare it against an expected value. |
BCM2711 legacy DMA controller used to allow other drivers to move data asynchronously.
Users declare a DmaControlBlock struct and register an ISR to unblock when the transaction is complete. The heaviest consumer of the DMA controller is the UART driver.
typedef struct {
uint32_t ti; // transfer information (DMA_TI_* flags, DREQ pacing)
uint32_t source_ad; // source bus address
uint32_t dest_ad; // destination bus address
uint32_t txfr_len; // transfer length in bytes
uint32_t stride; // 2D stride (0 in linear mode)
uint32_t nextconbk; // bus address of next CB, or 0 to stop
uint32_t reserved[2];
} __attribute__((aligned(32))) DmaControlBlock; // 8 words, DMA-visible RAM
When a UART controller optionally enables DMA usage, the uart_tx task will instead copy the desired uart buffer address to a new DmaControlBlock, start the DMA operation, and wait on a semaphore. When the hardware finishes the DMA_IRQ fires and gives the semaphore back. Since the uart buffer is a ring buffer, in some cases where the buffer contents wrap around the physical array boundary, the DMA transaction is automatically split in two.
| Function | Signature | Purpose |
|---|---|---|
| dma_channel_init | void dma_channel_init(uint8_t channel); | Power on and reset a channel. Call once before first use. |
| dma_start | void dma_start(uint8_t channel, const DmaControlBlock *cb); | Kick off the transfer described by cb on the given channel. |
| dma_wait | StatusCode dma_wait(uint8_t channel, uint32_t spin_limit); | Spin until the channel's transfer completes or times out. |
| dma_selftest | StatusCode dma_selftest(uint8_t channel); | Bring-up self-test: a mem→mem copy that proves the controller and BUS_ADDRESS() alias are correct. |
The eMMC flash memory controller driver is used by the bootloader and app to read/write persistent storage (boot flags, bootloader & app binaries).
| Function | Signature | Purpose |
|---|---|---|
| emmc_init | StatusCode emmc_init(void); | Bring up the eMMC controller: reset, clock to 400kHz, CMD0-CMD7 init, negotiate width/speed. Idempotent if already running. |
| emmc_read_blocks | StatusCode emmc_read_blocks(uint32_t sector, void *buf, uint32_t count); | Read count sectors starting at sector into buf, via ADMA2 or PIO fallback. |
| emmc_write_blocks | StatusCode emmc_write_blocks(uint32_t sector, const void *buf, uint32_t count); | Write count sectors starting at sector from buf. Refuses to write below EMMC_FIRMWARE_FLOOR. |
GIC-400 interrupt routing, and a dispatch table every registered handler goes through.
GIC initialization: gic_distributor_init() runs once globally. Every core, including core 0, must separately call gic_percore_init() to bring up its own banked CPU interface.
IRQ registration: irq_register(intid, handler) populates a flat 256-entry table (g_handlers[intid]) that the assembly _irq_handler calls into via irq_dispatch(intid).
ISR routing: When an interrupt fires, the PC jumps to the _irq_handler located in assembly startup code. It executes a context save and jumps to the appropriate ISR.
| Function | Signature | Purpose |
|---|---|---|
| gic_distributor_init | void gic_distributor_init(void); | Initialize GIC-400 distributor-global state (call once from core 0). |
| gic_percore_init | void gic_percore_init(void); | Init this core's GIC CPU interface, enable PPI 30 (timer) and ARM Local mailbox 0 IRQ. |
| gic_disable | void gic_disable(void); | Disable this core's GIC CPU interface and undo its timer/mailbox IRQ routing. |
| gic_enable_spi | void gic_enable_spi(uint32_t intid, uint8_t priority); | Enable a shared peripheral interrupt: priority, route to core 0, level-sensitive, enable. |
| gic_send_mailbox_ipi | void gic_send_mailbox_ipi(uint32_t target_core); | Send a targeted IPI to one core via its ARM Local mailbox 0 (bypasses the GIC). |
| irq_register | StatusCode irq_register(uint32_t intid, IrqHandler handler); | Register (or, with NULL, clear) the C handler for a GIC interrupt ID. |
| irq_dispatch | void irq_dispatch(uint32_t intid); | Invoke the handler registered for intid, or do nothing if none is registered. |
| enter_critical | static inline uint32_t enter_critical(void); | Disable IRQs and return the previous CPSR. |
| exit_critical | static inline void exit_critical(uint32_t saved_cpsr); | Restore a CPSR previously saved by enter_critical(). |
The physical timer (CNTP) that drives the 1 kHz scheduler tick.
gentimer_init() programs CNTP_CVAL for the first tick and enables the timer; read_cntpct() reads the free-running 64-bit physical counter.
The generic timer interrupt is treated as a special case by _irq_handler in assembly, directly calling (cntx_switch$) instead of routing it through the generic C dispatch table like every other interrupt.
| Function | Signature | Purpose |
|---|---|---|
| gentimer_init | StatusCode gentimer_init(uint32_t *clk_freq, uint32_t hz); | Configure the EL1 physical timer (CNTP) to fire at hz and enable its interrupt. |
| gentimer_disable | void gentimer_disable(void); | Stop the EL1 physical timer. |
| read_cntpct | uint64_t read_cntpct(void); | Read the current physical counter value (CNTPCT). |
Generic I2C driver with access to all six usable I2C channels.
i2c_channel_write()/read() run a simple poll FIFO → poll DONE sequence.
typedef struct {
uint8_t sda_pin;
uint8_t scl_pin;
GPIOFunc alt_func; // ALT function sda_pin/scl_pin get muxed to
I2cBaudrate baudrate; // I2C_BAUDRATE_STANDARD_100K or I2C_BAUDRATE_FAST_400K
} I2cConfig;
| Function | Signature | Purpose |
|---|---|---|
| i2c_channel_init | StatusCode i2c_channel_init(uint8_t channel, I2cConfig *config); | Configure an I2C channel's GPIO pins, pull-ups, and bus speed. |
| i2c_channel_deinit | void i2c_channel_deinit(uint8_t channel); | Disable the channel and release its GPIO pins. |
| i2c_channel_write | StatusCode i2c_channel_write(uint8_t channel, uint8_t addr, const uint8_t *buf, uint16_t len); | Write len bytes to a 7-bit slave address, blocking. |
| i2c_channel_read | StatusCode i2c_channel_read(uint8_t channel, uint8_t addr, uint8_t *buf, uint16_t len); | Read len bytes from a 7-bit slave address, blocking. |
| i2c_channel_write_read | StatusCode i2c_channel_write_read(uint8_t channel, uint8_t addr, const uint8_t *tx_buf, uint16_t tx_len, uint8_t *rx_buf, uint16_t rx_len); | Write tx_buf then, with a repeated START, read rx_len bytes. |
| i2c_channel_last_status | uint32_t i2c_channel_last_status(uint8_t channel); | Return the raw S register value as of the channel's last transfer. |
A full-duplex PCM/I2S driver
i2s_transfer() services both the TX and RX FIFOs in one interleaved polling loop rather than separate write/read calls, since RXON keeps sampling regardless of whether software is draining it.
| Function | Signature | Purpose |
|---|---|---|
| i2s_init | StatusCode i2s_init(I2sConfig *config); | Configure GPIO pins (ALT0), PCM clock (via cprman), and PCM peripheral for full-duplex 16-bit stereo. |
| i2s_deinit | void i2s_deinit(void); | Disable the PCM peripheral, stop the PCM clock generator, release GPIO pins. |
| i2s_is_initialized | StatusCode i2s_is_initialized(void); | Query init state. |
| i2s_transfer | StatusCode i2s_transfer(const int16_t *tx, int16_t *rx, uint32_t count); | Blocking full-duplex transfer of count interleaved stereo samples. |
| i2s_last_status | uint32_t i2s_last_status(void); | Debug aid: raw CS_A value as of the last transfer's last poll iteration. |
| i2s_transfer_totals | void i2s_transfer_totals(uint32_t *out_tx_total, uint32_t *out_rx_total); | Debug aid: cumulative sample counts moved since init. |
| i2s_error_total | uint32_t i2s_error_total(void); | Debug aid: count of transfer calls that observed TXERR/RXERR since init. |
One function that enables hardware debugging.
| Function | Signature | Purpose |
|---|---|---|
| jtag_gpio_init | void jtag_gpio_init(void); | Configure the JTAG pins (TMS/TDI/TCK/TDO/TRST) as ALT4 and enable debug routing via CHIPCTL_A. |
mbox_property_call() writes a bus address into MBOX_WRITE and polls MBOX_READ until the same address echoes back. The request buffer and the response occupy the same memory, in place. mbox_get_clock_rate(), mbox_get_board_serial(), and mbox_get_arm_memory() are one-tag convenience wrappers around it.
| Function | Signature | Purpose |
|---|---|---|
| mbox_property_call | StatusCode mbox_property_call(volatile uint32_t *buf); | Send a raw property-tag request buffer and block until the VC responds. |
| mbox_get_clock_rate | StatusCode mbox_get_clock_rate(uint32_t clock_id, uint32_t *out_hz); | Convenience wrapper: GET_CLOCK_RATE for clock_id, in Hz. |
| mbox_get_board_serial | StatusCode mbox_get_board_serial(uint32_t *out_serial_lo, uint32_t *out_serial_hi); | Convenience wrapper: GET_BOARD_SERIAL, 64-bit split across two words. |
| mbox_get_arm_memory | StatusCode mbox_get_arm_memory(uint32_t *out_base, uint32_t *out_size); | Convenience wrapper: GET_ARM_MEMORY — ARM-visible RAM base and size. |
| mbox_selftest | StatusCode mbox_selftest(void); | Bring-up self-test: queries firmware revision and prints it over UART. |
A driver for the PCA9685 16-channel I2C PWM controller, used for servos and LEDs.
pwm_pca9685_init() wakes the chip from sleep, computes PRE_SCALE from the target update rate via the datasheet formula, and restarts it with auto-increment enabled. pwm_pca9685_set_channel() sets a channel's on/off tick positions from delay/duty cycle fractions.
| Function | Signature | Purpose |
|---|---|---|
| pwm_pca9685_init | StatusCode pwm_pca9685_init(Pca9685Config *config); | Wake the PCA9685 from sleep and program it for pwm_freq_hz. |
| pwm_pca9685_deinit | StatusCode pwm_pca9685_deinit(void); | Force every channel full-off, sleep the chip, clear stored config. |
| pwm_pca9685_is_initialized | StatusCode pwm_pca9685_is_initialized(void); | Query init state. |
| pwm_pca9685_set_channel | StatusCode pwm_pca9685_set_channel(uint8_t pwm_channel, uint32_t delay, uint32_t duty_cycle); | Set one channel's pulse phase (delay) and width (duty_cycle) as fractions of UINT32_MAX. |
| pwm_pca9685_set_channel_full_off | StatusCode pwm_pca9685_set_channel_full_off(uint8_t pwm_channel); | Force one channel fully off, overriding ON/OFF ticks. |
| pwm_pca9685_set_channel_full_on | StatusCode pwm_pca9685_set_channel_full_on(uint8_t pwm_channel); | Force one channel fully on, overriding ON/OFF ticks. |
Two different ways to reboot - hard reset and soft reset into bootloader.
| system_hard_reset() | enter_bootloader() | |
|---|---|---|
| Mechanism | Full SoC reset via the PM watchdog | Direct jump to the bootloader already in RAM |
| Speed | Slow — everything reloads from the SD card | Fast — no hardware reset at all |
enter_bootloader() zeros the entire .stacks region, including the IRQ-mode stack, then jumps to the bootloader. This is used during DFU.
| Function | Signature | Purpose |
|---|---|---|
| system_hard_reset | void system_hard_reset(void); | Full SoC reset via the PM watchdog; reloads the bootloader from the SD card. Never returns. |
| enter_bootloader | void enter_bootloader(void); | Quiesce app peripherals and jump directly to the bootloader in RAM. Never returns. Task context only. |
A watchdog driver that monitors the health of the RTOS, manages boot flags and supports multicore mode.
watchdog_init() arms the BCM2711 PM watchdog (timeout clamped to 1–15s) from a caller-owned WatchdogConfig. The watchdog is kicked through a dedicated watchdog task.
If configured, the watchdog features a confirm-slot timer that clears the current app's "on trial" flag once it's run long enough to be trusted. wdt_meta (persisted to eMMC, CRC-checked) tracks reset count, active slot, and trial state across reboots.
typedef struct {
uint32_t timeout_s; // clamped to [1, 15]s by watchdog_init()
WatchdogResetPolicy policy; // what the bootloader does past tolerance
int32_t tolerance; // number of watchdog failures until the bootloder steps in
int64_t confirm_delay_ms; // WATCHDOG_CONFIRM_MANUAL = confirm yourself
bool multicore_mode; // aggregate kicks from every companion core
CompanionCoreContext *companion_core_ctx; // required when multicore_mode is true
} WatchdogConfig;
typedef struct {
uint32_t magic; // WDT_META_MAGIC; mismatch -> struct discarded
uint32_t wdt_reset_count; // consecutive watchdog resets since last confirm
int32_t wdt_reset_tolerance; // copy of WatchdogConfig.tolerance, persisted
uint32_t wdt_reset_policy; // copy of WatchdogConfig.policy, persisted
uint32_t wdt_reset_reason; // not yet written by the bootloader
uint32_t active_app_slot; // APP_SLOT_A / APP_SLOT_B (see eMMC page)
uint32_t app_slot_trial; // 1 = active slot unconfirmed (A/B trial)
uint32_t trial_boot_count; // bootloader re-entries while on trial
uint32_t crc; // CRC32 over every field above; MUST be last
} WdtMeta; // lives in eMMC EMMC_SECTOR_METADATA; wdt_meta is the in-RAM shadow
When multicore_mode is on, watchdog_core_kick() doesn't touch the hardware watchdog directly, it's guarded. This guarantees that one failing core triggers a watchdog reset. The watchdog keeps track of which cores are initialized via the compantion core context struct.
The watchdog supports the bootloader's app-slotting feature. A one shot timer callback signals a semaphore, and a separate dedicated task performs the eMMC write that signals to the bootloader that the app is healthy..
| Function | Signature | Purpose |
|---|---|---|
| wdt_meta_read | StatusCode wdt_meta_read(void); | Read/verify (magic + CRC) metadata from eMMC into the in-RAM shadow. |
| wdt_meta_write | StatusCode wdt_meta_write(void); | Recompute the CRC and persist the shadow to eMMC. |
| wdt_meta_confirm_slot | StatusCode wdt_meta_confirm_slot(void); | Confirm the active app slot, clearing the trial flag so the bootloader won't roll back. |
| watchdog_was_wdt_reset | bool watchdog_was_wdt_reset(void); | Return true if the previous boot was caused by a watchdog timeout. Call before watchdog_init(). |
| watchdog_init | StatusCode watchdog_init(WatchdogConfig *config); | Arm the watchdog from config (timeout clamped to [1,15]s). Stores the pointer, not a copy. |
| watchdog_kick | void watchdog_kick(void); | Reset the countdown. Safe from any context — one atomic MMIO write. |
| watchdog_core_kick | StatusCode watchdog_core_kick(void); | Per-core watchdog report; single-core builds just call watchdog_kick(). |
| watchdog_disable | void watchdog_disable(void); | Disarm the watchdog. No reset will occur after this returns. |
| watchdog_trigger_reset | void watchdog_trigger_reset(void); | Trigger an immediate reset via the watchdog. Never returns. |
| watchdog_task_start | StatusCode watchdog_task_start(void); | Arm the periodic kick timer and (if configured) the confirm-slot timer/task. |
| watchdog_core_task_start | StatusCode watchdog_core_task_start(void); | Start a companion core's periodic watchdog_core_kick() reports. |
Peripheral clock source for I2C, UART, and I2S.
| Function | Signature | Purpose |
|---|---|---|
| cprman_pcm_clock_enable | StatusCode cprman_pcm_clock_enable(uint32_t target_hz); | Enable the PCM clock generator at the closest achievable rate to target_hz. |
| cprman_pcm_clock_disable | void cprman_pcm_clock_disable(void); | Stop the PCM clock generator (graceful ENAB clear, KILL as timeout fallback). |
| cprman_pcm_clock_is_running | bool cprman_pcm_clock_is_running(void); | Query whether the PCM clock generator is currently running (CTL.BUSY). |
| cprman_pcm_clock_status | void cprman_pcm_clock_status(uint32_t *out_ctl, uint32_t *out_div); | Debug aid: read back the raw CTL/DIV registers. |
A 6-level priority, round-robin scheduler, with one instance per core.
Each core owns its own scheduler_init() call, its own ready/blocked lists, its own idle task, and its own Spinlock scheduler lock. The current implemention is based on a BMP (bound multiprocess) model, so each task is bound to a core. core_id never changes once created.
typedef struct {
List ready_list[NUM_TASK_PRIORITIES]; // one per priority level
List blocked_task_list; // sorted by wakeup_time
volatile uint32_t *s_clk_freq; // this core's timer frequency
volatile uint64_t *s_tick_count; // this core's tick counter
uint32_t hz; // tick rate (1000)
Spinlock lock; // this core's scheduler lock
TaskControlBlock idle_tcb; // this core's idle task
StackType_t idle_stack[IDLE_STACK_DEPTH]; // idle task's stack (64 words)
} SchedulerCore;
static SchedulerCore g_cores[COMPANION_CORE_MAX_CORES]; // one per core, indexed by core_id
The scheduler is responsible for transitioning tasks between READY, RUNNING, and BLOCKED. Calling mutex_lock(), semaphore_take(), or task_delay_ms() pulls the task off its priority's ready list, drops it onto the blocked_task_list sorted by wakeup_time.
scheduler_switch_context() runs on every 1 kHz tick and executes the round robin algorithm. Starting from the highest priority level and working down, the scheduler picks the first non-empty ready list and runs whichever task list->index currently points at. Several tasks sharing a priority rotate.
| Function | Signature | Purpose |
|---|---|---|
| scheduler_init | StatusCode scheduler_init(uint32_t core_id, volatile uint32_t *p_clk_freq, uint32_t new_hz, volatile uint64_t *p_tick_count); | Initialize the calling core's own scheduler instance (clock/tick links, ready/blocked lists, idle task). |
| scheduler_lock / scheduler_unlock | void scheduler_lock(uint32_t core_id); void scheduler_unlock(uint32_t core_id); | Acquire/release the given core's scheduler lock, guarding its ready/blocked lists. |
| scheduler_get_current_task | TaskControlBlock *scheduler_get_current_task(); | Return the calling core's currently running task's TCB. |
| scheduler_start | StatusCode scheduler_start(void); | Pick the calling core's first task to run and hand off to it (never returns). |
| scheduler_add_to_ready_list | StatusCode scheduler_add_to_ready_list(TaskControlBlock **tcb); | Add a task to the back of the ready list for its priority, on its own core. |
| scheduler_remove_from_ready_list | StatusCode scheduler_remove_from_ready_list(TaskControlBlock **tcb); | Remove a task from the ready list of its priority, on its own core. |
| scheduler_switch_context | void scheduler_switch_context(); | Perform a round-robin, priority-based context switch on the calling core. |
| task_delay_ms | void task_delay_ms(uint64_t ticks); | Block the calling task for the given number of ticks. |
| task_delay_until_ms | void task_delay_until_ms(uint64_t *wake_time, uint64_t ticks); | Block the calling task until wake_time + ticks, drift-free. |
| scheduler_get_tick_count | uint64_t scheduler_get_tick_count(void); | Return ticks since the calling core's scheduler_init(). |
| scheduler_add_to_blocked_list | StatusCode scheduler_add_to_blocked_list(TaskControlBlock *tcb, uint64_t wakeup_time); | Insert a task into its own core's blocked list, sorted by ascending wakeup_time. |
| scheduler_remove_from_blocked_list | StatusCode scheduler_remove_from_blocked_list(TaskControlBlock *tcb); | Remove a task from its own core's blocked list, if present. |
| start_first_task | void start_first_task(void); | Load the first ready task's saved context and branch to it (never returns; defined in startup.s). |
| scheduler_change_task_priority | void scheduler_change_task_priority(TaskControlBlock *tcb, TaskPriorityLevel new_priority); | Move a task to a new priority level, relocating it in its ready list if running/ready. |
Units of execution defined by the system.
task_create() allocates a TaskControlBlock from a fixed per-core pool (16 slots per core, MAX_NUM_TASKS) and a stack from the kernel heap, fills the stack with a watermark byte, and adds the task to its core's ready list.
Each task is identified by a TCB struct, which keeps track of priority, stack location, task state, and tracks if the task is part of a ready or blocked list.
struct TaskControlBlock {
volatile StackType_t *current_sp; // live SP, saved/restored on every switch
StackType_t *stack_high; // top of allocated stack (initial SP)
StackType_t *stack_base; // bottom of stack; watermark check
uint16_t stack_depth;
uint16_t task_id;
uint32_t core_id; // which core's scheduler owns this task
volatile TaskState current_state;
TaskPriorityLevel priority;
uint64_t wakeup_time;
ListItem state_list_item; // ready/blocked list node
ListItem event_list_item; // mutex/semaphore wait node
volatile TaskWakeupReason wakeup_reason;
TaskPriorityLevel base_priority; // pre-boost priority
uint32_t mutexes_held; // inheritance-enabled mutexes held
};
Tasks transition between three main states:
task_init_stack() builds a task's very first stack frame, which is essential to context switching.
A task function takes one void * parameter and, typically never return. task_exit_trap() exists specifically to catch one that does.
static TaskControlBlock *blink_tcb = NULL;
static void blink_task(void *params)
{
(void)params;
while (1) {
gpio_on(LED_PIN);
task_delay_ms(500);
gpio_off(LED_PIN);
task_delay_ms(500);
}
}
// after scheduler_init(), before scheduler_start():
StatusCode ret = task_create(blink_task, 256, TASK_PRIORITY_2, NULL, "blink", &blink_tcb);
if (ret != E_OK) {
uart_printf("task_create failed: %d\r\n", ret);
}
| Function | Signature | Purpose |
|---|---|---|
| task_create | StatusCode task_create(TaskFunction task_function, uint16_t stack_depth, TaskPriorityLevel priority, void *task_params, const char *name, TaskControlBlock **p_task_control_block); | Allocate a TCB and stack from static pools, initialize the task's stack frame, and add it to the ready list. |
A 256 KB bump allocator to manage dynamic memory in the kernel. Access is guarded by a spinlock.
static uint8_t heap[HEAP_SIZE_BYTES]; // 256 KB, HEAP_SIZE_BYTES = 262144
static uint32_t heap_offset = 0;
static Spinlock heap_lock; // guards heap_offset across cores
void *heap_malloc(uint32_t size)
{
size = (size + 3) & ~0b11; // round up to 4-byte alignment
if (heap_offset + size > HEAP_SIZE_BYTES) {
return NULL;
}
void *block_start = &heap[heap_offset];
heap_offset += size;
return block_start;
}
| Function | Signature | Purpose |
|---|---|---|
| heap_malloc | void *heap_malloc(uint32_t size); | Allocate size bytes from the static kernel heap pool. |
Releases cores 1–3 from their boot-time parking loop into independent RTOS instances.
companion_core_start(core_id, entry) hands a secondary core a bare function pointer and wakes it with a SEV.
The CompanionCoreContext struct is mainly used to manage the multicore watchdog feature
typedef struct {
volatile uint32_t expected_mask; // which cores should report in
volatile uint8_t core_kicked[COMPANION_CORE_MAX_CORES]; // per-core kick flags
} CompanionCoreContext;
What a released core actually does with that entry point is run its own complete, independent scheduler_init()/task_create()/scheduler_start() sequence: a second full instance of everything in the Base of RTOS / Kernel tier above.
static volatile uint32_t core1_clk_freq;
static volatile uint64_t core1_tick_count = 0;
static void led_task(void *params)
{
(void)params;
while (1) {
gpio_on(LED_PIN);
task_delay_ms(500);
gpio_off(LED_PIN);
task_delay_ms(500);
}
}
static void core1_kmain(void)
{
gpio_set_function(LED_PIN, GPIO_FUNC_OUTPUT);
gic_percore_init(); // this core's own CPU interface
gentimer_init(&core1_clk_freq, hz); // this core's own tick source
scheduler_init(1U, &core1_clk_freq, hz, &core1_tick_count);
TaskControlBlock *tcb;
task_create(led_task, 2048, TASK_PRIORITY_1, NULL, "led", &tcb);
__asm__ volatile ("cpsie i" ::: "memory"); // this core's own IRQs
scheduler_start(); // never returns
}
void kmain(void)
{
...
gic_distributor_init(); // global — must happen before any core is released
gic_percore_init(); // core 0's own PPI30 + CPU-interface enable
gentimer_init(&clk_freq, hz);
if (companion_core_start(1U, core1_kmain) == E_OK) {
uart_print("core 0: released core 1\r\n");
}
__asm__ volatile ("cpsie i" ::: "memory");
scheduler_start();
}
| Function | Signature | Purpose |
|---|---|---|
| companion_core_init | StatusCode companion_core_init(CompanionCoreContext *context); | Link context to the companion_core module; marks core 0 expected. |
| companion_core_start | StatusCode companion_core_start(uint32_t core_id, void (*entry)(void)); | Assign an entry function to a secondary core (1-3) and release it from the bootstrap parking loop. |
| companion_core_id | uint32_t companion_core_id(void); | Return this core's id (0-3), read from MPIDR. |
| companion_core_reset_active | void companion_core_reset_active(void); | Force every companion core released since the last call back into the bootstrap-style mailbox park loop. |