Встраиваемые системы и робототехника Прерывания и реальное время: ISR, приоритеты, гонки, детерминизм
0%

Прерывания и реальное время: ISR, приоритеты, гонки, детерминизм

Прерывания и реальное время: ISR, приоритеты, гонки, детерминизм

Зачем вообще прерывания и почему это не колбэк

На сервере вы никогда не писали обработчик прерывания. Не потому, что их нет — их там тысячи в секунду, — а потому, что между вами и ними стоит ядро операционной системы. Пришёл пакет, сетевая карта дёрнула линию IRQ, драйвер положил данные в сокет, планировщик разбудил ваш поток на read(). Всю грязную работу — сохранение контекста, приоритеты, защиту разделяемых структур — сделал кто-то другой, и результат приехал к вам в виде удобного блокирующего вызова.

На микроконтроллере этого «кого-то другого» нет. Вы и есть ядро. Между фронтом напряжения на ножке и вашей функцией нет ни одного слоя абстракции: аппаратура вызывает вашу функцию напрямую, посреди любой инструкции фонового кода, без вашего разрешения и без предупреждения. Отсюда рабочее определение, которое стоит держать в голове весь трек:

Прерывание — это аппаратно инициированный вызов функции, который может произойти между любыми двумя машинными инструкциями вашей программы. Не «событие», не «колбэк», не «сигнал» — именно вызов, вклинивающийся в произвольную точку, включая середину операции x++.

Разница с колбэком принципиальна, и её полезно проговорить явно:

Колбэк на «большой» машине Обработчик прерывания (ISR)
Кто вызывает ваш же код, из цикла событий аппаратура, асинхронно к коду
Когда в известной точке (await, poll) между любыми двумя инструкциями
Контекст обычный поток, есть куча и планировщик нет потока, нет планировщика, свой стек-кадр
Можно ли заблокироваться да нет: блокировка = зависание всей системы
Цена вызова десятки-сотни наносекунд, никого не волнует считается в тактах и попадает в бюджет
Что защищает данные мьютекс, GIL, однопоточность рантайма ничего, кроме того, что вы напишете руками

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

Чем обслуживать конкретное событие — вопрос инженерный, и решается он двумя осями: как часто событие происходит и как быстро нужно на него отреагировать.

Правая верхняя четверть — та, где прерывания уже не справляются: 1 МГц потока с АЦП означает прерывание каждую микросекунду, то есть вход-выход съедят всё ядро. Там работает DMA, а на RP2040 — блок PIO, который выполняет протокол собственной программой. Это тема статьи про периферию.

Что физически происходит при прерывании

Разберём вход в прерывание на Cortex-M — архитектуре, на которой построены STM32, RP2040, nRF52 и большинство современных 32-битных контроллеров. Это самая аккуратно сделанная модель прерываний в массовом железе, и она задаёт стандарт мышления.

Таймлайн прерывания: латентность входа, вытеснение и хвостовая цепочка

Последовательность такая:

  1. Источник поднимает запрос. Таймер досчитал до нуля, на ножке появился фронт, UART принял байт: в периферии взводится бит статуса, и, если прерывание разрешено, сигнал уходит в контроллер прерываний NVIC.
  2. NVIC решает. Он сравнивает приоритет запроса с текущим уровнем выполнения: приоритетнее — вытесняет текущий код, нет — ждёт своей очереди в состоянии pending.
  3. Аппаратура сохраняет контекст — восемь 32-битных слов уходят в стек без единой вашей инструкции, — читает адрес обработчика из таблицы векторов, кладёт в LR значение EXC_RETURN и передаёт управление в вашу функцию.
  4. На выходе запись EXC_RETURN в PC запускает обратный процесс: регистры восстанавливаются, управление возвращается ровно в ту инструкцию, на которой прервалось.

Ключевое следствие третьего пункта — то, почему на Cortex-M обработчик выглядит как обычная функция C:

Раскладка кадра исключения в стеке и значения EXC_RETURN

/* --- 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 есть собственный маленький автомат — знание его состояний экономит часы отладки.

Две самые частые аварии видны прямо на диаграмме. Первая: флаг источника не сброшен — при выходе 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 (см. Ввод-вывод и драйверы), только здесь вы реализуете её руками.

Канонический механизм передачи потока данных из 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, сеть — но не замыкание петли управления

Три следствия, которые дороже всего обходятся на практике:

  1. На 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
  2. На AVR внутри ISR прерывания запрещены целиком. Никакой вложенности: длинный обработчик задерживает вообще всё, включая millis(). Отсюда правило Arduino: в attachInterrupt()-обработчике только флаг и выход, никаких Serial.print() и delay().
  3. На 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. Полный набор приёмов производственной отладки разбирается в заключительной статье трека.

Типичные ошибки

  1. Длинный обработчик. Парсинг JSON, запись во флеш, sprintf, вычисление синуса в ISR — и латентность всей системы уезжает в миллисекунды. Норма — десятки инструкций.
  2. Забытый volatile на переменной, разделяемой с обработчиком. Работает на -O0, ломается на -Os, выглядит как «баг компилятора».
  3. Уверенность, что volatile даёт атомарность. Не даёт. Инкремент из двух мест — гонка на любой архитектуре.
  4. __disable_irq() вокруг долгой операции. Убивает детерминизм по всей системе: используйте BASEPRI и держите секцию в единицах микросекунд.
  5. Read-modify-write над регистром периферии из фона и из ISR одновременно. Ищите BSRR, SET/CLR, bit-banding — атомарный аналог почти всегда есть.
  6. Забыли сбросить флаг источника — бесконечный вход в обработчик. Или сбросили последней инструкцией без __DSB() — ложный повторный вход.
  7. Приоритеты «наоборот». На Cortex-M ноль — высший. Аварийный вход с приоритетом 15 не спасёт ничего.
  8. Вызов API RTOS из ISR приоритетнее configMAX_SYSCALL_INTERRUPT_PRIORITY или вызов обычной версии вместо ...FromISR. Порча внутренних структур планировщика с проявлением через часы работы.
  9. Абсолютное сравнение времени вместо разностного — отказ через 49,7 суток непрерывной работы. И неучтённый стек прерываний: четыре уровня вложенности с FPU — 416 байт только на контексты, а переполнение проявится не там, где произошло.
  10. Отладка принтами и дребезг кнопки на прерывании по фронту. Первое: явление, зависящее от времени, исчезает от самого факта измерения, тогда как маркер на 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 дают больше за час, чем неделя чтения логов. Начните прямо сейчас: поставьте маркер на вход и выход самого частого обработчика в вашем проекте, посмотрите на форму сигнала под нагрузкой — и вы, скорее всего, обнаружите там что-то, чего не ожидали увидеть.

Источники

Что дальше

Протоколы: UART, I2C, SPI, CAN — как устройства общаются — разберём, как байты физически превращаются в напряжения на проводах: асинхронный UART и почему рассинхронизация тактовой частоты ограничивает длину кадра, двухпроводный I2C с подтяжками и арбитражем, быстрый SPI и цена лишнего провода, а также CAN с его недеструктивным арбитражем по приоритету сообщений — протокол, который построен ровно на тех же идеях детерминизма, что и эта статья, только на уровне шины. Заодно посмотрим, как каждый из них выглядит на экране логического анализатора и какие отказы дают самые загадочные симптомы.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов