Part 19: From Output to Input — Why Buttons Are Harder Than LEDs
Congratulations on completing the 13-part LED tutorial. Now that we have the basics of GPIO output, and experience with templates and
constexpr, it is time to face a new challenge: letting the chip understand human operations.
From "Speaking" to "Listening"
The LED tutorial taught us one thing: how to make the chip "speak." We used GPIO output to drive the PC13 pin, controlling the LED's on and off states. Throughout this process, the initiative lay entirely with the chip—the code determined when to pull high and when to pull low, the pin faithfully executed the commands, and the LED obediently turned on or off. This is a one-way street: CPU → GPIO → Physical World.
Buttons do the exact opposite. A button is the physical world "speaking" to the chip—the user presses the button, the voltage on the pin changes, and the CPU needs to "listen" to this change and respond. It sounds like just swapping output for input, but once you actually do it, you'll find it's far from simple.
Why? Because in the LED tutorial, we controlled an ideal digital world. We write a logic high, and the pin is high. One is one, zero is zero, clean and simple. But buttons face real signals from the physical world, and the physical world is never as "clean" as the digital world.
Three New Challenges with Buttons
Challenge 1: Reading Instead of Writing
In the LED tutorial, our GPIO worked in output mode. The core operation of output mode is "write"—write a value to the ODR (Output Data Register), and the pin level follows. The chip is the master of the signal.
Buttons require GPIO to work in input mode. The core operation of input mode is "read"—read a value from the IDR (Input Data Register), which reflects the current actual voltage on the pin. The chip is an observer of the signal.
This role shift sounds trivial, but it means you need to understand a whole new set of things: What does the internal circuit of a GPIO look like in input mode? What is the difference between a pull-up resistor and a pull-down resistor? Why is floating input unreliable? What role does the Schmitt trigger play in the input path? We glossed over these in the LED tutorial, but now we must break them down in detail, because if the input configuration is wrong, you won't even be able to read the button state correctly.
Challenge 2: Noise from the Physical World
This is the most unexpected and tricky part of the button tutorial.
You might think a button is an ideal switch—pressed is low, released is high, a clean switch between 0 and 1. But reality is harsh: when mechanical switch contacts close and open, due to the elasticity of the metal, voltage oscillation occurs for 5 to 20 milliseconds. On an oscilloscope, what you think should be a clean falling edge turns out to be a rapid series of high-low jumps.
If your code doesn't handle this and simply reads the pin state in the main loop, a single normal button press might be misread by the CPU as three, four, or even seven or eight "press-release" cycles. The LED won't light, or it will flash frantically—not because the hardware is broken, but because your code is fooled by the physical world's noise.
The LED tutorial never encountered this problem. Because an LED is an output device, the signal is generated by the chip; 0 is 0, 1 is 1. A button is an input device, the signal comes from the physical world, and the physical world is never perfect. Debounce—filtering out these mechanical bounces at the software level—is a required course in the button tutorial.
Challenge 3: Timing Management
In the LED tutorial, we used HAL_Delay extensively to control the flashing interval. HAL_Delay is a dead wait of 500 milliseconds; the CPU does nothing, just looping ticks. In the LED scenario, this is fine—flashing is the only task, so waiting is acceptable.
But buttons are different. Button debouncing requires time (usually 20ms). If you use HAL_Delay to block and wait during this time, the whole system stops. If your project has not just buttons, but also LEDs to flash, sensors to read, and communication protocols to process, blocking for 20ms means all other tasks pause. This is unacceptable in a real-time system.
The solution is non-blocking debouncing: use HAL_GetTick to get the current timestamp, remember when the state change happened, and check "has enough time passed" on the next loop to confirm the state. This approach doesn't block the CPU, so the main loop can continue doing other things. But it introduces a new programming paradigm—the state machine. You need to use state variables to record "what stage are we in now" and "what is the next stage," rather than simply delaying.
These three challenges combined make button control seem several times more complex than LEDs. But don't worry—we have 12 articles to tackle them one by one.
Preview of the Final Result
Before we officially start, I want to show you the final effect we are aiming for, so you know what the finish line looks like. Here is the complete code of main.cpp after all refactoring:
// ... (Code content preserved) ...If you finished the LED tutorial, the first half should look familiar: SystemInit, system clock configuration, Link template instantiation—these are exactly the same as in the LED tutorial.
What's new is the second half. Button declares a button object, locking configurations like Port A, Pin 0, Pull-up mode, and Active Low into the type system at compile time. update() is the core method of this button object—it maintains a 7-state state machine internally, samples the pin level once when called, and determines whether a valid press or release event has occurred based on the current state and timestamp.
If a state change is confirmed, update() notifies you via a callback function. The callback parameter event is a std::variant—this is a type-safe union from C++17, Pressed represents "button pressed", and Released represents "button released". We use std::visit with a generic lambda to handle both events: press turns the LED on, otherwise off.
Don't be scared by these new terms—std::variant, std::visit, generic lambda, enum class—each one will be broken down in detail later. For now, you just need to know: this code handles button debouncing, state machine management, and event dispatch, all with zero overhead at compile time. The resulting machine code is no different from a version where you hand-write C to read the pin and manually debounce.
The Road Ahead
The button tutorial consists of 12 parts, divided into four stages. Each stage solves a problem, gradually evolving from bare metal to modern C++ abstraction.
Stage 1: Hardware Basics (Parts 02-03)
First, understand the hardware. Part 02 covers the internal circuit of GPIO input mode—what are the differences between pull-up, pull-down, and floating input modes, why the Schmitt trigger exists, and how the IDR register works. We mostly skipped this in the LED tutorial because output mode doesn't require deep understanding of the input path. But now it's different; the input path is our main battlefield.
Part 03 applies GPIO input knowledge to the button circuit. We will draw the button wiring diagram, calculate the current for the pull-up resistor, and most importantly—explain the physical principle of mechanical bounce and oscilloscope waveforms in detail. Only by understanding what bounce is can you truly understand the design motivation behind all subsequent debounce algorithms.
Stage 2: HAL + C in Practice (Parts 04-06)
With the hardware clear, we move to HAL API and C implementation. Part 04 breaks down the working principle of GPIO_TypeDef and the input mode initialization flow. Part 05 writes a simple button polling program in pure C—it runs, but triggers multiple times due to bounce. Part 06 introduces a non-blocking debounce algorithm using HAL_GetTick for time management to eliminate the bounce problem.
The value of these three parts is to "get your hands dirty"—solve the problem in the most direct way first, experience the limitations of C and the evolution of the debounce algorithm firsthand. With this practical experience, when we refactor to C++ later, you will feel "this is indeed how it should be refactored," rather than "why make it so complex."
Stage 3: State Machine Debouncing (Part 07)
Part 07 is the core of this series. We reimplement the debounce logic using a 7-state state machine. This state machine isn't over-engineered—each of the 7 states has a clear reason for existence, including a special "startup lock" mechanism to handle edge cases like "the button was already held down when the system powered on." This part will interpret the implementation of the update() method in Button.hpp line by line.
Stage 4: C++ Refactoring (Parts 08-12)
The final 5 parts are the highlight of the C++ refactoring. Part 08 uses enum class to redefine button-related enumeration types. Part 09 introduces std::variant and std::visit to build a type-safe event system. Part 10 designs the Button template class, encoding port, pin, pull-up/down, and active level polarity into compile-time types. Part 11 uses C++20 Concepts to constrain the callback function type, ensuring the signature passed to Button::on_event is correct. Part 12 introduces EXTI external interrupts as an alternative for button detection, along with a summary of common pitfalls and exercises.
Hardware Preparation
For hardware, you still need the same Blue Pill + ST-Link from the LED tutorial, plus an additional button switch. Specifically:
- STM32F103C8T6 Blue Pill Board — The same board as the LED tutorial.
- ST-Link V2 Debugger — For flashing and debugging, same as the LED tutorial.
- One Button Switch — Any standard tactile switch will do, 2-pin or 4-pin, they cost pennies.
The wiring scheme is very simple:
// ... (Diagram content preserved) ...Just two wires. No resistors needed—the STM32 has an internal pull-up resistor, we just enable it in software. The onboard LED on PC13 remains the same as in the LED tutorial, no extra wiring needed.
Why choose PA0? Two reasons. First, PA0 is easy to find on the Blue Pill headers, making wiring convenient. Second, in the STM32F103's EXTI (External Interrupt Controller), PA0 corresponds to EXTI0, which has its own independent interrupt vector EXTI0_IRQHandler. This means when we cover interrupt-driven buttons in Part 12, we won't need to deal with interrupt vector sharing issues. If you chose PA5, EXTI5 and EXTI9 would share an interrupt vector, adding a step to the configuration. Let's stick with the simplest PA0 for now to get the principles clear.
⚠️ If you don't have a button switch handy, you can simulate it with a DuPont wire—plug one end into PA0 and touch the other end to GND then release it. The effect is the same as a button. It just lacks the spring return, so the feel is different, but it's sufficient for learning.
Where to Next
Preparations are done, challenges are listed, and the final result is previewed. Starting from the next part, we are diving headfirst into the internal circuitry of GPIO input mode.
The next part covers the signal path of GPIO in input mode: what circuit components the pin voltage signal passes through, how pull-up and pull-down resistors are connected internally, why the Schmitt trigger is an indispensable part of the input path, and how every bit in the IDR register corresponds to the physical pin. Once you understand this, you won't be "copying parameters from code" when configuring GPIO input mode, but rather "I know what this parameter does in the circuit."
Ready? Let's go.