Skip to content

Repository files navigation

stm32-bare-metal

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.h so 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.

What you can learn from this repo

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

Active tracks

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.

Layout

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.

Quick start

git clone --recurse-submodules https://github.com/ViniBR01/stm32-bare-metal.git
cd stm32-bare-metal

make test                    # host unit tests (no board needed)

Preparing a board (one-time, per NUCLEO)

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-bootloader

That 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=066CFF3833554B3043154235

The 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 2 behind RDP_L2_BURN_BOARD=1 and refuses any write under STM32_BARE_METAL_CI=1 or against the CI ST-LINK serial. Read docs/wiki/plans/001-bootloader/rdp.md before running it on any board you want to keep.

Day-to-day flow

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 baud

By 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 port

Board 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 list

Recovering from a rejected image (rollback floor)

If 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.)

Build profiles

A single PROFILE= knob selects the memory map an app builds for:

  • PROFILE=bootloader (default) — links at an A/B slot (SLOT=A0x08010000, SLOT=B0x08040000) 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 at 0x08000000, 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.

Hardware-in-the-loop

make clean && make EXAMPLE=cli_simple HIL_TEST=1
make flash EXAMPLE=cli_simple HIL_TEST=1
python3 scripts/run_hil_tests.py

The 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.

Toolchain

  • arm-none-eabi-gcc (any reasonably recent GCC; tested with the Ubuntu and Homebrew distributions)
  • openocd for 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   # macOS

License

MIT — see LICENSE.

About

Toolchain and examples of bare metal programming for the STM32 NUCLEO-F411RE evaluation board.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Contributors

Languages