LECTURE 05

The Interrupt System

Duration: 121 min of teaching Level: undergraduate, year 2 - recommended after Lecture 04 Discipline: Microcontrollers and Microprocessors Associated laboratory: Laboratory 03 PDF: download the notes RO versiunea română

Programs so far have had a predictable structure: initialization, then an infinite loop in which the processor keeps asking the same things. The structure works as long as the world around has the patience to be asked. The problem shows up when it does not: a button pressed for a few tenths of a millisecond, a byte arriving on the serial line before the previous one has been read, a short pulse from a speed sensor. The mechanism that solves exactly this problem is called an interrupt - the doorbell, instead of repeated trips to the door to check whether someone has arrived.

1Purpose and structure of the course6 min

We will follow the interrupt step by step: what mechanically happens in the processor when it occurs, how to correctly write a handler routine, what the vector table is, what sources the ATmega328P has, how priority works on AVR and, most important for practice, why communication between a handler routine and the main program hides two traps that only discipline solves, not talent.

Recap from Lecture 04
  • Reading an input is done from PINx, never from PORTx (Lecture 04)
  • Sampling a pin can miss a pulse shorter than the duration of a polling loop (Lecture 04) - exactly the problem this lecture solves
  • Explain why cyclic polling can miss short events, with a concrete figure
  • List, in order, the operations the processor executes when accepting an interrupt
  • Write a correct, short handler routine, with no delays and no slow operations
  • Explain what priority means on AVR and why there is no preemption between interrupts
  • Recognize and fix a variable not protected with volatile and a race condition

2Cyclic polling and the interrupt alternative12 min

The classic method with no interrupts is called cyclic polling: in the main loop, the program periodically checks the state of each source it cares about.

while (1) {
    new_state = (PIND & (1<<PD2)) ? 1 : 0;
    if (old_state == 1 && new_state == 0) PORTB ^= (1<<PB5);
    old_state = new_state;
    read_temperature_sensor();   /* takes a few milliseconds */
    update_display();           /* takes even longer */
}

The program has two flaws that no rewrite can fix. The first: wasted time - the processor reads PIND tens of thousands of times a second, and in the vast majority of cases the answer is "nothing happened"; in a battery-powered application, a processor that keeps asking can never be put into a low-power mode. The second, more serious: loss of information - between two checks of the button, the program runs the sensor read and the display update; if these take five milliseconds, a shorter pulse can fall entirely within that interval and disappear without a trace.

Worked exercise - can a 40 µs pulse be detected by polling?

An optical sensor generates a 40 µs pulse on every rotation. The main loop, on an ATmega328P at 16 MHz, needs 1200 cycles for one complete pass.

See the solution

One cycle: T = 1/16×10⁶ = 62.5 ns. One complete loop pass: 1200×62.5 ns = 75 µs.

The 40 µs pulse is shorter than the interval between two consecutive reads - it can start and end completely between two glances of the program. Detection is not impossible, but it is random: the measured speed will be wrong unpredictably. For reliable detection by polling, the loop would need to close in under 640 cycles - very little room for the rest of the application.

The same pulse, seen by polling and by an interrupt
The doorbell analogy We are waiting for a package at a door with no doorbell: the only solution is to periodically go check. If we go often, we waste all our time in the hallway; if we go rarely, the courier manages to ring, wait and leave. There is no frequency of trips that solves both problems at once. With a doorbell, we go about our business calmly and react only when someone presses the button. This is exactly what the interrupt system does: the processor does not monitor, it only reacts when the event occurs - the event is generated by a peripheral or by the outside environment, the processor only receives the notification.
Polling or interrupt? The numerical comparison

3Characteristics of the interrupt system7 min

An interrupt is generated in response to a physical effect of a peripheral: a pin changing, a time period ending, a serial transmission finishing, an analog-to-digital conversion finishing. Their common trait is asynchrony - they occur at moments uncorrelated with the program's current position. This is precisely where the efficiency gain comes from: the program no longer needs to be built around the moments when events might occur.

