STM32 Bare-Metal Register Programming Without HAL: GPIO and SysTick
The HAL library is convenient, but it hides what the hardware is really doing. Every HAL call ends up as reads and writes to peripheral registers at fixed memory addresses. Learning to write those registers yourself makes you better at debugging, lets you write very small and fast code where it matters, and is often expected in embedded job interviews. It also makes the reference manual, the most important STM32 document, finally make sense.
In this tutorial you will program a Nucleo-F401RE with no HAL at all, using only the CMSIS device header. You will enable a GPIO clock, configure PA5 as an output and PC13 as an input, blink LD2 with the BSRR register, and write an accurate millisecond delay using the SysTick timer built into the Cortex-M4 core. A Blue Pill version shows how the older F1 GPIO registers differ.
Registers, Memory Map and CMSIS
On Cortex-M chips, peripherals are memory-mapped. GPIOA on the STM32F401 starts at address 0x40020000, and its registers follow at fixed offsets: MODER at 0x00, IDR at 0x10, ODR at 0x14, BSRR at 0x18. The CMSIS device header stm32f4xx.h (with STM32F401xE defined) declares structures for each peripheral, so GPIOA->MODER is a readable name for the 32-bit word at 0x40020000. It also defines bit masks such as RCC_AHB1ENR_GPIOAEN, so you rarely need to type raw numbers.
| Register | Purpose | Bits per pin |
|---|---|---|
| RCC->AHB1ENR | Enables clocks of GPIO ports A to H on the F4 | 1 per port |
| GPIOx->MODER | 00 input, 01 output, 10 alternate function, 11 analog | 2 |
| GPIOx->OTYPER | 0 push-pull, 1 open-drain | 1 |
| GPIOx->PUPDR | 00 none, 01 pull-up, 10 pull-down | 2 |
| GPIOx->IDR | Current input level of each pin | 1 |
| GPIOx->BSRR | Write 1 to bit n to set pin n; write 1 to bit n+16 to reset it | 2 (set and reset) |
The full description of every bit lives in the reference manual for the STM32F401, RM0368. The datasheet tells you which pins and peripherals exist; the reference manual tells you how to program them.
Project Setup Without HAL
- In STM32CubeIDE choose File, New, STM32 Project, select the NUCLEO-F401RE or the STM32F401RETx chip, and set Targeted Project Type to Empty. This gives a startup file, linker script and an empty
main.c, but no HAL. - From the STM32CubeF4 firmware package (CubeIDE stores it in its repository folder, usually under your user directory in STM32Cube/Repository), copy
Drivers/CMSIS/IncludeandDrivers/CMSIS/Device/ST/STM32F4xx/Includeinto your project. - Add both folders to Project Properties, C/C++ Build, Settings, MCU GCC Compiler, Include paths.
- Under Preprocessor, add the symbol
STM32F401xEso the header selects the correct chip. - Build once to check that
#include "stm32f4xx.h"compiles.
Register-Level Blink with Button
The program blinks LD2 at 2 Hz normally and at 10 Hz while the B1 button is held down.
#include "stm32f4xx.h"
#include <stdint.h>
#define LED_PIN 5U /* PA5 = LD2 */
#define BUTTON_PIN 13U /* PC13 = B1, low when pressed */
#define CPU_HZ 16000000U /* HSI after reset */
/* 1 ms tick using SysTick in polling mode (no interrupt) */
static void systick_init_1ms(void)
{
SysTick->LOAD = (CPU_HZ / 1000U) - 1U; /* 16000 cycles = 1 ms */
SysTick->VAL = 0U; /* clear current value */
SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk /* use processor clock */
| SysTick_CTRL_ENABLE_Msk; /* start counting */
}
static void delay_ms(uint32_t ms)
{
while (ms--)
{
/* COUNTFLAG becomes 1 each time the counter reaches 0; reading clears it */
while ((SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk) == 0U) { }
}
}
static void gpio_init(void)
{
/* 1. Enable clocks for GPIOA and GPIOC on the AHB1 bus */
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_GPIOCEN;
(void)RCC->AHB1ENR; /* dummy read: short wait after enabling */
/* 2. PA5 as general-purpose output: MODER5 = 01 */
GPIOA->MODER &= ~(3U << (LED_PIN * 2U));
GPIOA->MODER |= (1U << (LED_PIN * 2U));
GPIOA->OTYPER &= ~(1U << LED_PIN); /* push-pull */
GPIOA->PUPDR &= ~(3U << (LED_PIN * 2U)); /* no pull resistor */
/* 3. PC13 as input: MODER13 = 00. B1 has an external pull-up on the board */
GPIOC->MODER &= ~(3U << (BUTTON_PIN * 2U));
GPIOC->PUPDR &= ~(3U << (BUTTON_PIN * 2U));
}
static inline void led_on(void) { GPIOA->BSRR = (1U << LED_PIN); }
static inline void led_off(void) { GPIOA->BSRR = (1U << (LED_PIN + 16U)); }
static inline int button_pressed(void)
{
return (GPIOC->IDR & (1U << BUTTON_PIN)) == 0U; /* active low */
}
int main(void)
{
gpio_init();
systick_init_1ms();
while (1)
{
uint32_t half_period = button_pressed() ? 50U : 250U;
led_on();
delay_ms(half_period);
led_off();
delay_ms(half_period);
}
}
The Same Blink on a Blue Pill
The STM32F1 uses an older GPIO design. There is no MODER register; each pin has four bits in CRL (pins 0 to 7) or CRH (pins 8 to 15) that combine mode and type. Port clocks are on APB2, and the reset clock is the 8 MHz HSI.
#include "stm32f1xx.h"
int main(void)
{
RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; /* clock for GPIOC */
/* PC13 is in CRH, bits 23:20. MODE = 10 (output, 2 MHz), CNF = 00 (push-pull) */
GPIOC->CRH &= ~(0xFU << 20);
GPIOC->CRH |= (0x2U << 20);
while (1)
{
GPIOC->BSRR = (1U << (13U + 16U)); /* PC13 low -> LED on */
for (volatile uint32_t i = 0; i < 400000U; i++) { }
GPIOC->BSRR = (1U << 13U); /* PC13 high -> LED off */
for (volatile uint32_t i = 0; i < 400000U; i++) { }
}
}
Understanding the Register Code
- Clear then set: for multi-bit fields like MODER, first clear both bits with
&= ~(3U << n), then set the wanted value. Setting without clearing can leave an unintended mode such as analog (11). - BSRR instead of ODR: writing
GPIOA->ODR |= ...is a read-modify-write that can be interrupted, corrupting other pins. A single write to BSRR changes only the pins whose bits are 1, atomically. - COUNTFLAG polling: SysTick counts down from LOAD to 0 every millisecond. Polling the flag gives an accurate delay without interrupts. For a background tick, enable
SysTick_CTRL_TICKINT_Mskand write aSysTick_Handlerthat increments a counter, which is exactly what the HAL does. - Dummy read after enabling a clock: the reference manual notes a short delay after enabling a peripheral clock before its registers can be accessed. Reading the register back provides it.
- The volatile loop counter on the Blue Pill: without
volatile, the optimiser removes the empty loop. Busy loops are only for quick tests; their timing changes with compiler settings.
Compare the size of this project with the HAL blink: the bare-metal binary is a small fraction of it. You can confirm by looking at the text section size printed in the Console after each build.
Common Mistakes
- Nothing happens: the port clock was never enabled, so all register writes are ignored.
- Compiler cannot find
GPIOA: theSTM32F401xEsymbol is missing, so the device header did not include the chip-specific file. - Delays are about five times too long: you calculated LOAD for 84 MHz while the chip still runs at the 16 MHz HSI.
- Other pins on the port change unexpectedly: you wrote
GPIOA->MODER = ...instead of modifying only the bits for your pin. - Using
1 << 31: the signed shift is undefined behaviour in C. Always write1U << n.
Summary
You configured clocks and GPIO pins through RCC, MODER, OTYPER and PUPDR, read a button through IDR, drove an LED atomically through BSRR, and built a millisecond delay from the Cortex-M SysTick timer, all with CMSIS headers and no HAL. With RM0368 open beside you, the same approach extends to timers, UART and ADC. Knowing both levels lets you use the HAL for speed of development and drop to registers when you need control or performance.