Portfolio outcome: firmware for a microcontroller (Arduino/STM32) that counts button presses via an interrupt, debounces them, blinks an LED at a timer-driven rate, and reports the count over UART to your PC. Works on real hardware or in a simulator (e.g. Wokwi).
AThe project
This integrates GPIO, interrupts, timers, and serial communication into one small but complete embedded program, the kind of thing asked in interviews.
This project pulls GPIO, interrupts, timers, and serial communication together into one small but complete program. Exactly the kind of thing an embedded interview asks you to reason about. UART is your debugging lifeline: two wires and an agreed baud rate let the MCU print to a PC terminal so you can see what it's thinking.
Two real-world disciplines make or break it. A mechanical button doesn't switch cleanly: it
bounces, firing several rapid transitions per press, so a raw interrupt would count one press as
many; you debounce by ignoring edges that arrive too soon after the last. And the main loop must be
non-blocking: instead of delay(), which freezes the whole CPU, you track elapsed time with
millis() so blinking, reporting, and button-handling all proceed concurrently. Debouncing and
non-blocking timing are the habits that separate firmware that works on the bench from firmware
that works for real.
UART (serial) sends bytes over two wires (TX/RX) at an agreed baud rate (e.g. 9600). It's how an MCU talks to a PC terminal, your debugging lifeline.
The debounce problem. A mechanical button doesn't switch cleanly; it bounces, producing several rapid transitions per press. A raw interrupt would count one press as many. You debounce by ignoring edges that arrive too soon after the last (e.g. < 50 ms).
Notice: the ISR is tiny (debounce + increment), shared vars are volatile, and the
loop is non-blocking so blinking and reporting happen independently.
Syntax you need here. Three pieces from the earlier lessons (a non-blocking timer, an interrupt on a pin, and a UART write) plus the debounce idiom that makes a mechanical button usable:
// 1. non-blocking periodic work: NEVER delay() in a loop that must stay responsive
static uint32_t last_ms;
if (millis() - last_ms >= 500) { last_ms += 500; toggle_led(); }
// 2. an interrupt on a pin: the ISR does the MINIMUM and sets a flag
volatile uint32_t presses; // volatile: modified in an ISR, read in main
volatile bool changed;
void on_button_isr(void) { presses++; changed = true; }
// 3. debounce: ignore edges that arrive too soon after the last accepted one
if (millis() - last_edge_ms > 20) { last_edge_ms = millis(); /* accept */ }
// 4. report over UART from MAIN, never from the ISR
printf("presses=%lu\r\n", presses);
volatile is not optional on a variable shared between an ISR and main: without it the compiler is
free to cache the value in a register and your main loop reads a count that never changes.
| Piece | Lesson | Call |
|---|---|---|
| the periodic blink | Timers & interrupts | millis() / a hardware timer |
| the button edge | Timers & interrupts | pin-change interrupt, tiny ISR |
sharing with main |
MCU & GPIO | volatile, a flag plus a counter |
| the report | Serial protocols (UART) | printf / HAL_UART_Transmit |
Build/test plan:
Project layout. Even on a microcontroller the firmware is not one file: each peripheral gets a
module with a header, and main.c owns only the scheduling:
button-uart/
|-- blink.h / blink.c # the LED: blink_init(), blink_tick(now) - non-blocking, no delay()
|-- button.h / button.c # the button: ISR + debounce, button_count() reads the shared counter
|-- report.h / report.c # UART output: report_tick(now, count)
`-- main.c # the scheduler: init everything, then call the ticks forever
/* main.c - the composition root: it schedules, it does not implement */
#include "blink.h"
#include "button.h"
#include "report.h"
int main(void) {
blink_init(); button_init(); report_init();
for (;;) {
unsigned long now = millis();
blink_tick(now);
report_tick(now, button_count());
}
}
The _tick(now) shape is what keeps this cooperative loop honest: every module is handed the time and
must return promptly, so no module can block another. It is also the structure an RTOS later replaces
task-by-task. Each tick becomes a task, and the shared counter becomes a queue.
Add one mechanism at a time: timing, then interrupts, then debounce, then output. Each has its own characteristic failure, and mixing them makes every symptom ambiguous. Each step finishes one module from the layout, in the order the solution lists them.
Declare button_init(void) and button_count(void) in button.h, then implement them in button.c
around a static volatile unsigned long count. button_init sets the pin to INPUT_PULLUP and
attaches button_isr on the falling edge; the ISR does nothing but increment the counter and return.
button_count reads it with interrupts briefly disabled, because a multi-byte read that is interrupted
halfway can return a value that never existed. volatile is what stops the compiler caching the
counter in a register and never noticing the ISR changed it, and button.c is the only file that
needs to know any of this.
Check: each press changes the count read from main. Remove volatile and rebuild with optimisation
on: the count very likely stops changing at all, which is the bug worth seeing once.
Add the debounce: keep the timestamp of the last accepted edge and reject any edge arriving within
about 20-50 ms of it. Watch the raw count first, before adding this, so you see the problem: a
mechanical contact bounces for milliseconds and an interrupt is fast enough to count every one of those
bounces as a separate press. Debouncing inside the ISR, rather than in main, keeps the rule in the
one place that sees every edge.
Check: ten deliberate presses give exactly ten counts, including slow, sloppy and half-hearted ones. Before the fix, a single press typically registers as three or four.
Declare blink_init and blink_tick(now), and implement the tick as if (now - last >= PERIOD),
never delay(). Advance the deadline with last += PERIOD rather than last = now, so the phase does
not drift a little later on every pass. This LED is the liveness indicator for the whole project: from
here on, an LED that stops blinking means the main loop is blocked, which is exactly the fault a
blocking delay would have hidden from you.
Check: the LED blinks steadily at its period and keeps blinking while you hold the button down.
Time thirty blinks against a clock: with last = now they will run measurably slow.
Declare report_init and report_tick(now, count), and implement the same deadline pattern around a
Serial.print of the count. A UART write at 9600 baud takes milliseconds, an eternity, and doing it
inside an ISR blocks every other interrupt for that whole time, which is precisely how a "random"
timing bug is born. The count arrives here as an argument, so this file never touches the volatile
counter either.
Check: the report arrives at its interval carrying the current count, and neither the blink nor the button response stutters while it transmits. Watch the LED specifically during a transmission.
Write the composition root: call the three _init functions, then loop calling millis() once and
handing that same now to blink_tick and report_tick, with button_count() supplying the report's
argument. Reading the clock once per pass matters. Two calls give the two tasks slightly different
ideas of the current time. This file contains no pin numbers, no periods and no volatile; all three
now live with the module that owns them.
Check: main.c is under about twenty lines and contains no arithmetic beyond the loop itself. All
three behaviours run together: steady blink, correct count, periodic report.
Hold the button down, press it as fast as you can, and press it repeatedly during a transmission. The point is to establish that the ISR, the debounce and the reporting genuinely do not interfere with each other. A cooperative loop is only proven by the case where all three want the CPU at once.
Check: the count stays correct under rapid pressing, the blink never pauses or hiccups, and no report is garbled or lost. Record the fastest press rate that still counts correctly. That number is your debounce window, measured rather than assumed.
Portfolio presentation. Show the hardware and the timing evidence together: a photo of the wired board, the pin/peripheral map, and a scope or logic-analyzer capture proving the timing you claim. State the MCU, the toolchain and the flash/RAM footprint. Embedded reviewers look for whether you measured the real-time behaviour or merely hoped for it. Portfolio: the code, a wiring diagram, a screenshot of the serial monitor showing counts, and a sentence on how you proved debounce works.
Full solutiontry the steps first - click to reveal
The complete firmware, one file at a time. Work the steps above first.
/* button.h - the interface. main.c never touches the volatile counter directly. */
#ifndef BUTTON_H
#define BUTTON_H
void button_init(void);
unsigned long button_count(void); /* reads the ISR-shared counter safely */
#endif
/* button.c - the ISR and the debounce. The ONLY file that knows the counter is volatile. */
#include "button.h"
#define BTN 2
#define DEBOUNCE_MS 50
static volatile unsigned long count = 0; /* volatile: written in an ISR, read in main */
static volatile unsigned long last_edge = 0;
static void button_isr(void) { /* the ISR does the MINIMUM and returns */
unsigned long now = millis();
if (now - last_edge > DEBOUNCE_MS) { /* ignore contact bounce */
count++;
last_edge = now;
}
}
void button_init(void) {
pinMode(BTN, INPUT_PULLUP); /* button to GND */
attachInterrupt(digitalPinToInterrupt(BTN), button_isr, FALLING);
}
unsigned long button_count(void) {
unsigned long c;
noInterrupts(); /* a multi-byte read must not be interrupted */
c = count;
interrupts();
return c;
}
/* blink.h - the liveness indicator. If it stops, the main loop is blocked. */
#ifndef BLINK_H
#define BLINK_H
void blink_init(void);
void blink_tick(unsigned long now);
#endif
/* blink.c - periodic work with NO delay(): the loop must stay free for everything else. */
#include "blink.h"
#define LED 13
#define PERIOD_MS 500
static unsigned long last = 0;
static int on = 0;
void blink_init(void) { pinMode(LED, OUTPUT); }
void blink_tick(unsigned long now) {
if (now - last >= PERIOD_MS) {
last += PERIOD_MS; /* += PERIOD, not = now: no accumulating drift */
on = !on;
digitalWrite(LED, on);
}
}
/* report.h / report.c - the UART report, from MAIN, never from the ISR. */
#ifndef REPORT_H
#define REPORT_H
void report_init(void);
void report_tick(unsigned long now, unsigned long count);
#endif
/* report.c */
#include "report.h"
#define REPORT_MS 1000
static unsigned long last = 0;
void report_init(void) { Serial.begin(9600); }
void report_tick(unsigned long now, unsigned long count) {
if (now - last >= REPORT_MS) {
last += REPORT_MS;
Serial.print("count=");
Serial.println(count); /* a UART write is SLOW - never do this in an ISR */
}
}
/* main.c - the composition root: it schedules, it does not implement. */
#include "blink.h"
#include "button.h"
#include "report.h"
int main(void) {
blink_init();
button_init();
report_init();
for (;;) {
unsigned long now = millis();
blink_tick(now);
report_tick(now, button_count());
}
}
Note the boundary the split enforces: volatile and noInterrupts() appear in exactly one file, so
the rest of the firmware cannot get the ISR-sharing rules wrong.
Why it exists. A button counter that debounces, blinks, and reports over serial is the "hello world" of real firmware: it forces GPIO, interrupts, timers, and a PC link to cooperate in one non-blocking program, exactly the integration an interviewer probes for.
Mental model. Think of the firmware as a tidy office: the doorbell (interrupt) just notes a visitor and lets the receptionist get back to work; debouncing is ignoring the bell's mechanical rattle so one press counts once; and the loop is a clerk who watches the clock to blink and report on schedule without ever sitting and waiting.
Common misunderstandings.
- "One press gives one interrupt." A mechanical button bounces. Several edges per press; ignore edges that arrive too soon (e.g. < 50 ms) or you over-count.
- "Use
delay()between blinks and reports." That blocks everything: trackmillis()so blinking, reporting, and counting run independently. - "
countcan be an ordinary variable." It is shared with the ISR, so it must bevolatile.
Connections. This wires together GPIO + pull-ups and interrupts + the tiny-ISR rule from the two previous lessons, and UART (used again on STM32 in Turn 2). The non-blocking millis() loop is the fixed-rate idea from Python Turn 1, and the "flag and react" structure scales up into the Turn-2 RTOS logger project.
BImmediate Active Recall
QUERYWhat is switch bounce and how does the debounce code handle it?
REVEAL
A mechanical contact makes several rapid transitions per press. The code ignores any new edge that occurs within 50 ms of the previous one, so one press = one count.
QUERYWhy are count and lastEdge declared volatile?
REVEAL
count and lastEdge declared volatile?They're shared between the ISR and main code; volatile forces fresh reads so the loop sees the ISR's updates instead of a cached value.
QUERYWhat is baud rate, and what happens if the PC terminal uses a different one than the MCU?
REVEAL
The serial bit rate (bits/sec). A mismatch garbles the data. You'll see gibberish characters.
CConceptual Questions
Answer each in your own words in the box, then reveal the model answer to compare. These ask why, not how, and your answers are saved.
Why does a mechanical button need debouncing, and how does the time-based debounce work?
REVEAL MODEL ANSWER
A mechanical button's contacts physically bounce when they close, producing several fast HIGH/LOW transitions in a few milliseconds, so a raw interrupt fires multiple times and counts one press as many. Time-based debounce records the timestamp of the last accepted edge and ignores any new edge that arrives within a window (e.g. 50 ms), so only the first clean transition of each press is counted.
Why is the main loop written non-blocking (no delay()), using millis() timers?
REVEAL MODEL ANSWER
delay() halts the entire CPU for its duration, so nothing else (blinking, reporting, reacting to the button) can happen meanwhile. By recording timestamps and comparing them to millis(), the loop lets several periodic activities run concurrently, each acting when its own interval has elapsed. It's cooperative multitasking in one loop without ever freezing.
Why are the ISR-shared variables (count, lastEdge) marked volatile, and why is the ISR's work kept minimal?
REVEAL MODEL ANSWER
They're shared between the ISR and the main loop, so volatile prevents the compiler from caching stale copies and ensures both sides see updates. The ISR does only the minimum (read a timestamp, debounce-check, increment) and returns immediately, because a long ISR freezes the rest of the system. The heavier work (formatting and printing over UART) happens in the main loop.
DPractice Problems
P1 (easy). Why is the main loop written with millis() comparisons instead of
delay(500) then delay(1000)?
P2 (medium). The serial monitor shows random symbols instead of count=....
What's the most likely single cause?
P3 (project extension). You want to also send a message immediately when the count changes, not just every second. How would you trigger that without doing serial work inside the ISR?
Solutionsclick to reveal
P1. Because delay() blocks; with two different delays you couldn't blink the
LED and report on independent schedules. millis() comparisons let multiple timed
actions run without blocking each other.
P2. Baud-rate mismatch between the MCU (Serial.begin(9600)) and the terminal
(set it to 9600). Mismatched baud produces garbage characters.
P3. Have the ISR set a volatile bool changed = true; flag (cheap, allowed). In
the main loop, check if (changed) { changed = false; Serial.println(count); }. The
heavy serial output stays in the loop, the ISR only flags.
EFeynman Exercise
Explain your project to a beginner as "three things happening at once on a chip that
can really only do one thing at a time": blinking, counting, reporting. Explain how
interrupts + checking the clock (millis) create that illusion, and why a bouncy
button needed special handling.
REVEAL MODEL ANSWER
Debouncing a button is like ignoring the little rattle a light switch makes and counting only the first solid click. When you flip the switch, the contacts chatter for a few milliseconds before settling, and a too-eager counter hears each chatter as a separate flip. So you make a rule: once you've counted a click, ignore any further noise for the next blink of an eye, and only start listening again after things have surely settled. One press, one count.
FError Analysis Framework
- Counting bounces. Why: no debounce. Recognise: one press → many counts. Avoid: time-gate edges (hardware or software debounce).
- Serial in ISR. Why: wanting instant output. Recognise: hangs/corruption. Avoid: flag in ISR, print in loop.
- Baud mismatch. Why: terminal not matched. Recognise: garbled text. Avoid: same baud both ends.
- Blocking loop. Why:
delay()habit. Recognise: missed presses, jerky blink. Avoid: non-blockingmillis()structure.
GMini Challenge
Your interrupt-driven button counter sometimes jumps by 3-5 on a single press. Name the defect and give the debounce logic (in words or code) that fixes it.
REVEAL MODEL ANSWER
The defect is contact bounce: the button's contacts make several fast transitions per press, each firing the ISR, so one press registers as several. Fix it with time-based debouncing. Record when the last accepted edge happened and ignore any new edge within ~50 ms:
unsigned long now = millis();
if (now - lastEdge > 50) { count++; lastEdge = now; }
Only the first clean edge of each press passes the window, so one press counts once.
Quiz Check
A quick auto-graded check, separate from the recall cards above. Your score feeds the dashboard Mastery metric. On a project it is optional. Working through the build walkthrough in section A is what completes this module.