Series Overview: This is Part 2 of our 18-part STM32 Unleashed series. We build on the architectural foundation from Part 1 and dive into the most fundamental peripheral — GPIO — followed by a thorough treatment of mechanical button debouncing and external interrupt configuration.
1
Architecture & CubeMX Setup
STM32 family, clock tree, HAL vs LL, CubeMX workflow, first project
Completed
2
GPIO & Button Debounce
GPIO modes, pull-up/down, EXTI, software debounce, HAL_GPIO_ReadPin
You Are Here
3
UART Communication
Polling, interrupt, DMA modes, printf retargeting, ring buffers
4
Timers, PWM & Input Capture
TIM basics, PWM generation, input capture, encoder mode
5
ADC & DAC
Single/continuous conversion, DMA, injected channels, DAC waveforms
6
SPI Protocol
SPI master/slave, full-duplex, DMA transfers, sensor drivers
7
I2C Protocol
I2C master, 7/10-bit addressing, DMA, multi-master, error handling
8
DMA & Memory Efficiency
DMA streams, circular mode, memory-to-memory, zero-copy patterns
9
Interrupt Management & NVIC
Priority grouping, preemption, ISR design, HAL callbacks, latency
10
Low-Power Modes
Sleep, Stop, Standby modes, RTC wakeup, LP UART, power profiling
11
RTC & Calendar
RTC configuration, alarms, backup registers, calendar subseconds
12
CAN Bus
FDCAN/bxCAN, filters, message frames, error handling, automotive use
13
USB CDC Virtual COM Port
USB FS/HS, CDC class, virtual serial, control transfers, descriptors
14
FreeRTOS Integration
Tasks, queues, semaphores, mutexes, CMSIS-RTOS2 wrapper, stack sizing
15
Bootloader Development
Custom IAP bootloader, UART/USB DFU, flash programming, jump-to-app
16
External Storage: SD & QSPI Flash
FATFS on SD card, QSPI NOR flash, memory-mapped execution, wear levelling
17
Ethernet & TCP/IP Stack
LwIP integration, DHCP, TCP server, HTTP, MQTT, Ethernet DMA descriptors
18
Production Readiness
Watchdog, HardFault handler, flash option bytes, code signing, CI/CD
GPIO Architecture & Modes
The General Purpose Input/Output (GPIO) peripheral is the front door between your firmware and the physical world. Every LED, button, SPI chip-select, UART pin, and PWM output is routed through a GPIO cell. Despite its apparent simplicity, the STM32 GPIO block contains eight configuration registers per port — understanding them is the difference between reliable hardware interfacing and mysterious signal glitches.
GPIO Register Map
Each GPIO port (GPIOA through GPIOK on large devices) has the following registers, all 32-bit wide and mapped to AHB1:
- MODER — 2 bits per pin, selects: Input (00), Output (01), Alternate Function (10), Analog (11)
- OTYPER — 1 bit per pin: Push-Pull (0), Open-Drain (1)
- OSPEEDR — 2 bits per pin: Low (00), Medium (01), High (10), Very High (11)
- PUPDR — 2 bits per pin: No pull (00), Pull-up (01), Pull-down (10), Reserved (11)
- IDR — Input Data Register, read-only, reflects current pin voltage levels
- ODR — Output Data Register, read-write (but use BSRR for atomic access)
- BSRR — Bit Set/Reset Register, upper 16 bits reset, lower 16 bits set — atomic, no read-modify-write required
- LCKR — Lock Register, write sequence locks MODER/OTYPER/OSPEEDR/PUPDR/AFR until next reset
- AFR[0] — Alternate Function Register for pins 0–7 (4 bits each, AF0–AF15)
- AFR[1] — Alternate Function Register for pins 8–15
Set MODER[2n+1:2n] = 00 and the pin becomes an input. The PUPDR register then selects the bias:
- Floating (no pull) — PUPDR = 00. The pin voltage is undefined if externally disconnected. Use only when an external driver guarantees a defined level. Susceptible to EMI-induced false reads on long, undriven PCB traces.
- Pull-up — PUPDR = 01. An internal ~40 kΩ resistor connects the pin to VDD. The pin reads HIGH when open, LOW only when actively pulled to GND. Standard for active-low buttons where the switch pulls to ground.
- Pull-down — PUPDR = 10. An internal ~40 kΩ resistor connects to GND. Pin reads LOW when open, HIGH only when driven high. Useful for active-high enable signals.
- Analog — MODER = 11. Connects the pad directly to the ADC/DAC analog subsystem, completely bypassing the digital input buffer. Always configure ADC pins as Analog — leaving them as digital floating inputs draws unnecessary current through the schmitt trigger comparator.
Two Output Modes
Push-Pull (OTYPER = 0): The output stage has two transistors — one PMOS pulling to VDD, one NMOS pulling to GND. Exactly one is active at a time, giving a strong drive in both directions. Rise and fall times are controlled by the speed grade. This is the correct choice for driving LEDs, logic-level signals, chip-selects, and most GPIO outputs.
Open-Drain (OTYPER = 1): Only the NMOS pull-down is active; there is no PMOS. Writing 0 actively pulls the pin low; writing 1 releases the pin to float (high-Z). An external pull-up resistor provides the high level. This topology is mandatory for I2C (which requires both master and slave to pull the bus low) and is also used for wired-OR signalling and level-shifting to higher voltages. When you wire an LED to an open-drain pin in sink configuration (LED anode to VDD, cathode to pin), the LED illuminates when the pin is driven low.
Alternate Function Mapping
When MODER = 10, the pin is controlled by a peripheral rather than the ODR register. The 4-bit AFR field selects which peripheral: AF0 through AF15. These mappings are fixed in silicon and documented in the device datasheet "Alternate Function" table. CubeMX handles this automatically when you assign a peripheral pin.
Key rule: the correct AFR value for a given peripheral depends on which pin is used. USART2_TX is AF7 on PA2, but SPI1_MOSI is AF5 on PA7. Always verify against the datasheet table, not just the peripheral number.
Speed Grades and EMI
OSPEEDR controls the slew rate of the output driver — how quickly the voltage transitions between logic levels. A faster slew rate demands more instantaneous current from the supply, generates more high-frequency harmonic energy, and increases radiated EMI. The rule is: use the slowest speed that meets your timing requirement. For a 1 kHz LED blink, Low speed is fine. For a 50 MHz SPI clock, Very High is necessary. Running a 1 MHz I2C bus at Very High speed serves no benefit and degrades EMI compliance.
| GPIO Mode |
MODER Bits |
Direction |
Typical Use Case |
| Input Floating |
00 + PUPDR=00 |
In |
Externally driven signals with guaranteed level |
| Input Pull-Up |
00 + PUPDR=01 |
In |
Active-low buttons, open-collector bus signals |
| Input Pull-Down |
00 + PUPDR=10 |
In |
Active-high enable lines, EXTI wake sources |
| Output Push-Pull |
01 + OTYPER=0 |
Out |
LED drive, chip-select, logic signals |
| Output Open-Drain |
01 + OTYPER=1 |
Out (with ext pull) |
I2C SDA/SCL, wired-OR, level shifting |
| Alternate Function |
10 |
Peripheral |
UART, SPI, I2C, TIM PWM, USB |
| Analog |
11 |
Analog |
ADC input, DAC output, comparator |
The following code shows direct register configuration — no HAL — setting PA5 as push-pull output and PC13 as input with pull-up:
/* ---------------------------------------------------------------
* Direct register GPIO configuration (no HAL)
* Target: STM32F401RE, GPIOA pin 5 (LED), GPIOC pin 13 (button)
* --------------------------------------------------------------- */
#include "stm32f4xx.h"
void GPIO_Config_Register(void)
{
/* 1. Enable clocks for GPIOA and GPIOC on AHB1 */
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_GPIOCEN;
/* ---- PA5: push-pull output, low speed ---- */
/* MODER: bits [11:10] = 01 (output) */
GPIOA->MODER &= ~(0x3U << (5 * 2)); /* clear PA5 MODER bits */
GPIOA->MODER |= (0x1U << (5 * 2)); /* set PA5 MODER = 01 */
/* OTYPER: bit 5 = 0 (push-pull) — already 0 after reset */
GPIOA->OTYPER &= ~(1U << 5);
/* OSPEEDR: bits [11:10] = 00 (low speed) */
GPIOA->OSPEEDR &= ~(0x3U << (5 * 2));
/* PUPDR: bits [11:10] = 00 (no pull) */
GPIOA->PUPDR &= ~(0x3U << (5 * 2));
/* ---- PC13: input with internal pull-up ---- */
/* MODER: bits [27:26] = 00 (input) */
GPIOC->MODER &= ~(0x3U << (13 * 2));
/* PUPDR: bits [27:26] = 01 (pull-up) */
GPIOC->PUPDR &= ~(0x3U << (13 * 2));
GPIOC->PUPDR |= (0x1U << (13 * 2));
}
/* Atomic set/reset using BSRR — no read-modify-write needed */
static inline void LED_On(void) { GPIOA->BSRR = (1U << 5); }
static inline void LED_Off(void) { GPIOA->BSRR = (1U << (5 + 16)); }
static inline int BTN_Read(void){ return (GPIOC->IDR >> 13) & 1U; }
HAL GPIO API
The HAL GPIO driver wraps the register operations described above into a clean API. Understanding each field in GPIO_InitTypeDef directly maps to the register bits you just learned, making the HAL both readable and predictable.
GPIO_InitTypeDef Fields
- Pin — bitmask of pins to configure simultaneously, e.g.
GPIO_PIN_5 | GPIO_PIN_6
- Mode — combines MODER and OTYPER:
GPIO_MODE_INPUT, GPIO_MODE_OUTPUT_PP, GPIO_MODE_OUTPUT_OD, GPIO_MODE_AF_PP, GPIO_MODE_AF_OD, GPIO_MODE_ANALOG, plus EXTI variants
- Pull — PUPDR setting:
GPIO_NOPULL, GPIO_PULLUP, GPIO_PULLDOWN
- Speed — OSPEEDR:
GPIO_SPEED_FREQ_LOW, _MEDIUM, _HIGH, _VERY_HIGH
- Alternate — AFR value (0–15), only used when Mode is AF_PP or AF_OD
Core HAL GPIO Functions
HAL_GPIO_Init(GPIOx, &init) — applies GPIO_InitTypeDef to the specified port; enables clock automatically on some HAL versions
HAL_GPIO_DeInit(GPIOx, pin_mask) — resets specified pins to reset-state (analog, no pull, low speed)
HAL_GPIO_ReadPin(GPIOx, pin) — returns GPIO_PIN_SET or GPIO_PIN_RESET by reading IDR
HAL_GPIO_WritePin(GPIOx, pin, state) — writes BSRR atomically; state is GPIO_PIN_SET or GPIO_PIN_RESET
HAL_GPIO_TogglePin(GPIOx, pin) — reads ODR, XORs the bit, writes back; not atomic — avoid in ISR context if another context also writes the same ODR
HAL_GPIO_LockPin(GPIOx, pin) — performs the lock sequence (write 1, write 0, write 1, read 1, read 0) to lock AFR/MODER/OTYPER/OSPEEDR/PUPDR until next reset; useful to protect safety-critical pin configurations from accidental reconfiguration
/* ---------------------------------------------------------------
* HAL GPIO configuration — Nucleo-F401RE
* LED: PA5 (push-pull output, low speed)
* User Button: PC13 (input, pull-up, EXTI falling edge)
* --------------------------------------------------------------- */
#include "main.h"
void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
/* Enable peripheral clocks */
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_GPIOC_CLK_ENABLE();
/* Configure PA5 — LED, push-pull output, initially off */
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET);
GPIO_InitStruct.Pin = GPIO_PIN_5;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
/* Configure PC13 — User button, input pull-up */
GPIO_InitStruct.Pin = GPIO_PIN_13;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
}
/* Usage in main loop */
void LED_Control(void)
{
/* Read button (active-low: pressed = GPIO_PIN_RESET) */
if (HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13) == GPIO_PIN_RESET) {
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); /* LED on */
} else {
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); /* LED off */
}
}
HAL_GPIO_TogglePin Atomicity: The HAL implementation reads ODR, XORs the target bit, and writes back. On Cortex-M4 this is a read-modify-write sequence — not atomic. If both main-loop code and an ISR toggle the same pin, a race condition can silently skip or double-toggle. Use BSRR directly for ISR-safe toggling, or ensure only one context owns a given output pin.
Button Debounce Algorithms
Why Buttons Bounce
A mechanical switch contains two metal contacts that physically collide when pressed or released. At the microscopic level, this collision is not clean — the contacts bounce off each other multiple times before settling into stable electrical contact. During the bounce window, the signal oscillates between logic high and logic low at irregular intervals.
The bounce window depends on switch quality and mechanical construction. Cheap tactile switches can bounce for 1–50 milliseconds, though typical duration is 5–20 ms. Premium industrial switches may bounce for less than 1 ms. Without debouncing, firmware reading the GPIO will interpret each bounce transition as a separate button press — resulting in a single physical press triggering dozens of events.
Algorithm 1: Naive Polling (Broken)
The most common beginner mistake is to read the button pin once per loop iteration and act immediately on any change. This works perfectly in simulation but fails catastrophically on real hardware due to bouncing.
/* BROKEN: naive polling — fires multiple times per physical press */
static uint8_t last_state = 1; /* pulled high, starts released */
void Button_Poll_Naive(void)
{
uint8_t current = HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13);
if (current != last_state) {
if (current == 0) {
/* This fires on EVERY bounce transition, not just once! */
Button_PressHandler();
}
last_state = current;
}
}
/* Calling this in a tight main loop at 168 MHz will call
* Button_PressHandler() 10–50 times per physical button press. */
Algorithm 2: Software Counter Debounce
Sample the pin on every call. Only transition state after reading the same value N consecutive times. The call period and N together define the debounce window. For a 1 ms SysTick period and N = 20, the window is 20 ms. This approach is ISR-safe when the counter variable is private to a single context.
Algorithm 3: Timer-Based Debounce
Record a timestamp when the pin first changes state. Only accept the transition if the pin maintains its new state for the full debounce window (e.g. 20 ms) without returning to the old state. HAL_GetTick() provides millisecond resolution from the SysTick interrupt and is safe to call from the main loop.
/* ---------------------------------------------------------------
* Timer-based debounce using HAL_GetTick()
* Debounce window: 20 ms — rejects any transition shorter than that
* --------------------------------------------------------------- */
#define DEBOUNCE_MS 20U
typedef struct {
uint8_t raw_state; /* last raw GPIO reading */
uint8_t stable_state; /* confirmed debounced state */
uint8_t pending_state; /* candidate new state being timed */
uint32_t change_tick; /* HAL_GetTick() at first transition */
} DebounceCtx;
static DebounceCtx btn_ctx = { 1, 1, 1, 0 };
/* Call this from the main loop — NOT from an ISR.
* Returns 1 on a newly confirmed falling edge (press detected),
* returns 0 otherwise. */
uint8_t Button_Debounce_Timer(void)
{
uint8_t pressed = 0;
uint8_t raw = (uint8_t)HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13);
if (raw != btn_ctx.raw_state) {
/* Pin changed — start timing this candidate state */
btn_ctx.raw_state = raw;
btn_ctx.pending_state = raw;
btn_ctx.change_tick = HAL_GetTick();
}
if ((btn_ctx.pending_state != btn_ctx.stable_state) &&
((HAL_GetTick() - btn_ctx.change_tick) >= DEBOUNCE_MS)) {
/* Candidate has been stable for full window — accept it */
btn_ctx.stable_state = btn_ctx.pending_state;
/* Return true only on falling edge (press) */
if (btn_ctx.stable_state == 0) {
pressed = 1;
}
}
return pressed;
}
Algorithm 4: State Machine Debounce
The most robust approach for production firmware models debounce as an explicit finite state machine. Each state transition has clearly defined guard conditions, making the logic easier to review and test. The four states are: IDLE (button stable released), PRESSED_DEBOUNCING (first transition detected, timing), PRESSED (press confirmed), and RELEASED_DEBOUNCING (release first detected, timing).
/* ---------------------------------------------------------------
* State machine debounce — explicit FSM with 4 states
* Call Button_FSM_Update() every 1 ms (from SysTick or timer ISR)
* --------------------------------------------------------------- */
#define DEBOUNCE_TICKS 20U /* 20 calls = 20 ms at 1 ms rate */
typedef enum {
BTN_IDLE,
BTN_PRESSED_DEBOUNCING,
BTN_PRESSED,
BTN_RELEASED_DEBOUNCING
} BtnState;
typedef struct {
BtnState state;
uint16_t tick_count;
uint8_t event_pressed; /* set to 1 on confirmed press */
uint8_t event_released; /* set to 1 on confirmed release */
} BtnFSM;
static BtnFSM btn = { BTN_IDLE, 0, 0, 0 };
/* Call from SysTick_Handler or a 1 ms timer ISR */
void Button_FSM_Update(void)
{
uint8_t raw = (uint8_t)HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13);
btn.event_pressed = 0;
btn.event_released = 0;
switch (btn.state) {
case BTN_IDLE:
if (raw == 0) { /* pin went low */
btn.state = BTN_PRESSED_DEBOUNCING;
btn.tick_count = 0;
}
break;
case BTN_PRESSED_DEBOUNCING:
if (raw == 0) {
if (++btn.tick_count >= DEBOUNCE_TICKS) {
btn.state = BTN_PRESSED;
btn.event_pressed = 1; /* fire event */
}
} else {
btn.state = BTN_IDLE; /* glitch: cancel */
}
break;
case BTN_PRESSED:
if (raw == 1) { /* pin went high */
btn.state = BTN_RELEASED_DEBOUNCING;
btn.tick_count = 0;
}
break;
case BTN_RELEASED_DEBOUNCING:
if (raw == 1) {
if (++btn.tick_count >= DEBOUNCE_TICKS) {
btn.state = BTN_IDLE;
btn.event_released = 1; /* fire event */
}
} else {
btn.state = BTN_PRESSED; /* glitch: cancel */
}
break;
}
}
/* Call from main loop to consume events */
uint8_t Button_WasPressed(void) { return btn.event_pressed; }
uint8_t Button_WasReleased(void) { return btn.event_released; }
| Algorithm |
Code Size |
Accuracy |
CPU Load |
ISR Safe? |
| Naive Polling |
Tiny (~10 lines) |
Poor — fires on every bounce |
Negligible |
Yes, but useless |
| Counter Debounce |
Small (~20 lines) |
Good if call period is consistent |
Very low |
Yes (with volatile) |
| Timer-Based (HAL_GetTick) |
Medium (~35 lines) |
Excellent — wall-clock accurate |
Low |
No — HAL_GetTick relies on SysTick |
| State Machine FSM |
Medium (~55 lines) |
Excellent — rejects glitches |
Low (called at 1 ms) |
Yes — deterministic, tick-based |
External Interrupts (EXTI)
EXTI Line Mapping
STM32F4 has 16 GPIO EXTI lines (EXTI0–EXTI15) plus additional lines for internal event sources. The critical constraint is that EXTI line N can only be mapped to one GPIO port at a time. For example, EXTI5 can be connected to PA5, PB5, PC5, or PD5 — but only one of them at a time. The selection is made via the SYSCFG_EXTICRx registers (configured by CubeMX or HAL).
EXTI lines share IRQ vectors on most STM32F4 devices: EXTI0–EXTI4 have individual vectors, while EXTI5–EXTI9 share EXTI9_5_IRQHandler and EXTI10–EXTI15 share EXTI15_10_IRQHandler. In the combined handlers, your code must check __HAL_GPIO_EXTI_GET_IT(GPIO_PIN_x) to identify which line fired.
EXTI Trigger Modes and NVIC Priority
Set the Mode field in GPIO_InitTypeDef to configure trigger polarity:
GPIO_MODE_IT_RISING — fires on low→high transition
GPIO_MODE_IT_FALLING — fires on high→low transition (active-low button press)
GPIO_MODE_IT_RISING_FALLING — fires on both edges; use when you need both press and release events
NVIC priority for EXTI lines must be set carefully. Lower numbers = higher priority in STM32 NVIC. For a button interrupt, priority 5 or lower (numerically higher) is generally appropriate — it should not preempt time-critical peripherals like a high-frequency timer or DMA completion.
HAL_GPIO_EXTI_Callback
HAL routes all EXTI interrupts through a single weak callback: void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin). Override this in your application. The GPIO_Pin argument is the pin bitmask of the line that fired — use it to dispatch to the correct handler when multiple EXTI lines share an IRQ.
/* ---------------------------------------------------------------
* EXTI Configuration for PC13 Button (Nucleo-F401RE)
* Trigger: falling edge (button press = active low)
* NVIC Priority: 5 (preemption group 4, sub-priority 0)
* Debounce: handled inside callback with volatile counter
* --------------------------------------------------------------- */
#include "main.h"
volatile uint8_t g_btn_pressed = 0; /* set in EXTI, read in main */
static uint32_t s_last_exti_ms = 0;
void MX_GPIO_EXTI_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOC_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_13;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; /* press event */
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
/* NVIC: enable EXTI15_10, priority 5 */
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 5, 0);
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);
}
/* Override HAL weak callback — called from within IRQ handler */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_13) {
/* Software debounce: reject callbacks within 20 ms */
uint32_t now = HAL_GetTick();
if ((now - s_last_exti_ms) >= 20U) {
s_last_exti_ms = now;
g_btn_pressed = 1; /* signal main loop */
}
}
/* Add more GPIO_Pin checks here for other EXTI lines */
}
/* In main loop: */
void Process_Button_Events(void)
{
if (g_btn_pressed) {
g_btn_pressed = 0; /* consume the event atomically */
/* ... handle button press ... */
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
}
}
EXTI Line Conflict: A common CubeMX mistake is enabling EXTI on both PA5 and PB5 in the same project. Since both share EXTI line 5, only the last configured port mapping in SYSCFG_EXTICR2 will be active. CubeMX will usually warn about this, but always verify the SYSCFG register map in the generated code.
LL GPIO vs Register Access
The STM32 Low-Layer (LL) drivers sit one step below HAL. They are implemented as static inline functions in stm32f4xx_ll_gpio.h and compile to a single instruction — identical to direct register access but with type safety and a readable name. Enable LL drivers in CubeMX under "Advanced Settings → Driver Selector → LL".
Key LL GPIO functions:
LL_GPIO_SetOutputPin(GPIOx, pin_mask) — writes to BSRR lower half, single STR instruction
LL_GPIO_ResetOutputPin(GPIOx, pin_mask) — writes to BSRR upper half, single STR instruction
LL_GPIO_TogglePin(GPIOx, pin_mask) — reads ODR, XORs, writes back; same atomicity caveats as HAL
LL_GPIO_ReadInputPort(GPIOx) — returns full 16-bit IDR value; faster than reading a single pin when you need multiple inputs
LL_GPIO_IsInputPinSet(GPIOx, pin_mask) — single LDR + AND; equivalent to HAL_GPIO_ReadPin
/* ---------------------------------------------------------------
* Side-by-side comparison: HAL vs LL vs direct register
* Operation: Set PA5 HIGH, then read PC13
* --------------------------------------------------------------- */
/* --- HAL (3 function calls, overhead in each) --- */
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
GPIO_PinState s_hal = HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13);
/* --- LL (static inline, compiles to 1–2 instructions each) --- */
LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_5);
uint32_t s_ll = LL_GPIO_IsInputPinSet(GPIOC, LL_GPIO_PIN_13);
/* --- Direct register (identical assembly to LL) --- */
GPIOA->BSRR = GPIO_PIN_5; /* set PA5 */
uint32_t s_reg = (GPIOC->IDR >> 13) & 1U; /* read PC13 */
/* Cycle count at 168 MHz (Cortex-M4, no cache miss):
* HAL WritePin: ~6 cycles (function call + parameter checks)
* LL SetPin: ~2 cycles (inline STR)
* Register BSRR: ~2 cycles (STR)
* The 3× overhead of HAL is irrelevant for LED blinking.
* In a 1 MHz ISR toggling a debug pin, LL/register is essential. */
/* Interrupt-safe toggle using BSRR (atomic, single write): */
void ISR_Safe_Toggle_PA5(void)
{
static uint8_t state = 0;
if (state) {
GPIOA->BSRR = (GPIO_PIN_5 << 16); /* atomic reset */
} else {
GPIOA->BSRR = GPIO_PIN_5; /* atomic set */
}
state ^= 1;
}
In practice, use HAL for all initialisation and non-critical GPIO operations. Switch to LL or direct registers only in ISRs, tight control loops, or when profiling reveals that HAL overhead is measurable on the target. For most application code running on a 168 MHz Cortex-M4, the difference is academic.
Project — LED State Machine
This project combines everything covered so far: GPIO configuration, EXTI interrupt, debounce, and a non-blocking LED controller. Each button press cycles the LED through four states: OFF → SLOW_BLINK (500 ms period) → FAST_BLINK (100 ms period) → ALWAYS_ON → OFF. The blink timing uses SysTick millisecond counter comparison — no HAL_Delay, no blocking.
/* ---------------------------------------------------------------
* LED State Machine — 4 states, EXTI-driven, non-blocking
* Board: Nucleo-F401RE (PA5=LED, PC13=Button)
* --------------------------------------------------------------- */
#include "main.h"
/* ---- State definition ----------------------------------------- */
typedef enum {
LED_OFF = 0,
LED_SLOW_BLINK, /* 500 ms period, 50% duty */
LED_FAST_BLINK, /* 100 ms period, 50% duty */
LED_ALWAYS_ON
} LedMode;
/* ---- Shared state (ISR writes, main reads) --------------------- */
volatile uint8_t g_next_state_req = 0; /* set by EXTI callback */
static LedMode s_led_mode = LED_OFF;
static uint32_t s_blink_tick = 0; /* last toggle timestamp */
static uint32_t s_debounce_tick = 0; /* last EXTI timestamp */
/* ---- EXTI Callback (called from IRQ at priority 5) ------------- */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_13) {
uint32_t now = HAL_GetTick();
if ((now - s_debounce_tick) >= 20U) {
s_debounce_tick = now;
g_next_state_req = 1; /* request state advance */
}
}
}
/* ---- State machine update — call every main loop iteration ----- */
void LED_StateMachine_Update(void)
{
/* Advance state on button event */
if (g_next_state_req) {
g_next_state_req = 0;
s_led_mode = (LedMode)((s_led_mode + 1) % 4);
s_blink_tick = HAL_GetTick();
/* Immediately apply static states */
if (s_led_mode == LED_OFF) {
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET);
} else if (s_led_mode == LED_ALWAYS_ON) {
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
}
}
/* Non-blocking blink for timed states */
uint32_t period_ms = 0;
if (s_led_mode == LED_SLOW_BLINK) period_ms = 500U;
else if (s_led_mode == LED_FAST_BLINK) period_ms = 100U;
if (period_ms > 0) {
uint32_t half = period_ms / 2;
if ((HAL_GetTick() - s_blink_tick) >= half) {
s_blink_tick = HAL_GetTick();
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
}
}
}
/* ---- main() excerpt -------------------------------------------- */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init(); /* configures PA5 output + PC13 EXTI */
while (1) {
LED_StateMachine_Update();
/* Other application tasks here — no blocking calls */
}
}
Design Principle: The EXTI callback does the minimum possible work — it sets a flag. The state machine logic runs in the main loop. This separation keeps interrupt latency low and makes the logic easy to unit test without hardware.
Exercises
Exercise 1
Beginner
GPIO Speed Grade Measurement
Configure 4 GPIO outputs and 2 GPIO inputs using CubeMX on any STM32F4 Nucleo board. Assign the 4 outputs to the same physical pin but recompile with each of the four speed grades (Low, Medium, High, Very High) in turn. Connect an oscilloscope or logic analyser to the pin and toggle it at maximum rate in a tight loop. Measure the rise time, fall time, and maximum achievable toggle frequency at each grade. Document the relationship between speed grade and measured slew rate in a table, and comment on which grade you would choose for a 10 MHz SPI clock output.
GPIO
Speed Grade
Oscilloscope
CubeMX
Exercise 2
Intermediate
Debounce Algorithm Comparison
Implement all 4 debounce algorithms (naive, counter, timer-based, FSM) for the same tactile button on the same board. Instrument each algorithm with a counter that increments every time it reports a button press. Press the button exactly 10 times physically. Compare the press-count reported by each algorithm. Then connect an oscilloscope to the button pin and capture the bounce waveform — note the actual bounce duration for your specific button and adjust the debounce window accordingly. Which algorithm provides the best balance of response time and bounce rejection for your measured bounce duration?
Debounce
FSM
Oscilloscope
Measurement
Exercise 3
Advanced
Rotary Encoder Driver with EXTI
Build a rotary encoder driver using two EXTI lines for the A and B quadrature channels, plus a third EXTI line for the centre push button. Decode the quadrature signal in the EXTI callbacks to increment or decrement a 32-bit counter. Stream the counter value and direction over UART at 115200 baud on every change. Verify correct direction decoding at 300 RPM by measuring the A/B channel timing on a scope — at 300 RPM with a 20-pulse-per-revolution encoder, pulses arrive every 1 ms and you must not miss any. Test clockwise and counter-clockwise rotation, and confirm the counter rolls over gracefully at INT32_MAX and INT32_MIN.
EXTI
Quadrature Encoder
UART
ISR Design
GPIO Configuration Tool
Use this tool to document your STM32 GPIO configuration — outputs, inputs, EXTI lines, debounce strategy, and design notes. Download as Word, Excel, PDF, or PPTX for project documentation or design review.
Conclusion & Next Steps
In this article we have built a thorough understanding of STM32 GPIO from registers to application patterns:
- The GPIO register map — MODER, OTYPER, OSPEEDR, PUPDR, IDR, ODR, BSRR, LCKR, AFR — maps directly to HAL fields, making the API transparent and predictable.
- Push-pull vs open-drain determines whether the pin can source current (push-pull) or only sink (open-drain with external pull). I2C always requires open-drain; most logic outputs use push-pull.
- The four debounce algorithms — naive, counter, timer-based, FSM — trade code complexity for accuracy and ISR safety. The FSM approach gives the best rejection of transient glitches.
- EXTI line mapping constrains you to one GPIO port per line number; violating this produces silent hardware conflicts that CubeMX may not always catch.
- LL drivers and direct register access compile to single instructions and are essential in time-critical ISRs, but HAL is the right choice for initialisation and non-critical application code.
Next in the Series
In Part 3: UART Communication, we'll explore all three UART transfer modes (polling, interrupt, DMA), retarget printf to serial, build a lock-free ring buffer, and implement a command-line interface over UART — the foundation of every embedded debug console and communication link.
Related Articles in This Series
Part 3: UART Communication
Polling, interrupt, and DMA modes, printf retargeting, ring buffer implementation, and command-line interface over UART.
Read Article
Part 4: Timers, PWM & Input Capture
Timer fundamentals, PWM signal generation, input capture for frequency measurement, and encoder interface mode.
Read Article
Part 9: Interrupt Management & NVIC
Priority grouping, preemption, ISR design patterns, HAL callbacks, and measuring interrupt latency.
Read Article