Skip to content

Bare-Metal ARM Cortex-M (STM32)

Every board in Level 1 and 2 ran on top of a framework — Arduino-ESP32 or ESP-IDF — that booted FreeRTOS, set up the clock tree, and handed you a setup()/loop() or app_main() already running in a sane environment. This module strips all of that away. On an STM32 (a Cortex-M0/M3/M4/M7 chip from ST), you'll write the code that runs before main(), understand exactly what memory looks like at reset, and talk to peripherals as raw memory-mapped registers with no library in between. The toolchain is arm-none-eabi-gcc instead of the Arduino/IDF build system, and the flash tool is OpenOCD or ST-Link instead of esptool.py.

There is no STM32 board wired up for this course, so nothing in this module was flashed to real silicon. What follows is verified two ways: the C is technically accurate against the ARM Cortex-M architecture reference and ST's reference manuals, and every piece of portable logic (bit math, framing, state machines) is actually compiled and run with gcc on this machine — called out explicitly wherever that happens.

What "bare-metal" removes

Arduino's digitalWrite(13, HIGH) compiles down to a handful of instructions that: look up which port pin 13 maps to, compute the bit position, and finally write to a GPIO register. Bare metal skips straight to that last step — you write to the register yourself:

#include <stdint.h>

#define GPIOA_BASE   0x40020000UL
#define RCC_BASE     0x40023800UL

#define RCC_AHB1ENR  (*(volatile uint32_t *)(RCC_BASE + 0x30))
#define GPIOA_MODER  (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_ODR    (*(volatile uint32_t *)(GPIOA_BASE + 0x14))

void led_init(void) {
    RCC_AHB1ENR |= (1u << 0);          /* enable GPIOA clock */
    GPIOA_MODER &= ~(0x3u << (5 * 2)); /* clear mode bits for PA5 */
    GPIOA_MODER |=  (0x1u << (5 * 2)); /* set PA5 to general-purpose output */
}

void led_toggle(void) {
    GPIOA_ODR ^= (1u << 5);
}

Every peripheral on the chip — GPIO, UART, timers, ADC — is a block of registers at a fixed address in the memory map (given in the reference manual's memory map table, not the datasheet). volatile is not optional here: without it, the compiler is free to assume GPIOA_ODR never changes behind its back and can cache the read or eliminate what looks like a redundant write. That single keyword is the boundary between C that models memory and C that models hardware.

Enable-before-configure, and why the datasheet order matters

RCC_AHB1ENR gates the clock to GPIOA. Cortex-M peripherals are clock-gated to save power at reset — writing to GPIOA_MODER before enabling its clock in RCC_AHB1ENR is undefined in practice: on some silicon revisions the write is silently dropped, on others it stalls the bus. This ordering bug is one of the most common bare-metal traps, and it never shows up in a framework because the HAL's GPIO_Init() always enables the clock for you.

The vector table and reset entry

A Cortex-M chip doesn't run a bootloader that jumps to main() the way a PC does. At reset, the CPU reads exactly two 32-bit words from address 0x0: the initial stack pointer, then the reset handler's address. It loads SP with the first and jumps to the second — no code runs before that.

extern uint32_t _estack;      /* from the linker script, module 3-02 */
void Reset_Handler(void);
void Default_Handler(void);

void NMI_Handler(void)        __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void)  __attribute__((weak, alias("Default_Handler")));

__attribute__((section(".isr_vector")))
const void *vector_table[] = {
    &_estack,           /* [0]  initial SP */
    Reset_Handler,      /* [1]  reset */
    NMI_Handler,         /* [2] */
    HardFault_Handler,   /* [3] */
    /* ... remaining exception and IRQ slots ... */
};

Default_Handler is a weak alias so any vector you don't implement still resolves to something (typically an infinite loop) instead of a link error or a jump to garbage. This table lives at address 0 via the .isr_vector linker section — module 3-02 covers the linker script and startup code (Reset_Handler's body: copy .data, zero .bss, call main) in full.

Simulating register logic in portable C

The bit-manipulation patterns above don't need real hardware to verify — only the addresses do. This mock harness models the same MODER/ODR read-modify-write logic against a plain array standing in for memory, and was compiled and run with gcc on this machine:

#include <stdio.h>
#include <stdint.h>
#include <assert.h>

static uint32_t moder, odr;

static void set_mode_output(uint32_t *moder_reg, unsigned pin) {
    *moder_reg &= ~(0x3u << (pin * 2));
    *moder_reg |=  (0x1u << (pin * 2));
}

int main(void) {
    moder = 0xFFFFFFFFu;   /* reset state: all pins analog/reset mode */
    set_mode_output(&moder, 5);
    assert(((moder >> (5 * 2)) & 0x3u) == 0x1u);   /* PA5 now output */
    assert(((moder >> (4 * 2)) & 0x3u) == 0x3u);   /* PA4 untouched */

    odr ^= (1u << 5);
    assert(odr == (1u << 5));
    odr ^= (1u << 5);
    assert(odr == 0);

    printf("register model OK: MODER=0x%08x ODR=0x%08x\n", moder, odr);
    return 0;
}
$ gcc -Wall -o regtest regtest.c && ./regtest
register model OK: MODER=0xfffff7ff ODR=0x00000000

