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.
What is inside a pin
Section titled “What is inside a pin”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)Push-pull and open-drain
Section titled “Push-pull and open-drain”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.
Pull-up and pull-down
Section titled “Pull-up and pull-down”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.
Output speed and EMI
Section titled “Output speed and EMI”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.
The input path
Section titled “The input path”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.
5 V tolerance and current limits
Section titled “5 V tolerance and current limits”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.
The GPIO registers
Section titled “The GPIO registers”Each port (GPIOA at 0x40020000, then every 0x400 bytes up to GPIOI) has the same register set:
| Offset | Register | Bits per pin | Encoding |
|---|---|---|---|
0x00 | MODER | 2 | 00 input, 01 output, 10 alternate function, 11 analog |
0x04 | OTYPER | 1 (bits 15:0) | 0 push-pull, 1 open-drain |
0x08 | OSPEEDR | 2 | 00 low, 01 medium, 10 fast, 11 high |
0x0C | PUPDR | 2 | 00 none, 01 pull-up, 10 pull-down, 11 reserved |
0x10 | IDR | 1 (bits 15:0) | input level, read-only |
0x14 | ODR | 1 (bits 15:0) | output latch, read/write |
0x18 | BSRR | 1 set + 1 reset | write-only: bit n sets pin n, bit n+16 resets pin n |
0x1C | LCKR | 1 + key bit 16 | configuration lock |
0x20 | AFRL | 4 (pins 0 to 7) | alternate function number 0 to 15 |
0x24 | AFRH | 4 (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.
Why BSRR exists
Section titled “Why BSRR exists”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.
Four worked configurations
Section titled “Four worked configurations”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.
A small GPIO driver
Section titled “A small GPIO driver”Writing shifts and masks inline every time is error-prone. A thin driver puts the bit arithmetic in one tested place:
#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#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
0x400apart starting at0x40020000, 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 throughuintptr_tis 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_initis 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_togglereadsODRbut writes throughBSRR. If an ISR changes a different pin between the read and the write, its change survives, because theBSRRwrite 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 inlinein the header, sogpio_write(GPIOD, 12, true)with constant arguments compiles to a single store of0x1000to0x40020C18.
Pin locking
Section titled “Pin locking”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.
A board file
Section titled “A board file”Hard-coding GPIOD, 12 across the codebase makes a board revision painful. Collect the pin assignments in one place:
#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#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.
Reading a button reliably
Section titled “Reading a button reliably”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 ->| bouncesRead 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.
Sample and count
Section titled “Sample and count”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.
Integrator
Section titled “Integrator”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.
Timestamp lockout
Section titled “Timestamp lockout”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.
External interrupts
Section titled “External interrupts”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:
| Block | Base | Register | Offset | Purpose |
|---|---|---|---|---|
| SYSCFG | 0x40013800 | EXTICR1 to EXTICR4 | 0x08 to 0x14 | 4 bits per line select the port (0 = A, 1 = B, …, 8 = I) |
| EXTI | 0x40013C00 | IMR | 0x00 | interrupt mask: 1 = line enabled |
| EXTI | RTSR | 0x08 | rising edge trigger | |
| EXTI | FTSR | 0x0C | falling edge trigger | |
| EXTI | PR | 0x14 | pending: 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.
Driving real loads
Section titled “Driving real loads”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.
Motors, relays and solenoids
Section titled “Motors, relays and solenoids”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.
Level shifting
Section titled “Level shifting”- 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.
Protecting pins
Section titled “Protecting pins”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.
Unused pins
Section titled “Unused 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 complete example
Section titled “The complete example”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.
#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.stablein the condition. The debouncer reports both press and release; toggling only on the press edge gives one toggle per press.g_raw_edgesisvolatileand only written by the ISR; main never modifies it, so a plain increment in the ISR is safe.led_togglefrom both contexts. The ISR toggles PD13 and main toggles PD12. Becausegpio_togglewrites throughBSRR, neither can undo the other’s pin. WithGPIOD->ODR ^= maskin 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_pressesin gdb shows 20 andp g_raw_edgesshows 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.
Common mistakes
Section titled “Common mistakes”- 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->PRwith|=. Fix: write only the bit you mean,EXTI->PR = 1u << n. - Forgetting the SYSCFG clock (
APB2ENRbit 14), soEXTICRwrites are ignored and the EXTI line stays on port A. Fix: enable SYSCFG before writingEXTICR. - Using
ODR |=from main while an ISR writes the same port. Fix: useBSRRfor every runtime pin write. - Switching to output or AF mode before setting the level or AF number, causing a glitch. Fix: configure
ODR/AFRfirst,MODERlast. - 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.
Exercises
Section titled “Exercises”- 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.)
- Compile
gpio.cand a callgpio_write(GPIOD, 12u, true)with-O2and inspect the disassembly witharm-none-eabi-objdump -d. Confirm it becomes a singlestrof0x1000to0x40020C18(possibly via a literal pool load of the address). Then replace it withGPIOD->ODR |= 1u << 12and count the extra instructions. - 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?
- Move the interrupt from PA0 to PB5 with a falling-edge trigger. You will need
EXTICR[1],FTSR, and the sharedEXTI9_5_IRQHandler, which must checkPRbit 5 before acting. - Call
gpio_lockon PD12 and then try to change PD12 to input mode. ReadMODERback in gdb to confirm the write was ignored, then confirmBSRRstill toggles the LED. Finally, break the sequence deliberately (change a pin bit between the writes) and observeLCKKreading 0.
Check yourself
Section titled “Check yourself”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?
Main loaded ODR before the ISR ran, so its store writes back the old PD13 value. Writing through BSRR avoids the read entirely.
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?
Reading PR returns 1 for every pending line; writing those 1s back clears them all. Use EXTI->PR = 1u << 1;.
Question 3:Which configuration lets several devices share one signal line where any of them may pull it low?
Open-drain outputs only sink current, so none can short another by driving high; a pull-up provides the high level when all are released.
Question 4:You want interrupts on PA3 and PB3 at the same time. What is the problem?
EXTI line n is routed to pin n of exactly one port via SYSCFG_EXTICR, so PA3 and PB3 compete for the same line.
Question 5:Why do the debounced and raw counts in the example differ?
Hysteresis cleans up slow edges but each bounce is a full transition; only time-based filtering removes it.
Key takeaways
Section titled “Key takeaways”- Push-pull drives both levels; open-drain only pulls low and needs a pull-up. Shared lines and voltage translation use open-drain.
OSPEEDRsets 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; clearEXTI->PRby 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.