Chapter 22: Button Hardware and Debouncing
September 17, 2026 · View on GitHub
Introduction
A mechanical button is not the clean digital switch textbooks suggest. Inside the switch, two metal contacts close and bounce for a few milliseconds, flapping between closed and open dozens of times before settling. A firmware that samples naively can read a single press as five presses. This chapter teaches the hardware reality of the button driver — the wiring, the active-low convention, the pull-up, and the debounce strategy that makes presses trustworthy.
The Wiring
The button driver connects exactly two things beside power:
Button: one terminal -> GPIO15
other terminal -> GND
LED: anode (long leg) -> GPIO16 through a 330Ω resistor
cathode (short) -> GND
And in the shape we have used all course:
+-------------------------------+
| RP2350 Pico 2 |
| |
| GPIO15 o--------+---[Button]---+ GND
| | |
| GPIO16 o--------+---[330Ω]-----(>|)---> LED
| | |
| +-----+ GND
+-------------------------------+
The button shorts the line to ground when pressed. The LED lights through 330 Ω from GPIO16 when the pin is high.
Active-Low Logic
Look again at the button's wiring: pressing it grounds the input. There is no +V to release — the switch is tied to GND, so:
Button open -> GPIO15 reads HIGH (released)
Button pressed -> GPIO15 reads LOW (pressed)
This is active-low logic: LOW says "action happening". The button driver
bakes that inversion into update(gpio_high), where LOW input means "pressed":
let new_raw = !gpio_high; // active-low: LOW input => pressed
Every docstring in the driver repeats the convention — "Button is active-low (tied to GND when pressed)" — because a future reader must never have to deduce it from the schematic.
Pull-Up Resistors
When the button is open, nothing drives GPIO15, and a floating input reads whatever noise is in the air. The fix is a pull-up: a weak resistance that holds the line HIGH until the switch pulls it LOW.
+3.3 V -----[pull-up]----o----- GPIO15
|
[BUTTON]
|
GND
The RP2350 has internal pull-ups inside its pads. Embassy exposes them as
Pull::Up:
let button = Input::new(p.PIN_15, Pull::Up);
That is Chapter 17's Pull becoming real: the line is defined at rest (HIGH), so
the only way it goes LOW is a deliberate ground — the button. Ambiguous
floating is eliminated before a single sample is read.
Contact Bounce
When the button is pressed, the metal contacts converge and rebound. The voltage doesn't snap cleanly from high to low; it oscillates:
PRESSURE PRESS SETTLE
v
HIGH |‾‾‾‾‾‾|___|‾|__|________
LOW | | | | |‾‾‾‾‾‾‾|
bounce ~5-20 ms
For 5–20 ms the line reports an erratic sequence of highs and lows. A firmware that samples every microsecond could count ten "presses" from one physical press — a light turning on and off faster than the eye can follow.
Debouncing Strategies
Debounce = refuse to trust a state change until it has held for a while. Two classic approaches:
Time-based debounce — after detecting a change, ignore the pin for a fixed dead time. Simple, but it misses fast subsequent presses during the ignore window.
Sample-count debounce — sample on a fixed cadence and require N consecutive
identical samples before changing the reported state. This is what the button
driver uses: DEBOUNCE_COUNT samples at DEBOUNCE_DELAY_MS spacing.
Sample: H H H H H L L L L L L L L ...
Count: 0 -> raw changes to LOW, count resets
L(1) L(2) L(3) L(4) L(5) -> threshold hit, report PRESSED
The two knob-like constants live in config.rs:
DEBOUNCE_DELAY_MS: u64 = 5; // sample every 5 ms
DEBOUNCE_COUNT: u32 = 5; // require 5 consecutive stable samples
5 samples × 5 ms = 25 ms to confirm a press — comfortably beyond the worst bounce, and still instant to a human. Noise immunity and responsiveness are tuned by these two numbers, which is why both are documented, tested, and centralized in configuration.
Sample-Based Debouncing
The driver's filter keeps three pieces of memory:
pressed the public, debounced "is it pressed?" answer
raw_pressed the latest unfiltered reading
debounce_count consecutive identical samples so far
Each call to update(sample):
+----------------+ +-----------------+ +-----------------+
| New sample | == | Same as last? | NO | Reset: raw = |
| (active-low) | | | | new, count = 0 |
+----------------+ +-----------------+ +-----------------+
| YES
v
+------------------+
| increment count |
| (capped) |
+---------+--------+
v
+------------------+
| count >= 5? | YES -> pressed = raw
+------------------+
A bounce produces samples that keep changing (H L H L H), so raw_pressed
keeps flipping and the count keeps resetting to zero. Only a stable run of five
identical samples lets the count reach the threshold and commits the state.
Summary
- The button is wired active-low: press ground GPIO15, releasing reads HIGH.
- Internal pull-ups (
Pull::Up) keep the line defined at rest — no floating. - Contacts bounce for 5–20 ms, so raw readings are not trustworthy directly.
- The driver samples every
DEBOUNCE_DELAY_MSand requiresDEBOUNCE_COUNTstable samples before reporting a change. - Both knobs are documented constants in
config.rs, tunable per design.
Next we write the filter itself — button.rs, the file that turns raw
samples into a debounced truth.