Skip to content

Part 2 · C Meets the Metal32 min read

GPIO in Depth

You have already toggled an LED by poking GPIOD->ODR. That works on a dev board, but a real product connects pins to buttons with long cables, to relays, to 5 V sensors and to other chips that boot at different times. This lesson explains what is physically inside an STM32 pin, every field of the GPIO registers, a small reusable driver, how to read a mechanical button reliably, how to get an interrupt when a pin changes, and how to drive loads without damaging the chip. By the end you can configure any pin for any job and explain what the silicon is doing.

Each STM32F407 I/O pin is a small circuit, not a wire. Simplified:

VDD (3.3 V)
|
+--------+--------+
| |
[P-MOS] [pull-up ~40k] <- PUPDR = 01
| | (switch)
output --------+-----------------+--------+------ PAD (the pin)
driver | | |
[N-MOS] [pull-down ~40k] <- PUPDR = 10
| | |
+--------+--------+ |
| +--> [Schmitt trigger] --> IDR bit
VSS |
+--> analog path to ADC/DAC
ESD protection diodes to VDD and VSS on the pad (not shown)

In push-pull mode (OTYPER bit = 0), the output driver uses both transistors. Writing 1 turns the P-MOS on and pulls the pin to VDD; writing 0 turns the N-MOS on and pulls it to VSS. The pin is always actively driven. This is the right mode for LEDs, chip selects, and almost every digital output.

In open-drain mode (OTYPER bit = 1), the P-MOS is never turned on. Writing 0 pulls the pin low; writing 1 releases it, leaving it floating unless something pulls it up. That sounds less useful, and it is exactly what you need when several devices share one wire: any of them can pull it low, none can fight another by driving high. I2C is built on this. Open-drain also lets a 3.3 V chip talk to a 5 V input: the external pull-up goes to 5 V, and the pin only ever sinks current (the pin must be 5 V tolerant, see below).

The classic push-pull bug: two outputs connected together, one driving high and one low. You have built a short circuit from VDD to VSS through two transistors. The pins get hot, the supply droops, and eventually a pin dies.

The internal pull resistors are weak, nominally about 40 kΩ (the datasheet gives a range of roughly 30 to 50 kΩ). They exist so that an input pin with nothing actively driving it, such as a button that is open, sits at a defined level instead of floating. A floating CMOS input picks up noise from nearby traces and your finger, reads random values, and can sit near the switching threshold where both input transistors conduct, wasting current.

Because they are weak, the internal pulls are slow: 40 kΩ against even 20 pF of cable capacitance gives an RC time constant of 0.8 µs. Fine for a button, too slow for a fast bus. I2C needs external pull-ups of a few kΩ for that reason.

OSPEEDR does not set a frequency. It sets the slew rate, how hard the output transistors drive edges. The labels (low, medium, fast, high) correspond to the maximum toggle frequency the datasheet guarantees for a given load capacitance, roughly 2, 25, 50 and 100 MHz at 3.3 V on the F407, but what changes physically is the edge time.

Fast edges are not free. A sharp edge contains high-frequency harmonics, which radiate (EMI that fails compliance tests), cause ringing and overshoot on long traces, and inject current spikes into the ground. The rule: use the slowest speed that meets your timing. LEDs, relays, enable lines: low. SPI at 10 MHz: fast. MCO at 42 MHz: high.

Input signals pass through a Schmitt trigger: a comparator with hysteresis. It switches high only when the input rises above one threshold and back low only when it falls below a lower one. A slowly rising or noisy signal therefore produces one clean transition instead of a burst. Hysteresis does not remove mechanical switch bounce (each bounce is a full-swing transition), which is why debouncing is still needed.

The IDR register shows the Schmitt trigger’s output, sampled on each AHB clock. It is valid in input, output and alternate function modes. In analog mode the Schmitt trigger is disconnected and IDR reads 0 for that pin.

Most STM32F407 pins are marked FT (five-volt tolerant) in the datasheet pinout table. An FT pin configured as a digital input or open-drain output can have up to about 5 V applied while VDD is powered. Pins without FT, and any FT pin in analog mode, have a protection diode to VDD and must not go above VDD plus a few hundred millivolts. Check the pinout table for the specific pin; do not assume.

