Embedded HandbookApps
All sections › X. Design, networks and IoT
STM32F103, STM32F407 · level basic · 10 min read
Open in the interactive handbook

84. Interrupts and timing on a prototype

A prototype quickly runs into timing: blink three LEDs at different rates, do not miss a button press while delay() is running, go to sleep and wake up on an event. The STM32 interrupt mechanics are covered in section 7: the vector table, priorities, SysTick, EXTI, the rules for a handler. This section is about what comes up on a prototype and when porting from Arduino: error accumulation in periodic actions, bounce inside an interrupt, a torn 16-bit counter on AVR, and sleep that does not lose wake-ups.

The machine code in this section was taken from armclang for STM32 and from avr-gcc for the ATmega328P; the periodic actions and the bounce were verified by a test.

Terms used in the documentation

Term Meaning
interrupt service routine (ISR) the function that handles an interrupt
external interrupt, pin change interrupt an interrupt from a pin edge, an interrupt on any change of a pin
edge / level triggered fires on an edge / on a level
atomic operation, critical section an operation that cannot be split, a piece of code run with interrupts off
PRIMASK the mask that disables interrupts
wait for interrupt (WFI) sleep until an interrupt arrives
lost wake-up a wake-up event that arrived too early and was missed
clear timer on compare (CTC) the timer resets when it matches the compare value
prescaler, auto-reload the input clock divider, the reloaded count limit
drift accumulated timing error

84.1 Time on Arduino and on STM32

The Uno has a 16 MHz crystal — a clock period of 62.5 ns. Time for millis() and delay() is counted by one of the hardware timers, so you must not touch its settings: timekeeping will break. On STM32 the same role is played by SysTick — an interrupt once per millisecond, which HAL_GetTick() is built on (section 7.4), and the other timers are free (section 13).

delay() stops the whole program: while it waits, the sensor is not polled and the button is not read. Hence two ways out — time-based polling in the main loop, and interrupts.


84.2 A periodic action without error accumulation

The typical pattern "do something once a second without stopping the program":

if (millis() - timer >= 1000) {
    timer = millis();          /* срок — от «сейчас» */
    …
}

It works, but it slowly drifts. The next deadline is counted from the moment the main loop noticed that the time was up, and it noticed it late by a fraction of a pass. That delay is added to the period for good. The right way is to count the deadline from the previous deadline:

/* Без накопления: следующий срок — от прошлого срока. */
int every(uint32_t *last, uint32_t now, uint32_t period)
{
    if (now - *last < period)
        return 0;
    *last += period;
    return 1;
}

This pattern has a flip side: if the loop got stuck for several periods (a Flash erase, a halt in the debugger, a long blocking call), every fires several times in a row, catching up on the missed deadlines. Where that is harmful — sending a packet, stepping a motor — after a long pause the deadline is moved to the current moment.

Test: the main loop takes 7 ms per pass, the action runs once a second, for an hour of operation.

Pattern Firings per hour Lateness at the end
timer = millis() 3596 3.6 s
*last += period 3600 2 ms — less than one pass

Counting from "now", every period comes out at 1001 ms: the loop notices the deadline only on the next pass after the thousandth millisecond. Over an hour that adds up to 3.6 seconds, over a day — a minute and a half. With the period added, the lateness of each firing is less than one pass of the loop and does not accumulate.

Lateness over an hour of operation: counting from "now" it grows to 3.6 s, adding the period it stays at 2 ms

The difference now - *last is unsigned and survives counter overflow (sections 7.4 and 80.4).

⚠️ ledState =! ledState; from typical examples works — it is an assignment of the negation — but it reads like a non-existent operator =!. Write ledState = !ledState;.


84.3 External interrupts

Uno (ATmega328P) STM32
edge interrupts on a pin 2: INT0, INT1 16 EXTI lines, one pin with number n per line n (section 7.5)
pin change interrupts 23 inputs on three vectors no separate ones — the same EXTI; lines 5–9 and 10–15 also share the vectors EXTI9_5 and EXTI15_10, and the handler checks EXTI->PR
setup attachInterrupt(number, function, mode) EXTI, AFIO or SYSCFG, NVIC (section 7.6)

On the ATmega328P a pin change interrupt exists on every pin except the non-existent PCINT15: 23 inputs grouped onto three vectors, and the handler has to find out which pin changed. "Interrupts exist only on pins 2 and 3" refers to the two inputs with edge selection.

A button on an interrupt still bounces. A typical example counts presses with a falling-edge interrupt. But a mechanical contact bounces when it is pressed and released (section 50.7), and every bounce is an edge. In the test a single press and release gave five falling edges: the counter would have added five instead of one. A button interrupt is either disabled for the bounce time and re-enabled from a timer, or used only as a "wake up" signal, with the press itself confirmed by polling (sections 79.5 and 80.4).


84.4 volatile does not mean "atomic"

A variable that a handler changes is declared volatile; otherwise the compiler is free to read it once and never read it again (sections 3 and 7.7). But volatile does not make the access atomic.

On AVR a 16-bit counter is read one byte at a time. The avr-gcc machine code: reading volatile uint16_t counter is two lds instructions, the low and the high byte. If an interrupt fires between them and increments the counter from 0x00FF to 0x0100, the main program reads the low byte of the old value and the high byte of the new one — 0x01FF. The cure is to disable interrupts for the duration of the read:

