Learning Goals 5 min
- Recap switch bounce from L01-18 — the few-millisecond flutter at the moment of a press — and the L1 fix of
delay(50). - Replace that
delay-based debounce with themillis()-based version: track a last-change timestamp, only accept a state as "stable" once it's been steady for 50 ms. - Wrap the whole thing into a reusable
readDebounced(pin)helper that fits inside a tick function — Cluster F discipline preserved.
Warm-Up 10 min
L01-18 fixed bounce by sticking a delay(50) after detecting a press. It worked, but it broke the whole "don't block the loop" principle we cemented in Cluster F. Today we replace that with the clean version.
What bounce looks like in real life
A tactile push-button is two metal plates pressed together by your finger. The first contact happens, then bounces apart microscopically, makes contact again, bounces apart, makes contact... for a few milliseconds. Your finger feels one press. The Arduino sees 5–20 separate HIGH→LOW transitions.
Without debouncing, "press counter" sketches count 5–20 each press. Game-controller sketches think you mashed the button five times. State-machine sketches flip back and forth in mid-bounce.
New Concept · The millis() debouncer 20 min
The idea in one sentence
A button is only "really" in a state once it's been in that state for at least N milliseconds without changing. Set N = 50 (the standard tactile-switch debounce window) and you're done.
The pattern
const unsigned long DEBOUNCE_MS = 50;
unsigned long lastChange = 0;
int rawState = HIGH;
int stableState = HIGH;
void tickButton() {
int now = digitalRead(BUTTON);
if (now != rawState) {
rawState = now;
lastChange = millis(); // bounce started
}
if (millis() - lastChange >= DEBOUNCE_MS && rawState != stableState) {
stableState = rawState; // bounce has settled
// ... fire an event based on stableState ...
}
}Three variables: rawState = whatever digitalRead said last (bouncy, untrustworthy); stableState = the state we're confident about; lastChange = when we last saw rawState change. The if-block at the bottom only commits a new stableState once the raw signal has held its new value for 50 ms.
State-change detection on top
Once stableState is reliable, you can build the "fired exactly once per press" logic on top:
if (stableState == LOW && prevStable == HIGH) {
// a brand-new press just happened (and the bounce is over)
pressCount++;
}
prevStable = stableState;Combined: bounce-free, edge-triggered, instant-feeling, non-blocking. Best of both worlds.
Wrap it in a reusable helper
For one button this is easy; for three buttons it's six variables and getting messy. Roll into a struct:
struct DebouncedButton {
int pin;
unsigned long lastChange = 0;
int rawState = HIGH;
int stableState = HIGH;
int prevStable = HIGH;
bool justPressed = false; // set true for one tick after a settled press
void tick() {
int now = digitalRead(pin);
if (now != rawState) { rawState = now; lastChange = millis(); }
justPressed = false;
if (millis() - lastChange >= 50 && rawState != stableState) {
prevStable = stableState;
stableState = rawState;
if (stableState == LOW && prevStable == HIGH) justPressed = true;
}
}
};
DebouncedButton btn1{2}; // pin 2
DebouncedButton btn2{3};
void loop() {
btn1.tick();
btn2.tick();
if (btn1.justPressed) Serial.println("btn1 pressed!");
if (btn2.justPressed) Serial.println("btn2 pressed!");
}One struct per button. Each ticks itself. The flag justPressed is true for exactly one loop iteration per real press — perfect for "event" consumers.
Worked Example · The bounce-free press counter 20 min
Step 1 — wiring
| Component | Pin |
|---|---|
| Button 1 (INPUT_PULLUP, to GND) | D2 |
| Button 2 (INPUT_PULLUP, to GND) | D3 |
| Optional indicator LED | D13 (on-board) |
Step 2 — the sketch
Save as debounce-counter.ino:
// L02-37: Two debounced buttons with millis()
struct DebouncedButton {
int pin;
unsigned long lastChange;
int rawState, stableState, prevStable;
bool justPressed;
void begin(int p) {
pin = p;
pinMode(pin, INPUT_PULLUP);
rawState = stableState = prevStable = digitalRead(pin);
lastChange = millis();
justPressed = false;
}
void tick() {
int now = digitalRead(pin);
if (now != rawState) { rawState = now; lastChange = millis(); }
justPressed = false;
if (millis() - lastChange >= 50 && rawState != stableState) {
prevStable = stableState;
stableState = rawState;
if (stableState == LOW && prevStable == HIGH) justPressed = true;
}
}
};
DebouncedButton btn1, btn2;
int count1 = 0, count2 = 0;
void setup() {
Serial.begin(9600);
pinMode(13, OUTPUT);
btn1.begin(2);
btn2.begin(3);
}
void tickHeartbeat() {
static unsigned long prev = 0;
static int state = LOW;
if (millis() - prev >= 500) {
prev = millis();
state = !state;
digitalWrite(13, state);
}
}
void loop() {
btn1.tick();
btn2.tick();
tickHeartbeat();
if (btn1.justPressed) {
count1++;
Serial.print("btn1: "); Serial.println(count1);
}
if (btn2.justPressed) {
count2++;
Serial.print("btn2: "); Serial.println(count2);
}
}Step 3 — test the "rapid press" case
Open Serial Monitor. Press button 1 ten times in five seconds. The count should go up by exactly 10. Not 30. Not 7. Exactly 10.
Now hold button 1 down for 3 seconds. The count goes up by exactly 1, then stays. Releasing doesn't fire an event (the helper detects press, not release).
Step 4 — verify both buttons work independently
Press both buttons in alternation, very rapidly. Each count goes up by exactly the right amount. The heartbeat LED on D13 stays at a steady 1 Hz — the loop isn't blocked at any point.
Step 5 — see the bounce by removing the debounce
For comparison, temporarily change the 50 in the tick to 1. Re-upload. Press a button once. You'll likely see btn1: 1, then btn1: 2, btn1: 3 from the SAME physical press — the raw bounce coming through. Restore 50 — the bug disappears.
Try It Yourself 15 min
Goal: Add a third button on D4 that resets both counters when pressed. Use the same DebouncedButton struct.
Hint
DebouncedButton btnReset;
// in setup: btnReset.begin(4);
// in loop: btnReset.tick();
// after the other ifs:
if (btnReset.justPressed) {
count1 = count2 = 0;
Serial.println("RESET");
}Goal: Add a justReleased flag to the struct so you can detect button release as a separate event. Useful for "hold-to-do-X" gestures.
Hint
bool justReleased = false;
// inside the commit block:
justPressed = (stableState == LOW && prevStable == HIGH);
justReleased = (stableState == HIGH && prevStable == LOW);Both flags get reset to false at the top of every tick; only one can be true per tick. Now your loop can react to press OR release independently.
Goal: Detect a long press (button held for ≥ 1 second). Add a longPressFired flag that fires once when the button has been stably pressed for 1000 ms.
Hint
bool longPressFired = false;
unsigned long pressedAt = 0;
// inside tick, after computing stableState:
if (justPressed) { pressedAt = millis(); longPressFired = false; }
if (stableState == LOW && !longPressFired && millis() - pressedAt >= 1000) {
longPressFired = true; // fire once
// caller checks btn.longPressFired
}
if (stableState == HIGH) longPressFired = false;Short press → only justPressed fires. Long press → justPressed fires, then 1 second later longPressFired fires. Two-gesture UI from one button.
Mini-Challenge · The mode-cycling controller 15 min
Build a tiny controller with three debounced buttons and three LEDs. The system has 4 modes (off, mode A, mode B, mode C) and a single button cycles through them. The other two buttons do mode-specific actions. The LEDs visually represent which mode you're in.
Spec:
- D2 = mode-cycle button.
- D3 = action button A.
- D4 = action button B.
- D9 = mode-A LED.
- D10 = mode-B LED.
- D11 = mode-C LED.
Behaviour:
- Mode OFF: all three LEDs off. Action buttons do nothing.
- Mode A: D9 on. Action A toggles D9 brightness via PWM. Action B does nothing.
- Mode B: D10 on. Action A increments a counter (Serial logs it). Action B resets the counter.
- Mode C: D11 on (blinking at 250 ms). Both actions print messages to Serial.
It's done when:
- The mode button cycles cleanly through all 4 modes with no skip or stuck states.
- Action buttons only fire their effects in the right mode.
- All button presses are debounced — no doubles ever.
- The D11 blinker in Mode C is steady, not jittery.
- Zero
delay()in the entire sketch.
Recap 5 min
The millis() debouncer replaces L1's blocking delay(50) with a non-blocking "wait until the signal has been steady for 50 ms" check. The pattern lives in a small struct (DebouncedButton) that's reusable across any number of buttons. Each button exposes a justPressed flag that's true for exactly one loop iteration per real press — the cleanest possible API for event-driven code. Combined with the Cluster F discipline (no delays, tick functions), your buttons now feel like real product buttons: instant, single-fire, never missed.
- Switch bounce
- The brief flutter (1–30 ms typically) of contacts in a mechanical switch as they make or break. Without filtering it produces multiple HIGH/LOW transitions per physical press.
- Debouncing
- Any technique for filtering out bounce so each physical press registers as one logical event. Software with millis() is the Arduino-idiomatic way; capacitor + Schmitt-trigger is the hardware way.
- Debounce window
- The duration the signal must stay steady before we trust it. 50 ms is standard for tactile buttons; 20 ms feels snappier for gaming; 100 ms is safer for grimy old switches.
- Raw vs stable state
- Two variables:
rawState= whatdigitalReadsays right now (bouncy);stableState= our debounced answer (trustworthy). The debouncer's job is keeping them in sync only after stability. struct- A C++ keyword that bundles related variables (and optionally methods) into one type. Used here to group per-button state. We'll meet classes formally in L3-41.
- One-shot flag
- A boolean that becomes true for exactly one loop iteration when an event occurs, then resets.
justPressedis the classic example. - Long press
- A second gesture from the same button: hold for ≥ N ms = different action. Common in single-button consumer devices.
- Phantom press at boot
- A spurious press detected in the first 50 ms after power-on, caused by initialising
stableStateto a value different from the pin's actual state. Fix: initialise fromdigitalReadinbegin().
Homework 5 min
Bench-test a real button. Wire one tactile button to D2 and an oscilloscope-style sketch that prints the raw state every 1 ms for 100 ms after a press. Run it. Count the transitions.
Sketch outline:
unsigned long startCapture = 0;
bool capturing = false;
int lastRaw = HIGH;
void setup() {
pinMode(2, INPUT_PULLUP);
Serial.begin(115200); // faster baud for the burst
}
void loop() {
int raw = digitalRead(2);
if (raw == LOW && lastRaw == HIGH && !capturing) {
capturing = true;
startCapture = micros();
}
if (capturing) {
Serial.print(micros() - startCapture);
Serial.print(',');
Serial.println(raw);
if (micros() - startCapture > 100000) {
capturing = false;
Serial.println("---");
}
}
lastRaw = raw;
}Press the button once cleanly. Look at the burst of microsecond-stamped lines. Count: how many distinct LOW→HIGH or HIGH→LOW transitions happen during the first 30 ms? Most cheap buttons show 3–10. Some pristine new buttons show 0–1.
Bring back next class:
- Your
hw-l02-37.inosketch. - The captured burst data (paste a screenshot or the lines from one press).
- Your transition count and an estimate of the bounce duration in milliseconds.
- Note: would 50 ms debounce be enough for your button? Would 20 ms?