Embedded HandbookПриложения
Все разделы › IV. FreeRTOS
STM32F103, STM32F407 · уровень продвинутый · 11 мин чтения
Открыть в интерактивном справочнике

28. Прерывания в RTOS

Прерывания (раздел 7) никуда не делись — ядро лишь добавило к ним правила. Правил три, они простые, и нарушение любого даёт порчу внутренних структур ядра, которая проявится через час в другом месте. Раздел о том, откуда эти правила берутся, — понимание механики здесь важнее заучивания.

Как это называется в документации

Оригинал Перевод
interrupt safe API функции, допустимые в обработчике
maximum syscall interrupt priority порог приоритета для обращений к ядру
deferred interrupt processing отложенная обработка прерывания
higher priority task woken разбужена задача с более высоким приоритетом
yield from ISR переключиться на выходе из обработчика
context switch pending переключение отложено до выхода
binary / counting semaphore двоичный / считающий семафор
give / take отдать / взять семафор
interrupt nesting вложенность прерываний

28.1 Откуда берутся правила

Ядро работает со своими списками — готовых задач, ждущих, спящих. Пока оно переставляет задачу из одного списка в другой, структуры находятся в промежуточном состоянии. Прерывание, вклинившееся в этот момент и тоже полезшее в списки, застанет их полусобранными.

Защита — маскирование прерываний на время работы со списками. Но маскировать все прерывания подряд ядро не хочет: тогда любое переключение задач задерживало бы реакцию на быстрые внешние события.

Поэтому Cortex‑M даёт регистр BASEPRI: он маскирует не все прерывания, а только с приоритетом, равным порогу, и ниже (численно не меньше порога). Ядро выставляет в него порог — и прерывания старше порога продолжают работать даже во время переключения контекста.

BASEPRI есть у Cortex‑M3, M4, M7 и M33. У Cortex‑M0 и M0+ (F0, G0, L0, C0) его нет: порт FreeRTOS для них на время работы со списками запрещает все прерывания (cpsid i), быстрых прерываний выше порога не бывает, и порог из конфигурации там не действует. Всё, что ниже, — про ядра с BASEPRI.

Отсюда всё остальное. Прерывания делятся на два класса:

Приоритет Маскируется ядром Обращаться к ядру
выше порога (численно меньше) никогда нельзя — застанет структуры полусобранными
порог и ниже (численно больше) на время работы ядра можно, но только функциями FromISR

Порог задаётся в конфигурации:

#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY   5

У STM32 значащих битов четыре, лежат они в старшей половине регистра, и логический приоритет 5 попадает в него как 0x50. Это ровно то значение, которое стояло в дизассемблере обработчика PendSV в разделе 23: MOV r0, #0x50; MSR BASEPRI, r0.

При пороге 5 из шестнадцати уровней получается:

Уровни Класс Сколько
0…4 быстрые, без ядра 5
5…15 работают с ядром 11

Лестница из шестнадцати логических приоритетов с порогом 5, он же BASEPRI 0x50: уровни 0…4 ядро никогда не маскирует и функции ядра из них запрещены, уровни 5…15 маскируются на время работы ядра и могут звать только функции FromISR; по умолчанию у прерывания приоритет 0, поэтому вызов xQueueSendFromISR оттуда останавливает программу в vPortValidateInterruptPriority

Три правила, которые из этого следуют:

  1. Из прерывания приоритетом выше порога — никаких функций ядра. Только регистры периферии и свои переменные.
  2. Из прерывания приоритетом от порога и ниже — только функции с окончанием FromISR. Обычные вызывать нельзя.
  3. Приоритет каждого прерывания назначается явно. По умолчанию он нулевой — самый высокий, то есть заведомо запрещённый для обращений к ядру.