Handling requires a dedicated subroutine, the handler routine, invoked automatically by hardware, never explicitly from the program. The occurrence of the event sets a flip-flop called the interrupt flag - exactly what solves the short-pulse problem: the pulse can physically disappear, but the bit that marks it stays set until it is handled or cleared.

The three conditions for accepting a request
The source's flag is set, AND the source's local enable is on, AND the global I bit in SREG is on. All three at once - the absence of any one blocks the request, even if the flag remains set, waiting.
Real time means "guaranteed", not "fast" An interrupt-based program can offer a calculable guarantee on the time elapsed between an event and the start of its handling. A polling-based program only offers an average, with unpredictable deviations - the difference between "usually responds quickly" and "always responds within a known interval".

4The operating principle, step by step13 min

The starting point is the main program (MPF), with the program counter (PC) holding the address of the next instruction. Assume a peripheral sets a flag and all three conditions are met.

The sequence of operations

1. The processor finishes the instruction currently in progress - a correctness guarantee, not a detail: stopping halfway would leave a partial write or an uncomputed result. An interrupt is never accepted in the middle of an instruction, only at the boundary between two. A long instruction adds to the response delay.

2. Saves the PC onto the stack (push(PC)) - the exact return address. On the ATmega328P, two bytes, SP decreases by two. If the stack is corrupted, returning sends the program to an arbitrary place in Flash.

3. Automatically disables interrupts, clearing the I bit in SREG - the equivalent of "unplugging the doorbell" while we are talking to the courier. Without this step, a second interrupt would arrive immediately, would save yet another address, would itself be interrupted in turn - the stack would grow without bound.

4. Loads into the PC the address of the corresponding vector, from the interrupt vector table.

5. Executes the handler routine - the main program is completely stopped, does not run in parallel, does not run more slowly, does not run at all.

6. Ends with reti (return from interrupt) - pops the address off the stack into the PC, then re-enables interrupts, setting the I bit back. The difference from ret is exactly this re-enabling.

Why a routine cannot end with ret

The program would return correctly to the address, but would keep running with interrupts off - the system would appear "stuck" after the first interrupt, with no error message. It is a mistake impossible to make in C (the ISR macro always generates reti), but a real one in assembly.

Response latency

The time elapsed between an event and the first useful instruction of the routine is called latency - it is neither zero nor constant. Components: finishing the current instruction, recognizing the request and saving the PC, the jump from the vector table, the routine's prologue (saving context).

Worked exercise - the order of magnitude of latency

ATmega328P at 16 MHz. Recognizing the request takes 4 clock periods, the jump from the vector table 3 periods. Estimate the minimum latency.

See the solution

T = 1/16×10⁶ = 62.5 ns. Recognition + saving the PC: 4×62.5 = 250 ns. Jump: 3×62.5 = 187.5 ns. Total: ~437.5 ns.

To this interval must be added finishing the instruction in progress (up to four periods) and the prologue generated by the compiler (for a routine with several variables, it can exceed twenty periods). Conclusion: the real latency is on the order of a microsecond, not tens of nanoseconds, and varies from one occurrence to another - for precise measurements we do not rely on the moment of entering the routine, but on a value captured directly by hardware (timer capture).

Accepting an interrupt: put the eight steps in order

5The interrupt handler routine11 min

The handler routine, ISR (Interrupt Service Routine), is written like any function, but behaves differently.

Saving and restoring context

The hardware saves only the PC. Everything else - the SREG register with the condition flags (zero, carry, sign, overflow) and the working registers - is left up to the routine.

Why not saving SREG produces bugs impossible to reproduce by debugging

The main program performs a comparison, followed by a conditional branch based on it. If an interrupt occurs between the two, and the routine performs any arithmetic operation, the flags are overwritten - on return, the branch is taken based on the routine's computation, not the original comparison. The bug depends on the exact timing of the interrupt, so it appears once in a few thousand runs.

