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 |
Три правила, которые из этого следуют:
- Из прерывания приоритетом выше порога — никаких функций ядра. Только регистры периферии и свои переменные.
- Из прерывания приоритетом от порога и ниже — только функции
с окончанием
FromISR. Обычные вызывать нельзя. - Приоритет каждого прерывания назначается явно. По умолчанию он нулевой — самый высокий, то есть заведомо запрещённый для обращений к ядру.
⚠️ Третье правило нарушают чаще всего. Прерывание, которому забыли назначить приоритет, получает нулевой и попадает в первый класс. Вызов
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 кГц.
Само переключение контекста стоит полторы микросекунды. Забытая строка ухудшает реакцию на три порядка, и это не видно ни в одном сообщении об ошибке — прибор просто чуть медленнее, чем мог бы.
На трассе разница выглядит так:
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 Итоги
- Ядро маскирует прерывания не все, а до порога через
BASEPRI; порог 5 на STM32 — это0x50в регистре. - Прерывания выше порога никогда не маскируются и не имеют права
обращаться к ядру; от порога и ниже — только через
FromISR. - Приоритет по умолчанию — нулевой, то есть запрещённый; назначать явно каждому.
FromISRне ждут и не переключают контекст сами — они выставляют признак, а переключение делаетportYIELD_FROM_ISRна выходе.- Забытый
portYIELD_FROM_ISRоткладывает реакцию до ближайшего тика — на три порядка дольше самого переключения. - Отложенная обработка: в прерывании — сбросить флаг, забрать данные, разбудить задачу; всё остальное — в задаче.
- Двоичный семафор сливает события, пришедшие до разбора; считающий — накапливает.
- Критическая секция поднимает
BASEPRIдо порога и от быстрых прерываний не защищает.
Дальше: раздел 29 «Программные таймеры» — как получить десяток таймеров ценой одной задачи и почему их обработчики нельзя блокировать.