Bare-Metal RTOS for Raspberry Pi CM4

2026

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.

Architecture

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.

Libraries
Kernel Objects
Drivers
Base of RTOS / Kernel
Multicore

Surviving a bad update

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.

Visualizing the RTOS with the telemetry client

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.

Task Timing breakdown, ready and blocked lists, timing diagram

Sample: Companion cores alternating

Synchronization primitives viewer

Broadcast protocol

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.

Hardware setup