The set of SREG plus the registers used is called the context. In C, the ISR() macro from avr/interrupt.h automatically generates the prologue and epilogue: it saves SREG, saves exactly the registers the body actually uses, executes the body, restores everything in reverse order and ends with reti.

#include <avr/io.h>
#include <avr/interrupt.h>

ISR(INT0_vect)
{
    PORTB ^= (1 << PB5);   /* the routine's body: a single operation */
}
A typo in the vector name produces no compile error

INT0_vect is not just any parameter, but the symbolic name that ties the function to its position in the table. Misspelled, the compiler accepts the code without complaint - the routine is never called. When the event occurs, the program jumps into an unhandled entry and resets (see the section on the vector table).

Writing rules

For the duration of the routine, the main program is stopped and, on AVR, the other interrupts are blocked. Any cycle wasted needlessly is a cycle in which the application responds to nothing else.

The rules, treated as obligations The routine must be short - a few dozen instructions: setting a flag, reading a register, incrementing a counter. No delay functions (_delay_ms, delay) - they stop the entire system. No slow operations - serial, EEPROM, floating point, LCD, all take milliseconds. It returns no values, receives no parameters - there is no one to pass them to or take them from, the call comes from hardware, not from the program. The central pattern: the routine only notes the event, the main program processes it.

6The interrupt vector table8 min

The link between the physical source and the handler routine is a fixed data structure in memory: the interrupt vector table. On AVR it sits at the start of Flash, from address zero.

On the ATmega328P (32 KB), each entry contains a full jmp instruction (two words), because the distance to the routine can exceed the range of a relative jump. The vectors sit two words apart: RESET at 0x0000, INT0 at 0x0002, INT1 at 0x0004.

The table contains jumps, not the routines' code The space between two consecutive vectors is only two words, not enough for any real routine. The table entry is not the final destination, but a pointer to it: the processor jumps into the table, and from there the jump takes it wherever in Flash the compiler placed the function's body.
The first entry is the RESET vector, not an actual interrupt

It is the starting point after power-up, an external reset, or a watchdog timeout. A useful consequence: if an interrupt occurs with no routine written for it, the default entry sends execution to the RESET vector, and the program starts over from the beginning. A microcontroller that resets at seemingly random intervals is, very often, one with an interrupt source enabled and unhandled.

AddressSourceMeaning
0x0000RESETpower-up, external reset, watchdog
0x0002INT0external interrupt 0 (PD2)
0x0004INT1external interrupt 1 (PD3)
0x0006-0x000APCINT0-2level change on ports B, C, D
0x0012, 0x001A, 0x0020TIMERn_OVFoverflow of counters 0-2
0x0024, 0x0028USART_RX, USART_TXreception / transmission finished
0x002AADCanalog-to-digital conversion finished

The regularity of the table is not accidental: sources that need a fast reaction and are directly tied to the outside (external interrupts) sit at the head of the table, the slow ones (tied to memory) at the end - the position in the table has an extra role, discussed under priorities.

Match the vector with the event that triggers it

7Interrupt sources: external and internal9 min

Sources fall into external (the event comes from a pin) and internal (the event is produced by a module inside the chip).

External interrupts

Detection happens in two ways. Level-triggered: the request exists continuously as long as the line is in the active state - if the routine ends and the line is still low, the request reappears immediately, execution without end until the source releases the line. Edge-triggered: the request appears just once, at the transition (rising, falling, or either) - the mode used in most applications, because the event is by nature a single point in time.

The ATmega328P has two full external interrupts, INT0 (PD2) and INT1 (PD3), configured in EICRA through the pairs ISC01/ISC00 and ISC11/ISC10: 00 low level, 01 any change, 10 falling edge, 11 rising edge. Local enable through EIMSK, flags in EIFR (INTF0, INTF1).

PCINT - coarse, but covers every pin Pin change interrupts cover practically every pin, but do not allow choosing the edge, detect any change, and all pins of a port share a single vector - the routine must read the port and compare with the previous value to find out which pin changed. This solves the situation where many inputs need interrupt capability and only two full external interrupts are available.