Current: the absolute maximum is 25 mA sunk or sourced per pin, and the total for the whole chip is limited by the 240 mA maximum current into the VDD pins, which also has to feed the core. The datasheet output voltage levels are specified at 8 mA; above that, a “high” output sags noticeably below VDD. Design for a few milliamps per pin and use a transistor for anything bigger.

Each port (GPIOA at 0x40020000, then every 0x400 bytes up to GPIOI) has the same register set:

OffsetRegisterBits per pinEncoding
0x00MODER200 input, 01 output, 10 alternate function, 11 analog
0x04OTYPER1 (bits 15:0)0 push-pull, 1 open-drain
0x08OSPEEDR200 low, 01 medium, 10 fast, 11 high
0x0CPUPDR200 none, 01 pull-up, 10 pull-down, 11 reserved
0x10IDR1 (bits 15:0)input level, read-only
0x14ODR1 (bits 15:0)output latch, read/write
0x18BSRR1 set + 1 resetwrite-only: bit n sets pin n, bit n+16 resets pin n
0x1CLCKR1 + key bit 16configuration lock
0x20AFRL4 (pins 0 to 7)alternate function number 0 to 15
0x24AFRH4 (pins 8 to 15)alternate function number 0 to 15

In the CMSIS-style overlay these last two are an array, AFR[0] (AFRL) and AFR[1] (AFRH), which is what the code below uses. A pin n uses bits 2n+1:2n in the two-bit registers, bit n in the one-bit registers, and bits 4(n mod 8)+3 : 4(n mod 8) in AFR[n / 8].

After reset, all pins are inputs without pull resistors, except the debug pins: PA13 and PA14 (SWD) and PA15, PB3, PB4 (JTAG) are in alternate function mode with pulls, so the debugger can connect. Reconfigure PA13 or PA14 and you lose the debugger until the next reset under the probe’s control.

Setting a pin with GPIOD->ODR |= (1u << 12) compiles to three instructions: load ODR, OR in the bit, store ODR. If an interrupt fires between the load and the store and its handler changes another pin on port D, the main code’s store writes back the old value of that other pin, undoing the ISR’s change. That is the read-modify-write hazard from Bit manipulation.

BSRR is the fix. Writing GPIOD->BSRR = (1u << 12) sets PD12 and leaves every other pin alone, because zeros in BSRR mean “no change”. Writing 1u << (12 + 16) resets PD12. One store, no read, nothing to race with. If a set bit and its reset bit are both written in the same word, set wins.

Output (PD12, the green LED): push-pull, low speed, no pull.

GPIOD->BSRR = 1u << (12 + 16); /* start low */
GPIOD->OTYPER &= ~(1u << 12); /* push-pull */
GPIOD->OSPEEDR &= ~(3u << 24); /* low speed */
GPIOD->PUPDR &= ~(3u << 24); /* no pull */
GPIOD->MODER = (GPIOD->MODER & ~(3u << 24)) | (1u << 24); /* output */

Note the order: set the output level first, then switch to output mode. The other way around, the pin briefly drives whatever ODR held (usually 0). For an LED it does not matter. For a motor driver’s enable pin or a chip select, a glitch at boot can be a real fault.

Input with pull-up (PB0, an external button to ground):

GPIOB->PUPDR = (GPIOB->PUPDR & ~(3u << 0)) | (1u << 0); /* pull-up */
GPIOB->MODER &= ~(3u << 0); /* input */
bool pressed = (GPIOB->IDR & (1u << 0)) == 0; /* active low */

Active low is the common wiring for external buttons: the button shorts the pin to ground, the pull-up holds it high otherwise. The Discovery’s user button on PA0 is the opposite: it connects to 3.3 V and the board has an external pull-down, so it reads 1 when pressed.

Alternate function (USART2 TX on PA2, RX on PA3, AF7):

GPIOA->AFR[0] = (GPIOA->AFR[0] & ~((0xFu << 8) | (0xFu << 12)))
| (7u << 8) | (7u << 12); /* PA2, PA3 = AF7 */
GPIOA->PUPDR = (GPIOA->PUPDR & ~(3u << 6)) | (1u << 6); /* pull-up on RX */
GPIOA->OSPEEDR = (GPIOA->OSPEEDR & ~((3u << 4) | (3u << 6))) | (1u << 4) | (1u << 6);
GPIOA->MODER = (GPIOA->MODER & ~((3u << 4) | (3u << 6)))
| (2u << 4) | (2u << 6); /* AF mode */

