Timing and Interrupts
At the end of phase 4 your program read a button by asking, over and over, "are you pressed yet?" That works, but it wastes the entire CPU doing nothing but asking, and if the interesting event is faster than your asking, you miss it. This phase is about the better way: let the hardware interrupt you the instant something happens, and let a timer keep time in the background so your CPU is free for real work.
Polling versus interrupts
Polling is a loop that keeps checking: while (1) { if (PIND & (1 << PD2)) ... }. It is simple and predictable, but the processor is pinned to that check and can do nothing else useful. Worse, if you slip a delay into the loop (say, to blink an LED), a quick button tap that happens during the delay is gone - you were not looking.
An interrupt flips the relationship around. You tell the hardware "when this pin goes low, stop whatever you are doing and run this function." Then your main code gets on with its life, and the moment the event fires, the CPU pauses the main program, runs your handler, and resumes exactly where it left off. Nothing is missed, and no cycles are burned waiting.
📝 Terminology. The function the CPU jumps to is an ISR, an Interrupt Service Routine. Each interrupt source has a fixed slot called a vector; INT0_vect names the slot for external interrupt 0. You write an ISR with the ISR(vector_name) macro from <avr/interrupt.h>.
The rules of an ISR
An ISR is not a normal function, and treating it like one is how beginners hang their boards. Three rules:
- Keep it short. Do the bare minimum and return. An ISR usually runs with other interrupts held off, and while it runs your main program is frozen. A slow ISR delays everything.
- Never block inside it. No
_delay_ms(), no waiting onSerial, no long loops. Blocking in an ISR stalls the whole system, including the timers other features rely on. - Do the real work in the main loop. The ISR's job is to notice the event and record it. The loop reacts to it.
⚠️ Gotcha. Serial.println() inside an ISR is a classic trap - serial output is itself interrupt-driven, so printing from an ISR can deadlock or drop characters. Set a flag in the ISR and print from the loop.
Sharing data: the volatile flag
So the ISR records the event and the loop reacts. They share a variable - and this is exactly where volatile from phase 2 earns its keep.
The compiler optimizes aggressively. Looking at while (1) { if (button_pressed) ... }, it sees nothing in the loop that changes button_pressed, so it may "helpfully" read the variable once, cache it in a CPU register, and never look at memory again. It cannot see that an ISR writes the variable behind its back. Result: the ISR sets the flag, the loop never notices, your button does nothing.
volatile tells the compiler "this can change out from under you - reload it from memory every single time." Any variable shared between an ISR and the main code must be volatile.
💡 Key point. The pattern behind almost every interrupt you will ever write: the ISR sets a volatile flag and returns; the main loop sees the flag and does the work. Small ISR, no surprises.
A worked example: an interrupt-driven button
External interrupt INT0 is wired to pin PD2 on the ATmega328P. We arm it to fire on a falling edge (the moment a pull-up-held pin drops to ground, which is a button press), and each press toggles the LED.
volatile uint8_t button_pressed = 0; // shared between the ISR and loop()
void
void
press
press
press
What just happened: each press pulls PD2 low, the hardware fires INT0_vect, and the ISR does one thing - sets button_pressed. The loop notices the flag, clears it, toggles the LED, and prints. The heavy work (the print) stays out of the ISR, and because the interrupt catches the press directly, the loop could be busy with anything else and still not miss it.
Two register details worth naming, both from the datasheet: EICRA (External Interrupt Control Register A) picks the trigger, where ISC01 = 1 with ISC00 = 0 means falling edge; EIMSK (External Interrupt Mask Register) has one enable bit per external interrupt, so 1 << INT0 switches INT0 on. Finally, sei() sets the global interrupt-enable bit - until you call it, no interrupt fires at all. Its opposite is cli().
▶ Try it: start a new Arduino Uno project at wokwi.com, wire a pushbutton from pin 2 to GND, paste this in, and Run. Open the serial monitor and click the button - each click toggles the LED and logs a press.
Why buttons bounce, and what to do
Run that example and you may see one click log two or three presses. Your code is fine - the button is not. A pushbutton is two metal contacts snapping together, and for a few milliseconds after they touch they physically chatter, bouncing open and closed several times before settling. Each bounce is a real falling edge, so your ISR fires several times for one human press.
The fix is debouncing: after the first edge, ignore further edges for a short window - roughly 20 to 50 ms, longer than the bounce but shorter than a human can press twice. The lazy, reliable version records the time of the last accepted press and rejects any new one that arrives too soon after it:
if
That is enough to collapse the chatter back into one press. (Hardware debouncing with a small capacitor exists too, but a time window in software costs nothing and is usually all you need.)
Timers: keeping time without delay()
_delay_ms(500) from phase 4 is a busy-wait: the CPU sits in a counting loop doing nothing for the whole half second. During that delay it cannot read a sensor, answer an interrupt promptly, or do anything else. For a first blink that is fine; for a real program it is dead weight.
A hardware timer is a counter built into the chip that ticks on its own, driven by the system clock, completely independent of your code. You configure it once, and it counts in the background while your CPU does other things. When it reaches a target it can fire an interrupt - so you get a steady heartbeat for free.
Here is Timer1 (the 16-bit timer) set to fire once per second and toggle the LED, with no delay() anywhere:
volatile uint8_t tick = 0;
void
void
The clock ticks at 16 MHz. The /1024 prescaler slows the timer's counting to 15625 ticks per second, and counting 0 up to OCR1A (15624) and resetting takes exactly one second, at which point TIMER1_COMPA_vect fires. Same pattern as before: the ISR sets a flag, the loop acts. The difference from delay() is that the loop is never blocked - it is free every moment between ticks.
🪖 War story. A common first embedded project is a data logger that reads a sensor and blinks a status LED. Written with delay() for the blink, it drops readings every time the LED is mid-blink, and the bug hides because it only loses data half the time. Rebuilt with a timer interrupt for the blink and the main loop free for the sensor, the dropped readings vanish. Once you have felt that, you stop reaching for delay() in anything that has to do two things at once.
Recap
- Polling burns the CPU checking, and can miss events during a delay. Interrupts let the hardware call you the instant something happens, freeing the CPU in between.
- An ISR must be short and never block. No
_delay_ms(), noSerialprinting, no long loops inside it. - Share data between an ISR and the loop through a
volatilevariable, or the compiler may cache it and never see the ISR's update. - The core pattern: ISR sets a
volatileflag and returns; the main loop reacts. - On AVR, arm external interrupt INT0 (pin PD2) with
EICRAfor the edge,EIMSKto enable it,sei()to turn interrupts on, and handle it inISR(INT0_vect). - Mechanical buttons bounce: one press makes several edges. Debounce by ignoring further edges for ~20 to 50 ms.
- A hardware timer counts in the background and can fire an interrupt on a schedule - a steady heartbeat that replaces blocking
delay()and keeps the loop responsive.
You can now light pins, read pins, and react to the world the moment it changes without wasting a cycle. That is the core loop of embedded programming - everything else is more peripherals and the same three registers per feature, looked up in the datasheet.
Check yourself
[
{
"q": "Why must the shared flag in `ISR(INT0_vect) { button_pressed = 1; }` be declared `volatile`?",
"choices": [
"volatile makes the variable faster to access",
"Without volatile the compiler may cache the flag in a register and never see the ISR's update",
"volatile is required on every global variable in C",
"It stops the ISR from firing more than once"
],
"answer": 1,
"explain": "The compiler cannot see the ISR change the flag, so it may read the value once and reuse it. volatile forces every access to hit memory, so the loop sees the ISR's write."
},
{
"q": "Which line does NOT belong inside an interrupt service routine?",
"choices": [
"flag = 1;",
"count++;",
"_delay_ms(500);",
"reading a register into a variable"
],
"answer": 2,
"explain": "An ISR should do the minimum and return fast. A 500 ms busy-wait freezes the whole program while it runs and can make other interrupts miss their events. Do slow work in the main loop."
},
{
"q": "A mechanical button is pressed once but the press-counter jumps by three. What is the most likely cause?",
"choices": [
"The interrupt is disabled",
"Contact bounce - the metal contacts chatter for a few milliseconds, firing several edges per press",
"volatile was left off the counter",
"The pull-up resistor is too strong"
],
"answer": 1,
"explain": "Mechanical contacts physically bounce when they close, producing several fast edges for a single press. Debouncing - ignoring further edges for roughly 20 to 50 ms - collapses them back into one press."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. Why must the shared flag in `ISR(INT0_vect) { button_pressed = 1; }` be declared `volatile`?
2. Which line does NOT belong inside an interrupt service routine?
3. A mechanical button is pressed once but the press-counter jumps by three. What is the most likely cause?