Communication peripherals (USART, SPI, I2C) generate interrupts when data is ready to transfer or has been received. At 115200 b/s, a character arrives every ~87 µs - without an interrupt, the program would need to check the status register at least twice that frequency, impossible to reconcile with any other activity.

Internal interrupts

Counters generate overflow (a fixed period, dictated by size and divider) or compare match (a freely chosen period, through the compare register). The ADC generates an interrupt at the end of a conversion - the natural solution for acquisitions: the conversion is started, the processor does something else for tens of microseconds, the routine picks up the result.

Clearing the flag is not uniform across sources

For most sources, the flag is cleared automatically by hardware on entering the routine. If we handle it through polling, with no routine, manual clearing is done by writing a one into the bit, not zero. For other sources (USART reception), the flag clears itself when the data register is read - if the routine forgets to read UDR0, the flag stays set and the interrupt keeps recurring endlessly.

8Priorities and interrupt nesting9 min

Priority decides the handling order when several requests occur simultaneously, or when a new request arrives while another is being handled. It can be hardware (fixed by construction) or software (chosen by the programmer, through a configuration register) - 32-bit architectures usually offer a controller with programmable levels.

A mechanism common to all architectures is the interrupt mask: temporarily disabling some sources, regardless of priority. A masked source does not disappear - its flag keeps being set, and when the mask is lifted, the accumulated request is handled immediately.

How things stand on AVR

Priority is determined exclusively by the vector's position in the table A source closer to address zero has higher priority. INT0 > INT1 > the timer interrupts > USART or ADC - a fixed order, not modifiable through any register.

Priority matters only when two or more requests are pending at the same time, at the moment the processor is ready to accept an interrupt - then the one with the smaller vector is handled first, the other stays pending and is handled right after. No request is ever lost.

There is no preemption between interrupts on AVR

Once inside a routine, with the I bit cleared automatically, no other interrupt can interrupt it, however high its priority. An INT0 request arriving while an ADC interrupt routine is running does not pull the processor out of the ADC routine - it waits patiently until reti. Practical consequence: on AVR, priority matters much less than the duration of the routines - the best guarantee of a fast response for a critical source is not its position in the table, but the fact that all other routines are short.

Manually re-enabling interrupts inside a routine (sei() or ISR_NOBLOCK) produces nesting - routines that can interrupt each other. Almost always a bad idea, for three reasons: stack consumption grows with every level of nesting, on a 2 KB memory; the risk of recursion, if the flag of the routine's own source is not handled before re-enabling; and the resulting complexity makes it impossible to verify all the possible interleavings of execution. The correct fix for a routine that is too long is not nesting, but shortening it.

9Shared variables and race conditions11 min

Communication with the main program happens exclusively through global variables - and hides two distinct traps.

Compiler optimization and volatile

If it sees a global variable read in a loop, but never modified there or in the functions called from it, the compiler concludes the value does not change - it reads it once, keeps it in a register, uses the register for every iteration. Correct reasoning for the visible code, wrong in reality, because the handler routine modifies it with no call ever appearing in the visible program.

volatile uint8_t event = 0;   /* without volatile, the loop never ends */

ISR(INT0_vect) { event = 1; }              /* the routine only notes it */

int main(void) {
    configure_interrupt(); sei();
    while (1) {
        if (event) {                       /* the main program processes it */
            event = 0;
            PORTB ^= (1 << PB5);
        }
    }
}
The practical rule Any global variable written in a handler routine and read in the main program, or the other way around, must be declared volatile. The cost is a small speed loss, insignificant compared to the risk of a loop waiting forever on a flag already set.

The race condition

Not solved by volatile. AVR registers are eight bits wide - a 16-bit variable requires two successive reads, and between them there is an instruction boundary where an interrupt can be accepted.

Worked exercise - non-atomic reading of a 16-bit variable

pulses holds the value 255 = 0x00FF. The program reads the low byte (0xFF). Right then a pulse arrives, the routine increments the variable to 256 = 0x0100. The program reads the high byte (0x01).

