ROBOTECA FREE SAMPLE
Dashboard

A free lesson from Embedded Systems & Electronics: the whole module, nothing cut short.

PROJECT BUILD · Embedded Systems

PROJECT: Interrupt button + UART reporter

Turn 1 30 min PROJECT BUILD

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).

A‍​‌‌​​‌‌​​‌‌‌​​‌​​‌‌​​‌​‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​​‌‌​‌‌​​​​‌​‌‌​‌‌​‌​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​‌​‌‍The 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.

Build it step by step0/6

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: track millis() so blinking, reporting, and counting run independently.
  • ‍​‌‌​​‌‌​​‌‌‌​​‌​​‌‌​​‌​‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​​‌‌​‌‌​​​​‌​‌‌​‌‌​‌​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​‌​‌‍"count can be an ordinary variable." It is shared with the ISR, so it must be volatile.

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

QUERY

What is switch bounce and how does the debounce code handle it?

REVEAL
ANSWER

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.

Did you recall it?
QUERY

Why are count and lastEdge declared volatile?

REVEAL
ANSWER

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.

Did you recall it?
QUERY

‍​‌‌​​‌‌​​‌‌‌​​‌​​‌‌​​‌​‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​​‌‌​‌‌​​​​‌​‌‌​‌‌​‌​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​‌​‌‍What is baud rate, and what happens if the PC terminal uses a different one than the MCU?

REVEAL
ANSWER

The serial bit rate (bits/sec). A mismatch garbles the data. You'll see gibberish characters.

Did you recall it?

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.

PROMPT

Why does a mechanical button need debouncing, and how does the time-based debounce work?

‍​‌‌​​‌‌​​‌‌‌​​‌​​‌‌​​‌​‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​​‌‌​‌‌​​​​‌​‌‌​‌‌​‌​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​‌​‌‍REVEAL MODEL ANSWER
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.

Compared to the model answer - did you get it?
PROMPT

Why is the main loop written non-blocking (no delay()), using millis() timers?

‍​‌‌​​‌‌​​‌‌‌​​‌​​‌‌​​‌​‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​​‌‌​‌‌​​​​‌​‌‌​‌‌​‌​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​‌​‌‍REVEAL MODEL ANSWER
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.

Compared to the model answer - did you get it?
PROMPT

Why are the ISR-shared variables (count, lastEdge) marked volatile, and why is the ISR's work kept minimal?

‍​‌‌​​‌‌​​‌‌‌​​‌​​‌‌​​‌​‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​​‌‌​‌‌​​​​‌​‌‌​‌‌​‌​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​‌​‌‍REVEAL MODEL ANSWER
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.

Compared to the model answer - did you get it?

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.

‍​‌‌​​‌‌​​‌‌‌​​‌​​‌‌​​‌​‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​​‌‌​‌‌​​​​‌​‌‌​‌‌​‌​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​‌​‌‍P1Compared to this solution - did you get it right?

P2. Baud-rate mismatch between the MCU (Serial.begin(9600)) and the terminal (set it to 9600). Mismatched baud produces garbage characters.

P2Compared to this solution - did you get it right?

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.

P3Compared to this solution - did you get it right?

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
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.

Compared to the model answer - did you get it?

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-blocking millis() 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
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.

Compared to the model answer - did you get it?

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.

QUIZAuto-graded check · feeds your mastery score
  1. A mechanical button must be debounced because:

  2. The non-blocking loop schedules blink/report using:

  3. The shared count updated in the ISR must be declared:

  4. UART communication requires both ends to agree on the:

This is a free sample

Progress and the spaced-repetition reviews are part of the course. The full track continues from here.