In AF mode the peripheral, not ODR, drives the pin. Which peripheral depends on the AF number; the mapping is in the datasheet’s “alternate function mapping” table, not the reference manual. The pull-up on RX keeps the line idle-high (the UART idle level) if the cable is unplugged, which prevents a stream of framing errors from a floating input. Again, the AF number is written before switching the mode, so the pin never briefly connects to AF0.

Analog (PA1, an ADC input):

GPIOA->PUPDR &= ~(3u << 2); /* no pull: it would load the signal */
GPIOA->MODER |= (3u << 2); /* analog */

Analog mode disconnects the digital input path. That matters twice: for ADC accuracy, and for power, because a digital input sitting at a mid-level voltage draws current through the Schmitt trigger.

Writing shifts and masks inline every time is error-prone. A thin driver puts the bit arithmetic in one tested place:

gpio.h
#ifndef GPIO_H
#define GPIO_H
#include <stdbool.h>
#include <stdint.h>
#include "stm32f407_regs.h"
typedef enum {
GPIO_MODE_INPUT = 0u,
GPIO_MODE_OUTPUT = 1u,
GPIO_MODE_AF = 2u,
GPIO_MODE_ANALOG = 3u
} gpio_mode_t;
typedef enum { GPIO_PUSH_PULL = 0u, GPIO_OPEN_DRAIN = 1u } gpio_otype_t;
typedef enum {
GPIO_SPEED_LOW = 0u, GPIO_SPEED_MEDIUM = 1u,
GPIO_SPEED_FAST = 2u, GPIO_SPEED_HIGH = 3u
} gpio_speed_t;
typedef enum { GPIO_PULL_NONE = 0u, GPIO_PULL_UP = 1u, GPIO_PULL_DOWN = 2u } gpio_pull_t;
typedef struct {
gpio_mode_t mode;
gpio_otype_t otype;
gpio_speed_t speed;
gpio_pull_t pull;
uint8_t af; /* 0..15, used only in GPIO_MODE_AF */
bool initial; /* output level applied before the mode switch */
} gpio_cfg_t;
void gpio_init(GPIO_TypeDef *port, uint32_t pin, const gpio_cfg_t *cfg);
bool gpio_lock(GPIO_TypeDef *port, uint16_t pins);
static inline void gpio_write(GPIO_TypeDef *port, uint32_t pin, bool high)
{
port->BSRR = high ? (1u << pin) : (1u << (pin + 16u));
}
static inline bool gpio_read(const GPIO_TypeDef *port, uint32_t pin)
{
return ((port->IDR >> pin) & 1u) != 0u;
}
static inline void gpio_toggle(GPIO_TypeDef *port, uint32_t pin)
{
uint32_t mask = 1u << pin;
uint32_t odr = port->ODR;
/* Reset if currently high, set if currently low, other pins untouched. */
port->BSRR = ((odr & mask) << 16) | (~odr & mask);
}
#endif
gpio.c
#include <stdint.h>
#include "gpio.h"
#define GPIO_PORT_STRIDE 0x400u
#define GPIOA_BASE_ADDR 0x40020000u
static void gpio_clock_enable(const GPIO_TypeDef *port)
{
uint32_t index = (uint32_t)(((uintptr_t)port - GPIOA_BASE_ADDR) / GPIO_PORT_STRIDE);
RCC->AHB1ENR |= 1u << index; /* GPIOAEN is bit 0 ... GPIOIEN is bit 8 */
(void)RCC->AHB1ENR;
}
void gpio_init(GPIO_TypeDef *port, uint32_t pin, const gpio_cfg_t *cfg)
{
uint32_t pos2 = pin * 2u;
gpio_clock_enable(port);
if (cfg->mode == GPIO_MODE_AF) {
uint32_t reg = pin >> 3;
uint32_t pos4 = (pin & 7u) * 4u;
port->AFR[reg] = (port->AFR[reg] & ~(0xFu << pos4))
| ((uint32_t)(cfg->af & 0xFu) << pos4);
}
if (cfg->mode == GPIO_MODE_OUTPUT) {
gpio_write(port, pin, cfg->initial);
}
port->OTYPER = (port->OTYPER & ~(1u << pin)) | ((uint32_t)cfg->otype << pin);
port->OSPEEDR = (port->OSPEEDR & ~(3u << pos2)) | ((uint32_t)cfg->speed << pos2);
port->PUPDR = (port->PUPDR & ~(3u << pos2)) | ((uint32_t)cfg->pull << pos2);
port->MODER = (port->MODER & ~(3u << pos2)) | ((uint32_t)cfg->mode << pos2);
}
bool gpio_lock(GPIO_TypeDef *port, uint16_t pins)
{
uint32_t key = (1u << 16) | pins; /* LCKK = bit 16 */
port->LCKR = key; /* write 1 + pins */
port->LCKR = pins; /* write 0 + pins */
port->LCKR = key; /* write 1 + pins */
(void)port->LCKR; /* read */
return (port->LCKR & (1u << 16)) != 0u; /* LCKK reads 1 if locked */
}

