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.

Watch the demo video

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

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 .vsix attached to a release with Extensions: Install from VSIX….
  • From source:
    cd extension
    npm install
    npm run build
    
    Then press F5 in VS Code to launch an Extension Development Host.

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

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 target but st-info --probe finds 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 --probe reports an unexpected chipid/dev-type: confirm your board is actually a NUCLEO-F446RE (chip ID 0x421). Nucleus is built and validated against the F446RE only — other F4 boards (e.g. an F411RE, chip ID 0x431) 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 a nucleus build that includes the scaffold fix (nucleus init should create src/stm32f4xx_hal_conf.h, src/stm32f4xx_it.c, and STM32F446RETx_FLASH.ld). Re-run cargo install --path crates/nucleus-cli --locked --force from an updated checkout if these files are missing.

  • STM32CUBE_PATH errors (stm32f4xx_hal.h: No such file or directory): confirm echo $STM32CUBE_PATH points 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-gcc and an STM32CubeF4 (HAL) checkout pointed at by STM32CUBE_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

InputDefaultDescription
configstm32.tomlPath to the config to validate.
buildfalseAlso 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.
commenttruePost the summary as a PR comment (needs pull-requests: write).

Action outputs

OutputDescription
conflictsNumber of conflicts nucleus check reported.
firmware-sizeSize 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.

version defaults to * (the latest crates.io release). Pin a specific release (e.g. version: 0.1.0) for reproducible CI, or set version: git to build the CLI from main instead 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