How to Debounce a Pushbutton in Embedded C Without Blocking Delays

A pushbutton looks like the simplest part on the board. Two terminals, a spring, a plastic cap. Then you press it once and the menu jumps two entries, or the counter climbs by four, and suddenly it is late at night and you are reading about contact bounce.
The first fix most people reach for is a delay: read the pin, wait 20 ms, read it again. That works right up until the firmware has something else to do during those 20 ms. What follows is a way to get the same clean result without stopping the CPU or guessing at timing.
Why Blocking Delays Hurt More Than They Help
A call to HAL_Delay(20) or _delay_ms(20) does exactly one thing: it throws away time. No sensor sampling, no UART parsing, no display refresh, no watchdog service if your delays grow long enough. Put that inside the main loop and the loop period becomes unpredictable, which quietly breaks every other timing assumption in the project.
Two buttons with 20 ms each already cost 40 ms per pass. A 100 ms debounce inside a loop that is supposed to run every 10 ms is a bug waiting for a customer to find it. Blocking code also scales badly: the debounce gets slower exactly when the user presses more buttons.
There is a deeper problem than wasted cycles. A delay-based debounce has no memory. It reacts and forgets. A non-blocking debouncer keeps the pin's history, so it can answer useful questions later: was that a short tap or a long hold? Has the button been released yet? That history is what makes the pattern worth writing.
What the Contact Is Actually Doing
Metal contacts do not close cleanly. On a press, the moving contact bounces against its mate for anywhere from a few hundred microseconds to roughly 20 ms. Cheap tactile switches sit near the top of that range, and well-worn ones get worse. With a fast GPIO input and a pull-up resistor, the microcontroller sees every bounce as a legitimate logic edge.
If you count edges or wire the pin to an interrupt, each bounce becomes a real event. Bounce duration also depends on switch construction, temperature, age, and how firmly the user presses, so never tune your window to one lucky sample. Choose a value with margin and move on.
Hardware Debouncing: Fix the Signal First
Software can clean up almost anything, but a little help from the board makes the job easier and the firmware simpler.
- Always define the idle level. A pull-up of 10 k with the button shorting the pin to ground is the classic arrangement. Without a defined idle state, a floating pin picks up whatever the nearby traces are doing.
- Add an RC filter when edges matter. A 10 k resistor and a 100 nF capacitor give a time constant around 1 ms, which knocks the sharpest spikes off the waveform. Remember that this turns the edge into a slow ramp, not a step.
- Prefer pins with Schmitt-trigger inputs. Hysteresis turns a noisy ramp into a single clean transition. Many modern GPIOs include it, but some analog-capable pins disable it, and a slow edge on a non-Schmitt input can sit in the undefined region and draw extra current.
- Use a dedicated inverter when you need a hard guarantee. A spare gate from a 74HC14 with an RC network produces an edge clean enough for a hardware counter or a reset line.
Keep the capacitor physically close to the pin, and be careful with long cables to a panel-mounted button: the same wiring that carries your signal also carries noise. For most boards, a pull-up plus a software debouncer is the right balance. Hardware alone rarely solves everything and always adds parts.
The Non-Blocking Millisecond Tick
You need a clock. Any periodic timer works, and SysTick is the usual pick on Cortex-M parts. Increment a global counter in the handler and nowhere else:
volatile uint32_t g_ms = 0; void SysTick_Handler(void) { g_ms++; }
Two details matter here. The variable must be volatile, because it changes outside the normal flow of the code that reads it. And it should live in one place, so there is never a question about which timer owns it. Enabling a 1 ms interrupt is cheap on almost any MCU.
If your application spends its life in a deep sleep and stops the tick, you need a low-power timer that keeps counting, or a different approach: wake on the pin edge, start a short timer, and only accept the new level once that timer expires.
A Debounce State Machine in C
Give each button a small record: the last raw reading, the last accepted (stable) level, and a timestamp for the most recent change on the raw pin. Then the scan function becomes four lines of logic.
typedef struct { uint8_t stable, last_raw, pressed; uint32_t timestamp; } btn_t; void button_scan(btn_t *b, uint8_t raw) { if (raw != b->last_raw) { b->last_raw = raw; b->timestamp = g_ms; return; } if (raw != b->stable && (uint32_t)(g_ms - b->timestamp) >= DEBOUNCE_MS) { b->stable = raw; if (raw == PRESSED_LEVEL) b->pressed = 1; } }
Read it as a story. If the raw pin moved since last time, store the new reading and restart the timer; the signal is not trusted yet. If the pin has held that reading steady for the whole debounce window, promote it to the stable level, and if it is the pressed level, raise an event flag.
Note the subtraction: (uint32_t)(g_ms - b->timestamp) stays correct even when the millisecond counter wraps around, because unsigned arithmetic handles the overflow for you. Comparing g_ms > timestamp directly would not.
Call button_scan() on a fixed schedule, every 5 or 10 ms, from the main loop. Sampling does not need to be fast, it needs to be regular.
From Clean State to Useful Events
Once the stable level is trustworthy, adding behaviour is easy. The flag set on a press is consumed by the application, which clears it after acting. For a long press, count how long the stable level has stayed pressed and fire once when it crosses your threshold. For repeats, reset a second timer each time you emit a repeat event.
Multiple buttons are just an array of the structure, scanned in a loop. Keep the array in one place, and keep the pin configuration table next to it so adding a button is a two-line change.
A few traps are worth naming before you ship:
- Doing the debounce work inside the GPIO interrupt handler. Interrupts should set a flag and return; the main loop owns the state machine.
- Attaching an EXTI interrupt and treating every edge as a press. Either ignore further edges until the window expires, or use the interrupt only to wake the main loop.
- Forgetting volatile on the tick, then wondering why the optimiser removed the loop.
- Choosing a window that is too short and letting bounce through, or too long and losing quick double taps. Ten to twenty milliseconds suits most tactile switches; grimy panel buttons want closer to thirty.
- Mixing active-low and active-high conventions in the same project. Define one constant such as PRESSED_LEVEL and use it everywhere.
Testing It on the Bench
Put a scope or a cheap logic analyser on the pin and press the button a few times slowly. You will see the bounce cluster clearly, which tells you what your window has to cover. Then add a temporary counter for raw transitions and a second one for accepted presses. Slam the button fifty times in a row; the accepted count should be fifty.
Test the awkward cases too: a very light tap, a long hold, a press that lands while a large transfer is running. Try it with the enclosure fitted and a real finger rather than a jumper wire. If your sampling loop ever stalls because of a blocking routine elsewhere, the button will feel dead, and the debouncer will get blamed for a problem it did not cause.
Photo: Ludovic Delot / Pexels


