STM32 Push Button with EXTI Interrupt and Software Debounce
The simplest way to read a button is to check the pin in the main loop, which is called polling. It works, but the loop must run often enough to catch every press, and it wastes CPU time checking a pin that rarely changes. An interrupt reverses this: the hardware watches the pin and calls your code only when the level changes. On STM32 this is handled by the EXTI (Extended Interrupt and Event) controller.
In this tutorial you will connect the B1 user button on a Nucleo-F401RE to an EXTI interrupt, toggle an LED on each press, count presses, and filter out contact bounce. A Blue Pill version with an external button is also covered.
How EXTI Works on STM32
The EXTI controller has one line for each pin number, EXTI0 to EXTI15. Line 0 can be connected to PA0 or PB0 or PC0 and so on, but only one port at a time. This means you cannot use PA3 and PB3 as separate interrupt sources, because both share EXTI3. The port selection is made in the SYSCFG registers on the F4 (AFIO on the F1); CubeMX sets this for you.
Each line can trigger on a rising edge, a falling edge or both. The lines are then connected to the NVIC, the interrupt controller inside the Cortex-M core, through a limited set of interrupt vectors:
| EXTI lines | Interrupt vector (IRQn) | Handler function |
|---|---|---|
| 0, 1, 2, 3, 4 | EXTI0_IRQn to EXTI4_IRQn (one each) | EXTI0_IRQHandler to EXTI4_IRQHandler |
| 5 to 9 | EXTI9_5_IRQn (shared) | EXTI9_5_IRQHandler |
| 10 to 15 | EXTI15_10_IRQn (shared) | EXTI15_10_IRQHandler |
When an edge occurs, the pending bit for that line is set and the handler runs. The generated handler calls HAL_GPIO_EXTI_IRQHandler(pin), which clears the pending bit and then calls HAL_GPIO_EXTI_Callback(pin). You write that callback in your own code. Because lines 10 to 15 share one handler, the pin number passed to the callback tells you which pin fired.
Hardware
| Board | Button | Pin | Level when pressed | LED |
|---|---|---|---|---|
| Nucleo-F401RE | B1 (blue) | PC13 | Low (external pull-up on board) | LD2 on PA5 |
| Blue Pill | External push button to GND | PB12 | Low (internal pull-up) | PC13, active low |
For the Blue Pill, wire one side of a tactile switch to PB12 and the other side to GND. The internal pull-up holds the pin high, and pressing the switch pulls it low. No external resistor is needed for a short wire, though a 100 nF capacitor from the pin to GND helps reduce bounce in noisy setups.
CubeMX Configuration
- In the Nucleo project, click PC13 and select GPIO_EXTI13. The board template may already have done this with the label B1.
- Under System Core, GPIO, select PC13 and set GPIO mode to External Interrupt Mode with Falling edge trigger detection. Set pull to No pull-up and no pull-down, since the board has its own resistor.
- Open the NVIC tab in the same GPIO panel, or System Core, NVIC, and tick the Enabled box for EXTI line[15:10] interrupts.
- In the NVIC table, compare the EXTI preemption priority with the Time base: System tick timer entry. Give EXTI a larger number (lower urgency), for example 2, so the 1 ms tick can still interrupt it.
- For the Blue Pill, choose GPIO_EXTI12 on PB12, falling edge, Pull-up, and enable EXTI line[15:10] in the NVIC.
Interrupt Code with Debouncing
A mechanical button does not make one clean contact. For a few milliseconds the contacts bounce, producing several edges, and without filtering each press might count as three or four. The code below ignores any edge that arrives within 50 ms of the last accepted one. It keeps the interrupt short and leaves the slow work to the main loop.
/* USER CODE BEGIN PV */
#define DEBOUNCE_MS 50U
/* Shared between interrupt and main loop, so they must be volatile */
volatile uint32_t press_count = 0;
volatile uint8_t press_event = 0;
volatile uint32_t last_press_tick = 0;
/* USER CODE END PV */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init(); /* PC13 as EXTI falling edge, NVIC line 15:10 enabled */
/* USER CODE BEGIN 2 */
uint32_t handled = 0;
/* USER CODE END 2 */
while (1)
{
/* USER CODE BEGIN 3 */
if (press_event)
{
press_event = 0;
handled++;
HAL_GPIO_TogglePin(LD2_GPIO_Port, LD2_Pin);
/* Every fifth press, give a long flash as feedback */
if (handled % 5 == 0)
{
HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
HAL_Delay(600);
HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
}
}
/* The CPU could sleep here with __WFI() until the next interrupt */
/* USER CODE END 3 */
}
}
/* USER CODE BEGIN 4 */
/* Called by HAL_GPIO_EXTI_IRQHandler() from EXTI15_10_IRQHandler().
Keep it short: no HAL_Delay, no printf. */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == B1_Pin) /* B1_Pin is GPIO_PIN_13 */
{
uint32_t now = HAL_GetTick();
if ((now - last_press_tick) >= DEBOUNCE_MS)
{
last_press_tick = now;
press_count++;
press_event = 1; /* tell the main loop */
}
}
}
/* USER CODE END 4 */
You do not need to edit stm32f4xx_it.c. CubeMX has already generated EXTI15_10_IRQHandler(), which calls HAL_GPIO_EXTI_IRQHandler(B1_Pin). The HAL library provides a weak, empty HAL_GPIO_EXTI_Callback, and your version in main.c replaces it at link time.
Why the Code Is Written This Way
- volatile variables: the compiler cannot see that an interrupt changes
press_event. Withoutvolatile, it may read the variable once and loop forever on a stale copy. - Flag instead of work: the callback only records the event. Long operations inside an interrupt block other interrupts of equal or lower priority and make timing unpredictable.
- HAL_GetTick in the callback: reading the tick is safe and fast. Calling
HAL_Delaythere is not, because the tick may stop advancing while you wait inside a higher-priority handler. - Debounce window: 50 ms suits most tactile switches. Very cheap switches may need more; a fast double-click may need less. Measure with an oscilloscope if you have one.
Troubleshooting
- Callback never runs: the NVIC line was not enabled in CubeMX, so the EXTI sets a pending bit but no interrupt is taken.
- Callback name misspelled: the weak HAL version is used silently and nothing happens. Copy the exact name
HAL_GPIO_EXTI_Callback. - Two buttons on PA5 and PB5 do not both work: they share EXTI5. Move one to a different pin number.
- Counts jump by more than one per press: increase the debounce time or add a 100 nF capacitor.
- Program freezes on press: you called
HAL_Delayinside the callback while the EXTI priority was higher than SysTick.
Summary
EXTI lets the hardware watch a pin and call your code on an edge. You mapped PC13 to EXTI13, enabled the shared EXTI15_10 vector in the NVIC, implemented HAL_GPIO_EXTI_Callback, and debounced presses using HAL_GetTick. The main loop now does the real work, which keeps interrupts short and predictable. Next, you will send debug messages over UART using printf.