Bare-metal firmware for the STM32 NUCLEO-F411RE — written from the reset vector up with no HAL, no vendor libraries, no IDE, and no dynamic allocation. Every peripheral is driven by direct memory-mapped register access, on a stack of layered drivers, libs, and apps that I built and tested incrementally.
This repo doubles as a learning project and a portfolio piece. It exercises the hardware/software stack of a real Cortex-M4F system end to end:
- Boot & runtime: custom startup, vector table, hardened linker script with stack-overflow detection, fault handlers with register dumps, hardware FPU, 100 MHz PLL clock setup.
- Drivers: GPIO, EXTI, SysTick, multi-instance UART (DMA TX + IRQ/DMA RX), SPI master on all five instances (polled + DMA), generic DMA, general-purpose timers (basic / PWM / µs delay), independent watchdog (IWDG), hardware CRC, internal flash read/write — all implemented against the reference manual, not a SDK.
- Middleware (
lib/): image-header parser and ECDSA-P256 / SHA-256 crypto primitives consumed by the in-flight bootloader track. - Apps: an interactive CLI over UART (DMA-buffered printf, tab completion, command history, ISR-safe deferred dispatch) plus standalone peripheral demos.
- Host tooling: Python utilities that produce signed firmware images
(
tools/keygen.py,tools/sign_image.py) using a single source of truth for the on-flash format that's mirrored in C. - Three-layer test pyramid (~400+ tests total):
- Pure unit tests — CLI engine, string utils, calc-only helpers (PLL solver, baud divisor, PWM prescaler, IWDG timeout solver…).
- Driver tests — host-side, with a fake-peripheral header pattern that
shadows
stm32f4xx.hso unmodified driver code runs against in-memory register stubs and the test asserts on the resulting register state. - Hardware-in-the-loop — Unity on the target itself (UART/GPIO/EXTI/SPI loopback, FPU, RCC/Timer/SysTick accuracy), with performance baselines and regression detection. Cross-language round-trip tests verify the Python signing tools against the C parser.
- CI on every PR: GitHub Actions runs host tests + cross-app firmware builds + a self-hosted Raspberry Pi runner that flashes a real NUCLEO and executes the HIL suite. Both Unity and HIL results show up in the GitHub Test Summary tab.
- Agentic development infra: a Tailscale-fronted MCP server exposes the HIL rig to Claude Code so it can build, flash, and test from anywhere; a worktree workflow lets multiple agents work on independent issues in parallel without touching each other's branches.
| Topic | Where to look |
|---|---|
| Driving STM32 peripherals from the reference manual | drivers/src/, docs/wiki/drivers/ |
| Layered, ISR-safe driver design | docs/wiki/architecture.md |
| Testing register-banging code on a host | docs/wiki/testing.md, tests/driver_stubs/ |
| Pure-function extraction for testability | *_calc.h / *_calc.c pairs in drivers/inc/ |
| Hardware-in-the-loop testing & perf baselines | scripts/run_hil_tests.py, docs/wiki/testing.md |
| Self-hosted CI with a real board | .github/workflows/ci.yml, docs/wiki/ci.md |
| Image signing & verification (in progress) | tools/, lib/img/, lib/crypto/, docs/wiki/plans/001-bootloader-and-security.md |
| Multi-agent development workflow | scripts/worktree_new.sh, docs/wiki/agents.md |
Multi-phase plans live under docs/wiki/plans/:
- 001 — Bootloader & embedded security (in progress) — custom bootloader at sector 0, ECDSA-P256 signed images, A/B slots with rollback-on-fail, anti-rollback counter, OTA over UART, RDP option-byte protection. Crypto primitives, image format, and host signing tooling have already landed.
- 002 — Inter-board comms + DSP baseband (proposed) — two NUCLEOs talking over UART/SPI/I²C with framing, retransmit, and benchmarks; then a software BPSK modem with FEC over a wired analog link, with BER-vs-SNR curves.
Driver work and apps are tracked in docs/wiki/roadmap.md.
drivers/ Peripheral drivers (GPIO, UART, SPI, DMA, Timer, EXTI, SysTick, IWDG, CRC, Flash)
utils/ Reusable utilities (CLI engine, DMA-buffered printf, string utils)
lib/ Middleware libs (no main, no register access) — img, crypto, …
apps/
basic/ Standalone peripheral demos (blink, button, PWM, IWDG, CRC, …)
cli/ Interactive CLI app (default build target) + HIL test harness
tools/ Host-side utilities (image signing, future OTA)
tests/ Host unit tests (Unity, native gcc)
scripts/ Repo automation (HIL runner, MCP server, worktrees)
docs/wiki/ Persistent project knowledge base
The wiki (docs/wiki/index.md) is the source of truth for architecture, drivers, testing, CI, and plans.
git clone --recurse-submodules https://github.com/ViniBR01/stm32-bare-metal.git
cd stm32-bare-metal
make test # host unit tests (no board needed)Plan 001 Phase 1.5 reserves sector 0 for a custom bootloader and links every
app at slot A (0x08010000). A board with a blank or factory sector 0
won't run anything from this repo until the bootloader is in place. Each
NUCLEO needs the following step once:
make flash-bootloaderThat target builds the bootloader, programs sector 0, and reads back the
first two flash words to confirm the image is valid. Pin to a specific
ST-LINK probe with BOARD=ci|dev or HLA_SERIAL=<serial> if you have
multiple boards plugged in:
make flash-bootloader BOARD=dev
make flash-bootloader HLA_SERIAL=066CFF3833554B3043154235The wrapper script (scripts/flash_bootloader.py)
refuses to run when STM32_BARE_METAL_CI=1 is set, so CI runners can't
accidentally reprogram sector 0. Recovery if you ever brick the board is
documented in
docs/wiki/plans/001-bootloader/bootloader-skeleton.md.
DANGER — RDP Level 2 is permanent. Plan 001 Phase 1.10 ships
scripts/set_rdp.py, which can flip the chip into readout-protection Level 1 (recoverable, mass-erases on regression) or Level 2 (permanent, chip becomes a paperweight). The script gates--level 2behindRDP_L2_BURN_BOARD=1and refuses any write underSTM32_BARE_METAL_CI=1or against the CI ST-LINK serial. Read docs/wiki/plans/001-bootloader/rdp.md before running it on any board you want to keep.
make # build the default CLI app (linked at slot A)
make flash # flash signed image to slot A via OpenOCD
make serial # open the serial console at 115200 baudBy default these target the dev board (BOARD=dev) so manual commands
never disturb the CI board. With multiple ST-LINKs attached, pick the board
explicitly — the same BOARD=/HLA_SERIAL= knobs now work on flash,
serial, and debug, not just flash-bootloader:
make flash BOARD=ci # flash the CI board instead
make flash HLA_SERIAL=066... # pin a raw ST-LINK serial (overrides BOARD)
make serial BOARD=dev # console pinned to the dev board's portBoard roles and their ST-LINK serials live in one place — scripts/boards.json — read by the Makefile and every HIL script. To register or swap a board, edit that file only.
You should see the bootloader's BL: jumping to slot A @ 0x08010200 line
followed by the app's banner. Type help in the CLI prompt to list
commands (LED control, SPI throughput sweep with DWT-cycle-counter timing,
uptime, and so on). New commands plug into the dispatch table in
apps/cli/cli_commands.c.
Other useful targets:
make all # build every app
make EXAMPLE=blink_pwm # build a specific app
make EXAMPLE=cli_simple SLOT=B # build for slot B (0x08040000)
make EXAMPLE=blink_pwm PROFILE=standalone # legacy map, unsigned, at 0x08000000
make flash EXAMPLE=iwdg_basic # flash a specific app to slot A
make flash SLOT=B # flash the slot-B image at 0x08040000
make debug EXAMPLE=cli_simple # OpenOCD + GDB attached (pinned to BOARD)
make help # full target listIf the board boots only to the bootloader and logs something like:
BL: rollback ver=1 < floor=2
BL: both slots failed verify
the board is not bricked — the anti-rollback floor is rejecting your image.
The Phase 1.9 bootloader derives a floor from the highest monotonic_counter
ever committed, and an anti-rollback or OTA HIL run leaves that floor elevated.
A plain make flash signs images at the default IMAGE_VERSION=1, which is
then below the floor. Reset the floor by erasing the slot metadata, then
re-flash:
make sanitize-board # erase metadata sectors (floor -> 0) on the dev board
make flash
make serial(make sanitize-board honors BOARD=/HLA_SERIAL= like the other targets and
is the same step CI runs at the start of every HIL job.)
A single PROFILE= knob selects the memory map an app builds for:
PROFILE=bootloader(default) — links at an A/B slot (SLOT=A→0x08010000,SLOT=B→0x08040000) and signs the image so the bootloader will verify and boot it. This is the Plan 001 map.PROFILE=standalone— links the full 512 KB flash at0x08000000, unsigned, for flashing directly with a debugger (no bootloader). The pre-bootloader map, kept as a first-class build path.
Apps stay profile-agnostic, so any app builds under any profile. Note that
bootloader-profile images are position-dependent: a slot-A image will not
run at slot B (the linker bakes the base address into the code and vector
table), so the build produces a separate _b artifact per slot. See
docs/wiki/decisions/003-app-target-profiles.md.
make clean && make EXAMPLE=cli_simple HIL_TEST=1
make flash EXAMPLE=cli_simple HIL_TEST=1
python3 scripts/run_hil_tests.pyThe HIL build links Unity onto the target and runs the full on-board suite (UART/GPIO/EXTI/SPI loopback, FPU, RCC/Timer/SysTick accuracy) over the serial port, validating perf against checked-in baselines.
arm-none-eabi-gcc(any reasonably recent GCC; tested with the Ubuntu and Homebrew distributions)openocdfor flashing/debugging via the on-board ST-LINK- Python 3 for the host tools and HIL runner
sudo apt install gcc-arm-none-eabi openocd python3 # Ubuntu
brew install --cask gcc-arm-embedded && brew install openocd python # macOSMIT — see LICENSE.