Reading a button with GPIO input
Step 5 of 10, and the first one that gives the loop something to react to. Step 4 left the firmware with a panic handler of its own and a loop that blinked LD2 on its own schedule. This step replaces that schedule with the board’s user button: each press toggles the LED. That means the firmware has to read a pin, and reading a pin on a real board is three problems in a row — the level has to be defined when nobody is touching anything (the pull-up), the reading has to survive the contact bouncing (debounce), and the loop has to decide how often to look (polling) or hand that job to the chip (interrupts). It starts from tag stm32-ch04 and ends at stm32-ch05.
Everything here was captured on Rust 1.99.0 with edition 2024, the compiler this track is written against, with stm32f4xx-hal 0.23.0 and embedded-hal 1.0.0.
The button, electrically
Section titled “The button, electrically”The NUCLEO-F401RE carries one user button, the blue B1, and the board’s schematic wires it to PC13 — port C, pin 13. Step 3 put LD2 on PA5, so the button adds a second port to the firmware. The same schematic shows R30, 4.7 kΩ, pulling PC13 up to 3.3 V, the button between the pin and ground, and R29 (100 Ω) in series with C15 (100 nF) on the pin side. Three things follow from that, and they are the whole of the electrical side.
The line idles high. Pressing the button connects PC13 to ground and the pin reads low, so the button is active low: is_low() is true while it is held. The mirror wiring — a pull-down and a button to 3.3 V — reads high when pressed, and which one a board uses is a property of the board, not of the firmware. If a press does the opposite of what the code expects, this is the first thing to check.
A pull-up is not optional. An input with nothing defining its level floats: it picks up whatever the wires and the board radiate, and the firmware reads noise. R30 already does that job here, so why does the firmware enable the chip’s internal pull-up as well? Belt and braces. The internal resistor is tens of kΩ against R30’s 4.7 kΩ, so it barely shifts the idle level. What it buys is a pin that is still defined when nothing is wired to it — a button that is not soldered, a pin reused for something else, a different board. It costs one call, and about 0.7 mA while the button is held (3.3 V across 4.7 kΩ).
The RC network does not debounce anything. R29 and C15 form a low-pass with a 10 µs time constant, and the contact is mechanical: it bounces around each transition for far longer than that. Hardware smoothing and software debouncing are not alternatives here — the RC takes the electrical edge off, and the bounce sails through it.
Reading the pin
Section titled “Reading the pin”Two ports now, so split runs twice. The &mut rcc is what turns each port’s clock on, and GPIOC’s clock is off until it is asked for:
let gpioa = dp.GPIOA.split(&mut rcc);let gpioc = dp.GPIOC.split(&mut rcc);let mut led = gpioa.pa5.into_push_pull_output();
// PC13 carries the board's user button.let button_pin = gpioc.pc13.into_pull_up_input();into_pull_up_input is step 3’s into_push_pull_output one mode over: it sets PC13’s mode bits to input and the internal resistor to pull-up, and hands back a Pin in Input mode.
Reading goes through the InputPin trait, and this is where the pin’s own methods bite again — the same trap step 3 hit on the output side. A concrete Pin has an inherent is_low() that returns bool. The trait’s is_low() returns Result<bool, Infallible>. Inside a generic function only the trait method is visible, and that is the one the firmware wants, because the trait is what makes Button testable on the host. Write the read against the trait and forget the Result, and the compiler says so. The block needs embedded-hal, the crate the firmware already links against:
[dependencies]embedded-hal = "1.0.0"use embedded_hal::digital::InputPin;
/// The firmware's read, minus the error handling: the trait method/// returns a `Result`, and a `bool` return type does not accept one.fn read_pressed<I: InputPin>(pin: &mut I) -> bool { pin.is_low()}
fn main() {}error[E0308]: mismatched types --> src/main.rs:6:5 |5 | fn read_pressed<I: InputPin>(pin: &mut I) -> bool { | ---- expected `bool` because of return type6 | pin.is_low() | ^^^^^^^^^^^^ expected `bool`, found `Result<bool, <I as ErrorType>::Error>` | = note: expected type `bool` found enum `Result<bool, <I as embedded_hal::digital::ErrorType>::Error>`help: consider using `Result::expect` to unwrap the `Result<bool, <I as embedded_hal::digital::ErrorType>::Error>` value, panicking on a `Result::Err` |6 | pin.is_low().expect("REASON") | +++++++++++++++++
For more information about this error, try `rustc --explain E0308`.error: could not compile `verify` (bin "verify") due to 1 previous errorThe fix is one call: tick uses unwrap_or(last). The error type itself sits on a separate trait, ErrorType, which both digital traits require — one more line per mock, and no Err variant to construct on this chip, so the fallback never fires. The code says what would happen anyway, which is what a Result is for.
Bounce, and what debounce costs
Section titled “Bounce, and what debounce costs”A mechanical contact does not close once. It closes, bounces open, closes again, and settles — and a raw read of the pin flickers between levels through the whole performance. The firmware’s answer is a counter that only believes a level once it has held still for ten consecutive ticks. Because the pin is a trait, the same struct runs against a recorded trace on the host, where the output can be printed. This is the complete program, mocks and all:
use embedded_hal::digital::{ErrorType, InputPin, OutputPin};
/// A button that reports one event per accepted press.struct Button<I: InputPin> { pin: I, /// The level the debouncer currently believes the line is at: /// `true` while the button is held down. pressed: bool, /// How many consecutive ticks have disagreed with `pressed`. held: u8,}
/// A level change has to hold for this many consecutive 1 ms ticks/// before it counts.const DEBOUNCE_TICKS: u8 = 10;
impl<I: InputPin> Button<I> { fn new(pin: I) -> Self { // A board starts with the button released; if it is held at // power-on, the first accepted level corrects this on release. Button { pin, pressed: false, held: 0, } }
/// One tick of the poll loop. Returns `true` on the tick a press is /// accepted — not on every tick the button is held. fn tick(&mut self) -> bool { // Active low: the pin reads low while the button is pressed. let last = self.pressed; let raw = self.pin.is_low().unwrap_or(last); if raw == self.pressed { self.held = 0; false } else { self.held += 1; if self.held >= DEBOUNCE_TICKS { self.pressed = raw; self.held = 0; // `raw` is `true` exactly when the new level is pressed; // a release is accepted the same way but not reported. raw } else { false } } }}
/// A pin that plays back a recorded trace of levels instead of reading/// one: `true` means the line reads low.struct MockPin { trace: [bool; 32], tick: usize,}
// The error type lives on its own trait, `ErrorType`, which both digital// traits require — that is why the mocks each carry one of these.impl ErrorType for MockPin { type Error = core::convert::Infallible;}
impl InputPin for MockPin { fn is_high(&mut self) -> Result<bool, Self::Error> { Ok(!self.is_low()?) }
fn is_low(&mut self) -> Result<bool, Self::Error> { let low = self.trace.get(self.tick).copied().unwrap_or(false); self.tick += 1; Ok(low) }}
/// An LED that records its state instead of lighting one.struct MockLed { lit: bool,}
impl ErrorType for MockLed { type Error = core::convert::Infallible;}
impl OutputPin for MockLed { fn set_low(&mut self) -> Result<(), Self::Error> { self.lit = false; Ok(()) }
fn set_high(&mut self) -> Result<(), Self::Error> { self.lit = true; Ok(()) }}
fn main() { // A press that chatters on the way down, holds, and chatters again // on release. let trace = [ false, false, false, // at rest true, false, true, false, // the contact chatters true, true, true, true, true, true, true, true, true, true, // held true, true, // still held, nothing to report false, true, false, // it chatters again on release false, false, false, false, false, false, false, false, false, false, // released ];
let mut button = Button::new(MockPin { trace, tick: 0 }); let mut led = MockLed { lit: false }; let mut presses = 0;
for tick in 0..trace.len() { let raw = trace[tick]; if button.tick() { presses += 1; if led.lit { led.set_low().unwrap(); } else { led.set_high().unwrap(); } println!( "tick {:>2}: pin={:<5} press accepted, LD2 {}", tick + 1, if raw { "low" } else { "high" }, if led.lit { "on" } else { "off" } ); } else { println!( "tick {:>2}: pin={}", tick + 1, if raw { "low" } else { "high" } ); } } println!("presses counted: {presses}");}tick 1: pin=hightick 2: pin=hightick 3: pin=hightick 4: pin=lowtick 5: pin=hightick 6: pin=lowtick 7: pin=hightick 8: pin=lowtick 9: pin=lowtick 10: pin=lowtick 11: pin=lowtick 12: pin=lowtick 13: pin=lowtick 14: pin=lowtick 15: pin=lowtick 16: pin=lowtick 17: pin=low press accepted, LD2 ontick 18: pin=lowtick 19: pin=lowtick 20: pin=hightick 21: pin=lowtick 22: pin=hightick 23: pin=hightick 24: pin=hightick 25: pin=hightick 26: pin=hightick 27: pin=hightick 28: pin=hightick 29: pin=hightick 30: pin=hightick 31: pin=hightick 32: pin=highpresses counted: 1held counts consecutive disagreements with the accepted level. One flicker resets it to zero, so bounce cannot accumulate its way to a false press — the new level has to hold for ten ticks straight. The mocks are two impls each, because the error type lives on its own trait, ErrorType, which both digital traits require.
The trace is the interesting part. Ticks 4 to 7 flicker — low, high, low — and each flicker resets held to zero, so nothing is counted. Only at tick 17, after ten consecutive low readings, does the press count, and exactly one event is reported no matter how long the button stays down. The release is accepted the same way, at tick 32; nothing prints for it, because tick reports presses.
The window is a trade with a price on both sides. A press shorter than ten milliseconds is not counted at all — the level never holds still long enough. A release shorter than ten milliseconds is accepted late, which leaves the debouncer believing the button is still held, and the next press then reads the same as the held level and is missed until a full-length release lands. A firmware that has to catch quick double-taps counts releases as events too; that is a few more lines, and this firmware does not need them, because a missed press is a visible no-op rather than a wrong reading.
The firmware
Section titled “The firmware”src/main.rs in full — this is the tag’s file, byte for byte:
#![no_std]#![no_main]
use cortex_m::delay::Delay;use cortex_m_rt::entry;use embedded_hal::delay::DelayNs;use embedded_hal::digital::{InputPin, OutputPin};use stm32f4xx_hal::pac;use stm32f4xx_hal::prelude::*;use stm32f4xx_hal::rcc::Config;
#[entry]fn main() -> ! { let dp = pac::Peripherals::take().unwrap(); let cp = cortex_m::peripheral::Peripherals::take().unwrap();
// Constrain the RCC and apply the default clock configuration: the // chip's 16 MHz internal oscillator, no PLL. let mut rcc = dp.RCC.constrain().freeze(Config::default()); let clocks = rcc.clocks;
// Splitting a port hands out its pins one at a time; the `&mut rcc` // is what turns the port's clock on. The button sits on port C, so // that port is split alongside the one the LED is on. let gpioa = dp.GPIOA.split(&mut rcc); let gpioc = dp.GPIOC.split(&mut rcc); let mut led = gpioa.pa5.into_push_pull_output();
// PC13 is the board's user button. The line idles high and the press // pulls it low, so the pin reads low while the button is down. The // internal pull-up reinforces the idle level, which also keeps the // pin defined when nothing is wired to it. let button_pin = gpioc.pc13.into_pull_up_input(); let mut button = Button::new(button_pin);
// The delay counts SysTick ticks against the AHB frequency. let mut delay = Delay::new(cp.SYST, clocks.hclk().to_Hz());
react(&mut led, &mut button, &mut delay)}
/// A button that reports one event per accepted press.////// A mechanical contact does not close once: it bounces, and a raw read/// of the pin flickers between levels around each transition. This/// struct swallows that by only accepting a level that has held still/// for `DEBOUNCE_TICKS` consecutive ticks. The pin type is generic, so/// the same struct runs against a recorded trace on the host (that is/// how it was tested) and against PC13 on the board.struct Button<I: InputPin> { pin: I, /// The level the debouncer currently believes the line is at: /// `true` while the button is held down. pressed: bool, /// How many consecutive ticks have disagreed with `pressed`. held: u8,}
/// A level change has to hold for this many consecutive 1 ms ticks before/// it counts. Ten milliseconds is a starting point: long enough that/// chatter cannot reach it, short enough that a deliberate press/// registers on the first attempt.const DEBOUNCE_TICKS: u8 = 10;
impl<I: InputPin> Button<I> { fn new(pin: I) -> Self { // A board starts with the button released; if it is held at // power-on, the first accepted level corrects this on release. Button { pin, pressed: false, held: 0, } }
/// One tick of the poll loop. Returns `true` on the tick a press is /// accepted — not on every tick the button is held. fn tick(&mut self) -> bool { // Active low: the pin reads low while the button is pressed. An // error can't happen on this chip (`Infallible`), so a read that // did fail would keep the last accepted level. let last = self.pressed; let raw = self.pin.is_low().unwrap_or(last); if raw == self.pressed { self.held = 0; false } else { self.held += 1; if self.held >= DEBOUNCE_TICKS { self.pressed = raw; self.held = 0; // `raw` is `true` exactly when the new level is pressed; // a release is accepted the same way but not reported. raw } else { false } } }}
/// The loop, written against the traits instead of the chip's pin and/// timer types, as step 3's `blink` was: `P` drives the LED, `I` reads the/// button, `D` counts the time.////// Each accepted press toggles LD2, and the poll runs on a 1 ms cadence/// so the debounce window is ten milliseconds of wall time.fn react<P: OutputPin, I: InputPin, D: DelayNs>( led: &mut P, button: &mut Button<I>, delay: &mut D,) -> ! { let mut lit = false; loop { if button.tick() { lit = !lit; if lit { led.set_high().unwrap(); } else { led.set_low().unwrap(); } } delay.delay_ms(1); }}
/// What runs when the firmware panics. Step 2 filled this slot with the/// `panic-halt` crate; this step writes the handler out instead.////// `react` owns the LED pin and a panic ends that loop for good, so there/// is no pin to reach for here. GPIOA sits at a fixed address for as long/// as the chip has power, and the pin is still configured as a push-pull/// output — mode bits survive. Setting bit 5 of the port's bit set/reset/// register lights LD2 and leaves it lit: a panic now freezes the board/// with the LED on instead of mid-blink, which tells the two apart.#[panic_handler]fn panic(_: &core::panic::PanicInfo) -> ! { // Safety: `ptr()` returns the register block's address without // checking ownership. GPIOA exists at that address from reset onward, // nothing else runs once a panic has fired, and BSRR is write-only: // a set-bit write cannot race another reader or writer of the pin. unsafe { (*pac::GPIOA::ptr()).bsrr().write(|w| w.bs5().set_()); } loop {}}What changed since step 4 is the loop and the button, and the panic handler is not really part of either. blink is gone: the LED no longer blinks on a timer, and lit in react now tracks its state, flipped once per accepted press. The board powers up with LD2 dark — lit starts false and the pin starts low — and does nothing until a button is pressed. The handler’s body is untouched; only the name it mentions in its doc comment moved from blink to react, because the pin it can no longer reach is now owned by a different function.
The loop is step 3’s shape with the button in it: react is generic over the LED, the button and the delay, exactly as blink was generic over the LED and the delay. That is what let the Button struct above be tested on the host against a recorded trace before it ever met the board — the generic bounds are the seam. The cadence comes from the delay_ms(1) at the bottom of the loop, which makes one tick a millisecond and the debounce window ten of them.
Polling versus interrupts
Section titled “Polling versus interrupts”Polling is what this firmware does: the loop asks PC13 a thousand times a second. The cost is the CPU, which never does anything else and never sleeps, and the latency, which is at most one tick plus the debounce window. For a button that is a fine trade, and it is the one this step ships.
The alternative is the chip’s external interrupt controller. PC13 maps to EXTI line 13, which shares one vector with lines 10 to 15 — Interrupt::EXTI15_10. The HAL’s path there is the ExtiPin trait: make_interrupt_source(&mut syscfg) routes the port to the line, trigger_on_edge(&mut exti, Edge::Falling) arms it on the falling edge, enable_interrupt(&mut exti) enables external interrupts from the pin, and cortex_m::peripheral::NVIC::unmask(pin.interrupt()) lets the vector through to the CPU. The shape of it, as a sketch rather than firmware:
// in main, once the pins and the delay exist:let mut syscfg = dp.SYSCFG.constrain(&mut rcc);let mut exti = dp.EXTI; // the EXTI register block, unconstrained
button_pin.make_interrupt_source(&mut syscfg);button_pin.trigger_on_edge(&mut exti, Edge::Falling);button_pin.enable_interrupt(&mut exti);unsafe { NVIC::unmask(button_pin.interrupt()); // Interrupt::EXTI15_10}
#[interrupt]fn EXTI15_10() { // runs on the falling edge — bounce included}With that in place the handler runs on the press instead of the loop noticing it, and between presses the CPU can sit in cortex_m::asm::wfi() and draw sleep current instead of spinning. That is the prize, and it is step 10’s subject.
Two reasons this step polls anyway. The first is that an interrupt fires on every edge, bounce included: the debounce problem does not go away, it moves into the handler — and the handler runs outside main’s stack, so the press has to be handed to the main loop through a shared static before anything can toggle the LED. Polling keeps one code path. The second is that the loop already has a one-millisecond heartbeat, so the latency an interrupt would save is a millisecond at most, and that is not a difference anyone pressing a button can perceive.
Build it and run it
Section titled “Build it and run it”cargo build Compiling time v0.3.55 Compiling syn v2.0.119 Compiling syn v3.0.9 Compiling enumflags2_derive v0.7.12 Compiling cortex-m-rt-macros v0.7.7 Compiling enumflags2 v0.7.12 Compiling firmware v0.1.0 (/Users/rango/www/hackingwithrust/code/embedded-stm32) Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.56sThat is a cold build’s tail: the dependency closure step 3 listed, then the firmware itself. Cargo compiles dependencies in parallel, so the order of the middle lines varies between runs. No --target — .cargo/config.toml pins it, as it has since step 2. The path after the package name is this machine’s project directory, and the build time is this machine’s too.
With the board plugged in:
cargo run Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s Running `probe-rs run --chip STM32F401RE target/thumbv7em-none-eabihf/debug/firmware`Error: No connected probes were found.The last line is this machine again: no board attached, the same error a loose cable gives. Plug the Nucleo in and the same command writes the image, resets the chip and starts it. What the board does then: LD2 stays dark until B1 is pressed, lights on the press, and goes out on the next one. Holding the button down changes nothing — one event per press, not one per tick.
Pitfalls
Section titled “Pitfalls”The pin nobody pulled up
Section titled “The pin nobody pulled up”Skip the pull-up and the input floats. The firmware then sees presses that never happened, and the pattern changes when something moves near the board — a hand, a cable, a phone. On this board R30 hides the mistake for a while, which is what makes it worth naming: the internal pull-up is one call, and the day the firmware moves to a board without R30, or the button is left unwired, the floating pin is the bug. into_pull_up_input on the way in, not a rescue later.
Debouncing by blocking the loop
Section titled “Debouncing by blocking the loop”The other classic is to wait the bounce out: read the pin, and when it reads pressed, sleep for fifty milliseconds and read again. It works in a firmware whose only job is the button, and it stops working the moment the loop has anything else to do, because nothing else runs during those fifty milliseconds. The counter version costs one comparison per tick and never blocks. It also has a property the blocking version lacks: it is the same code on the host and on the board, which is how the trace above was produced.
A window that eats the tap
Section titled “A window that eats the tap”DEBOUNCE_TICKS is a trade, not a setting to maximise. Ten ticks rejects chatter and loses presses shorter than ten milliseconds; a hundred ticks would lose more. If the firmware ever needs to count quick taps — double-press to reset, say — count releases as events as well, so a fast tap is a press and a release instead of a level change nobody saw.
Where the code lives
Section titled “Where the code lives”The project lives in embedded-stm32/ in the examples repository, one commit and one tag per article. This article’s end state is stm32-ch05:
git clone https://github.com/hackingwithrust/examples.gitcd examples/embedded-stm32git checkout stm32-ch05cargo buildIf a build misbehaves, checking out that tag and diffing it against your tree shows which line drifted.
Step 6, PWM and controlling brightness, takes the LED back and dims it — timers, duty cycle, and what a breathing light needs that a toggle does not. It isn’t on the site yet, so there is nothing to link to; the track overview keeps the list of what has landed.