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.
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=!. WriteledState = !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.
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
PSCtakes effect only on an update event. WithoutEGR = UGthe timer afterCENcounts with the old divider of 1 up toARR: the first interrupt arrives after 10000 cycles at 72 MHz — 139 µs, not after a second, andticksandflagget an extra early firing.UGalso setsUIF, 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 toPR(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
- Arduino time is kept by a hardware timer, STM32 time by SysTick;
delay()stops everything. - A periodic action is counted from the previous deadline (
last += period): with a 7 ms loop that is 3600 firings per hour versus 3596 and 3.6 s of lag withlast = millis(). - The Uno has two interrupts with edge selection and 23 pin change interrupt inputs; STM32 has 16 EXTI lines. A button interrupt catches bounce: one press — five edges.
volatiledoes not make access atomic: on AVR a 16-bit read is two instructions; a change of a shared variable goes in a critical section that restores the previous PRIMASK.- A timer flag is cleared with a zero, an EXTI flag with a one.
- Go to sleep with interrupts disabled: check,
WFI, enable — this way an interrupt between the check and sleep is not lost.
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.