Design decisions worth calling out:

  • The port pointer identifies the clock bit. Ports are 0x400 apart starting at 0x40020000, and the AHB1 enable bits for GPIOA to GPIOI are bits 0 to 8 in the same order, so the index doubles as the bit number. Converting the pointer through uintptr_t is the portable way to do arithmetic on an address.
  • The mode is written last. AF number, initial level, type, speed and pull are all in place before the pin starts behaving as an output or AF pin.
  • gpio_init is not interrupt-safe. It uses read-modify-write on shared port registers. That is acceptable because configuration happens at startup before interrupts are enabled. If you reconfigure pins at runtime from both main and an ISR, protect the call with a critical section (see Interrupts done right).
  • gpio_toggle reads ODR but writes through BSRR. If an ISR changes a different pin between the read and the write, its change survives, because the BSRR write only touches the one pin. The read can still be stale for the pin being toggled itself if an ISR toggles that same pin concurrently, but toggling one pin from two contexts is a design bug anyway.
  • Small hot functions are static inline in the header, so gpio_write(GPIOD, 12, true) with constant arguments compiles to a single store of 0x1000 to 0x40020C18.

gpio_lock implements the LCKR key sequence: write with LCKK = 1, then 0, then 1, keeping the pin bits identical, then read. After a successful sequence, MODER, OTYPER, OSPEEDR, PUPDR and the AFR bits of the locked pins cannot be changed until the next reset. ODR and BSRR still work. This protects safety-relevant pins (a motor driver’s enable line, a power switch) from a wild pointer or a buggy driver that reconfigures them. The sequence must not be interrupted by another write to LCKR, and if any bit differs between the three writes the lock fails, which is why the function checks LCKK at the end.

Hard-coding GPIOD, 12 across the codebase makes a board revision painful. Collect the pin assignments in one place:

board.h
#ifndef BOARD_H
#define BOARD_H
#include "gpio.h"
typedef struct {
GPIO_TypeDef *port;
uint32_t pin;
gpio_cfg_t cfg;
} board_pin_t;
enum { PIN_LED_GREEN, PIN_LED_ORANGE, PIN_LED_RED, PIN_LED_BLUE, PIN_BUTTON, PIN_COUNT };
extern const board_pin_t board_pins[PIN_COUNT];
void board_init(void);
#endif
board.c
#include "board.h"
#define LED_CFG { GPIO_MODE_OUTPUT, GPIO_PUSH_PULL, GPIO_SPEED_LOW, GPIO_PULL_NONE, 0u, false }
const board_pin_t board_pins[PIN_COUNT] = {
[PIN_LED_GREEN] = { GPIOD, 12u, LED_CFG },
[PIN_LED_ORANGE] = { GPIOD, 13u, LED_CFG },
[PIN_LED_RED] = { GPIOD, 14u, LED_CFG },
[PIN_LED_BLUE] = { GPIOD, 15u, LED_CFG },
/* External pull-down on the Discovery board, so no internal pull. */
[PIN_BUTTON] = { GPIOA, 0u,
{ GPIO_MODE_INPUT, GPIO_PUSH_PULL, GPIO_SPEED_LOW,
GPIO_PULL_NONE, 0u, false } },
};
void board_init(void)
{
for (uint32_t i = 0; i < PIN_COUNT; i++) {
gpio_init(board_pins[i].port, board_pins[i].pin, &board_pins[i].cfg);
}
}

