The keypad is lying to you: matrix scanning and debouncing in Rust
Part 1 ended with a working RPN evaluator and a promise: connect it to a real keypad. The keypad in question is a 4×4 membrane unit salvaged from a parts bin. It cost nothing, and it behaves accordingly.
Two problems stand between a keypad and the truth. First, it has 16 keys and only 8 pins, so nobody gets a wire of their own. Second — and this is the part no datasheet puts on page one — a pushbutton is not a digital device. It's a springy piece of metal slapping another piece of metal, an analog event that we interpret as digital and mostly get away with.
Sixteen keys, eight pins
The keys sit in a grid: 4 row wires, 4 column wires, and a switch at every intersection. Pressing a key connects its row to its column, and that's the entire technology.
To read it: configure the rows as outputs and the columns as inputs with pull-ups, then drive one row low at a time and see which columns get dragged down with it. A low column means the key at (that row, that column) is closed. Repeat for all four rows and you've read the whole keypad in four steps.
With embedded-hal traits this stays generic over whatever pins the board actually provides — the function neither knows nor cares that it's an STM32:
use ;
NOTE — The STM32's internal pull-ups (a few tens of kΩ) are plenty for a membrane keypad at hand speeds. No external resistors, which keeps the bill of materials at: keypad, wire, optimism.
Phantoms in the matrix
The matrix has a famous failure mode. Press three keys that form three corners of a rectangle in the grid, and the fourth corner reads as pressed too. Current sneaks from the scanned row through key one to a column, across key two to a different row wire, and back through key three to a second column — which now reads low for no honest reason. That's ghosting, and it's why proper keyboards put a diode at every switch: diodes make the sneak path a one-way street.
A membrane keypad has no diodes. It has no anything. But here's the thing about calculators: the input language is one finger at a time. Ghosting needs three simultaneous keys, and if you're pressing three keys at once on a calculator, you're either cleaning it or having an emergency. I'm declaring this a non-problem and moving on with my life — but it's the reason your gaming keyboard brags about "N-key rollover" and your microwave doesn't.
The part where the button lies
Watch a "single" keypress on an oscilloscope and you'll see the contact close, open, close again, and generally have a small argument with itself for a millisecond or two before settling. Jack Ganssle, in his classic debouncing study, measured a pile of real switches and found typical bounce around a millisecond with ugly outliers several times that — every one of which your 72 MHz microcontroller will faithfully report as an individual, sincere keypress. Untreated, 5 ENTER becomes 555 ENTER ENTER, and your calculator develops what users will describe as a personality.
The wrong fix is to wire the keypad to interrupts. An interrupt per edge on a bouncing contact is a denial-of-service attack you build against yourself. The boring, correct fix: poll the matrix on a timer tick and require the reading to hold still before believing it.
Each key gets one byte of history — its last eight raw samples — plus a debounced state. The state only flips after eight consecutive agreeing samples:
Scanning at 1 kHz, eight clean samples means a keypress is believed 8 ms after the metal stops arguing — far below the ~100 ms where humans start noticing lag, and far above any bounce this keypad has managed to produce. The whole debouncer state for all 16 keys is 32 bytes. The borrow checker had no notes.
I did have tests, though, because Part 1 set a precedent. Feed it a noisy closure — [1,0,1,1,0,1,1,1,1,1,…] — and exactly one Pressed comes out. Hold it for a hundred ticks: silence. Bouncy release: exactly one Released. The keypad may lie; the Option<Event> does not.
Wiring it to the stack
The event stream is where Part 1 and Part 2 shake hands. Pressed events map to digits and operators; digits accumulate into a number; ENTER pushes it; operators call eval from Part 1, which — because errors are transactional — can reject a stray + on an empty stack without destroying anything the user typed. The hardware lies constantly and the software forgives constantly, and between them you get something that feels solid.
Next time, Part 3: putting numbers on the OLED, which sounds trivial right up until you have to decide what 1/3 should look like on a display with 16 characters, no pixels to spare, and a user who was promised decimal arithmetic. Floating point and I are going to have words.