Прерывания и реальное время: ISR, приоритеты, гонки, детерминизм
Зачем вообще прерывания и почему это не колбэк
На сервере вы никогда не писали обработчик прерывания. Не потому, что их нет — их там тысячи в секунду, — а потому, что между вами и ними стоит ядро операционной системы. Пришёл пакет, сетевая карта дёрнула линию IRQ, драйвер положил данные в сокет, планировщик разбудил ваш поток на read(). Всю грязную работу — сохранение контекста, приоритеты, защиту разделяемых структур — сделал кто-то другой, и результат приехал к вам в виде удобного блокирующего вызова.
На микроконтроллере этого «кого-то другого» нет. Вы и есть ядро. Между фронтом напряжения на ножке и вашей функцией нет ни одного слоя абстракции: аппаратура вызывает вашу функцию напрямую, посреди любой инструкции фонового кода, без вашего разрешения и без предупреждения. Отсюда рабочее определение, которое стоит держать в голове весь трек:
Прерывание — это аппаратно инициированный вызов функции, который может произойти между любыми двумя машинными инструкциями вашей программы. Не «событие», не «колбэк», не «сигнал» — именно вызов, вклинивающийся в произвольную точку, включая середину операции
x++.
Разница с колбэком принципиальна, и её полезно проговорить явно:
| Колбэк на «большой» машине | Обработчик прерывания (ISR) | |
|---|---|---|
| Кто вызывает | ваш же код, из цикла событий | аппаратура, асинхронно к коду |
| Когда | в известной точке (await, poll) |
между любыми двумя инструкциями |
| Контекст | обычный поток, есть куча и планировщик | нет потока, нет планировщика, свой стек-кадр |
| Можно ли заблокироваться | да | нет: блокировка = зависание всей системы |
| Цена вызова | десятки-сотни наносекунд, никого не волнует | считается в тактах и попадает в бюджет |
| Что защищает данные | мьютекс, GIL, однопоточность рантайма | ничего, кроме того, что вы напишете руками |
Альтернатива прерываниям — опрос (polling), и она честно работает: крутите цикл, читаете регистр, проверяете флаг. Проблема опроса не в «некрасиво», а в трёх вещах: ядро не может уснуть (энергия, см. Питание и ограничения), время реакции равно длине всего цикла (а он растёт по мере разработки), и редкое короткое событие вы пропустите совсем. Прерывание решает все три задачи ценой одной: ваша программа перестаёт быть последовательной, и все проблемы конкурентности приходят к вам без единой строчки про потоки.
Чем обслуживать конкретное событие — вопрос инженерный, и решается он двумя осями: как часто событие происходит и как быстро нужно на него отреагировать.
Правая верхняя четверть — та, где прерывания уже не справляются: 1 МГц потока с АЦП означает прерывание каждую микросекунду, то есть вход-выход съедят всё ядро. Там работает DMA, а на RP2040 — блок PIO, который выполняет протокол собственной программой. Это тема статьи про периферию.
Что физически происходит при прерывании
Разберём вход в прерывание на Cortex-M — архитектуре, на которой построены STM32, RP2040, nRF52 и большинство современных 32-битных контроллеров. Это самая аккуратно сделанная модель прерываний в массовом железе, и она задаёт стандарт мышления.
Последовательность такая:
- Источник поднимает запрос. Таймер досчитал до нуля, на ножке появился фронт, UART принял байт: в периферии взводится бит статуса, и, если прерывание разрешено, сигнал уходит в контроллер прерываний NVIC.
- NVIC решает. Он сравнивает приоритет запроса с текущим уровнем выполнения: приоритетнее — вытесняет текущий код, нет — ждёт своей очереди в состоянии pending.
- Аппаратура сохраняет контекст — восемь 32-битных слов уходят в стек без единой вашей инструкции, — читает адрес обработчика из таблицы векторов, кладёт в LR значение EXC_RETURN и передаёт управление в вашу функцию.
- На выходе запись EXC_RETURN в PC запускает обратный процесс: регистры восстанавливаются, управление возвращается ровно в ту инструкцию, на которой прервалось.
Ключевое следствие третьего пункта — то, почему на Cortex-M обработчик выглядит как обычная функция C:
/* --- STM32: обычная функция C, имя совпадает со слабым символом startup-файла --- */
void TIM2_IRQHandler(void)
{
if (TIM2->SR & TIM_SR_UIF) { /* проверяем, что флаг именно наш */
TIM2->SR = ~TIM_SR_UIF; /* сбрасываем вручную, иначе войдём сюда снова */
g_tick++;
}
}
/* --- AVR (Arduino Uno): макрос, компилятор сам делает пролог, эпилог и reti;
флаг сбрасывается аппаратно при входе в вектор --- */
ISR(TIMER1_OVF_vect) { g_tick++; }
/* --- ESP32 (ESP-IDF): обработчик регистрируется динамически и обязан жить в ОЗУ.
IRAM_ATTR критичен: при записи в SPI-флеш кэш инструкций отключается,
и ISR из флеша роняет ядро. --- */
static void IRAM_ATTR gpio_isr(void *arg)
{
BaseType_t hp_woken = pdFALSE;
uint32_t pin = (uint32_t)arg;
xQueueSendFromISR(g_evt_queue, &pin, &hp_woken); /* только FromISR-версии API */
if (hp_woken) portYIELD_FROM_ISR(); /* сразу отдать управление задаче */
}
Три платформы — три соглашения. Это первое, что нужно выяснить про новый чип: как объявляется обработчик, кто сбрасывает флаг и какие вызовы из него легальны. Цифры, которые стоит держать в голове:
| Ядро | Латентность входа | Выход | Хвостовая цепочка | Вложенность |
|---|---|---|---|---|
| Cortex-M0/M0+ | 15–16 тактов | 12 тактов | нет | по приоритетам, 4 уровня |
| Cortex-M3/M4 | 12 тактов | 12 тактов | 6 тактов | до 256 уровней (обычно 8–16) |
| Cortex-M7 | 12 тактов | 12 тактов | 6 тактов | то же, плюс кэш даёт разброс |
| AVR (ATmega) | 4 такта + текущая инструкция | 4–5 тактов | нет | нет, прерывания запрещены внутри ISR |
| Xtensa (ESP32) | ~20–30 тактов на уровень 1 | сопоставимо | нет | 5 уровней, из них 3 доступны из C |
При 168 МГц двенадцать тактов — это 71 наносекунда. Но эта цифра почти никогда не является вашей реальной латентностью, и понимание почему — половина статьи.
Латентность, джиттер и детерминизм
Различайте три величины. Латентность прерывания — от физического события до первой инструкции обработчика. Время реакции — от события до момента, когда система сделала то, что должна была (дёрнула ножку, остановила мотор); включает тело ISR и, возможно, работу в фоне. Джиттер — разброс этих величин между срабатываниями; для управления двигателем и измерения времени он часто вреднее самой задержки, потому что постоянную задержку можно скомпенсировать, а случайную — нет.
Полная латентность складывается из трёх слагаемых:
$$L = L_{hw} + L_{crit} + L_{hp}$$
где $L_{hw}$ — фиксированный аппаратный вход из таблицы выше, $L_{crit}$ — максимальное время, которое система проводит с запрещёнными прерываниями, $L_{hp}$ — суммарное время всех более приоритетных обработчиков, которые могут вклиниться. Даташит гарантирует только первое слагаемое. Два других — целиком результат вашего кода, и именно они дают миллисекунды там, где вы ожидали наносекунды.
Даже $L_{hw}$ на практике плавает: ожидания флеш-памяти (на 168 МГц флеш STM32F4 требует 5 тактов ожидания, и кэш ART Accelerator спасает не при первом заходе), длинные инструкции вроде LDM/STM на восемь регистров (на M0/M0+ их вообще нельзя прервать посередине), арбитраж шины с DMA и конкуренция за банки SRAM у ESP32 и RP2040. А вот $L_{crit}$ раздуваете только вы: каждая критическая секция, каждый __disable_irq(), каждая функция библиотеки, запрещающая прерывания «на всякий случай». Драйвер printf через UART в блокирующем режиме способен держать прерывания закрытыми миллисекундами.
Детерминизм — это не «быстро», а «предсказуемо». Система детерминирована, если для каждой реакции существует доказуемая верхняя граница времени. Быстрый в среднем и недетерминированный код в системе реального времени хуже, чем медленный и предсказуемый: средним значением нельзя гарантировать, что колесо робота остановится до столкновения. Отсюда классическое деление: жёсткое реальное время (пропуск дедлайна = отказ: подушка безопасности, коммутация обмоток BLDC-мотора, аварийный останов — здесь нужны доказательства, а не измерения), плотное (опоздавший результат бесполезен, но катастрофы нет: кадр видеопотока, пропущенный тик регулятора) и мягкое (опоздание лишь снижает качество: отклик кнопки, обновление дисплея). Одно устройство обычно содержит все три класса сразу, и приоритеты прерываний — главный инструмент их разделения; тот же вопрос со стороны планировщика ОС общего назначения разбирается в статье Процессы и планирование.
Приоритеты и вытеснение
NVIC даёт вам бесплатный вытесняющий планировщик с фиксированными приоритетами — ещё до всякой RTOS. Правила Cortex-M:
- Меньшее число — выше приоритет. Приоритет 0 — самый срочный. Это ловушка для новичков.
- Приоритет хранится в старших битах байта. У STM32 реализовано 4 бита (
__NVIC_PRIO_BITS == 4), значит доступно 16 уровней, а младшие 4 бита регистра игнорируются. У nRF52 — 3 бита, 8 уровней. У Cortex-M0 — 2 бита, 4 уровня. - Байт делится на группу вытеснения и подприоритет (поле PRIGROUP в SCB->AIRCR). Вытеснять может только более высокая группа; подприоритет решает лишь порядок обслуживания одновременно ожидающих запросов. Практический совет: отдайте все биты под группу вытеснения и не заводите подприоритеты — так поведение проще анализировать.
/* Инициализация: все 4 бита STM32 под вытеснение, подприоритетов нет. */
NVIC_SetPriorityGrouping(0x03); /* CMSIS: 4 бита группы, 0 бит подгруппы */
NVIC_SetPriority(EXTI0_IRQn, 1); /* аварийный концевик — почти самый срочный */
NVIC_SetPriority(TIM3_IRQn, 4); /* тик регулятора тока */
NVIC_SetPriority(USART2_IRQn, 9); /* телеметрия — может подождать */
NVIC_SetPriority(SysTick_IRQn, 15); /* системный тик — самый низкий */
NVIC_EnableIRQ(EXTI0_IRQn);
Как это выглядит во времени, когда события накладываются:
Обратите внимание на последнюю строку: для фонового кода произошедшее вообще ненаблюдаемо, кроме того, что одна инструкция «выполнялась» несколько микросекунд. Отсюда невозможность отладить временные явления печатью в лог: лог покажет корректную последовательность, а проблема — во времени между строчками.
Жизненный цикл исключения
У каждого источника прерывания в NVIC есть собственный маленький автомат — знание его состояний экономит часы отладки.
и прерывание разрешено Pending --> Active: NVIC выбрал по приоритету,
контекст сохранён Active --> Inactive: обработчик завершился,
флаг источника сброшен Active --> ActivePending: событие пришло снова,
пока ISR ещё работает ActivePending --> Active: хвостовая цепочка,
6 тактов вместо 24 Pending --> Inactive: программный сброс
NVIC_ClearPendingIRQ note right of ActivePending Больше одного запроса не копится: третье событие того же источника теряется молча и без ошибок end note
Две самые частые аварии видны прямо на диаграмме. Первая: флаг источника не сброшен — при выходе NVIC немедленно видит запрос снова, и система крутится в обработчике вечно. Симптом: фоновый код «не работает», отладчик всегда останавливается в ISR. Вторая: события приходят чаще, чем обработчик успевает их разгребать, а pending-бит один — потери данных при полном отсутствии ошибок в логике.
Есть и третья, более тонкая: ложный повторный вход из-за буфера записи. Если сбросить флаг периферии последней инструкцией обработчика, запись может ещё «висеть» в буфере шины, пока ядро уже выполняет выход, — NVIC увидит неснятый запрос и вызовет обработчик второй раз. Лечится сбросом флага в начале ISR либо барьером __DSB() сразу после записи (EXTI->PR = EXTI_PR_PR0; __DSB();), который ждёт, пока запись реально дойдёт до периферии. Подробности — в ARM Application Note 321 о барьерах памяти: https://developer.arm.com/documentation/dai0321/latest/
Маскирование: PRIMASK, BASEPRI, FAULTMASK
Cortex-M даёт три способа закрыть прерывания, и разница между ними — это разница между работающей и неработающей системой реального времени. __disable_irq() поднимает PRIMASK и глушит всё разом, включая аварийный останов: это кувалда для операций в единицы инструкций. BASEPRI — скальпель: он маскирует всё, что не приоритетнее заданного уровня, оставляя самые срочные обработчики живыми. Именно так устроены критические секции FreeRTOS.
/* Критическая секция, не трогающая прерывания приоритетнее уровня 3.
Значение BASEPRI сдвигается: реализованы старшие __NVIC_PRIO_BITS бит. */
#define CRIT_PRIO (3U << (8U - __NVIC_PRIO_BITS))
static inline uint32_t crit_enter(void)
{
uint32_t saved = __get_BASEPRI();
__set_BASEPRI_MAX(CRIT_PRIO); /* MAX-версия не понижает уже поднятый уровень */
__DMB(); /* обращения к памяти не «уедут» за границу секции */
return saved;
}
static inline void crit_exit(uint32_t saved) { __DMB(); __set_BASEPRI(saved); }
Из этого следует важнейшее архитектурное правило любой RTOS на Cortex-M: прерывания с приоритетом выше порога RTOS не могут вызывать её API вообще (во FreeRTOS порог задаётся configMAX_SYSCALL_INTERRUPT_PRIORITY). Такие «сверхприоритетные» ISR получают наносекундную латентность, не зависящую от планировщика, но живут в полной изоляции: ни очередей, ни семафоров, только регистры и голая память. Это законный и очень мощный приём для аварийных цепей и коммутации моторов — подробнее в статье про RTOS.
FAULTMASK глушит ещё и HardFault; он нужен исключительно в обработчиках отказов и в коде восстановления.
Гонки: почему volatile не спасает
Самая дорогая иллюзия начинающего: «поставил volatile — стало потокобезопасно». Это неверно, потому что за словом стоят два разных механизма. volatile — контракт с компилятором: он запрещает кэшировать переменную в регистре, выбрасывать «лишние» чтения и переставлять обращения между собой, и он обязателен для регистров периферии и любых переменных, разделяемых с ISR. Без него компилятор с -Os абсолютно законно превратит while (!g_flag) { } в бесконечный цикл, а чтение регистра статуса — в одно чтение вместо ста. Атомарность — контракт с аппаратурой: может ли операция быть прервана посередине. volatile про это не говорит ничего.
static volatile uint32_t g_pulses; /* пишет ISR энкодера, читает управление */
void EXTI1_IRQHandler(void) { EXTI->PR = EXTI_PR_PR1; g_pulses++; }
Инкремент g_pulses++ компилируется в три инструкции: LDR, ADD, STR. Если то же самое делает фоновый код, прерывание между LDR и STR даст потерянное обновление — классическая гонка read-modify-write, ничем не отличающаяся от той, что описана в статье про конкурентность. Пока пишет только ISR, а фон только читает, инкремент безопасен: обработчик не может быть прерван сам собой.
Второй эффект — разрыв чтения (tearing). На 32-битном Cortex-M выровненное чтение и запись 32-битного слова атомарны аппаратно, поэтому счётчик тиков читается безопасно. На 8-битном AVR та же переменная читается четырьмя инструкциями, и прерывание между ними даёт значение, которого никогда не существовало: старшие байты новые, младшие старые. Отсюда обязательный шаблон Arduino:
/* AVR: 32-битное значение читается четырьмя инструкциями, нужна критическая секция */
uint32_t pulses_get(void)
{
uint32_t v;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { v = g_pulses; } /* cli() + восстановление SREG */
return v;
}
/* Cortex-M: 64-битное время без запрета прерываний — двойное чтение старшей половины.
hi инкрементируется в ISR переполнения lo. */
static volatile uint32_t g_hi, g_lo;
uint64_t now_us64(void)
{
uint32_t h1, l, h2;
do {
h1 = g_hi; l = g_lo; h2 = g_hi;
} while (h1 != h2); /* старшая половина изменилась — перечитываем */
return ((uint64_t)h1 << 32) | l;
}
ATOMIC_RESTORESTATE вместо ATOMIC_FORCEON — не педантизм: FORCEON включит прерывания на выходе даже там, где они были запрещены вызывающим, и однажды вы обнаружите вложенное прерывание в критической секции. Сложность второго варианта — O(1) в среднем, число повторов ограничено частотой переполнений, то есть верхняя граница существует и детерминизм не нарушен.
Гонка, которую не видно в коде: регистр периферии
Самая коварная гонка на микроконтроллере вообще не выглядит как работа с общими данными:
GPIOA->ODR |= (1U << 3); /* фон: включает светодиод — ОПАСНО */
GPIOA->ODR |= (1U << 5); /* ISR: дёргает маркер для осциллографа */
GPIOA->BSRR = (1U << 3); /* то же самое атомарно: установить PA3 */
GPIOA->BSRR = (1U << (5 + 16)); /* и сбросить PA5 — одной записью */
Первые два выражения — read-modify-write над одним регистром: прерывание между чтением и записью в фоне сотрёт изменение обработчика. Отлаживается это неделями, потому что «пин иногда сам сбрасывается». Правильный ответ здесь не критическая секция, а аппаратный примитив: регистр BSRR в STM32 устанавливает бит записью единицы в младшую половину и сбрасывает — в старшую. Одна запись, никакого чтения, атомарность по построению.
Аналогичные механизмы есть почти везде: alias-регистры SET/CLR/XOR у RP2040 и nRF52, bit-banding у Cortex-M3/M4, регистры W1C (write-1-to-clear) для флагов. Прежде чем защищать доступ к периферии критической секцией, поищите в даташите атомарный регистр — он почти всегда есть, и он бесплатный.
Архитектура: короткий ISR и отложенная обработка
Практическое правило, из которого растёт вся структура прошивки: обработчик прерывания должен снять данные, зафиксировать факт и выйти. Всё остальное — фильтрация, парсинг, запись во флеш, математика — делается в фоне или в задаче RTOS. Причина не в эстетике: пока работает ISR приоритета 4, все прерывания приоритета 4 и ниже стоят, и каждая микросекунда в обработчике добавляется к латентности половины системы. Разделение «верхняя половина / нижняя половина» — та же идея, что в драйверах Linux (см. Ввод-вывод и драйверы), только здесь вы реализуете её руками.
фронт, байт, конец АЦП"] --> Q{"Нужна ли реакция
быстрее 10 мкс?"} Q -- "нет" --> P["Опрос или таймер:
прерывание не нужно"] Q -- "да" --> I["ISR: 10-50 инструкций"] I --> R["Прочитать регистр данных
сбросить флаг источника"] R --> S{"Объём данных"} S -- "одно значение" --> F["Записать в переменную,
затем поднять volatile-флаг"] S -- "поток байтов" --> B["Кольцевой буфер SPSC,
без блокировок"] S -- "большой блок" --> D["Не входить в ISR вовсе:
DMA + прерывание по половине буфера"] F --> W["Выход из ISR"] B --> W D --> W W --> M{"Кто разгребает"} M -- "bare-metal" --> L["Главный цикл:
конечный автомат"] M -- "RTOS" --> T["Задача, разбуженная
xSemaphoreGiveFromISR"] L --> A["Фильтрация, регулятор, протокол"] T --> A
Канонический механизм передачи потока данных из ISR в фон — кольцевой буфер на одного писателя и одного читателя. Он не требует ни критических секций, ни атомарных операций:
/* SPSC-очередь ISR -> фон. Размер обязательно степень двойки: маска дешевле деления. */
#define RB_SIZE 256U
#define RB_MASK (RB_SIZE - 1U)
typedef struct {
uint8_t buf[RB_SIZE];
volatile uint16_t head; /* двигает ТОЛЬКО ISR */
volatile uint16_t tail; /* двигает ТОЛЬКО фон */
} ring_t;
/* Вызывается из обработчика. Возврат false = переполнение, потери считаем явно. */
static inline bool rb_push_isr(ring_t *rb, uint8_t v)
{
uint16_t h = rb->head;
uint16_t next = (uint16_t)((h + 1U) & RB_MASK);
if (next == rb->tail) return false; /* буфер полон */
rb->buf[h] = v;
__DMB(); /* данные в памяти РАНЬШЕ, чем виден новый head */
rb->head = next;
return true;
}
/* Вызывается из фона или из задачи. */
static inline bool rb_pop(ring_t *rb, uint8_t *out)
{
uint16_t t = rb->tail;
if (t == rb->head) return false; /* пусто */
*out = rb->buf[t];
__DMB();
rb->tail = (uint16_t)((t + 1U) & RB_MASK);
return true;
}
Почему это корректно: каждый индекс имеет ровно одного писателя, чтение чужого индекса атомарно (16 бит, выровнено), а барьер __DMB() не даёт процессору и компилятору переставить публикацию head перед записью данных. Сложность обеих операций — O(1) по времени, память O(N) и выделяется статически, что критично при запрете динамики (см. C для встраиваемых систем). Ограничение честное: один писатель и один читатель — два обработчика, пишущих в общий буфер, ломают инвариант, и там уже нужна критическая секция или отдельные буферы.
Для одиночного значения работает более простой шаблон, но и в нём порядок записей имеет значение:
static uint16_t g_adc_value; /* НЕ volatile: пишется до флага, читается после */
static volatile bool g_adc_ready;
void ADC_IRQHandler(void)
{
g_adc_value = (uint16_t)ADC1->DR; /* чтение DR само сбрасывает флаг EOC */
__DMB();
g_adc_ready = true; /* публикация: только после того, как данные легли */
}
void main_loop(void)
{
if (g_adc_ready) {
g_adc_ready = false; /* сбрасываем ДО чтения: иначе потеряем следующее */
process(g_adc_value);
}
}
Порядок «сбросить флаг, потом прочитать данные» неочевиден, но важен: при обратном порядке событие, пришедшее между чтением и сбросом, потеряется.
Инверсия приоритетов: как теряют марсоходы
Приоритеты решают задачу вытеснения, но создают собственную патологию. Классика: низкоприоритетная задача захватила мьютекс, её вытеснила среднеприоритетная (которой мьютекс не нужен), а высокоприоритетная ждёт этот мьютекс — и в итоге ждёт задачу, которая приоритетнее её самой ничем не является.
Именно это случилось с марсоходом Mars Pathfinder в 1997 году: шина данных, мьютекс, задача метеорологии среднего приоритета — и сторожевой таймер, перезагружавший аппарат раз в несколько дней. Разбор от Гленна Ривза, ведущего разработчика ПО, стоит прочитать целиком: https://catless.ncl.ac.uk/Risks/19.49.html — там же видно, что спасло миссию: возможность включить наследование приоритетов на живом аппарате удалённой командой. Более подробный технический пересказ: https://www.cs.unc.edu/~anderson/teach/comp790/papers/mars_pathfinder_long_version.html
Лечится это двумя протоколами. Наследование приоритетов (priority inheritance): задача, держащая мьютекс, временно поднимается до приоритета самого приоритетного ожидающего — во FreeRTOS так работает xSemaphoreCreateMutex(), в отличие от бинарного семафора, где наследования нет; блокировка становится ограниченной, но возможны цепочки и взаимоблокировки. Потолок приоритета (priority ceiling): каждому ресурсу заранее назначается приоритет, равный максимальному среди всех его пользователей, и захват сразу поднимает задачу до потолка — дороже по накладным расходам, зато доказуемо исключает взаимоблокировки и ограничивает простой одной критической секцией (теория — Sha, Rajkumar, Lehoczky 1990: https://ieeexplore.ieee.org/document/57058).
В голом коде с прерываниями инверсия принимает другой вид: низкоприоритетный обработчик, захвативший критическую секцию через __disable_irq(), блокирует всё, включая аварийные прерывания. Отсюда практическое правило: критические секции строятся на BASEPRI с порогом, оставляющим сверхприоритетные ISR незамаскированными.
Анализ: как доказать, что дедлайны выполнимы
Для жёсткого реального времени измерений мало — нужен расчёт. Минимальный аппарат, который стоит знать любому embedded-разработчику, — Rate Monotonic Scheduling из работы Liu и Layland 1973 года (https://dl.acm.org/doi/10.1145/321738.321743). Модель: набор периодических задач, у задачи $i$ период $T_i$, худшее время выполнения $C_i$, дедлайн равен периоду; приоритеты назначаются по частоте — чем короче период, тем выше приоритет. Достаточное условие планируемости:
$$U = \sum_{i=1}^{n} \frac{C_i}{T_i} \le n \cdot \left( 2^{1/n} - 1 \right)$$
Граница убывает от 1.0 при одной задаче до $\ln 2 \approx 0{,}693$ при большом $n$. То есть при загрузке ядра меньше 69% периодическая система с приоритетами по частоте гарантированно укладывается в дедлайны. Это условие достаточное, но не необходимое: между 69% и 100% ответ даёт более точный анализ времени отклика (Joseph и Pandya, 1986 — https://academic.oup.com/comjnl/article/29/5/390/384754):
$$R_i = C_i + \sum_{j \in hp(i)} \left\lceil \frac{R_i}{T_j} \right\rceil \cdot C_j$$
Уравнение решается итеративно — поиском наименьшей неподвижной точки:
R := C_i
повторять:
R_new := C_i + сумма по j из hp(i) от ceil(R / T_j) * C_j
если R_new = R: вернуть R # сошлись
если R_new > D_i: вернуть НЕПЛАНИРУЕМО # дальше только хуже
R := R_new
Ключевой для нас момент: обработчики прерываний входят в эту модель как задачи с приоритетом выше любой программной задачи. Их «период» — минимальный интервал между событиями, их $C$ — время тела ISR плюс аппаратный вход и выход. Если посчитать без ISR, анализ будет оптимистичным ровно на величину, которая вас и подведёт.
# Анализ времени отклика для системы с фиксированными приоритетами.
# ISR описываются теми же полями, что и задачи, просто стоят выше по приоритету.
# Все величины — в микросекундах.
from math import ceil
from dataclasses import dataclass
@dataclass
class Task:
name: str
wcet: float # C: худшее время выполнения
period: float # T: период или минимальный интервал между событиями
deadline: float # D: дедлайн
def response_time(task, higher, blocking=0.0):
"""Наименьшая неподвижная точка уравнения отклика; None — дедлайн недостижим.
blocking — простой из-за критической секции менее приоритетного кода.
Время O(k*n), где k — число итераций (на практике единицы); память O(1).
"""
r = task.wcet + blocking
while True:
r_new = task.wcet + blocking + sum(ceil(r / h.period) * h.wcet for h in higher)
if r_new > task.deadline:
return None # доказано: не укладывается ни при каком раскладе
if r_new == r:
return r # сошлись
r = r_new
# Контроллер шагового привода на STM32F4 @ 168 МГц, от высшего приоритета к низшему.
system = [
Task("ISR энкодера", wcet=2.0, period=50.0, deadline=50.0),
Task("ISR таймера ШИМ", wcet=4.5, period=100.0, deadline=100.0),
Task("ISR UART", wcet=1.5, period=87.0, deadline=87.0),
Task("Задача регулятора", wcet=120.0, period=1000.0, deadline=1000.0),
Task("Задача телеметрии", wcet=800.0, period=20000.0, deadline=20000.0),
]
print(f"Загрузка ядра: {sum(t.wcet / t.period for t in system):.1%}")
for idx, task in enumerate(system):
r = response_time(task, higher=system[:idx], blocking=5.0)
verdict = f"{r:8.1f} мкс" if r is not None else " ДЕДЛАЙН СОРВАН"
print(f"{task.name:<22} отклик {verdict} (дедлайн {task.deadline:.0f} мкс)")
Запустив это на своих числах, вы получите ответ на вопрос, на который измерения не отвечают в принципе: существует ли момент, когда все прерывания совпадут и регулятор опоздает. Практика показывает, что редкая, но длинная ISR (запись во флеш, обмен по I2C в блокирующем режиме) съедает бюджет мгновенно.
Откуда брать $C_i$ — отдельная дисциплина (WCET-анализ). Два подхода: измерение на железе в наихудших условиях (быстро, но не даёт гарантии — вы могли не попасть в худший путь) и статический анализ по коду и модели процессора (даёт верхнюю границу, но требует инструментов вроде aiT и отказа от кэшей и ветвлений в критичном коде). Для 95% проектов достаточно измерений с запасом 2–3 раза; для авиации и медицины — только доказательства. Основательный разбор — в книге Buttazzo «Hard Real-Time Computing Systems»: https://link.springer.com/book/10.1007/978-1-4614-0676-1
Время: таймеры, переполнения и дрейф
Прерывания дают реакцию на события, но реальному времени нужен ещё и источник самого времени. Системный тик SysTick на Cortex-M — 24-битный счётчик, обычно настроенный на 1 кГц; им же тикает планировщик RTOS, и разрешения в 1 мс категорически мало для управления мотором. Аппаратные таймеры — настоящее оружие: тикая от системной частоты, они дают разрешение в такты, а захват (input capture) фиксирует значение счётчика в момент фронта аппаратно, то есть измерение длительности импульса вообще не зависит от латентности прерывания. Это единственный корректный способ мерить период и фазу: программное чтение счётчика в ISR добавит весь джиттер входа.
Переполнение — не крайний случай, а норма. 32-битный счётчик миллисекунд переполняется через 49,7 суток; 16-битный счётчик тактов при 84 МГц — через 780 микросекунд. Код, сравнивающий абсолютные значения, ломается ровно один раз за 49 дней, и найти это невозможно. Второй классический баг того же семейства — накопление дрейфа при задержках:
if (millis() >= deadline) { fire(); } /* НЕПРАВИЛЬНО: сломается после
переполнения millis() */
if ((int32_t)(millis() - deadline) >= 0) { fire(); } /* ПРАВИЛЬНО: беззнаковая разность
корректна по модулю 2^32 */
while (1) { do_work(); delay_ms(10); } /* ДРЕЙФ: период = 10 мс + работа + латентность */
uint32_t next = millis(); /* БЕЗ ДРЕЙФА: абсолютные дедлайны */
while (1) {
next += 10;
while ((int32_t)(millis() - next) < 0) { __WFI(); } /* спим до прерывания */
do_work();
}
Второй вариант при этом ещё и экономит энергию: __WFI() останавливает ядро до ближайшего прерывания. Во FreeRTOS ту же семантику даёт vTaskDelayUntil(). Лучший же вариант для жёсткой периодичности — вообще не участвовать программно: настроить таймер на нужный период и делать работу в его обработчике, тогда джиттер определяется только аппаратурой и более приоритетными ISR.
Железо: где какие прерывания и чем это оборачивается
Задание «взять контроллер» почти всегда сводится к вопросу о детерминизме, и платы отличаются здесь сильнее, чем по мегагерцам.
| Платформа | Модель прерываний | Латентность и джиттер | Когда брать |
|---|---|---|---|
| STM32 (Cortex-M0/M4/M7) | NVIC, 4–16 уровней, полное вытеснение, tail-chaining | 12 тактов, джиттер единицы тактов | Управление, приводы, всё, где нужен предсказуемый отклик |
| ESP32 (Xtensa/RISC-V) | 5 уровней, из C доступны 1–3, обработчик назначается на ядро | 20–30 тактов + риск промаха кэша флеш | Wi-Fi/BLE и облако; жёсткое реальное время — с оговорками |
| RP2040/RP2350 | NVIC (2 бита приоритета), два ядра, блок PIO | 12 тактов; PIO снимает потребность в ISR вовсе | Нестандартные протоколы, дёшево, второе ядро как «сопроцессор реального времени» |
| AVR (Arduino Uno/Nano) | Плоские векторы, приоритет = номер вектора, вложенности нет | 4 такта при 16 МГц, но ISR не прерывается | Обучение, простые задачи; не для многозадачности |
| Raspberry Pi (Linux) | Прерывания обрабатывает ядро Linux, вам достаётся poll/epoll на gpiochip | Десятки микросекунд в среднем, единицы миллисекунд в худшем | Зрение, ROS, сеть — но не замыкание петли управления |
Три следствия, которые дороже всего обходятся на практике:
- На ESP32 обработчик обязан быть в IRAM. Во время записи в SPI-флеш (сохранение настроек, OTA) кэш инструкций отключается, и ISR, живущий во флеше, роняет систему. Атрибут
IRAM_ATTRнужен и самому обработчику, и всему, что он вызывает, включая константы (DRAM_ATTR). Официальная документация по распределению прерываний: https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/intr_alloc.html - На AVR внутри ISR прерывания запрещены целиком. Никакой вложенности: длинный обработчик задерживает вообще всё, включая
millis(). Отсюда правило Arduino: вattachInterrupt()-обработчике только флаг и выход, никакихSerial.print()иdelay(). - На Raspberry Pi под обычным Linux жёсткого реального времени нет. Планировщик, вытеснение в ядре, управление частотой, тепловой троттлинг — всё это даёт хвост распределения в миллисекунды. Патч PREEMPT_RT (https://wiki.linuxfoundation.org/realtime/start) сокращает худший случай до десятков микросекунд, но не отменяет главного архитектурного вывода.
Этот вывод — стандартная топология современного робота: Linux-машина думает, микроконтроллер успевает.
#!/usr/bin/env python3
"""Джиттер периодического цикла на Linux — прежде чем поверить, что задачу
реального времени можно решить на Raspberry Pi. Запускать под нагрузкой
(stress-ng), иначе картина обманчиво хороша. Аналог из мира ядра — cyclictest."""
import time
PERIOD_S, N = 0.001, 20_000 # 1 кГц — типичная частота регулятора
late_us, next_wakeup = [], time.perf_counter()
for _ in range(N):
next_wakeup += PERIOD_S # абсолютные дедлайны, а не sleep(PERIOD_S)
delay = next_wakeup - time.perf_counter()
if delay > 0:
time.sleep(delay)
late_us.append((time.perf_counter() - next_wakeup) * 1e6)
late_us.sort()
print(f"медиана {late_us[N // 2]:8.1f} мкс")
print(f"p99 {late_us[int(N * 0.99)]:8.1f} мкс")
print(f"худший {late_us[-1]:8.1f} мкс")
Типичный результат на Raspberry Pi 4 без RT-патча: медиана около 60 мкс, p99 — сотни микросекунд, худший случай — единицы миллисекунд. Для планирования траектории в ROS 2 это приемлемо, для токовой петли шагового привода на 20 кГц — нет: там период 50 мкс, и худший случай Linux превышает его на порядки. Поэтому в реальных роботах петля тока и энкодеры живут на STM32, а Raspberry Pi через UART или CAN получает уже отфильтрованные величины. Подробнее об этом разделении — в статье Робототехника, а архитектурный взгляд на распределённые роботизированные системы разбирается в научных работах раздела НИР портала: Определение роботизированных систем и Теоретические обоснования микросервисной архитектуры для роботизированных систем.
Отладка временных явлений: приборы вместо принтов
Главный тезис раздела: любой вывод в лог из обработчика прерывания меняет то, что вы измеряете. printf по UART на 115200 бод — 87 микросекунд на байт; строка в 40 символов держит систему 3,5 миллисекунды. Гонка, которую вы ловите, исчезает, а на её месте появляется другая. Это классический гейзенбаг, и лечится он не аккуратностью, а сменой инструмента.
GPIO-маркер плюс осциллограф
Самый дешёвый и самый информативный приём в embedded вообще. Стоимость — две записи в регистр, около 4 наносекунд:
#define DBG_HI() (GPIOB->BSRR = (1U << 7)) /* атомарно, без RMW */
#define DBG_LO() (GPIOB->BSRR = (1U << (7 + 16)))
void TIM3_IRQHandler(void)
{
DBG_HI(); /* фронт вверх — вошли в обработчик */
TIM3->SR = ~TIM_SR_UIF;
control_loop_step();
DBG_LO(); /* фронт вниз — вышли */
}
С экрана вы читаете напрямую четыре вещи: длительность импульса — реальное время выполнения ISR со всеми ветвлениями; задержку от внешнего события до фронта — ту самую латентность с учётом всего происходящего в системе; джиттер — включите послесвечение (persistence), и фронт «размажется» ровно на величину разброса, это самый наглядный взгляд на детерминизм, который вообще существует; пропуски — если импульсы должны идти каждые 100 мкс, а иногда идут через 200, события теряются. Отдельный вывод под каждый интересующий обработчик плюс четырёхканальный осциллограф — и вложенность прерываний видна как есть.
Логический анализатор
Осциллограф хорош для одного-двух сигналов и аналоговых деталей (звон, дребезг, уровни). Логический анализатор берёт 8–16 каналов и глубокую память: он показывает всю картину во времени. Дешёвые клоны Saleae на 24 МГц плюс открытый PulseView из проекта sigrok (https://sigrok.org/wiki/PulseView) закрывают большинство задач, а встроенные декодеры сразу переводят диаграмму в байты — что понадобится уже в следующей статье трека. Типичный сценарий: канал 0 — событие, каналы 1–3 — маркеры трёх ISR, канал 4 — маркер главного цикла. Одним взглядом видно, кто кого вытеснил и где ушли 200 микросекунд.
JTAG/SWD: отладчик, который нельзя использовать наивно
SWD (двухпроводный вариант JTAG у ARM) даёт классическую отладку: точки останова, пошаговое выполнение, чтение памяти. И здесь есть жёсткое предупреждение: остановка ядра в точке останова не останавливает физический мир. Мотор продолжит вращаться, нагреватель — греть, конденсатор — заряжаться, а сторожевой таймер досчитает и перезагрузит контроллер, вырвав вас из отладки. Некоторые отладчики умеют останавливать таймеры вместе с ядром (в STM32 за это отвечают регистры DBGMCU — их стоит настроить сразу). Ниже — инструменты по цене, которую они берут со времени:
| Инструмент | Что даёт | Цена по времени |
|---|---|---|
| Точка останова (SWD) | полное состояние в момент останова | останавливает мир — для реального времени малоприменимо |
| Watchpoint / точка наблюдения | останов при записи в переменную — идеально ловит «кто портит память» | останавливает мир, но срабатывает точечно |
| DWT CYCCNT | счётчик тактов ядра: измерение с точностью до такта | 2 чтения регистра, около 4 нс |
| ITM/SWO trace | поток событий по одному проводу, не останавливая ядро | около 1 мкс на слово при 2 МГц SWO |
| SEGGER RTT | вывод в кольцевой буфер ОЗУ, отладчик вычитывает фоном | сотни наносекунд на строку |
| ETM trace | полная трассировка выполненных инструкций | нулевая для ядра, но нужен дорогой пробник |
Счётчик тактов включается тремя строчками и стоит того, чтобы всегда быть в проекте. Практика, которая окупается сразу: храните максимум времени выполнения каждого обработчика прямо в прошивке и отдавайте его в телеметрию — так WCET из умозрительной величины превращается в наблюдаемую метрику, ловящую регрессии на реальном железе.
static void cycle_counter_init(void) /* блок трассировки DWT, ARMv7-M */
{
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
DWT->CYCCNT = 0;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}
static volatile uint32_t g_isr_max_cycles; /* пишет только этот ISR — гонки нет */
void TIM3_IRQHandler(void)
{
uint32_t t0 = DWT->CYCCNT;
TIM3->SR = ~TIM_SR_UIF;
control_loop_step();
uint32_t dt = DWT->CYCCNT - t0; /* разность верна и при переполнении;
при 168 МГц такт = 5,95 нс */
if (dt > g_isr_max_cycles) g_isr_max_cycles = dt;
}
SEGGER RTT (https://www.segger.com/products/debug-probes/j-link/technology/about-real-time-transfer/) — это компромисс между «нужен текст» и «нельзя тормозить»: запись в буфер ОЗУ занимает сотни наносекунд, отладчик вычитывает его по SWD, не останавливая ядро. Из ISR допустим именно RTT, но не printf по UART. Полный набор приёмов производственной отладки разбирается в заключительной статье трека.
Типичные ошибки
- Длинный обработчик. Парсинг JSON, запись во флеш,
sprintf, вычисление синуса в ISR — и латентность всей системы уезжает в миллисекунды. Норма — десятки инструкций. - Забытый
volatileна переменной, разделяемой с обработчиком. Работает на-O0, ломается на-Os, выглядит как «баг компилятора». - Уверенность, что
volatileдаёт атомарность. Не даёт. Инкремент из двух мест — гонка на любой архитектуре. __disable_irq()вокруг долгой операции. Убивает детерминизм по всей системе: используйтеBASEPRIи держите секцию в единицах микросекунд.- Read-modify-write над регистром периферии из фона и из ISR одновременно. Ищите
BSRR,SET/CLR, bit-banding — атомарный аналог почти всегда есть. - Забыли сбросить флаг источника — бесконечный вход в обработчик. Или сбросили последней инструкцией без
__DSB()— ложный повторный вход. - Приоритеты «наоборот». На Cortex-M ноль — высший. Аварийный вход с приоритетом 15 не спасёт ничего.
- Вызов API RTOS из ISR приоритетнее
configMAX_SYSCALL_INTERRUPT_PRIORITYили вызов обычной версии вместо...FromISR. Порча внутренних структур планировщика с проявлением через часы работы. - Абсолютное сравнение времени вместо разностного — отказ через 49,7 суток непрерывной работы. И неучтённый стек прерываний: четыре уровня вложенности с FPU — 416 байт только на контексты, а переполнение проявится не там, где произошло.
- Отладка принтами и дребезг кнопки на прерывании по фронту. Первое: явление, зависящее от времени, исчезает от самого факта измерения, тогда как маркер на GPIO плюс осциллограф находят его за минуты. Второе: одно нажатие даёт 20–50 прерываний, и гасить дребезг надо схемой (RC), таймером или программным окном — но не задержкой в обработчике.
Чек-лист код-ревью обработчика в шести пунктах: короче 50 инструкций и без циклов с неограниченным числом итераций; флаг источника сброшен и порядок сброса относительно чтения данных продуман; все разделяемые с фоном переменные объявлены volatile, и для каждой известен единственный писатель; нет malloc, printf и блокирующих драйверов; при использовании RTOS — только ...FromISR-версии и приоритет ниже порога системных вызовов; есть GPIO-маркер или замер DWT->CYCCNT, а бюджет стека посчитан с учётом худшей вложенности.
Мини-итог
Прерывание — это не колбэк, а аппаратный вызов функции между любыми двумя инструкциями, и с ним в вашу однопоточную программу приходит вся конкурентность разом. На Cortex-M аппаратура берёт на себя сохранение контекста (32 байта, 12 тактов) и приоритетный арбитраж, но реальная латентность определяется не даташитом, а вашими критическими секциями и более приоритетными обработчиками: $L = L_{hw} + L_{crit} + L_{hp}$.
Три правила, из которых выводится почти всё остальное. Первое: обработчик снимает данные и выходит, вся обработка — в фоне или в задаче, а связь между ними — кольцевой буфер SPSC или флаг с барьером. Второе: volatile — контракт с компилятором, а не с аппаратурой; атомарность обеспечивают либо разрядность доступа, либо аппаратные регистры вроде BSRR, либо критическая секция на BASEPRI, но никогда не сам по себе volatile. Третье: детерминизм считается, а не измеряется — загрузка ниже 69% по критерию Liu–Layland или точный анализ времени отклика с учётом ISR как задач высшего приоритета.
И практический вывод про инструменты: временные явления не отлаживаются печатью, потому что печать сама занимает время. Два вывода GPIO под маркеры, осциллограф с послесвечением, логический анализатор на 8 каналов и включённый DWT->CYCCNT дают больше за час, чем неделя чтения логов. Начните прямо сейчас: поставьте маркер на вход и выход самого частого обработчика в вашем проекте, посмотрите на форму сигнала под нагрузкой — и вы, скорее всего, обнаружите там что-то, чего не ожидали увидеть.
Источники
- Arm. Cortex-M4 Devices Generic User Guide — https://developer.arm.com/documentation/dui0553/latest/ ; ARMv7-M Architecture Reference Manual (модель исключений, DWT, ITM) — https://developer.arm.com/documentation/ddi0403/latest/
- Arm. Application Note 321: Programming Guide to Memory Barrier Instructions — https://developer.arm.com/documentation/dai0321/latest/
- Joseph Yiu. The Definitive Guide to Arm Cortex-M3 and Cortex-M4 Processors — https://www.sciencedirect.com/book/9780124080829/the-definitive-guide-to-arm-cortex-m3-and-cortex-m4-processors
- C. L. Liu, J. W. Layland. Scheduling Algorithms for Multiprogramming in a Hard-Real-Time Environment, JACM 1973 — https://dl.acm.org/doi/10.1145/321738.321743
- M. Joseph, P. Pandya. Finding Response Times in a Real-Time System, The Computer Journal 1986 — https://academic.oup.com/comjnl/article/29/5/390/384754
- L. Sha, R. Rajkumar, J. Lehoczky. Priority Inheritance Protocols, IEEE Transactions on Computers 1990 — https://ieeexplore.ieee.org/document/57058 ; популярное изложение у Michael Barr — https://barrgroup.com/embedded-systems/how-to/rtos-priority-inversion
- G. Buttazzo. Hard Real-Time Computing Systems — https://link.springer.com/book/10.1007/978-1-4614-0676-1
- Glenn Reeves. What Really Happened on Mars, RISKS Digest 19.49 — https://catless.ncl.ac.uk/Risks/19.49.html
- FreeRTOS. Прерывания на Cortex-M и
configMAX_SYSCALL_INTERRUPT_PRIORITY— https://www.freertos.org/RTOS-Cortex-M3-M4.html - Espressif. Interrupt Allocation в ESP-IDF — https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/intr_alloc.html ; avr-libc
util/atomic.h— https://www.nongnu.org/avr-libc/user-manual/group__util__atomic.html - Raspberry Pi. RP2040 Datasheet (NVIC, PIO, межъядерные примитивы) — https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf
- Linux Foundation. Real-Time Linux и cyclictest — https://wiki.linuxfoundation.org/realtime/start
- SEGGER. Real Time Transfer — https://www.segger.com/products/debug-probes/j-link/technology/about-real-time-transfer/ ; sigrok и PulseView — https://sigrok.org/wiki/PulseView
- Jack Ganssle. The Embedded Muse, заметки об измерении латентности — http://www.ganssle.com/
Что дальше
Протоколы: UART, I2C, SPI, CAN — как устройства общаются — разберём, как байты физически превращаются в напряжения на проводах: асинхронный UART и почему рассинхронизация тактовой частоты ограничивает длину кадра, двухпроводный I2C с подтяжками и арбитражем, быстрый SPI и цена лишнего провода, а также CAN с его недеструктивным арбитражем по приоритету сообщений — протокол, который построен ровно на тех же идеях детерминизма, что и эта статья, только на уровне шины. Заодно посмотрим, как каждый из них выглядит на экране логического анализатора и какие отказы дают самые загадочные симптомы.