The table is const, so it lives in flash. Designated initializers indexed by the enum keep the table and enum in sync even if someone reorders one of them. Application code says gpio_write(board_pins[PIN_LED_RED].port, board_pins[PIN_LED_RED].pin, true), usually behind a one-line helper, and a new board revision changes only board.c.

A mechanical switch does not make a clean transition. The contacts bounce for typically 1 to 10 ms, sometimes more on cheap or worn switches:

pressed ............+ +-+ +--+ +-----------------------
| | | | | |
released ------------+--+ +-+ +-+
|<-- ~5 ms ->|
bounces

Read that pin in a tight loop and one press looks like five. Software debouncing filters it. Three methods, each with a different trade-off. All three assume they are called at a fixed rate, here every 1 ms using millis() from Clocks, reset, and timing.

Accept a new level only after the raw input has disagreed with the current stable state for N consecutive samples:

typedef struct { bool stable; uint8_t count; } debounce_count_t;
/* Call every 1 ms. Returns true on a stable change. */
static bool debounce_count(debounce_count_t *d, bool raw)
{
if (raw == d->stable) {
d->count = 0;
return false;
}
if (++d->count >= 20u) { /* 20 ms of agreement */
d->stable = raw;
d->count = 0;
return true;
}
return false;
}

Simple and predictable: a change is reported exactly 20 ms after the bouncing stops. Any single sample that matches the old state restarts the count.

Keep a counter that moves toward the raw level on each sample, saturating at 0 and a maximum. The output only flips at the extremes, which gives hysteresis:

typedef struct { bool stable; uint8_t level; } debounce_int_t;
#define DEBOUNCE_MAX 10u
static bool debounce_integrate(debounce_int_t *d, bool raw)
{
if (raw && d->level < DEBOUNCE_MAX) { d->level++; }
if (!raw && d->level > 0u) { d->level--; }
if (d->level == DEBOUNCE_MAX && !d->stable) { d->stable = true; return true; }
if (d->level == 0u && d->stable) { d->stable = false; return true; }
return false;
}

Unlike sample-and-count, an occasional noisy sample only sets the integrator back by one step instead of restarting it, so it handles electrically noisy inputs (long cables near motors) better.

Act on the first edge immediately, then ignore all edges for a lockout period:

typedef struct { bool stable; uint32_t last_change; } debounce_ts_t;
static bool debounce_timestamp(debounce_ts_t *d, bool raw, uint32_t now)
{
if (raw != d->stable && (uint32_t)(now - d->last_change) >= 50u) {
d->stable = raw;
d->last_change = now;
return true;
}
return false;
}

Zero latency on the press, which feels responsive in a user interface, and it works well with edge interrupts because it only needs to run when something changes. The weakness: a single noise spike is accepted as a press. Use it for buttons on short traces, not for inputs exposed to interference.

Polling every millisecond is fine for buttons. For signals that need a fast reaction or that should wake the chip from sleep, use the EXTI controller. On the STM32F407 it has 16 lines for GPIO, EXTI0 to EXTI15, plus lines for internal events. Line n can be connected to pin n of exactly one port, chosen in the SYSCFG EXTICR registers. So PA0 and PB0 cannot both have interrupts; they share EXTI0.

The registers involved:

BlockBaseRegisterOffsetPurpose
SYSCFG0x40013800EXTICR1 to EXTICR40x08 to 0x144 bits per line select the port (0 = A, 1 = B, …, 8 = I)
EXTI0x40013C00IMR0x00interrupt mask: 1 = line enabled
EXTIRTSR0x08rising edge trigger
EXTIFTSR0x0Cfalling edge trigger
EXTIPR0x14pending: reads 1 when triggered, write 1 to clear

EXTI0 to EXTI4 each have their own NVIC interrupt (EXTI0_IRQn is 6, then 7, 8, 9, 10). Lines 5 to 9 share EXTI9_5_IRQn (23) and lines 10 to 15 share EXTI15_10_IRQn (40); a shared handler must check PR to find which line fired. SYSCFG is on APB2 and needs its clock: RCC_APB2ENR bit 14.

