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.
Enabling ITM Trace
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.
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 (and optionally build), and posts a PR
summary with the conflict count and firmware size.
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)
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). |
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). |
The action also writes the summary to the workflow run's Job Summary, so it is visible even without comment permissions.
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