Skip to content

Repository files navigation

brio

A small modern-C++ framework for bare-metal microcontrollers, built around a cooperative active-object kernel: event queues, flat state machines, time events, one loop, one stack, no heap, no virtual functions, nothing resolved at run time that the compiler could resolve first. Written in C++23 (gnu++23), header-only, in one flat namespace brio ("con brio" - the musical marking for liveliness).

The kernel knows nothing about the silicon it runs on. Today it runs on AVR DA/DB (an AVR128DB48 on the bench), on SAM C21 (Cortex-M0+, an ATSAMC21J18A) and on STM32G0 (Cortex-M0+ from the other vendor, an STM32G0B1RE on a Nucleo-64) - each of the two Cortex-M0+ families compiled the kernel and its services unchanged, which was the design's promise.

What an application looks like

using P = brio::AvrPlatform;             // the one place the target is named

struct Toggle {};                        // events are small plain structs
struct SetPeriod { uint16_t ticks; };

struct Blinker : brio::Fsm<Blinker, Toggle, SetPeriod> {
    static inline brio::EventQueue<Event, 4, P> queue;                 // its own queue
    static inline brio::TimeEvent<P, Blinker, Toggle> heartbeat{Toggle{}};

    static void init() { Led::output(); start(&running); }

    static Status running(const Event& e) {
        return brio::match(e,
            [](brio::Entry) { heartbeat.arm_every(brio::ticks_from_ms<P>(500));
                              return handled(); },
            [](Toggle)      { Led::toggle(); return handled(); },
            [](SetPeriod p) { heartbeat.arm_every(p.ticks); return handled(); },
            [](auto)        { return unhandled(); });
    }
};

int main() {
    /* clock, timebase, sei() - target glue */
    brio::Tenuto<P, Blinker, Supervisor>::run();   // priority = pack order
}

Somewhere else, brio::post<Blinker>(SetPeriod{...}) changes the cadence - from another active object, from a time event, or from an ISR: same call, always safe, never blocking.

The ideas

  • Active objects, run-to-completion. An AO owns a queue and reacts to one event at a time; it never blocks, waits or polls. The kernel serves the highest-priority non-empty queue, one event per turn, and sleeps when nothing is pending. The only concurrency is ISR vs main loop, and an ISR may do exactly one kernel thing: post an event.
  • Everything is a type, resolved at compile time. AOs, drivers and the kernel are monostate classes selected by type; priorities, subscriptions and reply channels are template parameters. No tables walked at run time; RAM is exactly what is declared.
  • Contracts are concepts. What the kernel needs from an AO (ActiveObject), from the machine (Platform), what a text sink must offer (ByteSink) - stated as C++20 concepts, checked where a type is used, with errors that name the requirement.
  • Events are values, per-AO variants. Each AO declares its own std::variant of small trivially-copyable structs; events are copied into queues (a few bytes, one brief critical section). Plain shared structs are the lingua franca between publisher and subscriber; no global signal enum. Handlers dispatch with match(e, lambdas...).
  • Timers post events; failures leave a breadcrumb. A time event posts to its owner AO in main context (never runs user code in an ISR); a full queue is a sizing mistake counted, never blocking; panic() writes a reset-surviving record before any LED blinks.

The full rationale, decision by decision, is in docs/design/ - start with overview.md and kernel.md.

Layering and portability

brio strata

open the diagram full size (zoomable in the browser)

brio/ has ten strata; the include prefix makes a file's portability readable at a glance:

