28. Interrupts in an RTOS
Interrupts (section 7) have not gone anywhere — the kernel has only added rules to them. There are three rules, they are simple, and breaking any of them corrupts the kernel's internal structures in a way that shows up an hour later somewhere else. This section is about where these rules come from — understanding the mechanics here matters more than memorizing.
Terms used in the documentation
Term Meaning interrupt safe API functions allowed in a handler maximum syscall interrupt priority the priority threshold for calls into the kernel deferred interrupt processing handing the interrupt's work over to a task higher priority task woken a task with a higher priority was woken yield from ISR switch tasks on exit from the handler context switch pending the switch is postponed until exit binary / counting semaphore a semaphore that counts to one / to N give / take give / take the semaphore interrupt nesting interrupts preempting interrupts
28.1 Where the rules come from
The kernel works with its own lists — of ready tasks, waiting ones, sleeping ones. While it moves a task from one list to another, the structures are in an intermediate state. An interrupt that cuts in at that moment and also reaches into the lists will find them half-assembled.
The protection is masking interrupts while the lists are being worked on. But the kernel does not want to mask every interrupt: then every task switch would delay the reaction to fast external events.
So Cortex‑M provides the BASEPRI register: it masks not all
interrupts, but only those with a priority equal to the threshold and lower (numerically
not less than the threshold). The kernel writes the threshold into it — and interrupts above
the threshold keep working even during a context switch.
BASEPRI exists on Cortex‑M3, M4, M7 and M33. Cortex‑M0 and M0+ (F0, G0, L0,
C0) do not have it: for them the FreeRTOS port disables
all interrupts (cpsid i) while working on the lists, there are no fast interrupts above the threshold, and
the threshold from the configuration has no effect there. Everything below is about cores with
BASEPRI.
Everything else follows from this. Interrupts fall into two classes:
| Priority | Masked by the kernel | Calling the kernel |
|---|---|---|
| above the threshold (numerically less) | never | not allowed — it will find the structures half-assembled |
| the threshold and below (numerically greater) | while the kernel is working | allowed, but only with FromISR functions |
The threshold is set in the configuration:
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5
The STM32 has four significant bits, they sit in the upper half of the register,
and logical priority 5 lands there as 0x50. This is exactly
the value that appeared in the disassembly of the PendSV handler
in section 23: MOV r0, #0x50; MSR BASEPRI, r0.
With threshold 5 out of sixteen levels you get:
| Levels | Class | How many |
|---|---|---|
| 0…4 | fast, no kernel | 5 |
| 5…15 | work with the kernel | 11 |
The three rules that follow from this:
- From an interrupt with priority above the threshold — no kernel functions at all. Only peripheral registers and your own variables.
- From an interrupt with priority at the threshold and below — only functions
ending in
FromISR. Ordinary ones must not be called. - The priority of every interrupt is assigned explicitly. By default it is zero — the highest, that is, certainly forbidden for calls into the kernel.
⚠️ The third rule is broken most often. An interrupt that was never assigned a priority gets zero and falls into the first class. Calling
xQueueSendFromISRfrom it is a bug, and withconfigASSERTenabled the kernel halts the program right inport.c, in the priority check function. WithoutconfigASSERTthe bug is silent and corrupts the lists. If the program is stuck invPortValidateInterruptPriority— this is it.
28.2 Why ordinary functions are not allowed
An ordinary kernel function assumes it was called by a task: it may block the caller and switch to another one. In an interrupt there is no one to block — there is no task, only a handler on top of a randomly interrupted task. An ordinary function called from here will try to block that random task or switch context from inside the handler, and the result is unpredictable.
The FromISR twins work differently:
| Ordinary | FromISR |
|
|---|---|---|
| can wait | yes | no — there is no time parameter at all |
| switches context | by itself | does not switch — only reports that it is needed |
| masks interrupts | through a critical section | through BASEPRI up to the threshold |
Hence the second distinguishing feature — an output parameter:
BaseType_t woken = pdFALSE;
xQueueSendFromISR(q, &item, &woken);
portYIELD_FROM_ISR(woken);
The function does not switch tasks itself. It only sets woken
if it woke a task with higher priority than the interrupted one. The decision —
to switch or not — is made once, on exit.
28.3 portYIELD_FROM_ISR: what forgetting it costs
If the flag is not handled, nothing breaks. The woken task still gets the CPU — at the next tick. That is, with a delay of up to one tick period: up to a millisecond at 1 kHz.
The context switch itself costs a microsecond and a half. The forgotten line makes the reaction worse by three orders of magnitude, and it does not show up in any error message — the device is simply a bit slower than it could be.
On a trace the difference looks like this:
void ADC1_2_IRQHandler(void)
{
BaseType_t woken = pdFALSE;
ADC1->SR &= ~ADC_SR_EOC;
g_raw = ADC1->DR;
xSemaphoreGiveFromISR(sem, &woken);
portYIELD_FROM_ISR(woken); /* переключиться прямо на выходе */
}
The mechanics on Cortex‑M: the macro pends the PendSV exception, which has
the lowest priority and fires right after the current
handler exits. The handler itself finishes its work and exits normally —
the switch happens "in the doorway".
portEND_SWITCHING_ISR is the same thing under another name; in the port
for Cortex‑M the second macro is defined through the first. A historical
legacy — pick either one and stick to it.
⚠️ One flag per handler, not per call. If a handler has several
FromISRcalls, they all get the samewokenvariable; each call can only raise it, never lower it. And there is oneportYIELD_FROM_ISR, at the very end.
28.4 Deferred processing
The main technique for interrupts and tasks to work together. The idea is to do the minimum in the interrupt and hand everything else to a task.
| In the handler | In the task |
|---|---|
| clear the peripheral flag | parse the data |
| take the data from the register | compute, filter |
| wake the task | write to the log, pass it on |
| exit | wait for the next event |
There are two reasons. The first is duration: while a handler runs,
all interrupts of the same and lower priority wait. A long handler
spoils the reaction of the whole device. The second is capabilities: in a task you can
wait, take mutexes, call anything; in a handler — only
FromISR.
The simplest pairing is through a binary semaphore:
static SemaphoreHandle_t sem_ready;
void EXTI0_IRQHandler(void) /* короткий */
{
BaseType_t woken = pdFALSE;
EXTI->PR = EXTI_PR_PR0;
xSemaphoreGiveFromISR(sem_ready, &woken);
portYIELD_FROM_ISR(woken);
}
static void handler_task(void *arg) /* длинный */
{
for (;;) {
if (xSemaphoreTake(sem_ready, portMAX_DELAY) == pdTRUE) {
process_event(); /* сколько угодно долго */
}
}
}
The handler is four lines and microseconds. The task waits on the semaphore without using the CPU and wakes exactly at the moment of the event. If you give it a high priority, processing starts right after the exit from the interrupt — almost as if in the handler itself, but with all the capabilities of a task.
What a semaphore is here. It is not "mutual exclusion" but an event flag: the interrupt gives it, the task takes it. It is built as a queue without data — and costs the same 80 bytes of header.
⚠️ A binary semaphore counts to one. Two events arriving before the task has handled the first one merge into one. If events must not be lost, you need a counting semaphore: it accumulates gives, and the task handles them all one by one.
sem_events = xSemaphoreCreateCounting(8, 0); /* до восьми накопленных */
Even cheaper than a semaphore is a task notification (section 31): the same pairing with no kernel object at all. For a simple "the event happened" this is usually the best choice; the semaphore stays for when there may be several waiting tasks.
28.5 How to assign priorities
An order of steps that saves you from mysterious failures:
1. Grouping. All significant bits are preemption bits (section 24):
NVIC_SetPriorityGrouping(0);
2. An explicit priority for every interrupt. Before enabling it:
NVIC_SetPriority(TIM1_CC_IRQn, 1); /* быстрое: ядра не касается */
NVIC_SetPriority(ADC1_2_IRQn, 6); /* работает с ядром */
NVIC_EnableIRQ(ADC1_2_IRQn);
3. Decide which interrupts are fast. Levels above the threshold are for what cannot tolerate delay even for the duration of a context switch: timer capture, a time-critical exchange. There are usually only a handful of these, and often none at all.
4. Everything else — at the threshold and below, with the work done through tasks.
⚠️ Priorities in the HAL library. The function
HAL_NVIC_SetPrioritytakes a logical priority, as CMSIS does — the threshold is compared with it directly. But the configurator by default gives many interrupts zero priority, and you must change it by hand for all that call the kernel.
28.6 Nesting and critical sections
An interrupt above the current one preempts it — that was so without the kernel too.
The kernel adds one thing: a critical section in a task raises BASEPRI
to the threshold rather than disabling everything:
taskENTER_CRITICAL();
/* прерывания от порога и ниже не придут; быстрые — придут */
taskEXIT_CRITICAL();
So a critical section does not protect against fast interrupts. Data shared between a task and an interrupt of the first class needs different protection — usually atomicity of the operations themselves or disabling that particular interrupt for the duration of the access.
Inside handlers there is a separate pair:
UBaseType_t saved = taskENTER_CRITICAL_FROM_ISR();
/* ... */
taskEXIT_CRITICAL_FROM_ISR(saved);
It saves and restores the previous mask value — because the handler may have been called on top of an already raised threshold.
28.7 Pitfalls
The program is stuck in vPortValidateInterruptPriority. An interrupt
with priority above the threshold called a kernel function. Most often the priority
was not assigned and stayed zero.
The interrupt works, but the task reacts a millisecond late.
portYIELD_FROM_ISR was forgotten.
Everything builds, and an hour later — corrupted data or a hang. An ordinary
kernel function was called from a handler instead of FromISR, and configASSERT
is disabled.
The task misses events. A binary semaphore with frequent events; you need a counting one or a queue.
A critical section does not protect a variable. The variable is touched by
a fast interrupt that BASEPRI does not mask.
A handler waits on a semaphore and hangs. You cannot wait in an interrupt —
xSemaphoreTake is not allowed there; there are only Give and TakeFromISR
without waiting.
Nothing changed after NVIC_SetPriority. The function was given
the shifted value (0x50) instead of the logical one (5): it shifts by itself, and of
0x50 only zero is left — the highest priority. Or the priority was overwritten after yours
by initialization code generated by the configurator. A priority
set after enabling takes effect at once, but before that the interrupt may have
fired with zero; hence the order — grouping, priority, enabling.
28.8 Summary
- The kernel masks not all interrupts but those up to the threshold, through
BASEPRI; threshold 5 on STM32 is0x50in the register. - Interrupts above the threshold are never masked and have no right
to call the kernel; at the threshold and below — only through
FromISR. - The default priority is zero, that is, forbidden; assign one explicitly to each interrupt.
FromISRfunctions do not wait and do not switch context themselves — they set a flag, and the switch is done byportYIELD_FROM_ISRon exit.- A forgotten
portYIELD_FROM_ISRdelays the reaction to the next tick — three orders of magnitude longer than the switch itself. - Deferred processing: in the interrupt — clear the flag, take the data, wake the task; everything else — in the task.
- A binary semaphore merges events that arrive before they are handled; a counting one accumulates them.
- A critical section raises
BASEPRIto the threshold and does not protect against fast interrupts.
Next: section 29 "Software timers" — how to get a dozen timers at the cost of one task and why their callbacks must not block.