typedef struct {
volatile uint32_t IMR, EMR, RTSR, FTSR, SWIER, PR;
} EXTI_TypeDef;
#define EXTI ((EXTI_TypeDef *)0x40013C00u)
typedef struct {
volatile uint32_t MEMRMP, PMC;
volatile uint32_t EXTICR[4];
} SYSCFG_TypeDef;
#define SYSCFG ((SYSCFG_TypeDef *)0x40013800u)
#define RCC_APB2ENR_SYSCFGEN (1u << 14)
#define EXTI0_IRQn 6u
static void exti_pa0_rising_init(void)
{
RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN; /* forget this and nothing happens */
(void)RCC->APB2ENR;
SYSCFG->EXTICR[0] &= ~(0xFu << 0); /* EXTI0 <- port A (0) */
EXTI->RTSR |= 1u << 0; /* rising edge */
EXTI->FTSR &= ~(1u << 0);
EXTI->PR = 1u << 0; /* clear anything stale */
EXTI->IMR |= 1u << 0; /* unmask line 0 */
NVIC->ISER[0] = 1u << EXTI0_IRQn; /* enable IRQ 6 in the NVIC */
}
void EXTI0_IRQHandler(void)
{
EXTI->PR = 1u << 0; /* write 1 to clear: no |= */
(void)EXTI->PR; /* make sure it landed */
/* ... record the event ... */
}

The pin itself stays configured as a normal input; EXTI watches its Schmitt trigger output. For PA0 the EXTICR value 0 is also the reset value, so the line in the code is documentation. For PB5 it would be SYSCFG->EXTICR[1] = (SYSCFG->EXTICR[1] & ~(0xFu << 4)) | (1u << 4): line 5 lives in EXTICR2 (array index 1), field 5 mod 4 = 1, port B = 1.

The read-back after clearing is there because the write to PR travels over the APB bus and can still be in flight when the handler returns, in which case the NVIC sees the interrupt still asserted and runs the handler a second time. Why that happens and every other detail of writing handlers, priorities and sharing data with main is the subject of Interrupts done right. Here, notice that an EXTI line on a bouncing button fires on every bounce: interrupts do not debounce for you.

An LED needs a series resistor to set its current: R = (V_supply - V_f) / I. A typical green LED has V_f around 2.0 V at a few mA. For 5 mA from a 3.3 V pin: R = (3.3 - 2.0) / 0.005 = 260 Ω; pick the next standard value, 270 Ω. Modern LEDs are bright at 2 to 5 mA; 20 mA from an MCU pin is both unnecessary and close to the per-pin limit. Blue and white LEDs have V_f near 3 V and may barely light from 3.3 V at all.

Anything over a few milliamps, or any inductive load, goes through a transistor. The standard circuit is a low-side N-channel MOSFET:

+V load supply (5 V, 12 V ...)
|
+----+----+
| |
[load/coil] [diode] cathode to +V (flyback)
| |
+----+----+
|
D
MCU pin --[100R]--G N-MOSFET (logic-level)
| S
[100k] |
| |
GND GND
  • Pick a logic-level MOSFET whose datasheet specifies R_DS(on) at V_GS = 2.5 V or lower. A part rated only at V_GS = 10 V is barely on at 3.3 V and will overheat.
  • The 100 kΩ gate pull-down keeps the MOSFET off while the MCU pin is still a floating input during reset and boot.
  • The small gate resistor limits the current spike when charging the gate capacitance and damps ringing.
  • The flyback diode is mandatory for inductive loads. When the MOSFET turns off, the coil’s current has to go somewhere; without a path, the voltage on the drain spikes to tens or hundreds of volts and destroys the transistor, and the spike can couple back into the MCU.
  • 3.3 V output into a 5 V input: works directly if the 5 V input’s V_IH is at or below about 2.4 V (TTL-compatible logic such as 74HCT). Otherwise use open-drain with a pull-up to 5 V on an FT pin, or a level shifter.
  • 5 V output into a 3.3 V input: safe on an FT pin in digital input mode. On a non-FT pin, use a resistor divider or a buffer.
  • Bidirectional open-drain buses (I2C) between 3.3 and 5 V: the BSS138 MOSFET circuit or a dedicated translator.