⚠️ Третье правило нарушают чаще всего. Прерывание, которому забыли назначить приоритет, получает нулевой и попадает в первый класс. Вызов xQueueSendFromISR из него — ошибка, и при включённом configASSERT ядро остановит программу прямо в port.c, в функции проверки приоритета. Без configASSERT ошибка молчит и портит списки. Если программа стоит в vPortValidateInterruptPriority — это оно.


28.2 Почему обычные функции нельзя

Обычная функция ядра рассчитывает, что её вызвала задача: она может заблокировать вызывающего и переключиться на другого. В прерывании блокировать некого — нет задачи, есть обработчик поверх случайно прерванной задачи. Обычная функция, вызванная отсюда, попытается заблокировать ту случайную задачу или переключить контекст изнутри обработчика, и результат непредсказуем.

Двойники FromISR устроены иначе:

Обычная FromISR
ждать может да нет — параметра времени нет вовсе
переключает контекст сама не переключает — только сообщает, что надо
маскирует прерывания через критическую секцию через BASEPRI до порога

Отсюда второй отличительный признак — выходной параметр:

BaseType_t woken = pdFALSE;

xQueueSendFromISR(q, &item, &woken);
portYIELD_FROM_ISR(woken);

Функция не переключает задачи сама. Она лишь выставляет woken, если разбудила задачу приоритетнее прерванной. Решение — делать переключение или нет — принимается один раз, на выходе.


28.3 portYIELD_FROM_ISR: чего стоит забыть

Если признак не обработать, ничего не сломается. Разбуженная задача всё равно получит процессор — при ближайшем тике. То есть с задержкой до одного периода тика: до миллисекунды при 1 кГц.

Само переключение контекста стоит полторы микросекунды. Забытая строка ухудшает реакцию на три порядка, и это не видно ни в одном сообщении об ошибке — прибор просто чуть медленнее, чем мог бы.

На трассе разница выглядит так:

Трасса: задача, разбуженная из прерывания, с portYIELD_FROM_ISR и без него

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);          /* переключиться прямо на выходе */
}

Механика на Cortex‑M: макрос взводит исключение PendSV, оно имеет самый низкий приоритет и срабатывает сразу после выхода из текущего обработчика. Сам обработчик доделывает своё и выходит нормально — переключение происходит «в дверях».

portEND_SWITCHING_ISR — то же самое под другим именем; в адаптации для Cortex‑M второй макрос определён через первый. Историческое наследие, выбирайте любое и держитесь его.

⚠️ Признак — один на обработчик, а не на вызов. Если в обработчике несколько вызовов FromISR, все они получают одну и ту же переменную woken; каждый вызов может только поднять её, но не опустить. И portYIELD_FROM_ISR — одна, в самом конце.


28.4 Отложенная обработка

Главный приём взаимодействия прерываний и задач. Смысл — в прерывании делать минимум, а всё остальное отдавать задаче.

В обработчике В задаче
сбросить флаг периферии разобрать данные
забрать данные из регистра вычислить, отфильтровать
разбудить задачу записать в журнал, отправить дальше
выйти ждать следующего события

Причины две. Первая — длительность: пока выполняется обработчик, все прерывания того же и низшего приоритета ждут. Длинный обработчик портит реакцию всему прибору. Вторая — возможности: в задаче можно ждать, брать мьютексы, вызывать что угодно; в обработчике — только FromISR.

Самая простая связка — через двоичный семафор:

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();            /* сколько угодно долго */
        }
    }
}

Обработчик — четыре строки и микросекунды. Задача ждёт на семафоре, не потребляя процессора, и просыпается ровно в момент события. Если ей дать высокий приоритет, обработка начнётся сразу после выхода из прерывания — почти как в самом обработчике, но со всеми возможностями задачи.

Что такое семафор здесь. Это не «взаимное исключение», а признак события: прерывание его отдаёт, задача берёт. Устроен он как очередь без данных — и стоит те же 80 байт заголовка.

⚠️ Двоичный семафор считает до единицы. Два события, пришедшие до того, как задача успела разобрать первое, сольются в одно. Если события нельзя терять, нужен считающий семафор: он накапливает отдачи, и задача разберёт их все по очереди.