Stratum Contains Depends on
kernel/ queues, scheduler, FSM, delivery, time events, panic - pure logic nothing of brio
util/ services on top of the kernel: SerialPort, BusMaster (SPI/I2C arbiter), print, Ring, line parsers kernel/
avrdx/ everything that knows avr/io.h: clock, pins, UART, SPI, TWI, ticker, AvrPlatform kernel/, util/
cortexm/ what ARM designed into every Cortex-M and the four ARM families share: NVIC + PRIMASK guard, the SysTick ticker util/ (and the including family's device header)
samc21/ everything that knows sam.h (Cortex-M0+): clock tree, pins, SERCOM UART, SamPlatform; its NVIC and ticker are cortexm/'s kernel/, util/, cortexm/
stm32g0/ everything that knows stm32g0xx.h (Cortex-M0+): RCC/PLL, GPIO, USART, Stm32g0Platform; its NVIC and ticker are cortexm/'s kernel/, util/, cortexm/
stm32f4/ everything that knows stm32f4xx.h (Cortex-M4F, brio's first ARMv7-M): RCC/PLL with the regulator scale and over-drive and the dynamic clock, GPIO, USART, EXTI, the RTC, the DMA, the timers, ADC/DAC, SPI/I2S, I2C and FMPI2C, the USB OTG core, PWR with the sleep sites, the flash engine, CRC/RNG/bxCAN, the FMC with its SDRAM, the LTDC and the DMA2D, Stm32f4Platform; its NVIC and ticker are cortexm/'s kernel/, util/, cortexm/
ch32v00x/ everything that knows the CH32V00x (QingKe V2C, RV32EC - the smallest core brio runs on): its own register map (no vendor header), the clock, the pads and their remaps, USART, SPI, I2C, DMA, the timers, the ADC and the OPA, flash and the two watchdogs, sleep, the PFIC guard, the STK ticker, Ch32v00xPlatform kernel/, util/
host/ HostPlatform: the native test "target" (virtual clock, recording idle/break) kernel/

Targets are siblings, never meet in one binary, and are the only place where hardware headers, ISR vector names and tick rates live. Nothing above them uses #ifdef to tell targets apart: where behaviour must differ, the target states a fact (ticks_per_second, atomic_width) and generic code chooses with if constexpr or a concept.

A target is either supported - its peripheral chapters are implemented and bench-verified, and everything kernel/ and util/ claim holds there - or in bring-up, where the stratum exists, part of it is proven on silicon, and the rest is still to be validated. This table is the one place that question is answered: no source file carries its own list of the targets it was tried on. Where a contract's realizations differ - and where they are one interface - is the REALIZATIONS TABLES, one per design page, indexed in docs/design/overview.md ("One interface where it can, its exceptions where a reader looks").

Target State Bench silicon Notes
AVR DA/DB (avrdx/) supported AVR128DB48 avr-gcc 16.2, see docs/avrdx/README.md
SAM C21 (samc21/) supported ATSAMC21J18A arm-none-eabi-gcc 16.2, SysTick tick at 1000 Hz against the AVR's 1024 - the kernel tick's opacity, exercised for real; see docs/samc21/README.md
STM32G0 (stm32g0/) supported STM32G0B1RE, STM32G071RB, STM32G031K8 arm-none-eabi-gcc 16.2, HSI16 x PLL at 64 MHz, the third clock model (shared bus prescalers + per-peripheral enables) and a tickless timebase option; see docs/stm32g0/README.md
CH32V00x (ch32v00x/) supported CH32V006K8U6 WCH's riscv32 gcc 15.2 with its xw extension, RV32EC (sixteen registers, 8 KB of RAM - the smallest core brio runs on), HSI x2 at 48 MHz, its own register map with no vendor header, the console on the WCH-Link's own serial; the CH32V003F4P6 (16 KB, 2 KB, no multiplier) is the family's second and last part, supported on the same stratum with its own part table, presets and ISA - every chapter tiered for it, its suites green on the board as group images, the buses on the wire against a peer, the smallest silicon brio runs on; see docs/ch32v00x/README.md
CH32V203 (ch32v203/) supported CH32V203C8T6 (a WeAct core board) WCH's riscv32 gcc 15.2 with its xw extension, RV32IMAC on the QingKe V4B - the second WCH family and a bigger core than the CH32V00x's, with the STM32F1's peripheral generation under WCH's names; its own register map with no vendor header, the nine parts of the series in one part table, the PLL at 144 MHz from the internal RC or from the board's crystal, the console on the WCH-Link's own serial AND on the chip's own USB (util/usb's CDC over the USBD controller, which is ST's device peripheral under WCH's names); every chapter of the reference manual's plan has its document and its suite green on the board, the two buses on the wire against a peer board, and CAN is the one chapter still open, waiting for the cross-platform CAN pass; the power model here rests on a finding of this silicon's own, that in a sleep of any depth no bus master but the core gets a cycle, so a count of active bus masters keeps the idle path awake while a DMA channel or the USB controller is working; the 32 KB tier has a preset of its own as the link guard, and a suite too big for it builds there as one image per group of letters; see docs/ch32v203/README.md
RP2040 (rp2040/) supported RP2040 (a Raspberry Pi Pico, a WeAct board) arm-none-eabi-gcc 16.2, the third Cortex-M0+ family on cortexm/ - two cores, a kernel on each with the inbox bridge between them, or one kernel on core 0; the USB device stack with a CDC console on the chip's own connector; the 12 MHz crystal through the PLL at 125 MHz, the fourth clock model (a generator per clock domain, a separate peripheral clock, no bus prescaler); the pico-sdk's device description vendored, its own crt and boot stage; see docs/rp2040/README.md
STM32F4 (stm32f4/) supported STM32F429ZI, STM32F446RE, STM32F411CE arm-none-eabi-gcc 16.2 with the hard-float ABI, brio's first ARMv7-M family on the same cortexm/ core files; the PLL at 180 MHz in over-drive on two boards and 100 MHz on the third, the APB prescalers unpinned (a rate per bus); every chapter of the three reference manuals with its document and its suite green on every board it builds for, against the devices the boards carry (a gyroscope, a touch controller, an SDRAM, a panel) and the kernel console on the black pill's own USB connector; see docs/stm32f4/README.md
host (host/) supported - doctest suites, cd test && ctest --preset host, see docs/host/README.md

Building and testing

The framework in brio/ is header-only, included directly. The builds are seven sibling CMake projects, one per toolchain, all peers (the repo root is not a CMake project): avrdx/, samc21/, stm32g0/, ch32v00x/ and rp2040/ each auto-discover one main() per src/apps/<app>.cpp at configure time

  • an app may pin build options such as its console baud with // build: monitor_speed = 115200 header lines - and test/ holds the host unit tests (a configure has exactly one compiler).
(cd test    && ctest --preset host)                                       # host tests: kernel, queues, FSM, time events, buses, ring...
(cd avrdx   && cmake --build --preset avr128db48-release --target <app>)  # build one AVR app (release, -Os)
(cd avrdx   && cmake --build --preset avr128db48-release --target <app>-upload)   # flash it over UPDI
(cd samc21  && cmake --build --preset samc21j-release --target <app>)     # build one SAM app
(cd samc21  && cmake --build --preset samc21j-release --target <app>-upload)      # flash it over SWD
(cd stm32g0 && cmake --build --preset stm32g0b1re-release --target <app>)  # build one STM32G0 app
(cd stm32g0 && cmake --build --preset stm32g0b1re-release --target <app>-upload)   # flash it over the ST-LINK
(cd ch32v00x && cmake --build --preset ch32v006k8-release --target <app>-upload)    # flash it through the WCH-Link

With more than one board on the desk the bench has one command, bin/brio (put bin/ on the PATH): brio list shows the boards the manifest knows and which are plugged in, brio flash <board> <app> builds for that board's type and flashes it whatever its probe, brio run <board> <letter> drives a test suite's console and judges its summary, brio check <stratum> runs the family compile fixtures and brio prose the prose net. brio --help lists the rest.

Everything target-specific - toolchain, board, probe, debugger, its quirks - is documented per target in each target's folder under docs/. The apps are the framework's test bench: disposable by design, they document themselves in their own header comment (brio apps lists them), and what a suite needs wired is in that comment. The boards and the probes brio is tested with are docs/boards/ and docs/probes/.

Status

The kernel and the services above it compile unchanged on every supported target and are bench-tested on all three families: the kernel loop and time events, a serial console over SerialPort, an arbitrated SPI bus (a display and a touch controller sharing one) and an arbitrated I2C bus (a DAC written and read back, an ADC measured through SPI), the power model with its sleep sites, the flash storage classes, and the analog and metering services. Each target's own chapters are covered by the reference suites its documentation names.

brio is still revised freely: nothing below the kernel contract is promised stable, and where a limitation can be removed by rewriting what sits below, the rewrite wins (the governing rule in overview.md). What has settled is measured rather than declared: the kernel and the services above it reached each new family unchanged, and the contracts that survived three realizations - the AO contract, the bus vocabularies, the power model, the storage classes, the analog and metering services, the clock model - are the ones a program can lean on. Clean-room with respect to QP: the concepts come from Samek's book, never the QP source.

License

brio is released under the MIT license (LICENSE): use it, change it, ship it, keep the notice with it. The vendored components under third_party/ keep their own licenses - doctest (MIT), CMSIS-Core, cmsis-device-g0 and the SAM C21 DFP (Apache-2.0).

About

brio: a header-only C++23 active-object framework for bare-metal MCUs - one cooperative kernel and its services, unchanged on AVR DA/DB, SAM C21 and STM32G0

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages