Bare-Metal STM32 Driver Stack
A from-scratch driver stack for the STM32F401RE (Cortex-M4) in C — no HAL, no CubeMX, no vendor libraries. Five milestones from a hand-written C runtime to an I2C sensor read, then extended with SysTick, an 84 MHz clock tree, and DMA — every peripheral verified against a physical stimulus.
- C
- ARM Cortex-M4
- STM32
- Bare-Metal
- I2C
- DMA
- Logic Analyzer
A bare-metal driver stack for the STM32F401RE, written in C with no HAL, no CubeMX-generated code, and no vendor libraries — every register write derived directly from RM0368 and the datasheet. The point wasn't to make an LED blink. It was to build the layer underneath the layer most embedded projects start from — the linker script, the vector table, the C runtime — and then to prove each piece works rather than assume it does because the output looked right.
The foundation came first: a hand-written linker script, startup code, and my own C runtime (initializing memory before main), verified with GDB plus a poison-and-restore negative control — deliberately corrupting state to confirm the test could actually catch a failure. Then register-level GPIO (timing measured on a logic analyzer), UART transmit (verified by disassembling the flashed ELF and decoding the wire), interrupt-driven UART receive with a lock-free ring buffer, and finally an I2C driver reading a real BME280 environmental sensor — verified by breathing on it and watching humidity spike and decay back to baseline, a shape no stuck register can fake.
The recurring bug in the I2C work was the useful kind: a poll predicate true both before and after a conversion, returning stale data instantly, and only 'working' because unrelated debug prints delayed it. The fix is a principle — wait for the edge, not the level, because the edge is the event. And a wedged bus with no error flag was diagnosed entirely from register state, by reasoning that the slave had physically ACKed (which no dead wire can fake), ruling out hardware without touching the bench.
After shipping the five milestones, I kept extending it: a SysTick millisecond time base (replacing the busy-wait, with non-blocking sampling), a clock-tree bump from 16 to 84 MHz via the PLL, and DMA-driven UART transmit. The clock change exposed how three peripheral constants were all silently derived from the old clock and had to be rederived together; the DMA work turned on a single non-obvious field (CHSEL) whose reset value silently routes the wrong request line with no error at all. SPI was dropped deliberately — the sensor breakout doesn't bring the needed pin to the header, so it wasn't worth soldering to the chip package for marginal gain.
Everything was built, flashed, and debugged from a Raspberry Pi 5 over SSH, with a logic analyzer for verification. The through-line across all of it: a correct-looking output is never evidence that the mechanism producing it is correct. You prove it with controls, edges, disassembly, and physical stimulus — and sometimes by checking for what should be absent (like the DMA transfer showing zero inter-byte stretch, proving the controller never once arrived late) rather than only what should be present.