sem_events = xSemaphoreCreateCounting(8, 0);   /* до восьми накопленных */

Ещё дешевле, чем семафор, — уведомление задачи (раздел 31): та же связка без объекта ядра вовсе. Для простого «событие произошло» это обычно лучший выбор; семафор остаётся, когда ждущих задач может быть несколько.


28.5 Как назначать приоритеты

Порядок действий, который избавляет от загадочных сбоев:

1. Группировка. Все значащие биты — биты вытеснения (раздел 24):

NVIC_SetPriorityGrouping(0);

2. Каждому прерыванию — явный приоритет. До включения:

NVIC_SetPriority(TIM1_CC_IRQn, 1);      /* быстрое: ядра не касается */
NVIC_SetPriority(ADC1_2_IRQn, 6);       /* работает с ядром */
NVIC_EnableIRQ(ADC1_2_IRQn);

3. Решить, какие прерывания быстрые. Уровни выше порога — для того, что не терпит задержки даже на время переключения контекста: захват таймера, критичный по времени обмен. Таких обычно единицы, а часто нет вовсе.

4. Всё остальное — от порога и ниже, с работой через задачи.

⚠️ Приоритеты в библиотеке HAL. Функция HAL_NVIC_SetPriority принимает логический приоритет, как и CMSIS, — с ней порог сравнивается напрямую. Но конфигуратор по умолчанию раздаёт многим прерываниям нулевой приоритет, и его нужно менять вручную для всех, что обращаются к ядру.


28.6 Вложенность и критические секции

Прерывание старше текущего вытесняет его — так было и без ядра. Ядро добавляет одно: критическая секция в задаче поднимает BASEPRI до порога, а не запрещает всё:

taskENTER_CRITICAL();
/* прерывания от порога и ниже не придут; быстрые — придут */
taskEXIT_CRITICAL();

Значит, критическая секция не защищает от быстрых прерываний. Общие данные между задачей и прерыванием первого класса требуют другой защиты — обычно атомарности самих операций или запрета конкретно этого прерывания на время доступа.

Внутри обработчиков — своя пара:

UBaseType_t saved = taskENTER_CRITICAL_FROM_ISR();
/* ... */
taskEXIT_CRITICAL_FROM_ISR(saved);

Она сохраняет и восстанавливает прежнее значение маски — потому что обработчик мог быть вызван поверх уже поднятого порога.


28.7 Грабли

Программа стоит в vPortValidateInterruptPriority. Прерывание с приоритетом выше порога вызвало функцию ядра. Чаще всего — приоритет не назначен и остался нулевым.

Прерывание работает, но реакция задачи запаздывает на миллисекунду. Забыт portYIELD_FROM_ISR.

Всё собирается, а через час — порча данных или зависание. Обычная функция ядра вызвана из обработчика вместо FromISR, и configASSERT выключен.

Задача пропускает события. Двоичный семафор при частых событиях; нужен считающий или очередь.

Критическая секция не защищает переменную. Переменную трогает быстрое прерывание, которое BASEPRI не маскирует.

Обработчик ждёт на семафоре и висит. Из прерывания ждать нельзя — xSemaphoreTake там недопустим, есть только Give и TakeFromISR без ожидания.

После NVIC_SetPriority ничего не изменилось. В функцию передали сдвинутое значение (0x50) вместо логического (5): она сдвигает сама, и от 0x50 остаётся ноль — самый высокий приоритет. Либо приоритет после вашего перезаписал код инициализации, сгенерированный конфигуратором. Приоритет, заданный после разрешения, действует сразу, но до этого прерывание могло сработать с нулевым; поэтому порядок — группировка, приоритет, разрешение.


28.8 Итоги

Дальше: раздел 29 «Программные таймеры» — как получить десяток таймеров ценой одной задачи и почему их обработчики нельзя блокировать.

Открыть в интерактивном справочнике