uint16_t read_atomic(void)
{
    uint16_t v;
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE)
    {
        v = counter;
    }
    return v;
}

ATOMIC_BLOCK adds cli around the same two reads. And counter++ in the handler itself is two reads, an addition and two writes.

Two lds instructions with an interrupt in between: the low byte of the old value and the high byte of the new one give 0x01FF

On Cortex-M a read of up to 32 bits is a single instruction: armclang reads a 16-bit counter with one ldrh. But a change from the main code is still "read, modify, write", and an interrupt can slip in in the middle (section 7.7). The reliable pattern is to disable interrupts and restore the previous state, not simply re-enable them: then the function can also be called where interrupts are already disabled.

uint32_t ticks_add(uint32_t d)
{
    uint32_t pm = __get_PRIMASK();
    __disable_irq();
    ticks += d;
    uint32_t v = ticks;
    __set_PRIMASK(pm);
    return v;
}

84.5 A timer interrupt

On AVR it is done with registers: Timer1 in clear timer on compare mode (CTC) with a prescaler of 1024 and a limit of 15624 gives 16 MHz / 1024 / 15625 = 1 Hz. The period is one more than the written limit — counting starts from zero. On the STM32F103 the same is done with a general-purpose timer:

/* TIM2: 72 МГц / 7200 = 10 кГц, 10 кГц / 10 000 = 1 Гц */
void tim2_1hz_init(void)
{
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    TIM2->PSC = 7200 - 1;
    TIM2->ARR = 10000 - 1;
    TIM2->EGR = TIM_EGR_UG;             /* загрузить PSC сразу */
    TIM2->SR = 0;                       /* UG взвёл UIF — сбросить до разрешения */
    TIM2->DIER = TIM_DIER_UIE;
    TIM2->CR1 = TIM_CR1_CEN;
    NVIC_EnableIRQ(TIM2_IRQn);
}

void TIM2_IRQHandler(void)
{
    if (TIM2->SR & TIM_SR_UIF) {
        TIM2->SR = (uint16_t)~TIM_SR_UIF;   /* флаг таймера сбрасывают НУЛЁМ */
        ticks++;
        flag = 1;
    }
}

⚠️ The prescaler is buffered. A written PSC takes effect only on an update event. Without EGR = UG the timer after CEN counts with the old divider of 1 up to ARR: the first interrupt arrives after 10000 cycles at 72 MHz — 139 µs, not after a second, and ticks and flag get an extra early firing. UG also sets UIF, so the flag is cleared before the interrupt is enabled (section 13).

⚠️ Flags are cleared in different ways. The timer update flag is cleared by writing zero to its bit — HAL does exactly that: SR = ~flag. EXTI is cleared by writing one to PR (section 7.7). Carry the habit from one block to the other and the handler is called endlessly.

The rules for a handler are in section 7.7: keep it short, no delays and no output to the serial port — printing a line at 9600 baud takes milliseconds. Examples on the web include a handler "declared" inside loop(). The C standard does not allow nested functions: an Arduino sketch (that is C++) and armclang answer function definition is not allowed here. GCC in C mode, however, accepts it as an extension — and that is worse: the function is built as a local one, under a different name, and does not get into the vector table.


84.6 Sleeping until an interrupt — without sleeping through it

A low-power device sleeps while there are no events and wakes up on an interrupt (section 10). The naive way:

while (1) {
    if (!flag)
        __WFI();        /* а если прерывание пришло ровно здесь? */
    …
}

If the interrupt fires between the flag check and __WFI(), the handler sets the flag, but the core still goes to sleep — until the next interrupt, which may come an hour later. This is a lost wake-up. The right way is to check and fall asleep with interrupts disabled:

void idle(void)
{
    __disable_irq();
    if (!flag)
        __WFI();
    __enable_irq();
}

WFI wakes up on a pending interrupt even when interrupts are disabled: the core leaves sleep, executes __enable_irq() — and only then is the handler called. Whether the interrupt arrived before the check, between the check and sleep, or during sleep — it is not lost. armclang compiled this into cpsid, the flag check, a conditional wfieq without a branch, and cpsie.

On the ATmega328P in the deepest sleep an interrupt on INT0/INT1 wakes the chip only on a level, not on an edge; it can also be woken by a pin change interrupt, a TWI address match and the watchdog timer — that is what is usually used for periodic wake-ups. A low-level interrupt repeats while the level is held — so the typical example disables it right after waking up.


84.7 Pitfalls

A "once a second" action drifts by a minute and a half per day. The deadline is counted from millis(), not from the previous deadline.

One press is counted several times. The edge interrupt catches the bounce.

A counter from an interrupt sometimes reads with a huge jump. AVR: the 16-bit counter was read byte by byte across an interrupt.

A flag from an interrupt is "not visible" to the main loop. No volatile.

The device sometimes does not wake up on a button press. A lost wake-up: the interrupt arrived between the check and WFI.

The timer handler is called endlessly. The flag was cleared by writing one, as for EXTI, but the timer needs a zero.

millis() broke after setting up a timer. You reconfigured the timer that Arduino timekeeping depends on.

The program is slow in an interrupt. Output to the serial port from the handler.

A handler inside loop() does not compile. Functions cannot be nested.


84.8 Summary

Next: section 84а "From sketch to registers: one device on four levels" — the same device as a sketch, on HAL, on LL and on registers: size, contents and the cost of one write to a port.

Open in the interactive handbook