A pin that leaves the board (a connector, a cable) meets ESD, wiring mistakes and voltages you did not plan for. A series resistor of a few hundred ohms limits the current into the internal clamp diodes; a TVS diode handles ESD. Never rely on the internal clamp diodes for continuous current: injecting current through them can power the chip from an I/O pin or disturb ADC readings on neighbouring pins.

A floating unused input wastes power and picks up noise. The lowest-power state for an unused pin is analog mode (the Schmitt trigger is disconnected, so it cannot oscillate on noise). Alternatively, enable a pull-down. Do not configure unused pins as outputs driving low unless you are sure nothing on the board connects to them. Power management returns to this.

The program counts raw EXTI interrupts from the user button (toggling the orange LED on each one) and, separately, debounces the same button by polling with the integrator (toggling the green LED on each debounced press). Press the button twenty times: green toggles exactly twenty times; orange toggles once per bounce edge, so it usually ends up out of step. It uses gpio.c, board.c and clock.c from Clocks, reset, and timing, plus your startup file and linker script.

main.c
#include <stdbool.h>
#include <stdint.h>
#include "stm32f407_regs.h"
#include "gpio.h"
#include "board.h"
#include "clock.h"
typedef struct {
volatile uint32_t IMR, EMR, RTSR, FTSR, SWIER, PR;
} EXTI_TypeDef;
#define EXTI ((EXTI_TypeDef *)0x40013C00u)
typedef struct {
volatile uint32_t MEMRMP, PMC;
volatile uint32_t EXTICR[4];
} SYSCFG_TypeDef;
#define SYSCFG ((SYSCFG_TypeDef *)0x40013800u)
#define RCC_APB2ENR_SYSCFGEN (1u << 14)
#define EXTI0_IRQn 6u
#define DEBOUNCE_MAX 10u
typedef struct { bool stable; uint8_t level; } debounce_int_t;
volatile uint32_t g_raw_edges; /* incremented by the ISR */
volatile uint32_t g_clean_presses; /* incremented by main */
static void led_toggle(uint32_t id)
{
gpio_toggle(board_pins[id].port, board_pins[id].pin);
}
static bool debounce_integrate(debounce_int_t *d, bool raw)
{
if (raw && d->level < DEBOUNCE_MAX) { d->level++; }
if (!raw && d->level > 0u) { d->level--; }
if (d->level == DEBOUNCE_MAX && !d->stable) { d->stable = true; return true; }
if (d->level == 0u && d->stable) { d->stable = false; return true; }
return false;
}
static void exti_pa0_rising_init(void)
{
RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;
(void)RCC->APB2ENR;
SYSCFG->EXTICR[0] &= ~(0xFu << 0);
EXTI->RTSR |= 1u << 0;
EXTI->FTSR &= ~(1u << 0);
EXTI->PR = 1u << 0;
EXTI->IMR |= 1u << 0;
NVIC->ISER[0] = 1u << EXTI0_IRQn;
}
void EXTI0_IRQHandler(void)
{
EXTI->PR = 1u << 0;
(void)EXTI->PR;
g_raw_edges++;
led_toggle(PIN_LED_ORANGE);
}
int main(void)
{
(void)clock_init_168mhz();
systick_init_1ms();
board_init();
exti_pa0_rising_init();
/* Lock the LED pins' configuration until reset. */
if (!gpio_lock(GPIOD, (uint16_t)0xF000u)) {
gpio_write(GPIOD, 14u, true); /* red: lock failed */
}
debounce_int_t button = { false, 0u };
uint32_t next_sample = millis();
for (;;) {
if (time_reached(next_sample)) {
next_sample += 1u;
bool raw = gpio_read(board_pins[PIN_BUTTON].port, board_pins[PIN_BUTTON].pin);
if (debounce_integrate(&button, raw) && button.stable) {
g_clean_presses++;
led_toggle(PIN_LED_GREEN);
}
}
}
}

