Introduction
A CLI-first STM32 developer platform: declarative stm32.toml → validated HAL
init code → flashed firmware, plus a real-time ITM trace dashboard.
Nucleus replaces STM32CubeIDE/CubeMX's graphical pin configuration and proprietary debug tooling with a version-controllable, CI-friendly CLI and a thin VS Code extension.
At a glance
# stm32.toml
[device]
family = "STM32F446RE"
board = "NUCLEO-F446RE"
clock_hz = 180_000_000
[peripherals.usart2]
tx = "PA2"
rx = "PA3"
baud = 115200
nucleus check # validate against the constraint database
nucleus build # generate HAL init code + build firmware
nucleus trace # decode ITM/SWO and stream to the dashboard
Nucleus supports two NUCLEO boards out of the box: NUCLEO-F446RE
(STM32F446RE) and NUCLEO-F411RE (STM32F411RE) — pick one with
nucleus init --board <name>.
What's in this book
- Installation — install the
nucleusCLI and the VS Code extension. - Quickstart: Blink an LED — from a clean machine to a blinking LED on a NUCLEO-F446RE.
- CLI Usage —
check,init,build,flash,lsp,trace. - Enabling ITM Trace — wire up SWO/ITM in firmware and
OpenOCD for
nucleus trace. - CI Integration — gate PRs with
nucleus checkvia the reusable action.
Installation
The nucleus CLI
Prerequisites
- A Rust toolchain ≥ 1.85 (the MSRV). Install via rustup.
- Optional, only for
nucleus build/flash:arm-none-eabi-gcc,cmake,st-flash, and a NUCLEO-F446RE board.
From crates.io
cargo install nucleus-cli
This installs the nucleus binary into ~/.cargo/bin (on your PATH if you
installed Rust via rustup). To install a specific version, pass
--version <x.y.z>.
From source
# From a clone
git clone https://github.com/harshverma27/nucleus
cd nucleus
cargo install --path crates/nucleus-cli --locked
# …or directly from GitHub
cargo install --git https://github.com/harshverma27/nucleus nucleus-cli --locked
Prebuilt binaries
Each tagged release attaches prebuilt nucleus binaries for Linux, macOS, and
Windows (x86_64 + arm64), with SHA-256 checksums, on the
Releases page. Download,
verify, extract, and put nucleus on your PATH.
Verify
nucleus --version
nucleus --help
The VS Code extension
The extension is a thin client for the CLI (LSP + trace dashboard).
- From the Marketplace (once published): search for Nucleus in the
Extensions view, or install the
.vsixattached to a release with Extensions: Install from VSIX…. - From source:
Then press F5 in VS Code to launch an Extension Development Host.cd extension npm install npm run build
The extension expects nucleus on your PATH; override with the
nucleus.serverPath setting if needed.
Quickstart: Blink an LED
This walks through setting up everything from scratch — toolchain, STM32
HAL sources, the nucleus CLI — and ends with a blinking LED (LD2, pin
PA5) on a NUCLEO-F446RE.
1. Install the ARM cross toolchain
nucleus build cross-compiles with arm-none-eabi-gcc.
Arch Linux:
sudo pacman -S arm-none-eabi-gcc arm-none-eabi-newlib arm-none-eabi-binutils arm-none-eabi-gdb
arm-none-eabi-newlib is required too — it provides nano.specs/nosys.specs,
which the generated build links against.
Other platforms: install the ARM GNU Toolchain
release and add its bin/ directory to PATH.
Verify:
arm-none-eabi-gcc --version
You'll also need cmake and st-flash (from stlink-tools):
sudo pacman -S cmake stlink
2. Get the STM32CubeF4 HAL/CMSIS sources
This is not STM32CubeIDE/CubeMX (no GUI needed) — just a source checkout of ST's HAL driver and CMSIS device headers.
git clone https://github.com/STMicroelectronics/STM32CubeF4 ~/STM32CubeF4
cd ~/STM32CubeF4
git submodule update --init Drivers/STM32F4xx_HAL_Driver Drivers/CMSIS/Device/ST/STM32F4xx
(Only those two submodules are needed — the full set includes BSPs and middleware for boards/features this demo doesn't use.)
Verify the key headers exist:
ls Drivers/STM32F4xx_HAL_Driver/Inc/stm32f4xx_hal.h
ls Drivers/CMSIS/Device/ST/STM32F4xx/Include/stm32f446xx.h
Export the path (add this to your ~/.bashrc/~/.zshrc so it persists
across shells):
export STM32CUBE_PATH=~/STM32CubeF4
3. Install nucleus
From a clone of this repo:
git clone https://github.com/harshverma27/nucleus
cd nucleus
cargo install --path crates/nucleus-cli --locked
Verify:
nucleus --version
nucleus --help
4. Scaffold a new project
mkdir ~/my-blink && cd ~/my-blink
nucleus init .
This creates stm32.toml, CMakeLists.txt, a linker script, HAL config
header, interrupt-handler stubs, and a starter src/main.c that calls the
generated Nucleus_Init(). The default stm32.toml configures USART2
(the ST-Link virtual COM port) — nothing else is required for this demo.
Validate the config:
nucleus check
5. Add the LED blink code
LD2 (the on-board green LED) is wired to PA5 on the NUCLEO-F446RE.
Plain GPIO toggling isn't part of stm32.toml's declarative peripheral
model (that's reserved for pin-muxed peripherals like USART/SPI/I2C/TIM),
so it's hand-written in main.c — same as any bare-metal HAL project.
Edit src/main.c:
/* Application entry point. Hand-written — Nucleus only owns nucleus_init.c. */
#include "stm32f4xx_hal.h"
#include "generated/nucleus_config.h"
void SystemClock_Config(void);
int main(void)
{
HAL_Init();
SystemClock_Config();
Nucleus_Init(); /* generated from stm32.toml */
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitTypeDef led = {0};
led.Pin = GPIO_PIN_5;
led.Mode = GPIO_MODE_OUTPUT_PP;
led.Pull = GPIO_NOPULL;
led.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &led);
while (1) {
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
HAL_Delay(500);
}
}
/* Replace with a clock setup matching [device].clock_hz in stm32.toml.
* A full clock-tree solver is intentionally out of Nucleus's scope. */
__attribute__((weak)) void SystemClock_Config(void) {}
6. Build
nucleus build
This validates stm32.toml, generates src/generated/nucleus_config.h and
nucleus_init.c, then drives CMake + arm-none-eabi-gcc. On success you'll
see:
Generating firmware.bin / firmware.hex
[100%] Built target firmware
build OK — firmware in ./build
(A few _close/_read/_write is not implemented linker warnings are
expected and harmless — they come from -specs=nosys.specs.)
7. Flash
Plug the NUCLEO board in via the ST-Link USB port (the one nearer the edge of the board, not the "USB User" port). Then:
nucleus flash
This programs build/firmware.bin at 0x08000000 via st-flash and resets
the board. LD2 should start blinking at ~1 Hz.
Troubleshooting
-
st-flash: Failed to enter SWD mode/Failed to connect to targetbutst-info --probefinds the programmer: the ST-Link itself is detected, but the SWD link to the target MCU isn't up. Unplug/replug the USB cable, or press the board's black RESET button, then retry. -
st-info --probereports an unexpectedchipid/dev-type: confirm your board is actually a NUCLEO-F446RE (chip ID0x421). Nucleus is built and validated against the F446RE only — other F4 boards (e.g. an F411RE, chip ID0x431) often happen to work for simple GPIO demos since the F4 family shares register layouts, but pin/AF validation (nucleus check) and any clock-tree setup are F446-specific. -
Missing
stm32f4xx_hal_conf.h/ undefined HAL functions at link time: make sure you're on anucleusbuild that includes the scaffold fix (nucleus initshould createsrc/stm32f4xx_hal_conf.h,src/stm32f4xx_it.c, andSTM32F446RETx_FLASH.ld). Re-runcargo install --path crates/nucleus-cli --locked --forcefrom an updated checkout if these files are missing. -
STM32CUBE_PATHerrors (stm32f4xx_hal.h: No such file or directory): confirmecho $STM32CUBE_PATHpoints at your STM32CubeF4 checkout and that the submodules from step 2 are initialized (their directories aren't empty).
CLI usage
The nucleus binary is the whole toolchain. All commands are scriptable and
composable; nucleus check exits non-zero on conflicts so CI can gate on it.
nucleus <command>
check Validate stm32.toml against the constraint database
init Scaffold a new STM32 project
build Generate HAL init code and build firmware (.elf/.bin)
flash Flash the built firmware to a connected board
trace Decode ITM/SWO trace and stream events over a WebSocket
lsp Start the language server over stdio (used by the editor extension)
nucleus init [dir]
Scaffolds a project: stm32.toml, CMakeLists.txt, a cross-toolchain CMake
file, src/main.c, a CI workflow, and .gitignore. Idempotent — it never
overwrites existing files.
mkdir blinky && cd blinky
nucleus init .
Use --board to target a specific NUCLEO board (default NUCLEO-F446RE):
nucleus init blinky --board NUCLEO-F411RE
Supported boards: NUCLEO-F446RE, NUCLEO-F411RE. The board sets the
[device].family, the linker script, the MCU define, the startup file, and the
default clock_hz in the scaffolded project.
nucleus check [path]
Validates stm32.toml (default ./stm32.toml) against the constraint database
for its [device].family (STM32F446RE or STM32F411RE) and prints any conflicts.
Detects pin collisions, AF mismatches, missing required pins, and disabled clock
domains. Exit code: 0 when clean, 1 on any conflict or read/parse error.
nucleus check
nucleus check configs/board-a.toml
nucleus build [dir]
Validates the config, regenerates src/generated/nucleus_config.h +
nucleus_init.c (typed config structs + a single Nucleus_Init() that calls
stock ST HAL Init functions), then drives CMake + arm-none-eabi-gcc to
produce firmware.elf / firmware.bin.
Requires
cmake+arm-none-eabi-gccand an STM32CubeF4 (HAL) checkout pointed at bySTM32CUBE_PATH. Codegen still runs (and is observable) if the toolchain is missing; you'll get a clear error at the compile step. Build refuses to generate code for a conflicting config.
nucleus flash [dir]
Programs build/firmware.bin to a connected board with st-flash.
nucleus trace
Decodes the ITM/SWO byte stream and streams structured JSON events over a
WebSocket (default :7878) for the dashboard.
# Live, via OpenOCD's internal trace TCP port
nucleus trace --trace-tcp 127.0.0.1:3344 --openocd 127.0.0.1:4444
# Replay a captured raw-SWO file (great for development and demos)
nucleus trace --replay capture.swo
# Options
# --config <stm32.toml> read [[trace.variables]] (default stm32.toml)
# --ws-port <port> WebSocket port (default 7878)
Event shapes (JSON): {"kind":"log","message":…},
{"kind":"variable","port":…,"name":…,"type":…,"value":…},
{"kind":"cpuload","load":…}, {"kind":"overflow"}.
nucleus lsp
Starts the language server over stdio. Normally spawned by the VS Code extension, not run by hand.
Pinout & Peripheral Verification
The base layer every other feature in this book builds on: a deterministic
pin/alternate-function/peripheral database for the F446RE and F411RE, and a
solver that checks a declared stm32.toml against it.
The database (nucleus-db)
nucleus-db is generated at build time from ST's own
STM32_open_pin_data
pack XML (vendored under crates/nucleus-db/packdata/), not hand-written. For
each supported family it produces:
- Every physical pin and its package position.
- Every alternate-function (AF) mapping: which pin, on which AF number, routes
to which peripheral signal (e.g.
PA9AF7 →USART1_TX). - The peripheral instance list for that family (e.g. F411RE has no USART3/UART4/UART5; F446RE does).
Because it's generated deterministically from the same pack data every build,
the database never drifts between machines — there's no "works on my CubeMX
install" class of bug. Pack-data inconsistencies are the one place hand
correction is allowed: small patches live in nucleus-db/src/pack.rs's patch
table, never by editing the vendored XML.
What nucleus check validates
nucleus check [path] (default ./stm32.toml) parses your config and runs it
through the solver in nucleus-compiler/src/solver.rs, which reports a
Conflict for each of:
| Conflict | Trigger |
|---|---|
PinCollision | Two peripheral signals assigned to the same physical pin. |
AfMismatch | A pin assigned to a peripheral signal that pin doesn't actually expose (no AF row for it on this family). |
InvalidPin | A pin role's string value isn't a real pin name (typo). |
MissingPin | A peripheral declared without a required pin role set. |
ClockDomainDisabled | A peripheral configured while its bus ([clocks]) is turned off. |
PeripheralUnavailable | An instance that doesn't exist on the selected family (e.g. usart5 on an F411RE). |
(Clock-tree, DMA, IRQ, and auto-routing conflicts build on this same Conflict
enum and solver pass — see their own pages.)
Exit code: 0 clean, 1 on any conflict or parse error — designed to gate CI
(see CI Integration).
Example: a pin collision
# tests/fixtures/pa5_collision.toml — PA5 claimed by both SPI1_SCK and TIM2_CH1
[device]
family = "STM32F446RE"
[peripherals.spi1]
mosi = "PA7"
miso = "PA6"
sck = "PA5"
[peripherals.tim2]
channel1 = "PA5"
$ nucleus check tests/fixtures/pa5_collision.toml
tests/fixtures/pa5_collision.toml: 1 conflict found:
error: pin collision on PA5: assigned to SPI1_SCK and TIM2_CH1
Example: clean
# tests/fixtures/clean.toml
[device]
family = "STM32F446RE"
board = "NUCLEO-F446RE"
clock_hz = 180_000_000
[peripherals.usart2]
tx = "PA2"
rx = "PA3"
baud = 115200
[peripherals.spi1]
mosi = "PA7"
miso = "PA6"
sck = "PA5"
nss = "PA4"
mode = 0
[peripherals.i2c1]
sda = "PB9"
scl = "PB8"
speed = "standard"
[peripherals.tim2]
channel1 = "PA0"
channel2 = "PA1"
frequency_hz = 1000
$ nucleus check tests/fixtures/clean.toml
tests/fixtures/clean.toml OK — 4 peripheral(s), no conflicts.
Where this surfaces in the editor
nucleus-lsp (analysis.rs) maps every Conflict to the most relevant byte
span in the source stm32.toml and reports it as an LSP diagnostic, so VS
Code (via the extension's LSP client) underlines the exact offending line —
no separate "run check" step needed while editing.
See also
- Clock-Tree Solver — frequency-domain checks (M1)
- DMA Arbitration — stream/channel contention (M2)
- IRQ / NVIC Verifier — interrupt coverage (M3)
- Constraint Auto-Router — fill in pins automatically (M4)
Clock-Tree Solver
CubeMX's clock configurator lets you build a clock tree visually and never
checks whether the numbers it produces are actually legal silicon — an APB1
bus quietly clocked at 180 MHz instead of its 45 MHz limit will boot and run
until something subtly misbehaves. Nucleus's clock-tree solver (Conflict::ClockConstraint)
rejects that config before it ever reaches a board.
The model (nucleus-db/src/clock.rs)
A hand-maintained, family-parameterized description of:
-
Oscillator sources:
Hsi,Hse,Lsi,Lse. -
The main PLL's M/N/P/Q divider ranges and VCO input/output frequency ranges.
-
The SYSCLK source set and the AHB/APB1/APB2 prescaler sets.
-
Per-family silicon limits, cited to ST's reference manuals (RM0390 for F446RE, RM0383 for F411RE):
Bus F446RE limit SYSCLK ≤ 180 MHz AHB ≤ 180 MHz APB1 ≤ 45 MHz APB2 ≤ 90 MHz
This module is pure data — it performs no arithmetic. It can't be generated from ST's pack XML (that data carries pin↔AF mappings only, no clock-tree information), so it's hand-maintained and cross-checked by a seed test against the reference manuals.
The solver (nucleus-compiler/src/clocks.rs)
validate() walks the effective config in four stages, stopping early at the
first stage that fails (a downstream frequency number computed from an
already-illegal divider would just produce a confusing cascade of unrelated
errors):
- PLL divider legality — M/N/P/Q each checked against the model's ranges.
- Prescaler legality — AHB/APB1/APB2 prescaler must be one of the hardware's allowed divisor values.
- PLL VCO range — input and output VCO frequency must land inside the silicon's specified band.
- Silicon limits — SYSCLK, AHB, APB1, APB2 each checked against the family's max, in that fixed order.
stm32.toml schema
[clocks]
source = "pll" # "hsi" | "hse" | "pll" (default "pll")
pll_source = "hse" # PLL input: "hsi" | "hse"
pll_m = 8
pll_n = 360
pll_p = 2
pll_q = 7 # optional, only needed if something derives from PLLQ
apb1_prescaler = 2
apb2_prescaler = 1
ahb1 = true # bus enable flags; default true if [clocks] section omitted
apb1 = true
apb2 = true
Omitting [clocks] entirely is valid and enables every bus by default.
Example: an over-clocked APB1
# tests/fixtures/overclock_apb1.toml — 180 MHz HCLK ÷ prescaler 1 = 180 MHz
# on a bus whose limit is 45 MHz. CubeMX accepts this; Nucleus doesn't.
[device]
family = "STM32F446RE"
[clocks]
source = "pll"
pll_source = "hse"
pll_m = 8
pll_n = 360
pll_p = 2
apb1_prescaler = 1
[peripherals.usart2]
tx = "PA2"
rx = "PA3"
$ nucleus check tests/fixtures/overclock_apb1.toml
tests/fixtures/overclock_apb1.toml: 1 conflict found:
error: clock constraint [APB1]: APB1 = 180 MHz exceeds the 45 MHz limit
Other reasons you'll see inside clock constraint [<node>]: ...: PLL M = 3 is outside the valid 2–63 range, PLL VCO output = 432 MHz is outside the 100–432 MHz range, APB2 prescaler 3 is not one of the allowed values {1, 2, 4, 8, 16}.
Baud-rate reachability
Peripheral baud/bit rates (USART, SPI, I2C) are derived from whichever bus
clocks them (nucleus-db::peripheral_bus). If the resolved bus frequency
can't reach a requested baud rate within the peripheral's divider range, that
surfaces as the same ClockConstraint conflict, on the peripheral's node
rather than SYSCLK/AHB/APBn.
See also
- Pinout & Peripheral Verification — the base solver pass
- DMA Arbitration, IRQ / NVIC Verifier — the other M1–M3 conflict classes
DMA Arbitration
STM32F4's DMA1/DMA2 controllers have eight streams each, and each stream
multiplexes eight peripheral-request channels. Two peripherals can be wired
in CubeMX onto the same physical stream — both will compile and flash, and
the second one's DMA transfers will simply never fire. Nucleus catches this
at nucleus check time instead of at "why is my second sensor not updating".
The model (nucleus-db/src/dma.rs)
A hand-maintained, family-parameterized map of:
- DMA1 and DMA2, their eight streams each.
- Each stream's eight request channels.
- The peripheral-request table: which
(peripheral, direction)— e.g.(usart2, Rx)— is servable by which(controller, stream, channel)slots.
Cited to ST's reference manuals: RM0390 Tables 28–29 (F446RE), RM0383 Tables 27–28 (F411RE). Like the clock-tree model, this can't be generated from pack XML (no DMA data there) so it's hand-maintained and seed-tested.
The solver (nucleus-compiler/src/dma.rs)
validate() is a deterministic, greedy first-fit:
- Collect every
dma = [...]request across configured peripherals, inBTreeMapperipheral order. - For each request, in order, assign the first candidate stream that's still free.
- If every candidate for a request is already taken, record a
Conflict::DmaCollisionagainst that stream — once per contested stream, not once per colliding pair — naming the existing holder, the new contender, and (if either side has a free alternative slot) a suggestion.
stm32.toml schema
[peripherals.i2c1]
sda = "PB7"
scl = "PB6"
dma = ["rx"] # request DMA for specific signals…
[peripherals.uart5]
tx = "PC12"
rx = "PD2"
dma = true # …or `true` for every direction the peripheral supports
Example: a stream collision
# tests/fixtures/dma_collision.toml — I2C1_RX and UART5_RX both map to
# DMA1 stream 0; I2C1_RX has a free alternative on stream 5, UART5_RX doesn't.
[device]
family = "STM32F446RE"
[peripherals.i2c1]
sda = "PB7"
scl = "PB6"
dma = ["rx"]
[peripherals.uart5]
tx = "PC12"
rx = "PD2"
dma = ["rx"]
$ nucleus check tests/fixtures/dma_collision.toml
tests/fixtures/dma_collision.toml: 1 conflict found:
error: DMA collision: I2C1 and UART5 both need DMA1 stream 0 (move I2C1 to DMA1 stream 5 (channel 1))
See also
IRQ / NVIC Verifier
NVIC vectors and EXTI lines are shared, finite resources CubeMX never checks
for you: two GPIO pins wired to interrupts that happen to share an EXTI line,
a peripheral interrupt enabled with no vector modeled, or a DMA-vs-IRQ
priority inversion that quietly starves a completion handler. The M3 verifier
(Conflict::IrqConflict) catches all three at nucleus check time.
The model (nucleus-db/src/irq.rs)
Pure data, hand-maintained (the pack XML carries no NVIC/IRQ information), cross-checked by a reference-manual seed test:
- EXTI → NVIC grouping: lines 0–4 each get a dedicated vector
(
EXTI0..EXTI4); lines 5–9 shareEXTI9_5; lines 10–15 shareEXTI15_10. Identical on F446RE and F411RE (RM0390 / RM0383 Table 38). - Peripheral → vector map: one row per modeled peripheral kind
(USART/UART, SPI, I2C, TIM), restricted to the instances each family
actually has. I2Cx is the one irregular case — two vectors per
instance,
I2Cx_EV(event) andI2Cx_ER(error); everything else has exactly one.
The solver (nucleus-compiler/src/irq.rs)
Three independent checks, run in fixed order, each producing its own
IrqConflict entries:
- Unhandled IRQ — a peripheral table sets
irq = truefor a peripheral the family'sIrqMaphas no vector for. (Error.) - EXTI collision — two or more
[[exti]]entries resolve to the same EXTI line (shared across all eight GPIO ports —PA0,PB0, ...PH0all land on line 0). One conflict per contested line, naming every distinct port claiming it — not one conflict per colliding pair. (Error.) - Priority inversion — a peripheral with both
dma_priorityandirq_priorityset, where the DMA priority is numerically less urgent (a larger number) than the IRQ priority: the peripheral's own ISR can then preempt the DMA-completion interrupt it's meant to hand off to. (Warning —nucleus checkdoes not fail on this alone.)
IrqConflict is the first Conflict variant carrying its own explicit
Severity rather than always being an error — see Pinout &
Peripheral Verification for the severity rule
nucleus check's exit code follows.
stm32.toml schema
[peripherals.usart2]
tx = "PA2"
rx = "PA3"
irq = true # opt in to NVIC-vector verification (never inferred)
irq_priority = 5
dma = true
dma_priority = 2 # numerically smaller = more urgent
[[exti]]
pin = "PA0"
priority = 3
Example: an EXTI line collision
# tests/fixtures/exti_collision.toml — PA0 and PB0 both resolve to EXTI0
[device]
family = "STM32F446RE"
[[exti]]
pin = "PA0"
[[exti]]
pin = "PB0"
$ nucleus check tests/fixtures/exti_collision.toml
tests/fixtures/exti_collision.toml: 1 conflict found:
error: IRQ conflict [PA0]: EXTI0 is shared by PA0 and PB0 but only one can trigger it
See also
Constraint Auto-Router
M1–M3 all validate an explicit, fully-pinned stm32.toml and report a
conflict when it's wrong. M4 inverts the problem: name a peripheral instance
without assigning some or all of its pins, and nucleus route searches the
pin/AF database for a complete, valid, deterministic assignment for exactly
the roles left open — declare intent, get a pinout.
Scope: pins only
You always name the exact peripheral instance (usart2, spi1); the router
never picks which instance to use, only which pins:
- A role with its key absent → route this role.
- A role with its key present → respect this pin as fixed (validated
exactly like
nucleus checkwould; a pre-existing hard conflict aborts routing before search starts). - An optional role left absent → left alone. The router never force-allocates a pin for a signal you have no use for.
[[exti]]entries are pre-occupied pins, treated like any other already-pinned role — there's no EXTI auto-routing in M4.
Cost function
Candidate pins for each open role are sorted by a strict lexicographic priority — not a weighted sum, so there are no tuning constants to fight:
- Ascending pin-demand — how many AF-mapping rows reference that pin across the whole database. Lower demand first: using an uncontested pin now keeps high-demand pins free for later roles.
- DMA pressure — a documented no-op for this milestone.
nucleus-db's DMA candidate slots key off(peripheral, direction)only, with no dependency on which pin/AF was chosen, so this tier can never break a tie in the pins-only scope M4 ships. Kept as an explicit always-equal comparison (rather than silently dropped) so a future instance-selection extension has an obvious place to plug in. - Same-port-as-sibling preference — for a multi-role instance, if one GPIO port has a free candidate for every still-open role of that instance, every role's sort prefers that port. Lowest priority, so it only breaks ties demand left open.
Search
A backtracking depth-first search over the open roles: try the sorted candidates for the first open role, recurse on the rest, undo and try the next candidate on dead-end. A global step counter spans the whole search — past a fixed budget (100,000 steps; a pathological-input safety net, not a normal-path concern) the search aborts and falls back to a pure greedy strategy (same sort order, first candidate per role, no undo).
On a complete candidate assignment, the result is merged into a synthetic
fully-pinned config and re-run through the same clock/DMA/IRQ/pin-collision
checks nucleus check runs — a successful route is valid by construction,
not just by the router's own bookkeeping. Any failure (search exhaustion,
greedy exhaustion, or this final validation pass) is reported as
Conflict::Unroutable.
Usage
nucleus route [path] # print the routed config to stdout
nucleus route [path] --out FILE # write it to FILE instead
path defaults to ./stm32.toml. Exit code 0 on a successful route, 1
if any role couldn't be routed (or on a parse error) — same gating
discipline as nucleus check.
Example: a clean route
# tests/fixtures/route_simple.toml
[peripherals.usart2]
[peripherals.spi1]
$ nucleus route tests/fixtures/route_simple.toml
[peripherals.usart2]
rx = "PA3"
tx = "PA2"
[peripherals.spi1]
miso = "PA6"
mosi = "PA7"
sck = "PA5"
Example: unroutable
# tests/fixtures/route_overconstrained.toml — tim5.channel3 pre-occupies PA2,
# USART2_TX's only candidate pin on the F446, leaving zero free candidates.
[peripherals.tim5]
channel3 = "PA2"
[peripherals.usart2]
$ nucleus route tests/fixtures/route_overconstrained.toml
tests/fixtures/route_overconstrained.toml: could not route (1 conflict):
error: unroutable [USART2_TX]: no free pin among candidates: PA2 (held by tim5.channel3)
See also
Dual-Backend HIL Substrate
Nucleus v1 stopped at "does this firmware build and flash." v2's whole second
half is "does this firmware actually behave correctly" — and answering that
needs a way to observe a running target. The M5 substrate, nucleus-hil,
gives every later test feature (declarative assertions, scripted tests,
lockstep) one common Backend trait so they never care whether the target is
a simulator or real silicon.
The Backend trait (nucleus-hil/src/backend.rs)
#![allow(unused)] fn main() { pub trait Backend { fn name(&self) -> BackendKind; fn start(&mut self, firmware: &FirmwareArtifact, check_report: &CheckReport) -> Result<(), HilError>; fn pin(&mut self, port: nucleus_db::Port, pin_num: u8) -> Result<bool, HilError>; fn register(&mut self, peripheral: &str, offset: u32) -> Result<u32, HilError>; fn read_mem32(&mut self, addr: u32) -> Result<u32, HilError>; fn write_mem32(&mut self, addr: u32, value: u32) -> Result<(), HilError>; fn await_itm_event(&mut self, timeout: Duration) -> Result<Option<ItmEvent>, HilError>; fn finish(&mut self) -> Result<(), HilError>; } }
Every test feature built on top — [[test]] assertions (M6), scripted tests
(M7), lockstep (M10) — only ever calls these seven methods. A new backend
(a different simulator, a different probe) only has to implement this trait
once to plug into everything that already exists.
Key supporting types:
BackendKind—Qemu|Hardware.FirmwareArtifact { elf, bin }— the build outputnucleus buildproduces.ItmEvent { port, data }— one decoded ITM stimulus-port packet.SampleTarget—Pin { port, pin_num }orRegisterChanged { peripheral, offset }, what lockstep's checkpoints sample.HilError—Preflight|ToolMissing|Io|Protocol|NotObservable.ToolMissing(e.g. noqemu-system-armonPATH, no probe attached) is treated as skip, not fail — see below.RunStatus—Completed|Skipped { reason }|Failed { error }.
Two backends, one fidelity gap
QEMU backend
Runs firmware under qemu-system-arm -M netduinoplus2 (the closest QEMU
machine to an F4 Nucleo board). No probe, no hardware needed — always
available in CI.
Empirically confirmed fidelity gap: this QEMU machine has no real GPIO
model. pin() reads/writes hit an unimplemented_device stub — writes are
silently dropped, reads always return 0. It does fully model USART2
bidirectionally via serial_hd(1), so UART-based tests work end-to-end on
QEMU. This is why M7's scripted UART loopback test passes identically on
both backends, while a GPIO toggle assertion is only meaningfully verified on
hardware — nucleus test's declarative engine still runs the GPIO assertion
on QEMU (it doesn't special-case this), but it can only ever observe 0/no
toggling there.
Hardware backend
Drives a real attached Nucleo board over SWD/SWO (via OpenOCD), reusing
nucleus-trace's ITM decode path (nucleus-itm) for await_itm_event. If
no board/probe is present, start() returns HilError::ToolMissing and
nucleus test reports that backend as skipped, not failed — a CI runner
with no hardware attached still gets a green run for the backends it can
exercise, never a false failure for the ones it can't.
Running tests: nucleus test
nucleus test [path] # both backends, every [[test]] block
nucleus test [path] --backend qemu # one backend only
nucleus test [path] --backend hardware
nucleus test [path] --test <name> # one test only
path defaults to . (expects stm32.toml and build/firmware.{elf,bin} —
run nucleus build first). For each declarative test, on each selected
backend: start(), evaluate the assertion (see Declarative
Tests), finish(). Each result prints as
PASS/FAIL/SKIP and is appended to tests/test_history.json (see Test
History). Exit code 1 if any test fails on any backend
that actually ran (skips never fail the run).
See also
- Declarative Tests — the
[[test]]assertion grammar this substrate evaluates - Scripted Tests — the device-agent SDK built on the same
Backendtrait - Lockstep Co-Execution — runs both backends side-by-side over this same trait to detect divergence
- Test History & CI — where
nucleus test's results end up
Declarative Tests
M6 lets you state a hardware behavior as one line in stm32.toml instead of
writing a test harness: [[test]] blocks with an assertion string, parsed
into a typed Assertion and evaluated against the HIL
backend trait — no host code to write for the common
cases (a pin toggling, a UART echoing, a trace event firing).
[[test]] schema
[[test]]
name = "uart_echo" # required
assertion = "USART2 echoes \"ping\" within 10ms"
timeout_ms = 100 # optional, default 1000
backend = "both" # optional: "qemu" | "hardware" | "both" (default)
type = "declarative" # optional, default — see scripted-tests.md for "scripted"
assertion and the other fields are independently optional in the parser,
but an entry with no assertion and no script is meaningless — fill at
least one.
The assertion grammar (nucleus-compiler/src/assertion.rs)
Four forms, hand-parsed (no parser-combinator dependency, consistent with the rest of the config parser):
| Form | Produces |
|---|---|
pin <PIN> toggles at <N>Hz ±<N>% | Assertion::PinToggles { pin, hz, tolerance_pct } |
pin <PIN> is <high|low> within <N>ms | Assertion::PinState { pin, level, within } |
<PERIPH> echoes "<text>" within <N>ms | Assertion::UartEcho { instance, payload, within } |
trace event "<pattern>" within <N>ms | Assertion::ItmEvent { pattern, within } |
This is the syntactic layer only — pin and instance are kept as raw
strings here; resolving them against the nucleus-db pin/peripheral tables
and actually sampling the backend happens when nucleus test runs the
assertion. A malformed assertion string is a parse error, reported the same
way a malformed TOML value is.
Examples of each form:
assertion = "pin PA5 toggles at 1Hz ±5%"
assertion = "pin PC13 is low within 10ms"
assertion = "USART2 echoes \"ping\" within 10ms"
assertion = "trace event \"boot complete\" within 500ms"
Evaluating against a backend
nucleus test resolves each assertion against whichever Backend
is selected:
PinToggles/PinState→ repeatedBackend::pin()samples.UartEcho→Backend::write_mem32/read_mem32against the peripheral's data register, or (on hardware) a UART transaction over the same channelnucleus-traceuses.ItmEvent→Backend::await_itm_event(), matching the decoded packet againstpattern.
Remember the QEMU fidelity gap:
QEMU's netduinoplus2 machine has no real GPIO model, so a PinToggles/
PinState assertion only meaningfully verifies on the hardware backend —
it still runs on QEMU (nothing special-cases it), but will read back 0
there. UART and trace-event assertions are fully verified on both.
Running
nucleus test # all backends, all [[test]] blocks in ./stm32.toml
nucleus test --test uart_echo # just this one
nucleus test --backend qemu # just this backend
See Dual-Backend HIL Substrate for the full CLI surface,
exit-code rules, and how results land in
tests/test_history.json.
See also
- Dual-Backend HIL Substrate
- Scripted Tests — for assertions too complex to express as one string
- Test History & CI
Scripted Tests
Declarative tests cover one-line behaviors. M7 is the
escape hatch for everything else: a real Rust #[test] function, driving the
target over the same Backend trait, for assertions too
stateful or sequential to express as one string (a multi-step protocol
exchange, a GPIO-then-UART interaction, anything with branching logic).
The device test-agent + RAM mailbox
Scripted tests don't poke arbitrary memory blind — they talk to a small,
fixed-protocol test-agent that runs on the target firmware itself, over a
RAM mailbox at a pinned address. The host SDK (nucleus-test-sdk) drives
that mailbox using only Backend::read_mem32/write_mem32 — no new
backend capability needed, any Backend impl supports scripted tests for
free.
Protocol (v1), mirrored byte-for-byte between nucleus-test-sdk's Rust
constants and the agent firmware's C header:
- Mailbox base:
0x2000_0000(start of SRAM, pinned by the agent's linker script). - Magic:
0x4E54_4167('NTAg'), written by the agent last, after clocks/USART/GPIO init — so a matching magic always implies a fully initialized mailbox. The host polls for it rather than reading once, to avoid a boot race against backends that halt the target the instant the host attaches. - Fields (offsets from base):
MAGIC,VERSION,SEQ,CMD,ARG0,ARG1,STATUS,RESP. - Status values:
IDLE(0) → host setsBUSY(1) → agent postsDONE(2) orERR(3). - Commands:
PING,SET_GPIO,READ_GPIO,READ_REG,UART_TX,UART_RX_POLL(returnsRX_NONE = 0xFFFF_FFFFwhen nothing's buffered).
The host SDK (nucleus-test-sdk)
#![allow(unused)] fn main() { use nucleus_test_sdk::AgentClient; let mut client = AgentClient::new(backend); // backend: &mut dyn Backend client.connect()?; // polls for magic + checks protocol version client.set_gpio(nucleus_db::Port::A, 5, true)?; let level = client.read_gpio(nucleus_db::Port::A, 5)?; client.uart_tx(b'p')?; let byte = client.uart_rx_poll()?; // Option<u8>: None if nothing buffered let reg = client.read_register(0x4002_0000)?; }
Every method blocks until the agent posts DONE/ERR or a fixed poll
timeout (500 ms) elapses, surfacing as SdkError::{BadMagic, VersionMismatch, AgentError, Timeout, Hil}.
[[test]] schema for scripted tests
[[test]]
name = "uart_loopback"
type = "scripted"
script = "uart_loopback" # the `cargo test` name nucleus-cli invokes
backend = "both" # "qemu" | "hardware" | "both" (default)
nucleus test runs cargo test <script> -- --exact --nocapture for each
selected backend label, the same --backend filter and per-test backend
field intersection that declarative tests use (a backend = "qemu" test is
never force-run on hardware just because --backend hardware wasn't passed
— it's simply skipped).
Worked example: UART loopback (both backends)
crates/nucleus-hil/tests/fixtures/agent_loopback/stm32.toml:
[device]
family = "STM32F411RE"
[peripherals.usart2]
tx = "PA2"
rx = "PA3"
[[test]]
name = "uart_loopback"
type = "scripted"
script = "uart_loopback"
backend = "both"
The test (crates/nucleus-hil/tests/e2e_scripted_uart.rs) starts each
backend, connects an AgentClient, sends bytes via uart_tx, and polls
uart_rx_poll until they come back. This is the test that empirically proved
QEMU's netduinoplus2 machine fully models USART2 — even though the same
machine has no real GPIO model, so a set_gpio/read_gpio round-trip in a
scripted test only meaningfully verifies on the hardware backend.
See also
- Dual-Backend HIL Substrate — the
Backendtrait this SDK is built on - Declarative Tests
- Test History & CI
Trace Dashboard & ITM Decoding
nucleus trace decodes ARM CoreSight ITM/SWO packets and streams them to the
trace dashboard. Getting data flowing end-to-end needs two pieces wired
together: a few lines of C in your firmware that configure the ITM/TPIU
peripherals and write to stimulus ports, and an OpenOCD session that captures
the resulting SWO byte stream and forwards it to nucleus trace.
Firmware (ITM) --SWO--> ST-Link --USB--> OpenOCD --TCP--> nucleus trace --WebSocket--> dashboard
Firmware side: the CoreSight register setup
CMSIS headers (pulled in by stm32f4xx_hal.h) define CoreDebug, TPI, and
ITM as memory-mapped structs. Enabling SWO output is six register writes:
#include "stm32f4xx_hal.h"
/* Configure SWO output. `core_hz` is [device].clock_hz; `swo_hz` must match
* [trace].swo_freq in stm32.toml (and the value passed to
* `nucleus trace --openocd`). */
void itm_init(uint32_t core_hz, uint32_t swo_hz)
{
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* 1. enable tracing */
TPI->SPPR = 2; /* 2. SWO protocol = NRZ/UART */
TPI->ACPR = (core_hz / swo_hz) - 1; /* 3. SWO baud rate divisor */
ITM->LAR = 0xC5ACCE55; /* 4. unlock the ITM registers */
ITM->TCR |= ITM_TCR_ITMENA_Msk; /* 5. enable the ITM */
ITM->TER |= 1UL; /* 6. enable stimulus port 0 (log text) */
}
Call itm_init(...) once, after SystemClock_Config(), alongside the
generated Nucleus_Init().
Logging text (port 0)
nucleus-trace reassembles single-byte writes to stimulus port 0 into UTF-8
log lines, splitting on \n. Write one byte at a time, blocking while the
port's FIFO is full:
void itm_log(const char *s)
{
while (*s) {
while (ITM->PORT[0].u32 == 0) { } /* wait for FIFO space */
ITM->PORT[0].u8 = (uint8_t)*s;
s++;
}
}
A trailing \n flushes the line to the dashboard's log panel:
itm_log("system init complete\n");
Tracing typed variables (ports 1-7)
Each [[trace.variables]] entry in stm32.toml names a port and a type
(f32, u16, u32, or i32). The access width of the write to
ITM->PORT[n] determines the packet size nucleus-itm reports, so match the
width to the type: 4 bytes for f32/u32/i32, 2 bytes for u16.
static inline void itm_write32(uint8_t port, uint32_t value)
{
if (!(ITM->TCR & ITM_TCR_ITMENA_Msk) || !(ITM->TER & (1UL << port)))
return;
while (ITM->PORT[port].u32 == 0) { }
ITM->PORT[port].u32 = value;
}
static inline void itm_write16(uint8_t port, uint16_t value)
{
if (!(ITM->TCR & ITM_TCR_ITMENA_Msk) || !(ITM->TER & (1UL << port)))
return;
while (ITM->PORT[port].u16 == 0) { }
ITM->PORT[port].u16 = value;
}
To trace an f32 on port 1, write its bits as u32:
float temperature = read_temperature();
uint32_t bits;
memcpy(&bits, &temperature, sizeof(bits));
itm_write32(1, bits);
Remember to enable each port you use — add it to itm_init():
ITM->TER |= (1UL << 1); /* enable port 1 for the temperature trace */
OpenOCD side
OpenOCD captures SWO from the ST-Link and forwards it to a TCP port via
tpiu config internal. nucleus trace --openocd <telnet_addr> sends this
sequence for you over OpenOCD's telnet console (default port 4444):
tpiu config internal :<trace_port> uart off <core_hz> <swo_hz>
itm ports on
<trace_port> is the TCP port nucleus trace --trace-tcp connects to, and
<core_hz>/<swo_hz> must match the values passed to itm_init() in
firmware. If you'd rather configure OpenOCD by hand (e.g. to debug a version
mismatch), connect with telnet localhost 4444 and run the same two commands.
Putting it together
# stm32.toml
[trace]
enabled = true
swo_freq = 2_000_000
[[trace.variables]]
name = "temperature"
port = 1
type = "f32"
nucleus trace --trace-tcp 127.0.0.1:3344 --openocd 127.0.0.1:4444 \
--config stm32.toml
See CLI Usage for the full flag reference, and open
the dashboard via the VS Code command Nucleus: Open Trace Dashboard (or
extension/dist/index.html standalone) to see the log lines and temperature
chart update live.
The dashboard (React + Canvas)
The VS Code extension's webview hosts a React dashboard that connects to
nucleus trace's WebSocket and renders two live views, both pure display —
per the project's "extension has zero business logic" rule, every decode
step (packet framing, DWT/ITM disambiguation, variable typing) happens in
nucleus-itm/nucleus-trace; the dashboard only draws JSON events it's
handed:
- Log panel — port-0 lines, newest at the bottom.
- Variable chart — a
<canvas>line plot per[[trace.variables]]entry, keyed by name, updating as each decoded value event arrives.
The same dashboard also renders nucleus history --graph's output as a bar
chart — see Test History & CI
for what that shows and what was descoped from the original design.
See also
- Test History & CI — the dashboard's other data source
- Lockstep Co-Execution — decodes ITM checkpoints with this same
Translator/[trace.variables]config
Test History & CI
Nucleus v2's history is deliberately minimal: an earlier design called
for a content-addressed .nucleus/ ledger with version hashing and signed
verification reports. That was built, then ripped out — the team decided the
complexity wasn't earning its keep for what's actually needed (a simple
record of what passed) and replaced it with one flat JSON file. If you're
looking for hashing, artifacts, or a version store: there isn't one, on
purpose.
tests/test_history.json
nucleus test creates this file on first run, then appends one run
every time after. No hashing, no content-addressing — just an append-only
JSON file that travels with the repo (commit it, or don't; it's a normal
file).
{
"runs": [
{
"timestamp": 1750000000,
"tests": [
{ "name": "uart_echo", "backend": "qemu", "status": "pass", "detail": "echoed within 8ms" },
{ "name": "uart_echo", "backend": "hardware", "status": "skip", "detail": "no probe attached" }
]
}
]
}
One TestEntry per assertion-on-a-backend (so a single [[test]] block run
on both backends produces two entries in the same run).
nucleus history
nucleus history [path] # human table, oldest run first
nucleus history [path] --last N # only the N most recent runs
nucleus history [path] --graph # per-run pass/fail/skip as JSON
$ nucleus history
#1 2025-06-15 15:06:40Z 1 passed, 0 failed, 1 skipped
--graph is the dashboard's/export data source — always valid JSON, even
against an empty history:
$ nucleus history --graph
{
"schema": "nucleus.history.v1",
"runs": [
{ "timestamp": 1750000000, "pass": 1, "fail": 0, "skip": 1 }
]
}
nucleus show [run]
Every assertion result for one run — defaults to the latest, or pass the
1-based number nucleus history listed.
$ nucleus show
run #1
recorded 2025-06-15 15:06:40Z
summary 1 passed, 0 failed, 1 skipped
tests:
PASS uart_echo [qemu]: echoed within 8ms
SKIP uart_echo [hardware]: no probe attached
The dashboard's history bar chart
The VS Code extension's React dashboard (see Trace Dashboard & ITM
Decoding) renders nucleus history --graph's output as a bar
chart — one bar per run, stacked pass/fail/skip. Per the "extension has zero
business logic" rule, all counting happens in nucleus-history/Rust; the
TypeScript side only draws what it's given. Trend lines, CPU/timing charts,
and a clickable trend-graph link were all part of the original M9 design and
were descoped along with the ledger — the bar chart is what shipped.
CI: the composite action
.github/actions/nucleus/action.yml wraps check → build → test (QEMU
always, hardware optionally) → a PR summary, with each stage degrading
gracefully if the previous one's inputs are off.
- uses: harshverma27/nucleus/.github/actions/nucleus@main
with:
config: stm32.toml
build: "true" # nucleus build (needs the ARM toolchain)
run_tests: "true" # nucleus test, after a successful build (default)
hardware: "false" # also run the hardware leg (needs a self-hosted runner + board)
| Input | Default | Description |
|---|---|---|
config | stm32.toml | Path to the config to validate. |
build | false | Also run nucleus build. |
run_tests | true | Run nucleus test after a successful build (QEMU always; hardware only if hardware: true). Requires build: true. |
hardware | false | Also run the hardware test leg. Off by default — the report records it as skipped, never failed, so a hosted runner with no board stays green. |
version | * | nucleus-cli version from crates.io, or git to build from main. |
comment | true | Post the summary as a PR comment. |
Outputs: conflicts, firmware-size, qemu-passed, hardware-passed. The
PR comment (and the workflow's Job Summary, visible even without comment
permissions) reports per-backend pass/fail counts and uploads
tests/test_history.json as the nucleus-test-history artifact. A signed,
cryptographic verification report was part of the original M9 design and was
descoped with the ledger — the plain-text summary + raw history artifact is
what ships.
See also
Lockstep Co-Execution
The crown of v2: run the same firmware on both HIL backends at once and detect the moment they disagree. Passing on QEMU only proves your logic is correct in an idealized model — lockstep is how you find out a timing assumption, a register quirk, or a peripheral behavior QEMU doesn't model faithfully is masking a real bug that only shows up on silicon.
How it works (nucleus-hil/src/lockstep.rs)
Sync points are ITM events — both backends already expose
Backend::await_itm_event, so no new backend capability is needed.
Observables are whatever [trace.variables] decodes a port's bytes into
(the same Translator the trace daemon and dashboard use): a traced
variable's value already arrives inside the ITM packet that names its port.
- Collect —
collect(backend, vars, timeout_per_event, total_timeout)drives one already-started backend withawait_itm_eventuntil it times out or stops producing events, decoding each event into aCheckpoint { itm_event, decoded }.decodedisNonefor port-0 log lines and unconfigured ports — they still compare on the raw event bytes. - Compare —
compare(sim_trace, silicon_trace)walks bothObservationTraces checkpoint by checkpoint and returns aDivergenceReport:Agreement { checkpoints_compared }— same length, same content at every index.Diverged { first_checkpoint, observable, sim_value, silicon_value }— the first index where they disagree. A differing decoded value is reported by variable name (friendlier); a differing raw event, or one trace running out before the other, is reported as"itm_event".
This is a bare diff, by design — no inference, no root-cause engine.
Explaining why two traces diverged (was it a timing race? a register model
gap? a real bug?) is explicitly deferred past v2; nucleus lockstep --explain exists as a flag but only prints a note that it isn't implemented
yet, never silently swallowed.
Running it: nucleus lockstep
nucleus lockstep [path] # both backends, compare
nucleus lockstep [path] --explain # same, plus an "not implemented, see v3" note on divergence
path defaults to . (expects stm32.toml and build/firmware.{elf,bin}
— run nucleus build first). Sequence: run nucleus check (abort on any
conflict, same as nucleus test) → start both backends (a ToolMissing
backend — e.g. no QEMU binary, no probe attached — is reported as
skipped, not a hard failure) → collect from each → compare the two
that started.
If fewer than two backends actually started, lockstep prints which leg (if
any) ran and exits 0 — "no comparison possible" is not itself a failure.
With two legs:
$ nucleus lockstep
agreement across 12 checkpoint(s) (Qemu vs Hardware)
$ nucleus lockstep
diverged at checkpoint 4: speed Qemu=10 Hardware=12
Exit code: 0 on agreement (or fewer than two legs), 1 on a detected
divergence — the gating signal for "trust this build before flashing it."
Why this catches what single-backend testing can't
Recall the QEMU fidelity gap:
the netduinoplus2 machine fully models USART2 but has no real GPIO model.
A test that only runs on QEMU can pass cleanly while quietly never
exercising real GPIO behavior at all. Lockstep doesn't fix the simulator's
fidelity gap — it makes that gap visible: a divergence at a
GPIO-driven checkpoint is the system telling you "QEMU and hardware
disagree here," which is exactly the signal a model gap (or a real bug)
produces.
See also
- Dual-Backend HIL Substrate — the
Backendtrait and the fidelity gap this builds on - Declarative Tests, Scripted Tests
- Trace Dashboard & ITM Decoding — the same
Translator/[trace.variables]config lockstep decodes checkpoints with
CI integration
Because nucleus check exits non-zero on any conflict, validating your
stm32.toml in CI is a one-liner. Nucleus ships a reusable composite action
that installs the CLI, runs check → build → test (QEMU always,
hardware optionally), and posts a PR summary with per-backend results — see
Test History & CI for the full per-backend report format.
Quick start: copy-paste nucleus.yml
Drop this into .github/workflows/nucleus.yml:
name: nucleus
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
pull-requests: write # needed to post the PR summary comment
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: harshverma27/nucleus/.github/actions/nucleus@main
with:
config: stm32.toml
build: "true" # also compile firmware (needs the ARM toolchain)
# run_tests defaults to "true" once build is on: runs `nucleus test --backend qemu`
# hardware: "true" # also run the hardware leg (self-hosted runner + board only)
nucleus init scaffolds an equivalent workflow for you.
Action inputs
| Input | Default | Description |
|---|---|---|
config | stm32.toml | Path to the config to validate. |
build | false | Also run nucleus build (requires arm-none-eabi-gcc + cmake in the runner). |
run_tests | true | Run nucleus test after a successful build (QEMU always; hardware only if hardware: true). Requires build: true. |
hardware | false | Also run the hardware test leg (needs a self-hosted runner with a connected board + OpenOCD). Off by default — recorded as skipped, never failed, so a hosted runner with no board stays green. |
version | * | nucleus-cli version to install from crates.io, or git to build from main. |
comment | true | Post the summary as a PR comment (needs pull-requests: write). |
Action outputs
| Output | Description |
|---|---|
conflicts | Number of conflicts nucleus check reported. |
firmware-size | Size of build/firmware.bin in bytes (when build: true). |
qemu-passed | Assertions passed on the QEMU backend (empty if tests didn't run). |
hardware-passed | Assertions passed on the hardware backend (empty if the leg was skipped). |
The action also writes the summary to the workflow run's Job Summary, so it
is visible even without comment permissions, and uploads
tests/test_history.json as the nucleus-test-history artifact whenever
tests ran.
versiondefaults to*(the latest crates.io release). Pin a specific release (e.g.version: 0.1.0) for reproducible CI, or setversion: gitto build the CLI frommaininstead of crates.io.
Doing it by hand
If you'd rather not use the action:
- uses: dtolnay/rust-toolchain@stable
- run: cargo install nucleus-cli --locked
- run: nucleus check