This confirms the bit math is correct in isolation; it says nothing about whether 0x40020000 is really GPIOA on a given STM32 part — that comes only from the reference manual for that exact chip.

Traps specific to bare-metal register work

  • Read-modify-write on shared registers isn't atomic. GPIOA_ODR |= bit is a load, an OR, and a store as three separate instructions. If an ISR also touches ODR between the load and the store, the ISR's change is lost. STM32 GPIO provides BSRR (bit set/reset register) specifically to make single-bit set/clear atomic — always prefer it over read-modify-write on ODR once an ISR is in the picture (module 3-06).
  • Wrong peripheral base address compiles cleanly. 0x40020000 vs 0x40020400 (GPIOA vs GPIOB on many STM32F4 parts) is just a number to the compiler — the bug only shows up as "the wrong LED blinks" or "nothing happens" on real hardware.
  • Forgetting the clock enable produces a write that appears to succeed in code but has no effect on the pin — a classic silent bug with no compiler warning.
  • Bit-width mismatches: some registers pack multiple 2-bit or 4-bit fields (MODER, AFR); a shift computed with the wrong field width silently corrupts an adjacent pin's configuration.

How It Actually Works

Why the CPU reads exactly two words from address 0x0: this isn't convention, it's wired into the Cortex-M core's reset logic itself. On Cortex-M, address 0x0 is architecturally defined to alias the start of the vector table (STM32 further lets you remap this via the SYSCFG_MEMRMP register, so it can point at flash, system boot ROM, or SRAM depending on boot pin state), and the core's hardware reset sequence — before fetching a single instruction — performs two fixed memory reads at that address and 0x4, loads them directly into the MSP (Main Stack Pointer) and PC registers, and only then begins normal instruction fetch/decode/execute at whatever PC now points to. There is no instruction that performs this load — it's dedicated reset-sequencing hardware, which is exactly why a wrong value at word 0 (a bad _estack) or word 1 (a misaligned Reset_Handler address, which must have its Thumb bit set) hangs the chip before any code, even a debugger breakpoint set in main(), has a chance to run.

Why BSRR is genuinely atomic and ODR |= genuinely isn't: ODR |= bit compiles to a load instruction, a bitwise OR, and a store instruction — three separate bus transactions, any of which an interrupt can land between. If an ISR's own ODR write happens after this code's load but before its store, the store overwrites the ISR's change with stale data the instant it executes — a classic read-modify-write race, identical in kind to the volatile uint32_t torn-read problem from module 1-07, just on a write instead of a read. BSRR sidesteps this entirely at the hardware level: it isn't a mirror of ODR you read back — writing a 1 to its low 16 bits sets the corresponding output bit and writing a 1 to its high 16 bits clears it, and writing 0 anywhere does nothing, all inside the GPIO peripheral's own combinational logic in a single bus write with no read step at all. Two different contexts (main code and an ISR) can each write BSRR for a different pin in the same register with zero possibility of one clobbering the other's intended effect, because neither write ever depends on first observing the register's current value.

Why the wrong base address "compiles cleanly": 0x40020000 is just an integer literal to the compiler and linker — nothing in the C type system or build toolchain has any notion of "this number denotes GPIOA" versus "this number denotes GPIOB." That knowledge exists only in the reference manual's memory map table (a document, not a machine-checkable artifact) and in the programmer's head when writing the #define. This is the fundamental reason bare-metal register work has no compiler safety net that Arduino's digitalWrite(pin, ...) API provides — the Arduino core's pin-to-register lookup table is machine-checkable (a bad pin number is caught or ignored at runtime), whereas a raw address typo is, to the compiler, indistinguishable from a correct one.

Cheat sheet

Concept Detail
Memory-mapped register *(volatile T *)ADDRESSvolatile prevents caching/reordering
Enable order Clock enable (RCC_*ENR) before touching any peripheral register
Reset entry CPU loads SP from 0x0, PC from 0x4 — no bootloader code runs first
Weak alias Unimplemented vectors fall back to Default_Handler instead of link errors
BSRR vs ODR |= BSRR is a true atomic set/clear; ODR read-modify-write is not
Toolchain arm-none-eabi-gcc + OpenOCD/ST-Link, not Arduino/ESP-IDF build system
Verification here Portable bit-logic compiled/run with gcc; addresses reviewed against ARM/ST docs only

Exercise

Write a standalone C file (compilable with plain gcc, no ARM toolchain needed) that models a 32-bit GPIOx_MODER-style register as a uint32_t plus a set_pin_mode(reg, pin, mode) function supporting mode values 00 (input), 01 (output), 10 (alternate function), 11 (analog). Add assertions that setting one pin's mode never disturbs the two bits belonging to any other pin, then extend it with a set_pin_output_atomic(bsrr_reg, pin, level) function that models BSRR (writing to the low 16 bits sets, the high 16 bits resets) and prove in a comment why this form is ISR-safe where ODR |= /|= ~ is not.