What to look at:

  • button.stable in the condition. The debouncer reports both press and release; toggling only on the press edge gives one toggle per press.
  • g_raw_edges is volatile and only written by the ISR; main never modifies it, so a plain increment in the ISR is safe.
  • led_toggle from both contexts. The ISR toggles PD13 and main toggles PD12. Because gpio_toggle writes through BSRR, neither can undo the other’s pin. With GPIOD->ODR ^= mask in both places, an orange toggle landing in the middle of main’s read-modify-write could be lost.
  • Results. After 20 presses, p g_clean_presses in gdb shows 20 and p g_raw_edges shows a number at or above 20. The Discovery board’s button has some filtering on it, so you may see only a few extra edges; a bare push button on a breadboard wired to PB0 with a pull-up typically shows many more.
  • Configuring a pin before enabling its port clock in RCC_AHB1ENR. Fix: enable and read back first; the driver does this for you.
  • Clearing EXTI->PR with |=. Fix: write only the bit you mean, EXTI->PR = 1u << n.
  • Forgetting the SYSCFG clock (APB2ENR bit 14), so EXTICR writes are ignored and the EXTI line stays on port A. Fix: enable SYSCFG before writing EXTICR.
  • Using ODR |= from main while an ISR writes the same port. Fix: use BSRR for every runtime pin write.
  • Switching to output or AF mode before setting the level or AF number, causing a glitch. Fix: configure ODR/AFR first, MODER last.
  • Leaving inputs floating, including unused pins. Fix: enable a pull, or set unused pins to analog.
  • Driving a relay coil directly or without a flyback diode. Fix: logic-level MOSFET plus diode across the coil.
  • Expecting EXTI to debounce. Fix: debounce in software, or ignore edges for a lockout period.
  1. Compute the series resistor for a red LED (V_f = 1.8 V) at 3 mA from a 3.3 V pin, and the power it dissipates. (Expected: 500 Ω, use 510 Ω, about 4.5 mW.)
  2. Compile gpio.c and a call gpio_write(GPIOD, 12u, true) with -O2 and inspect the disassembly with arm-none-eabi-objdump -d. Confirm it becomes a single str of 0x1000 to 0x40020C18 (possibly via a literal pool load of the address). Then replace it with GPIOD->ODR |= 1u << 12 and count the extra instructions.
  3. Wire an external push button from PB0 to GND, configure PB0 with the internal pull-up, and run all three debouncers side by side on the same samples. Count reported presses for each over 50 presses. Which one produces false presses when you tap the button lightly?
  4. Move the interrupt from PA0 to PB5 with a falling-edge trigger. You will need EXTICR[1], FTSR, and the shared EXTI9_5_IRQHandler, which must check PR bit 5 before acting.
  5. Call gpio_lock on PD12 and then try to change PD12 to input mode. Read MODER back in gdb to confirm the write was ignored, then confirm BSRR still toggles the LED. Finally, break the sequence deliberately (change a pin bit between the writes) and observe LCKK reading 0.

Question 1:Main executes GPIOD->ODR |= (1u << 12); and an ISR that clears PD13 with GPIOD->ODR &= ~(1u << 13); fires between main's load and store. What is the result?

Question 2:Two ISRs handle EXTI lines 1 and 2. The line 1 handler clears its flag with EXTI->PR |= (1u << 1); while line 2 is also pending. What happens?

Question 3:Which configuration lets several devices share one signal line where any of them may pull it low?

Question 4:You want interrupts on PA3 and PB3 at the same time. What is the problem?

Question 5:Why do the debounced and raw counts in the example differ?

  • Push-pull drives both levels; open-drain only pulls low and needs a pull-up. Shared lines and voltage translation use open-drain.
  • OSPEEDR sets edge rate, not frequency. Use the slowest speed that meets timing to reduce EMI.
  • Write pins at runtime through BSRR; one store, no read-modify-write race with interrupts.
  • Configure level and AF number before switching MODER, and enable the port clock before any of it.
  • Mechanical buttons bounce for milliseconds. Debounce in software; EXTI fires on every bounce.
  • EXTI line n serves pin n of one port, selected in SYSCFG_EXTICR; clear EXTI->PR by writing 1 to only your bit.
  • Pins are good for a few mA. Anything bigger, or inductive, goes through a logic-level MOSFET with a flyback diode.

Next: Interrupts done right covers what really happens when that EXTI line fires, and how to write handlers that share data with main without races.