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 секунды, за сутки — полторы минуты. С прибавлением периода опоздание каждого срабатывания меньше одного прохода цикла и не копится.
Разность 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++ в
самом обработчике — это два чтения, сложение и две записи.
На 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 Итоги
- Время Arduino держит аппаратный таймер, у STM32 — SysTick;
delay()останавливает всё. - Периодическое действие считают от прошлого срока (
last += period): при цикле 7 мс это 3600 срабатываний за час против 3596 и 3,6 с отставания уlast = millis(). - У Uno два прерывания с выбором фронта и 23 входа прерываний по изменению; у STM32 — 16 линий EXTI. Прерывание по кнопке ловит дребезг: одно нажатие — пять фронтов.
volatileне делает доступ неделимым: на AVR 16-битное чтение — две команды; изменение общей переменной — в критической секции с восстановлением прежнего PRIMASK.- Флаг таймера сбрасывают нулём, флаг EXTI — единицей.
- Засыпают при запрещённых прерываниях: проверка,
WFI, разрешение — так прерывание между проверкой и сном не теряется.
Дальше: раздел 84а «От скетча к регистрам: один прибор на четырёх уровнях» — тот же прибор скетчем, на HAL, на LL и на регистрах: размер, состав и цена одной записи в порт.