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

84. Прерывания и время на прототипе

Прототип быстро упирается во время: мигать тремя светодиодами с разной частотой, не пропустить нажатие кнопки, пока идёт delay(), уснуть и проснуться по событию. Механика прерываний STM32 разобрана в разделе 7: таблица векторов, приоритеты, SysTick, EXTI, правила для обработчика. Здесь — то, что всплывает на прототипе и при переносе с Arduino: накопление ошибки в периодических действиях, дребезг внутри прерывания, разрыв 16-битного счётчика на AVR и сон, в котором не теряются пробуждения.

Машинный код раздела снят с armclang для STM32 и с avr-gcc для ATmega328P; периодические действия и дребезг проверены тестом.

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

Оригинал Перевод
interrupt service routine (ISR) обработчик прерывания
external interrupt, pin change interrupt внешнее прерывание, прерывание по изменению вывода
edge / level triggered по фронту / по уровню
atomic operation, critical section неделимая операция, критическая секция
PRIMASK маска, запрещающая прерывания
wait for interrupt (WFI) ждать прерывания
lost wake-up потерянное пробуждение
clear timer on compare (CTC) сброс таймера по совпадению
prescaler, auto-reload предделитель, перезагружаемый предел
drift накопление ошибки, уход

84.1 Время на Arduino и на STM32

У Uno кварц 16 МГц — такт 62,5 нс. Время для millis() и delay() отсчитывает один из аппаратных таймеров, поэтому трогать его настройки нельзя: сломается счёт времени. На STM32 ту же роль играет SysTick — прерывание раз в миллисекунду, на нём построен HAL_GetTick() (раздел 7.4), а остальные таймеры свободны (раздел 13).

delay() останавливает программу целиком: пока она ждёт, датчик не опрашивается, кнопка не читается. Отсюда два пути — опрос по времени в основном цикле и прерывания.


84.2 Периодическое действие без накопления ошибки

Типовой шаблон «делать раз в секунду, не останавливая программу»:

if (millis() - timer >= 1000) {
    timer = millis();          /* срок — от «сейчас» */
    …
}

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

/* Без накопления: следующий срок — от прошлого срока. */
int every(uint32_t *last, uint32_t now, uint32_t period)
{
    if (now - *last < period)
        return 0;
    *last += period;
    return 1;
}

У этого шаблона есть обратная сторона: если цикл застрял на несколько периодов (стирание Flash, остановка в отладчике, долгий блокирующий вызов), every сработает несколько раз подряд, догоняя пропущенные сроки. Где это вредно — отправка пакета, шаг двигателя, — после долгой паузы срок переносят на текущий момент.

Тест: основной цикл проходит за 7 мс, действие — раз в секунду, час работы.

Шаблон Срабатываний за час Опоздание в конце
timer = millis() 3596 3,6 с
*last += period 3600 2 мс — меньше одного прохода

С «сейчас» каждый период выходит 1001 мс: цикл замечает срок только на следующем проходе после тысячной миллисекунды. За час набегает 3,6 секунды, за сутки — полторы минуты. С прибавлением периода опоздание каждого срабатывания меньше одного прохода цикла и не копится.

Опоздание за час работы: с отсчётом от «сейчас» растёт до 3,6 с, с прибавлением периода остаётся 2 мс

Разность now - *last беззнаковая и переживает переполнение счётчика (разделы 7.4 и 80.4).

⚠️ ledState =! ledState; из типовых примеров работает — это присваивание отрицания, — но читается как несуществующий оператор =!. Пишут ledState = !ledState;.


84.3 Внешние прерывания

Uno (ATmega328P) STM32
прерывания по фронту на выводе 2: INT0, INT1 16 линий EXTI, по одному выводу с номером n на линию n (раздел 7.5)
прерывания по изменению 23 входа на трёх векторах нет отдельных — те же EXTI; линии 5–9 и 10–15 тоже делят векторы EXTI9_5 и EXTI15_10, и в обработчике смотрят EXTI->PR
настройка attachInterrupt(номер, функция, режим) EXTI, AFIO или SYSCFG, NVIC (раздел 7.6)

У ATmega328P прерывание по изменению есть на всех выводах, кроме несуществующего PCINT15: 23 входа, сгруппированных по трём векторам, и в обработчике приходится выяснять, какой вывод изменился. «Прерывания есть только на выводах 2 и 3» — это про два входа с выбором фронта.

Кнопка на прерывании всё равно дребезжит. Типовой пример считает нажатия прерыванием по спадающему фронту. Но механический контакт при нажатии и отпускании прыгает (раздел 50.7), и каждый прыжок — фронт. В тесте одно нажатие с отпусканием дало пять спадающих фронтов: счётчик прибавил бы пять вместо одного. Прерывание по кнопке либо запрещают на время дребезга и включают снова по таймеру, либо используют лишь как сигнал «проснись», а само нажатие подтверждают опросом (разделы 79.5 и 80.4).


84.4 volatile — не значит «неделимо»

Переменную, которую меняет обработчик, объявляют volatile, иначе компилятор вправе прочитать её один раз и больше не перечитывать (разделы 3 и 7.7). Но volatile не делает доступ неделимым.

На AVR 16-битный счётчик читается по байту. Машинный код avr-gcc: чтение volatile uint16_t counter — это две команды lds, младший и старший байт. Если между ними сработает прерывание и увеличит счётчик с 0x00FF до 0x0100, основная программа прочтёт младший байт старого значения и старший нового — 0x01FF. Лечится запретом прерываний на время чтения:

uint16_t read_atomic(void)
{
    uint16_t v;
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE)
    {
        v = counter;
    }
    return v;
}