See the solution

The reassembled value: 0x01FF = 511. The number 511 never existed - the variable held 255, then 256, but never 511. The program read half of an old value and half of a new one - an error of over 100%, occurring once every few thousand reads, which passes all tests and shows up at the client's site.

Solution: an atomic read, by temporarily disabling interrupts, with the previous state restored (not an unconditional re-enable):

#include <util/atomic.h>
uint16_t read_pulses(void) {
    uint16_t copy;
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { copy = pulses; }
    return copy;
}
A critical section is kept short, used to copy, not to process

For the duration of the critical section the system is deaf to any event - a long section produces exactly the same problems as a long handler routine. The value is copied inside, processed after leaving. A useful note: on AVR, reading/writing an eight-bit variable is atomic by construction (a single instruction) - but counter++ is not atomic even on eight bits, being read+increment+write, three separate instructions.

10A complete application using the interrupt system11 min

A complete application: the LED toggles state on every press of a button, using INT0, on the rising edge.

The setup and initialization

The button on PD2 (the only pin with INT0), wired between the pin and VCC, with an external ~10 kΩ pull-down - low at rest, high when pressed (rising edge). The LED on PB5, with a limiting resistor calculated as in Lecture 04.

static void init_ports(void) {
    DDRB = (1 << DDB5); PORTB = 0x00;      /* PB5 output, LED off */
    DDRD = 0x00; PORTD = 0x00;              /* PD2 input, no pull-up (external) */
}

static void init_interrupt(void) {
    EICRA |= (1 << ISC01) | (1 << ISC00);   /* rising edge on INT0 */
    EIFR  |= (1 << INTF0);                  /* clear any leftover flag */
    EIMSK |= (1 << INT0);                   /* local enable */
}

ISR(INT0_vect) { PORTB ^= (1 << PB5); }      /* toggle the LED; nothing else */

int main(void) {
    init_ports(); init_interrupt();
    sei();                                   /* global enable, after all initialization */
    while (1) { /* free for anything else */ }
}
Why the order of the three steps matters, and why sei() comes last The trigger mode is written before clearing the flag, because changing the mode can itself set the flag - without clearing it, the routine would run once unjustifiably right after the global enable. And sei() is always called at the end of initialization: if interrupts were active earlier, a routine might run before the data structures it uses have been initialized.

The Arduino variant

volatile bool toggle_request = false;

void handle_button() { toggle_request = true; }   /* just flags it */

void setup() {
    pinMode(PIN_LED, OUTPUT);
    pinMode(PIN_BUTTON, INPUT);
    attachInterrupt(digitalPinToInterrupt(PIN_BUTTON), handle_button, RISING);
}
void loop() {
    if (toggle_request) {
        toggle_request = false;
        digitalWrite(PIN_LED, !digitalRead(PIN_LED));
    }
}

The difference is not just syntax: here the routine does not directly command the output, it only raises a flag, and the toggle happens in loop() - because digitalRead/digitalWrite are not elementary operations (they translate a pin into port+mask, check for an active PWM), taking a few microseconds, a lot for a handler routine. The same pattern: the routine notes it, the loop processes it.

11The contact bounce problem6 min

The program above works perfectly in simulation. On a real setup, the LED will sometimes toggle once, other times three or five times for a single press - contact bounce (Lecture 04), seen by the interrupt system just as faithfully as any other edge.

Filtering the bounce directly in the handler routine
const unsigned long MIN_INTERVAL = 40;   /* milliseconds */
volatile bool toggle_request = false;
volatile unsigned long last_moment = 0;

void handle_button() {
    unsigned long now = millis();       /* can be read, does not advance inside the routine */
    if (now - last_moment >= MIN_INTERVAL) {
        toggle_request = true;
        last_moment = now;
    }
}
Why the comparison is written as "difference ≥ interval", not as "now ≥ moment + interval"

The first form stays correct even when the millisecond counter exceeds its maximum value and wraps to zero - subtraction in unsigned arithmetic gives the correct difference regardless. The second form produces, at the moment of wraparound, a button that stops responding for a long interval.

