An interrupt that does not fire gives you nothing to debug. No error, no warning, no output — the handler simply never runs, and the code looks correct because every individual line is correct.
There are only a few reasons this happens. This walks through the mechanism, then the list.
What an external interrupt is for
Polling a button means asking, over and over, whether it has been pressed. It works, it wastes the processor, and it misses anything shorter than the loop.
An external interrupt inverts that. You tell the hardware which pin to watch and which edge to care about, and the hardware watches it. When the edge arrives the processor stops what it is doing, saves its place, jumps to your handler, and resumes afterwards. Between edges your main loop is entirely free — it can be empty, or asleep.
Both the ATmega16 and the ATmega328P put INT0 on PD2 and INT1 on PD3. On an Arduino Uno those are the pins labelled D2 and D3, which is why every interrupt tutorial uses pin 2. The ATmega16 adds a third, INT2 on PB2, which differs from the other two: it is edge-triggered only, with a single ISC2 bit instead of a pair, and it is the one external interrupt the simulator does not yet reproduce.
The four steps, at register level
#include <avr/io.h>
#include <avr/interrupt.h>
ISR(INT0_vect) {
PORTB ^= (1 << 0);
}
int main(void) {
DDRB |= (1 << 0); // LED pin as output
DDRD &= ~(1 << 2); // 1. PD2 as input
PORTD |= (1 << 2); // 2. pull-up, so it idles high
MCUCR = (1 << ISC01); // 3. falling edge (ATmega16)
GICR = (1 << INT0); // enable INT0 (ATmega16)
sei(); // 4. global interrupt enable
for (;;) { }
}
Four things, and all four are required: the pin is an input, it has a defined idle level, the edge is chosen, and interrupts are enabled both individually and globally.
The sense-control encoding is the same two bits on both chips — 00 low level, 01 any change, 10 falling edge, 11 rising edge — but the register names differ:
| ATmega16 | ATmega328P | |
|---|---|---|
| Sense control | MCUCR — ISC01:ISC00, ISC11:ISC10 | EICRA — same bit names |
| Interrupt enable | GICR | EIMSK |
| Interrupt flag | GIFR | EIFR |
That difference is why an ATmega16 example pasted into an Arduino project does not compile, and vice versa. It is a rename, not a redesign.
The Arduino form
The library wraps the same four steps:
const int BUTTON = 2;
const int LED = 13;
volatile bool lit = false;
void toggle() {
lit = !lit;
digitalWrite(LED, lit);
}
void setup() {
pinMode(LED, OUTPUT);
pinMode(BUTTON, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BUTTON), toggle, FALLING);
}
void loop() { }
INPUT_PULLUP does the direction and the pull-up together. digitalPinToInterrupt(2) converts the pin number to an interrupt number — use it rather than writing 0, because the mapping differs between boards. attachInterrupt picks the edge and sets both enables, sei() included.
An empty loop() is the demonstration: nothing is polling the button, and the LED still responds.
Why it does not fire
In rough order of how often each one is the answer.
sei() was never called. Every mask bit set correctly, every edge chosen correctly, and the global enable off. Nothing fires. With attachInterrupt this is handled for you, which is why it shows up almost exclusively in register-level code.
The pin has no idle level. An interrupt on a falling edge needs the pin to be high first. A button wired from the pin to ground, with no pull-up, leaves the pin floating when the button is open — it drifts, and either fires at random or never at all. PORTD |= (1 << 2) on the input, or INPUT_PULLUP, or an external resistor to 5 V.
The edge is the wrong way round. A button pulling to ground with a pull-up produces a falling edge on press. RISING will fire on release instead, which looks like a delay of one press and is genuinely confusing to watch.
The wrong interrupt was enabled. INT0 is PD2 and INT1 is PD3. Enabling INT0 while wiring the button to PD3 is a one-character mistake with no symptom other than silence.
The handler runs but you cannot tell. A variable changed inside an ISR and read in loop() must be declared volatile, or the compiler is entitled to cache it in a register and never re-read it. Your loop then tests a copy that never changes. This one is worse than a silent interrupt, because the handler is firing and the evidence is being optimised away.
Something inside the handler cannot work there. Interrupts are disabled while an ISR runs, so anything that depends on them stalls. delay() counts timer overflows and will hang. Serial.print() waits on the serial interrupt and may hang or corrupt. millis() will not advance. The rule that avoids all of it: set a flag, return, and do the work in the main loop.
The button is bouncing. A mechanical contact makes and breaks several times over a few milliseconds, and an edge-triggered interrupt is fast enough to catch every one of them. One press, five interrupts. This does not look like a dead interrupt — it looks like an erratic one.
Confirming it in a simulator
The advantage of a simulator here is specific: on hardware, a silent interrupt and a handler whose effect you cannot see are indistinguishable. In a simulator you can toggle the pin deliberately, at a known moment, and watch whether the output pin changes.
That separates the two halves of the problem. If the output changes, your interrupt configuration is correct and the bug is in what the handler does. If it does not, the configuration is wrong and the list above applies. Either way you know which half to look at, which is most of debugging.
Both forms above run in the browser simulator — the register version on an ATmega16, the attachInterrupt version on an Arduino Uno. Place the chip, wire a push button to the interrupt pin and an LED through a resistor to the output, press Run and click the button on the canvas.
For the surrounding material: simulating an Arduino circuit covers the button-and-LED wiring itself, and the ATmega16 walkthrough covers the other peripherals at register level.