ATOMIC_BLOCK добавляет cli вокруг тех же двух чтений. А counter++ в самом обработчике — это два чтения, сложение и две записи.

Две команды lds с прерыванием посередине: младший байт старого значения и старший нового дают 0x01FF

На Cortex-M чтение до 32 бит — одна команда: 16-битный счётчик armclang читает одной ldrh. Но изменение из основного кода — всё равно «прочитать, изменить, записать», и прерывание может вклиниться посередине (раздел 7.7). Надёжный шаблон — запретить прерывания и вернуть прежнее состояние, а не просто разрешить: тогда функцию можно вызывать и там, где прерывания уже запрещены.

uint32_t ticks_add(uint32_t d)
{
    uint32_t pm = __get_PRIMASK();
    __disable_irq();
    ticks += d;
    uint32_t v = ticks;
    __set_PRIMASK(pm);
    return v;
}

84.5 Прерывание по таймеру

На AVR — регистрами: Timer1 в режиме сброса по совпадению (CTC) с предделителем 1024 и пределом 15 624 даёт 16 МГц / 1024 / 15 625 = 1 Гц. Период на единицу больше записанного предела — счёт идёт от нуля. На STM32F103 то же — таймер общего назначения:

/* TIM2: 72 МГц / 7200 = 10 кГц, 10 кГц / 10 000 = 1 Гц */
void tim2_1hz_init(void)
{
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    TIM2->PSC = 7200 - 1;
    TIM2->ARR = 10000 - 1;
    TIM2->EGR = TIM_EGR_UG;             /* загрузить PSC сразу */
    TIM2->SR = 0;                       /* UG взвёл UIF — сбросить до разрешения */
    TIM2->DIER = TIM_DIER_UIE;
    TIM2->CR1 = TIM_CR1_CEN;
    NVIC_EnableIRQ(TIM2_IRQn);
}

void TIM2_IRQHandler(void)
{
    if (TIM2->SR & TIM_SR_UIF) {
        TIM2->SR = (uint16_t)~TIM_SR_UIF;   /* флаг таймера сбрасывают НУЛЁМ */
        ticks++;
        flag = 1;
    }
}

⚠️ Предделитель буферизован. Записанный PSC вступает в силу только на событии обновления. Без EGR = UG таймер после CEN считает с прежним делителем 1 до ARR: первое прерывание придёт через 10 000 тактов 72 МГц — 139 мкс, а не через секунду, и ticks с flag получат лишнее раннее срабатывание. UG при этом взводит UIF, поэтому флаг сбрасывают до разрешения прерывания (раздел 13).

⚠️ Флаги сбрасывают по-разному. У таймера флаг обновления сбрасывается записью нуля в его бит — HAL так и делает: SR = ~флаг. У EXTI — записью единицы в PR (раздел 7.7). Перенесли привычку с одного блока на другой — обработчик вызывается бесконечно.

Правила для обработчика — в разделе 7.7: коротко, без задержек и без вывода в последовательный порт — печать строки на 9600 бод занимает миллисекунды. В примерах из сети встречается обработчик, «объявленный» внутри loop(). Стандарт C вкладывать функции не разрешает: скетч Arduino (это C++) и armclang отвечают function definition is not allowed here. А GCC в режиме C принимает такое как расширение — и это хуже: функция соберётся локальной, с другим именем, и в таблицу векторов не попадёт.


84.6 Сон до прерывания — и не проспать его

Экономный прибор спит, пока нет событий, и просыпается по прерыванию (раздел 10). Наивно:

while (1) {
    if (!flag)
        __WFI();        /* а если прерывание пришло ровно здесь? */
    …
}

Если прерывание сработает между проверкой флага и __WFI(), обработчик взведёт флаг, а ядро всё равно уснёт — до следующего прерывания, которое может прийти через час. Это потерянное пробуждение. Правильно — проверять и засыпать при запрещённых прерываниях:

void idle(void)
{
    __disable_irq();
    if (!flag)
        __WFI();
    __enable_irq();
}

WFI просыпается от ожидающего прерывания, даже когда прерывания запрещены: ядро выходит из сна, выполняет __enable_irq() — и только тогда вызывается обработчик. Пришло ли прерывание до проверки, между проверкой и сном или во сне — оно не потеряется. armclang собрал это в cpsid, проверку флага, условный wfieq без перехода и cpsie.

У ATmega328P в самом глубоком сне прерывание на INT0/INT1 будит только по уровню, а не по фронту; будят ещё прерывание по изменению вывода, совпадение адреса TWI и сторожевой таймер — им обычно и просыпаются периодически. Прерывание по низкому уровню повторяется, пока уровень держится, — поэтому в типовом примере его отключают сразу после пробуждения.


84.7 Грабли

Действие «раз в секунду» за сутки уходит на полторы минуты. Срок отсчитывается от millis(), а не от прошлого срока.

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

Счётчик из прерывания иногда читается с огромным скачком. AVR, 16-битный счётчик читается по байту, и между двумя чтениями приходит прерывание.

Флаг из прерывания «не виден» основному циклу. Нет volatile.

Прибор иногда не просыпается по кнопке. Потерянное пробуждение: прерывание пришло между проверкой и WFI.

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

Сломался счёт millis() после настройки таймера. Перенастроен таймер, на котором держится время Arduino.

Программа тормозит в прерывании. Вывод в последовательный порт из обработчика.

Обработчик внутри loop() не компилируется. Функции не вкладываются.


84.8 Итоги

Дальше: раздел 84а «От скетча к регистрам: один прибор на четырёх уровнях» — тот же прибор скетчем, на HAL, на LL и на регистрах: размер, состав и цена одной записи в порт.

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