Embedded HandbookApps
All sections › IV. FreeRTOS
STM32F103, STM32F407 · level advanced · 11 min read
Open in the interactive handbook

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

A ladder of sixteen logical priorities with threshold 5, which is BASEPRI 0x50: levels 0…4 are never masked by the kernel and kernel functions are forbidden in them, levels 5…15 are masked while the kernel works and may call only FromISR functions; by default an interrupt has priority 0, so calling xQueueSendFromISR from there halts the program in vPortValidateInterruptPriority

The three rules that follow from this:

  1. From an interrupt with priority above the threshold — no kernel functions at all. Only peripheral registers and your own variables.
  2. From an interrupt with priority at the threshold and below — only functions ending in FromISR. Ordinary ones must not be called.
  3. 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 xQueueSendFromISR from it is a bug, and with configASSERT enabled the kernel halts the program right in port.c, in the priority check function. Without configASSERT the bug is silent and corrupts the lists. If the program is stuck in vPortValidateInterruptPriority — 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:

Trace: a task woken from an interrupt, with portYIELD_FROM_ISR and without it

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 FromISR calls, they all get the same woken variable; each call can only raise it, never lower it. And there is one portYIELD_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_SetPriority takes 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

Next: section 29 "Software timers" — how to get a dozen timers at the cost of one task and why their callbacks must not block.

Open in the interactive handbook