Every Arduino beginner eventually runs into the same frustrating problem: your button press registers multiple times when it should register once. This is the button bounce problem — and solving it properly requires moving beyond simple delay() hacks into genuine software architecture. State machine programming is the professional solution used in industrial and automotive embedded systems. This tutorial teaches you the Finite State Machine (FSM) design pattern applied to reliable button debounce on Arduino, with scalable code you can reuse in any project.
Table of Contents
- Why Buttons Bounce: The Physics
- Why delay() Debounce Fails in Real Projects
- Finite State Machine Theory in Plain English
- Building the Basic 4-State Debounce FSM
- Adding Edge Detection: Press, Release, and Hold
- Scaling to Multiple Buttons Without Blocking
- Advanced Patterns: Long Press and Double Click
- Frequently Asked Questions
Why Buttons Bounce: The Physics
A mechanical push button consists of two metal contacts that physically touch when pressed. At the microscopic level, the metal surfaces are rough. When they come together, they bounce — literally vibrating apart and back together dozens of times in the first 5–50 milliseconds after a press. To a microcontroller sampling at 16 million times per second, each bounce looks like a separate button press.
With a scope, a typical button press looks like this: a series of rapid 0→1→0→1 transitions over 20–30 ms, finally settling to a stable state. The total bounce time varies by button quality — cheap tactile switches often bounce for 5–10 ms; panel-mount buttons with springs can bounce for 20–50 ms.
Hardware debounce (RC filter + Schmitt trigger) is the purest solution but adds cost and PCB real estate. Software debounce in your Arduino code is free and flexible — when done right with a state machine approach.
Why delay() Debounce Fails in Real Projects
The standard Arduino debounce example uses delay(50) after detecting a state change — wait 50 ms, then re-read the pin. This works for trivial single-button sketches but is fundamentally flawed for anything real:
- It blocks everything: During
delay(50), your Arduino does nothing. No sensor reads, no display updates, no serial communication. A 50 ms blocking call at 20 Hz gives you a maximum system response rate of 20 updates/second — terrible for any reactive application. - It cannot detect press duration: You cannot detect long press vs short press if you are busy waiting.
- It does not scale: Three buttons with independent 50 ms delays in sequence means a worst-case 150 ms latency before any button registers — the system feels unresponsive.
- It misses other buttons during the wait: While waiting for button A to debounce, a simultaneous press on button B is missed entirely.
The state machine approach solves all of these by making the debounce logic non-blocking — the FSM checks elapsed time without ever calling delay().
Finite State Machine Theory in Plain English
A Finite State Machine is a mathematical model of computation that exists in exactly one of a finite number of states at any given time. State transitions happen when defined conditions (events) occur. Three things define an FSM:
- States: The possible conditions the system can be in
- Events: Inputs or conditions that trigger transitions
- Transitions: The rules for moving between states based on events
For a debounced button, the natural states are:
- IDLE: Button is released and stable. Waiting for a press.
- PRESSING: Pin went LOW but we are not sure yet — could be bounce. Waiting for debounce timer.
- PRESSED: Pin has been LOW for long enough — confirmed press.
- RELEASING: Pin went HIGH but debounce timer not expired — could be bounce.
Drawing the state transition diagram (even on paper) before writing code is the key discipline that separates robust embedded code from buggy hacks.
Building the Basic 4-State Debounce FSM
Here is a complete, well-commented implementation using millis() for non-blocking timing:
// Button Debounce using Finite State Machine
// Non-blocking: uses millis(), never delay()
const int BUTTON_PIN = 2;
const unsigned long DEBOUNCE_MS = 20; // Adjust 15-50ms per your button
// FSM states
enum ButtonState {
BTN_IDLE, // Released, stable
BTN_PRESSING, // Went LOW, debouncing
BTN_PRESSED, // Confirmed pressed
BTN_RELEASING // Went HIGH, debouncing
};
ButtonState btnState = BTN_IDLE;
unsigned long debounceTimer = 0;
bool buttonEvent = false; // True on confirmed press
bool releaseEvent = false; // True on confirmed release
void updateButton() {
int rawPin = digitalRead(BUTTON_PIN); // LOW = pressed (pull-up)
unsigned long now = millis();
switch (btnState) {
case BTN_IDLE:
if (rawPin == LOW) {
// Possible press detected — start debounce timer
btnState = BTN_PRESSING;
debounceTimer = now;
}
break;
case BTN_PRESSING:
if (rawPin == HIGH) {
// Bounced back before timeout — false alarm
btnState = BTN_IDLE;
} else if (now - debounceTimer >= DEBOUNCE_MS) {
// Stable LOW for full debounce period — genuine press!
btnState = BTN_PRESSED;
buttonEvent = true; // Signal a press event
}
break;
case BTN_PRESSED:
if (rawPin == HIGH) {
// Button released — start release debounce
btnState = BTN_RELEASING;
debounceTimer = now;
}
break;
case BTN_RELEASING:
if (rawPin == LOW) {
// Bounced back during release — still pressed
btnState = BTN_PRESSED;
} else if (now - debounceTimer >= DEBOUNCE_MS) {
// Stable HIGH for full debounce period — genuine release!
btnState = BTN_IDLE;
releaseEvent = true; // Signal a release event
}
break;
}
}
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP); // Internal pull-up: unpressed = HIGH
}
void loop() {
updateButton(); // Call every loop iteration
// Check events (fire-and-forget flags)
if (buttonEvent) {
buttonEvent = false;
Serial.println("Button PRESSED");
// Your action here
}
if (releaseEvent) {
releaseEvent = false;
Serial.println("Button RELEASED");
}
// Rest of your code here — runs at full speed, NEVER blocked
}
Notice that updateButton() is called every loop iteration — hundreds of thousands of times per second — but the FSM only transitions when the time condition is met. The CPU spends almost zero time in this function, leaving full bandwidth for the rest of your application.
Adding Edge Detection: Press, Release, and Hold
The boolean flags buttonEvent and releaseEvent implement edge detection — you get exactly one event per physical press, regardless of how long the button is held. This is essential for counters, toggles, and menus.
Detecting Button Hold Duration
To detect how long a button has been held (for long-press functionality), record the timestamp when the press is confirmed and check elapsed time in BTN_PRESSED state:
case BTN_PRESSED:
// Long press detection: fire after 1 second of holding
if (!longPressTriggered && (millis() - pressStartTime >= 1000)) {
longPressTriggered = true;
Serial.println("LONG PRESS detected!");
}
if (rawPin == HIGH) {
btnState = BTN_RELEASING;
debounceTimer = now;
longPressTriggered = false; // Reset for next press
}
break;
Scaling to Multiple Buttons Without Blocking
The beauty of the FSM approach is that it encapsulates all state in variables — making it trivially scalable to multiple buttons. Use a struct:
struct Button {
int pin;
ButtonState state;
unsigned long timer;
bool pressEvent;
bool releaseEvent;
};
Button buttons[] = {
{2, BTN_IDLE, 0, false, false}, // Button 1 on pin 2
{3, BTN_IDLE, 0, false, false}, // Button 2 on pin 3
{4, BTN_IDLE, 0, false, false}, // Button 3 on pin 4
};
const int NUM_BUTTONS = 3;
void updateAllButtons() {
unsigned long now = millis();
for (int i = 0; i < NUM_BUTTONS; i++) {
updateSingleButton(&buttons[i], now);
}
}
Now all three buttons are debounced simultaneously with zero blocking. Each button’s state is independent — bouncing on button 1 does not block button 2 from being detected.
Advanced Patterns: Long Press and Double Click
Double Click Detection FSM
Double click requires tracking time between successive presses — this extends the FSM with additional states:
- WAIT_SECOND_PRESS: First press confirmed; waiting to see if a second press arrives within the double-click window (typically 300–500 ms)
- DOUBLE_CLICKED: Second press arrived in time — fire double click event
- SINGLE_CLICKED: Timeout expired without second press — fire single click event
// After BTN_PRESSED confirms first press:
btnState = WAIT_SECOND_PRESS;
firstPressTime = millis();
// In WAIT_SECOND_PRESS state:
if (secondPressDetected) {
doubleClickEvent = true;
btnState = BTN_IDLE;
} else if (millis() - firstPressTime > DOUBLE_CLICK_TIMEOUT_MS) {
singleClickEvent = true; // No second press came
btnState = BTN_IDLE;
}
Why This Matters for Real Projects
State machine button handling is used in virtually every commercial embedded product — from washing machine control panels to industrial HMIs to automotive steering wheel controls. Mastering this pattern is one of the most transferable skills in embedded programming. Once you have a solid Button FSM struct, you can drop it into any future project and have reliable button handling in minutes.
Frequently Asked Questions
What debounce time should I use — 20ms, 50ms?
Most mechanical tactile switches (the small square ones common in Arduino starter kits) debounce within 5–10 ms. Using 20 ms is a safe conservative choice that handles nearly all push buttons. For high-quality panel-mount switches, you may reduce to 10 ms. For cheap microswitches or reed switches, you may need 50 ms. Test your specific button with a logic analyser or scope for the best value.
Can I use interrupts instead of polling for button FSM?
Yes, but interrupts alone do not solve debounce — they make it worse by firing on every bounce. The correct approach is to use an interrupt to wake from sleep (for power saving) and then run the polling FSM once awake. In normal active code, polling in the main loop (as shown here) is simpler and equally responsive given Arduino’s loop speed.
Why does INPUT_PULLUP reverse button logic?
With INPUT_PULLUP, the pin is pulled to 5V internally. When you press the button connecting the pin to GND, the pin reads LOW. When released (no connection to GND), the pull-up holds it HIGH. So pressed = LOW, released = HIGH — inverted from what you might expect. Always account for this in your FSM conditions.
Is there an Arduino library that does all this automatically?
Yes — the Bounce2 library by Thomas O Fredericks implements a clean debounced button abstraction with update(), fell(), rose(), and read() methods. It uses exactly the state-machine approach described here. Understanding how to write it yourself first, however, makes you a better developer and allows customisation (long press, double click) that generic libraries may not support.
My button works fine in testing but misses presses in production — why?
Several causes: (1) Long wire runs from button to Arduino pick up electrical noise — add a 100nF capacitor from the button pin to GND as hardware pre-filtering; (2) The Arduino’s loop is spending time in delay() calls elsewhere — any delay() in your loop means updateButton() is not called during that time; (3) Using attachInterrupt() and the interrupt firing during sensitive operations. The fix for (2) and (3) is to fully eliminate delay() from your sketch using the millis() pattern throughout.
Level up your embedded programming skills — explore the full range of Arduino boards and development kits at Zbotic.in, delivered across India with fast shipping.
Add comment