The hardware alternative, an RC filter possibly followed by a Schmitt trigger, does not consume processor time, but requires one extra component per button - the decision depends on how many buttons the setup has and how critical processor time is.

A note on millis() inside a routine On the Arduino platform, millis() relies on a counter interrupt. Since interrupts are disabled inside a handler routine, the value returned by millis() does not advance for the duration of its execution - it can be read (useful for marking the moment of the event), but it cannot measure a duration inside the routine, and even less can it be used for waiting. A delay() placed inside a handler routine never finishes.

12Frequent mistakes4 min

  • "A longer handler routine does no harm, it just does a bit more." False - for its duration, the main program is completely stopped and, on AVR, all other interrupts are blocked. Any wasted cycle is a cycle in which the application responds to nothing else, including critical events. The routine only notes the event (a flag, a read, an increment); processing happens in the main loop.
  • "A global variable read both in an interrupt and in the main loop does not need anything special if it is small." False - without volatile, the compiler may keep a stale copy in a register, and the loop never sees the update. For variables larger than one byte, even with volatile, the read is not atomic and can reassemble a value that never existed. Declare any shared variable volatile; protect reads of more than one byte with ATOMIC_BLOCK.
  • "If a source has higher priority, it can interrupt any other routine in progress." False on AVR - there is no preemption. Once inside a routine, no other interrupt can interrupt it, regardless of priority; it waits patiently until reti. Do not rely on priority for a fast response - the real guarantee comes from the fact that all routines are short.

13Summary and glossary5 min

Cyclic polling can miss events shorter than the duration of one loop iteration; the interrupt solves this through a hardware flag that remembers the event even if the physical signal has already disappeared. On accepting a request, the processor finishes the current instruction, saves the PC onto the stack, disables interrupts globally, jumps to the vector from the table, executes the routine and returns with reti, which re-enables interrupts - unlike ret. A handler routine must be short, with no delays and no slow operations: it notes the event, leaving processing for the main loop. On AVR, priority is given exclusively by the vector's position in the table and only matters between simultaneously pending requests - there is no preemption between routines already in progress. Communication through global variables requires volatile for visibility correctness and, for variables larger than one byte, critical sections for atomic reads - otherwise race conditions appear, hard to reproduce and devastating when they do.

ISR
Interrupt Service Routine - the handler routine, invoked automatically by hardware.
Flag
a flip-flop that remembers the occurrence of an interrupt event.
Vector table
a fixed area of Flash with the address of every handler routine (through a jump).
Latency
the time between the event and the first useful instruction of the routine.
Context
SREG plus the working registers used - saved on entering the routine, restored on exit.
Race condition
a bug depending on the exact timing of an interrupt, from a non-atomic read of a shared variable.

14Self-check questions7 min

  1. Explain why cyclic polling cannot guarantee the detection of a short event, even if the loop is optimized. What quantity must be compared with the event's duration?
  2. List, in order, the operations of the AVR processor between accepting a request and the first instruction of the routine. Which are hardware and which are generated by the compiler?
  3. What is the difference between ret and reti? What happens if a routine mistakenly ends with ret?
  4. What is meant by the processor's context, and why must it be saved on entering a routine? Give a concrete example of a bug caused by not saving SREG.
  5. An ATmega328P resets at irregular intervals, even though the program contains no reset instruction, and the watchdog is disabled. Propose a plausible explanation.
  6. On AVR, INT0 has higher priority than USART reception. An INT0 request arrives while the reception routine is executing. What happens, and why?
  7. A 16-bit counter is incremented in a routine and read in the main program. Construct a sequence of events in which the program obtains a value that never existed.

15Directions for further study2 min

The next lecture uses exactly the interrupt mechanism discussed here, applied to a specific peripheral: timer/counter modules - the time base, generating PWM and capturing external events with clock-cycle precision.

The setup, initialization and handler routine from this lecture become a real setup in Laboratory 03.