Прерывания и общие данные: обработчики, атомарность, очереди
Обработчик прерывания может вклиниться между любыми двумя командами основной программы, и всё, что они делят, становится общими данными, как у двух потоков. Как AVR и Cortex-M входят в обработчик, что гарантирует и чего не гарантирует volatile, как читать многобайтные переменные, делать короткие критические секции и передавать данные через кольцевой буфер без запрета прерываний — с примерами на C, расчётами и разбором типичных ошибок.
Обновлено Редакция GAW
Прерывание — аппаратный вызов обработчика (ISR, interrupt service routine) между двумя командами основной программы: периферия взводит флаг, процессор откладывает текущую работу, выполняет обработчик и возвращается. Для программиста это первая встреча с параллельностью: основной цикл и обработчик работают с одними и теми же переменными, и обработчик может вклиниться в любой момент — в том числе посередине чтения многобайтного числа или между чтением и записью одной переменной. Отсюда три класса ошибок: компилятор не перечитывает значение, которое изменилось «за его спиной», чтение разрывается пополам, обновление теряется. Ниже — как их избежать на 8-битном AVR и на ARM Cortex-M.
Коротко
- AVR: при входе в обработчик флаг I сбрасывается, и обработчики не вложены; команда RETI возвращает I = 1. После SEI и после выхода из обработчика всегда выполняется ещё одна команда основной программы.
- Cortex-M: приоритеты в NVIC, меньшее число важнее; вход в обработчик — ядро само сохраняет 8 слов (32 байта, с контекстом FPU — 104), обработчик — обычная функция C (на STM32F1 — после включения бита STKALIGN).
- volatile заставляет компилятор выполнять каждое обращение, но не делает его атомарным и не упорядочивает обычные переменные относительно volatile.
- Атомарность: на AVR без запрета прерываний неделимо только обращение к одному байту; на Cortex-M — к байту, выровненному полуслову и слову, а
x++и 64-битные числа — нет. - Критическая секция — запрет прерываний с восстановлением прежнего состояния:
ATOMIC_BLOCK(ATOMIC_RESTORESTATE)на AVR, PRIMASK или BASEPRI на Cortex-M. Её длительность добавляется к задержке всех прерываний. - Кольцевой буфер с одним писателем и одним читателем работает без запрета прерываний, если каждый индекс пишет только одна сторона.
Как процессор входит в обработчик
AVR
Таблица векторов ATmega328P — 26 записей по 4 байта в начале флеш-памяти, в каждой команда перехода на обработчик. Чем меньше адрес вектора, тем выше приоритет: если ждут несколько прерываний, первым обслуживается вектор с меньшим номером, сразу после сброса идёт INT0. Обработчик в avr-libc объявляют макросом ISR() из <avr/interrupt.h>: он создаёт функцию с именем вектора (TIMER1_COMPA_vect — это __vector_11) и атрибутом GCC signal. Компилятор добавляет к ней пролог и эпилог: сохраняет все регистры, которые обработчик использует, а также регистр состояния SREG и служебные R0 и R1, и завершает функцию командой RETI. С GCC 8 при включённой оптимизации (кроме -Og) пролог строится псевдокомандой ассемблера __gcc_isr, и SREG, R0 и R1 сохраняются, только если обработчик их действительно трогает (ключ -mgas-isr-prologues; отключается атрибутом no_gccisr).
Что происходит при прерывании (DS40002061B, разд. 7.7):
- Текущая команда, даже многотактная, выполняется до конца.
- Флаг I в SREG сбрасывается — все прерывания запрещены; адрес возврата (2 байта) сохраняется в стеке. Это занимает не меньше 4 тактов, ещё 3 такта — переход из таблицы векторов.
- Флаг источника у большинства прерываний сбрасывается аппаратно при переходе на вектор. Есть и прерывания «по уровню», которые держатся, пока есть условие: например, прерывание приёма USART повторяется, пока программа не прочитает регистр UDR0.
- Обработчик выполняется с запрещёнными прерываниями. SREG аппаратура не сохраняет — это делает пролог, сгенерированный компилятором.
- RETI достаёт адрес возврата и устанавливает флаг I. Перед следующим ожидающим прерыванием процессор обязательно выполнит ещё одну команду основной программы.
Флаги, которые взвелись, пока прерывание было запрещено, запоминаются: прерывание сработает сразу после разрешения. Вложенность включают явно: атрибут ISR_NOBLOCK заставляет компилятор поставить SEI первой командой обработчика. Пользоваться им стоит осторожно: каждый уровень вложенности добавляет кадр в стек, а прерывание по уровню без сброса условия войдёт в обработчик снова и снова.
Если прерывание разрешено, а обработчика нет, срабатывает вектор по умолчанию __vector_default, который в avr-libc ведёт на начало таблицы векторов, то есть к перезапуску программы. Опечатка в имени вектора даёт именно такой симптом: программа «сама перезагружается» при первом прерывании. Имя с опечаткой не начинается с __vector, и avr-gcc предупреждает об этом (предупреждение -Wmisspelled-isr включено по умолчанию) — игнорировать его нельзя. Поймать остальные случаи помогает собственный обработчик ISR(BADISR_vect).
#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/atomic.h>
static volatile uint16_t ticks; /* changed in the ISR, read in main */
ISR(TIMER1_COMPA_vect) /* prologue saves SREG and used registers, ends with RETI */
{
ticks++;
}
uint16_t ticks_get(void)
{
uint16_t copy;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { /* the 2-byte read must not be split by the ISR */
copy = ticks;
}
return copy;
}
Чем меньше обработчик трогает регистров, тем короче пролог. Если обработчик вызывает функцию из другого файла, компилятор не знает, какие регистры она испортит, и сохраняет все, которые по соглашению avr-gcc может портить вызываемая функция: R18–R27, R30 и R31. Поэтому короткие обработчики пишут без вызовов или со встраиваемыми (static inline) функциями.
Cortex-M
У Cortex-M прерываниями управляет контроллер NVIC в ядре. У каждого канала программируемый приоритет: поле до 8 бит, меньшее число означает более высокий приоритет, 0 — самый высокий. Сколько бит реализовано, решает производитель: у STM32F1 и STM32F4 — 4 бита, 16 уровней. Прерывание с более высоким приоритетом вытесняет работающий обработчик, с равным — ждёт его окончания; из нескольких ожидающих с одинаковым приоритетом первым идёт канал с меньшим номером. Поле приоритета делится на приоритет группы, который решает вытеснение, и подприоритет, который решает только очерёдность. Посчитать значения регистров по группе помогает калькулятор приоритетов NVIC, а номера каналов и особенности STM32 разобраны в статье о прерываниях STM32.
При входе в исключение ядро без участия программы сохраняет в текущий стек восемь слов: R0–R3, R12, LR, адрес возврата и xPSR — и одновременно читает адрес обработчика из таблицы векторов. В LR записывается код EXC_RETURN, по которому обычная команда возврата из функции становится выходом из прерывания. У Cortex-M3 на это уходит до 12 тактов при памяти без тактов ожидания, столько же на возврат (TRM, разд. 3.9.2).
Регистры R0–R3, R12 и LR по стандарту вызовов AAPCS может портить любая функция, а остальные функция сохраняет сама. Поэтому обработчик на Cortex-M — обычная функция C без атрибутов. Одна оговорка — выравнивание стека. AAPCS требует, чтобы на входе в функцию SP был кратен 8, а ядро выравнивает кадр на 8 только при установленном бите STKALIGN регистра CCR. У ядра Cortex-M3 ревизии r1p1, которое стоит в STM32F1, после сброса этот бит равен 0 (в r2p0 значение по умолчанию сменили на 1, TRM Cortex-M3 r2p1, разд. 1.6.3), и обработчик может получить SP, кратный лишь 4. Поэтому на STM32F1 в начале программы, до разрешения прерываний, выполняют SCB->CCR |= SCB_CCR_STKALIGN_Msk; — подробнее в статье об архитектуре Cortex-M3. Атрибут GCC interrupt для ARMv7-M означает как раз это: функцию могут вызвать со стеком, выровненным только на 4 байта. Если у Cortex-M4F активен контекст FPU, кадр больше: добавляются S0–S15 и FPSCR, и вместо 0x20 байт ядро резервирует 0x68 (ARMv7-M ARM, разд. B1.5.6).
Две оптимизации ускоряют серию прерываний. Хвостовая цепочка: если к концу обработчика ждёт другое прерывание, ядро не восстанавливает регистры, а сразу переходит к следующему обработчику, у Cortex-M3 за 6 тактов. Опоздавшее прерывание: если во время сохранения кадра пришло более приоритетное, ядро продолжает сохранение, но первым запускает обработчик нового. Подробно с диаграммами — в статье об архитектуре Cortex-M3.
Одна деталь, о которой часто забывают: канал становится ожидающим, даже если он запрещён в NVIC. Запрет лишь не даёт ядру его обслужить, и после NVIC_EnableIRQ() старый запрос сработает сразу. То же у AVR с флагами периферии. Перед разрешением прерывания сбрасывают его флаг в периферии и ожидание в NVIC (NVIC_ClearPendingIRQ()).
AVR и Cortex-M рядом
| AVR (ATmega328P) | Cortex-M3 и M4 | |
|---|---|---|
| Приоритеты | фиксированные: по адресу вектора | программируемые в NVIC; у STM32F1 и F4 — 16 уровней |
| Вложенность | нет, пока обработчик сам не выполнит SEI | да: более приоритетное прерывание вытесняет обработчик |
| Что сохраняет аппаратура | адрес возврата, 2 байта | 8 слов, 32 байта; 104 байта с контекстом FPU |
| Что сохраняет компилятор | используемые регистры, SREG, R0 и R1 (с GCC 8 при оптимизации — только если нужны) | R4–R11, если обработчик их использует, как в обычной функции |
| Запрет всех прерываний | cli(), флаг I | __disable_irq(), PRIMASK |
| Запрет ниже порога приоритета | нет | BASEPRI (нет у Cortex-M0 и M0+) |
| Неделимое чтение-изменение-запись | только с запретом прерываний; SBI и CBI для одного бита младших регистров ввода-вывода | LDREX и STREX (нет у Cortex-M0 и M0+) или запрет прерываний |
| Время входа | не меньше 4 тактов и 3 такта на переход | до 12 тактов у Cortex-M3 без тактов ожидания памяти |
volatile: что он гарантирует, а что нет
Компилятор оптимизирует программу так, будто её выполняет один поток. Если в цикле while (!flag) {} переменная flag не меняется, он вправе прочитать её один раз. Квалификатор volatile запрещает это: каждое чтение и каждая запись такой переменной должны остаться в коде. Поэтому volatile объявляют все переменные, которые обработчик меняет, а основная программа читает, и наоборот. Подробно — в статье о языке C для микроконтроллеров.
Две вещи volatile не делает. Первая — атомарность, о ней ниже. Вторая — порядок относительно обычных переменных. Документация GCC прямо предупреждает: обращения к не-volatile объектам не упорядочены относительно обращений к volatile, и volatile-переменную нельзя использовать как барьер. Без барьера компилятор может перенести запись в буфер после записи флага; исправленный вариант:
static uint8_t buf[8]; /* not volatile */
static volatile uint8_t buf_ready;
void publish(const uint8_t *src)
{
for (uint8_t i = 0; i < sizeof buf; i++)
buf[i] = src[i];
__asm__ __volatile__ ("" ::: "memory"); /* compiler barrier: finish buf before the flag */
buf_ready = 1;
}
Пустая ассемблерная вставка с clobber "memory" — барьер для компилятора: перед ней он записывает в память значения объектов, к которым вставка могла бы обратиться через память, а после неё не считает прочитанные раньше значения актуальными и читает их заново. Локальные переменные, адрес которых нигде не брался, могут остаться в регистрах. Процессор она не останавливает, это барьер только для оптимизатора. В avr-libc такой барьер уже встроен в cli() и sei(), в CMSIS — в __disable_irq(), __set_PRIMASK() и __DMB(). Альтернатива с тем же смыслом из встроенных функций GCC — __atomic_signal_fence(__ATOMIC_SEQ_CST): барьер между потоком и обработчиком сигнала в том же потоке.
Атомарность: разорванное чтение и потерянное обновление
16-битная переменная на 8-битном AVR
AVR читает uint16_t двумя командами, по байту. Пусть ticks из примера выше равна 0x00FF, а основная программа читает её без защиты:
- Программа читает младший байт: 0xFF.
- Срабатывает прерывание таймера,
ticks++даёт 0x0100. - Программа читает старший байт: 0x01.
Результат — 0x01FF = 511, хотя переменная была равна 255, а стала 256. Ошибка не случайная: она возникает всякий раз, когда прерывание попадает между двумя чтениями в момент переноса из младшего байта в старший. Читай компилятор байты в другом порядке, ошибка была бы другой (0x0000), но тоже была бы. С 32-битной переменной окно ещё шире: четыре команды.
Лечится это чтением копии при запрещённых прерываниях — ATOMIC_BLOCK из <util/atomic.h>:
ATOMIC_BLOCK(ATOMIC_RESTORESTATE)сохраняет SREG, выполняет CLI и на выходе восстанавливает SREG — прежнее состояние флага I. Если блок вызвали при уже запрещённых прерываниях, они такими и останутся.ATOMIC_BLOCK(ATOMIC_FORCEON)на выходе всегда разрешает прерывания. Он на пару команд короче, но годится, только если точно известно, что до блока прерывания были разрешены.- Блок устроен как цикл
forс переменной, у которой есть атрибутcleanup, поэтому состояние восстанавливается при любом выходе, в том числе поreturn. Затоbreakиcontinueвнутри блока относятся к этому скрытому циклу, а не к внешнему. И нужен стандарт не ниже C99.
У AVR есть и аппаратный случай. 16-битные регистры таймера 1 обмениваются старшим байтом через общий для таймера 8-битный регистр TEMP: при чтении младшего байта TCNT1 или ICR1 старший защёлкивается в TEMP, а при записи любого 16-битного регистра (TCNT1, OCR1A, OCR1B, ICR1) старший байт сначала ждёт в TEMP. Чтение OCR1A и OCR1B регистр TEMP не использует. Если обработчик тоже обращается к таймеру так, что задействует TEMP, он портит его, и 16-битное обращение в основной программе даёт мусор. Документация требует в этом случае запрещать прерывания в основной программе на время 16-битного обращения (DS40002061B, разд. 16.3).
Чтение-изменение-запись
Операция x |= 0x02 над переменной в ОЗУ — это три команды: прочитать (LDS), изменить (ORI), записать (STS). Если обработчик между чтением и записью изменит другой бит той же переменной, основная программа запишет обратно старое значение, и изменение обработчика пропадёт. То же с counter++, когда счётчик увеличивают и основная программа, и обработчик, и с регистрами периферии: PORTB |= mask в основной программе и PORTB &= ~other в обработчике.
Исключения есть, но узкие. У AVR установка или сброс одного бита в регистре ввода-вывода с адресом 0x00–0x1F компилируется в одну команду SBI или CBI — пример в документации avr-libc показывает, что PORTB |= _BV(0) превращается в SBI. Маска из нескольких бит или регистр выше этой области — уже три команды. У STM32 для выводов есть регистр BSRR, который устанавливает и сбрасывает биты одной записью без чтения, — подробнее в статьях о битовых операциях и о GPIO STM32. Во всех остальных случаях операцию защищают критической секцией или, на Cortex-M, командами LDREX и STREX.
Cortex-M: что атомарно само и LDREX/STREX
В архитектуре ARMv7-M однократно-атомарны обращения к байту, к полуслову по чётному адресу и к слову по адресу, кратному 4 (ARMv7-M ARM, разд. A3.5.3). Значит, выровненную uint32_t основная программа прочитает целиком без запрета прерываний. А вот uint64_t — уже два слова. Одиночную команду LDRD прерывание не разрывает: ядро прерывает её и после возврата выполняет заново. Но компилятор вправе прочитать такую переменную двумя LDR или командой LDM, и между словами может случиться прерывание, поэтому 64-битные переменные защищают так же, как 16-битные на AVR. Операции чтение-изменение-запись не атомарны никогда.
Для них у Cortex-M3 и M4 есть пара команд эксклюзивного доступа. LDREX читает слово и помечает адрес в мониторе эксклюзивного доступа, STREX записывает новое значение, только если метка ещё на месте, и возвращает 0 при успехе и 1 при неудаче. Метку снимает, в частности, любое исключение: если между LDREX и STREX сработал обработчик, STREX не запишет ничего, и операцию повторяют. В CMSIS это функции __LDREXW() и __STREXW():
#include "stm32f1xx.h" /* device header, pulls in CMSIS core_cm3.h */
static volatile uint32_t pending_events; /* bits set by several ISRs and by main */
void events_set(uint32_t mask) /* safe from main and from any ISR */
{
uint32_t value;
do {
value = __LDREXW(&pending_events) | mask;
} while (__STREXW(value, &pending_events) != 0U); /* 1: interrupted, try again */
}
uint32_t events_take(void) /* read and clear all bits at once */
{
uint32_t value;
do {
value = __LDREXW(&pending_events);
} while (__STREXW(0U, &pending_events) != 0U);
return value;
}
Цикл без запрета прерываний ничего не задерживает: если его прервали, он просто повторится. Тот же результат дают встроенные функции GCC __atomic_fetch_or() и __atomic_exchange_n(): компилятор подставляет команды, если у ядра они есть, а если нет — оставляет вызов внешней функции, которую должна предоставить библиотека. У Cortex-M0 и M0+ (архитектура ARMv6-M) команд LDREX и STREX нет, как и регистра BASEPRI: там остаётся только запрет прерываний через PRIMASK.
Барьеры памяти: когда нужен __DMB
Команда DMB гарантирует, что обращения к памяти до неё завершатся раньше обращений после неё. В Application Note 321 Arm разбирает, когда она действительно нужна. У Cortex-M3 и M4 все обращения к памяти завершаются в порядке программы, и в одноядерной системе DMB избыточен — достаточно, чтобы порядок не нарушил компилятор. Arm всё же рекомендует ставить барьеры, где их требует архитектура: такой код будет работать и на других ядрах ARMv7-M. Архитектурно DMB требуется при обмене с другими ведущими шины:
- перед запуском DMA по данным, которые только что записал процессор;
- при обмене данными с другим ядром через общую память.
На Cortex-M3 и M4 он и в этих случаях избыточен, но нужен для переносимости. Обмен между обработчиком и основной программой на одном ядре барьера не требует и на Cortex-M7: ядро видит свои обращения в порядке программы. А вот на Cortex-M7 с кэшем данных для DMA одного DMB мало: если процессор записал данные в кэш, DMA прочитает из памяти старые (ARMv7-M ARM, разд. A3.8.3), поэтому строки кэша очищают или аннулируют либо держат буфер в некэшируемой области.
После __disable_irq() барьер не нужен: команда CPSID сама синхронизирована с потоком команд. А __DMB() в CMSIS объявлена с clobber "memory" и потому служит и барьером для компилятора.
Критические секции
Критическая секция — участок, на время которого запрещены прерывания, способные тронуть те же данные. Два правила: восстанавливать прежнее состояние, а не включать прерывания безусловно, и делать участок как можно короче.
AVR
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) — уже готовая критическая секция. Без макроса то же пишут так, как в примере из документации на ATmega328P: сохранить SREG, выполнить cli(), сделать работу и записать SREG обратно. Команда CLI действует сразу: даже прерывание, пришедшее одновременно с ней, будет обслужено только после восстановления флага.
Свойство «после SEI выполняется ещё одна команда» решает классическую гонку со сном. Если проверить флаг и уснуть двумя отдельными действиями, прерывание, пришедшее между ними, не разбудит программу: оно уже отработало. Документация avr-libc предлагает такой порядок:
#include <avr/interrupt.h>
#include <avr/sleep.h>
extern volatile uint8_t work_pending;
void idle(void)
{
cli();
if (!work_pending) {
sleep_enable();
sei(); /* the next instruction runs before any interrupt */
sleep_cpu(); /* so SLEEP is executed, and the interrupt then wakes us */
sleep_disable();
}
sei();
}
Режимы сна разобраны в статье о спящих режимах AVR.
Cortex-M: PRIMASK и BASEPRI
__disable_irq() выполняет команду CPSID i и устанавливает PRIMASK — задерживаются все исключения с настраиваемым приоритетом. Прежнее значение читают __get_PRIMASK() и возвращают __set_PRIMASK(). Пример — чтение 64-битной метки времени, которую обновляет таймер:
static volatile uint64_t uptime_us; /* updated in a timer ISR */
uint64_t uptime_get(void)
{
uint32_t primask = __get_PRIMASK(); /* interrupts may already be disabled */
__disable_irq();
uint64_t copy = uptime_us; /* two word loads must not be split */
__set_PRIMASK(primask); /* restore, do not enable unconditionally */
return copy;
}
BASEPRI задерживает только исключения с приоритетом, численно большим или равным порогу, а более срочные продолжают работать. Значение пишется в формате байта приоритета: у STM32F1 и F4 реализованы 4 старших бита, поэтому порог 6 — это 6 << 4 = 0x60. __set_BASEPRI_MAX() меняет порог, только если новый строже текущего, и удобна во вложенных участках:
void shared_config_update(void)
{
uint32_t basepri = __get_BASEPRI();
__set_BASEPRI_MAX(6U << (8U - __NVIC_PRIO_BITS)); /* hold priorities 6..15, let 0..5 run */
/* ... change data shared with handlers of priority 6..15 ... */
__set_BASEPRI(basepri);
}
Обработчики с приоритетом 0–5 при этом не должны трогать защищаемые данные — иначе защита не работает. Если ядро спит в ожидании прерывания, полезно ещё одно свойство: при PRIMASK = 1 разрешённое прерывание всё равно будит ядро из WFI, но обработчик запускается только после снятия PRIMASK. Так та же гонка со сном решается на Cortex-M: __disable_irq(); if (!work_pending) __WFI(); __enable_irq();.
Сколько длится критическая секция
Пока прерывания запрещены, каждый запрос ждёт. Поэтому к худшей задержке любого прерывания добавляется самая длинная критическая секция программы (и самый длинный обработчик с тем же или более высоким приоритетом). Длительность лучше измерить, а не угадывать: компилятор вправе переносить вычисления, не связанные с памятью, через cli() и sei(). Документация avr-libc приводит пример, где деление 16-битных чисел после оптимизации оказалось внутри участка с запрещёнными прерываниями, хотя в исходном тексте стояло до него.
Два способа измерить:
- Вывод и осциллограф. На AVR запись единицы в регистр PINx переключает вывод:
PINB = _BV(PB0);до и после участка, и ширина импульса на осциллографе — его длительность. - Счётчик тактов CYCCNT на Cortex-M3 и M4. Это необязательный 32-битный счётчик в блоке DWT, который увеличивается на каждом такте процессора:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* enable DWT (DCB->DEMCR in CMSIS 6) */
DWT->CYCCNT = 0U;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
uint32_t t0 = DWT->CYCCNT;
uint64_t t = uptime_get(); /* the code being measured */
uint32_t cycles = DWT->CYCCNT - t0; /* unsigned subtraction survives wraparound */
Пусть получилось 144 такта на 72 МГц: 144 / 72 = 2 мкс. Для сравнения, символ UART на скорости 115 200 бит/с в формате 8N1 (10 бит) приходит каждые 10 / 115 200 ≈ 86,8 мкс, так что двухмикросекундная секция приёму не мешает. А участок, который копирует 2-килобайтный буфер при запрещённых прерываниях, уже может стоить потерянного символа на высокой скорости — такие операции переносят в кольцевой буфер или DMA.
Обмен данными между прерыванием и программой
Флаг события
Самый простой обмен — флаг: обработчик ставит, основной цикл проверяет и сбрасывает. Если вместе с флагом передаётся значение, его забирают вместе с флагом внутри одной критической секции, чтобы значение и флаг относились к одному событию:
static volatile uint8_t adc_done;
static volatile uint16_t adc_value;
ISR(ADC_vect)
{
adc_value = ADC; /* 16-bit result register */
adc_done = 1;
}
void adc_poll(void)
{
if (adc_done) {
uint16_t v;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
v = adc_value;
adc_done = 0;
}
process_sample(v);
}
}
Флаг сообщает «что-то случилось», но не сколько раз: два события подряд до проверки сольются в одно. Если важно количество, вместо флага ведут счётчик и в критической секции забирают его значение с обнулением, или передают события через очередь.
Кольцевой буфер: один писатель, один читатель
Кольцевой буфер — массив и два индекса: голова (head), куда пишет производитель, и хвост (tail), откуда читает потребитель. Если производитель — обработчик прерывания, а потребитель — основной цикл, буфер работает без запрета прерываний при трёх условиях:
- каждый индекс записывает только одна сторона:
head— обработчик,tail— основной цикл; - индекс читается и пишется одной неделимой операцией — на AVR это значит, что он однобайтный;
- производитель записывает данные в ячейку раньше, чем публикует новый индекс; потребитель читает ячейку только после того, как прочитал новый индекс, и освобождает её только после чтения.
Удобный вариант — индексы-счётчики, которые просто растут и переполняются через 255 в 0, а номер ячейки получается маской. Размер буфера N — степень двойки не больше 128: тогда переполнение счётчика не ломает нумерацию ячеек, а разность head − tail по модулю 256 сразу даёт число байт в буфере, от 0 до N. Полный буфер (N) отличается от пустого (0), и используются все N ячеек.
#include <stdbool.h>
#include <stdint.h>
#define RB_SIZE 16u /* power of two, at most 128 */
#define COMPILER_BARRIER() __asm__ __volatile__ ("" ::: "memory")
static uint8_t rb_data[RB_SIZE];
static volatile uint8_t rb_head; /* written only by the producer */
static volatile uint8_t rb_tail; /* written only by the consumer */
/* Producer: called from the receive ISR. */
static bool rb_put(uint8_t byte)
{
uint8_t head = rb_head;
if ((uint8_t)(head - rb_tail) == RB_SIZE)
return false; /* full: the byte is dropped */
COMPILER_BARRIER(); /* see the freed slot before writing it */
rb_data[head & (RB_SIZE - 1u)] = byte;
COMPILER_BARRIER(); /* store the byte before publishing it */
rb_head = (uint8_t)(head + 1u);
return true;
}
/* Consumer: called from the main loop. */
static int rb_get(void)
{
uint8_t tail = rb_tail;
if (rb_head == tail)
return -1; /* empty */
COMPILER_BARRIER(); /* see the new head before reading the byte */
uint8_t byte = rb_data[tail & (RB_SIZE - 1u)];
COMPILER_BARRIER(); /* read the byte before freeing the slot */
rb_tail = (uint8_t)(tail + 1u);
return byte;
}
Приведение (uint8_t)(head - rb_tail) нужно потому, что операнды продвигаются до int и разность может быть отрицательной; преобразование в беззнаковый тип по стандарту берёт её по модулю 256 (C11, 6.3.1.3). Барьеры нужны потому, что массив не volatile, а обращения к нему компилятор вправе переставлять относительно чтения и записи индексов. Без барьера после проверки на пустоту он может прочитать ячейку заранее, ещё до чтения rb_head: если между этими чтениями сработает обработчик, rb_get() увидит новый индекс и вернёт старый байт. Когда производитель — обработчик, барьеры в rb_put() для этой пары формально не обязательны: основная программа не прерывает обработчик. Но они нужны в обратной схеме, например в буфере передачи, где пишет основная программа, а читает обработчик, поэтому функции написаны симметрично. На одном ядре этого достаточно; если другая сторона — другое ядро, вместо барьера компилятора ставят __DMB().
Пример с числами. N = 16, маска 15. Пусть head = 250, tail = 243.
| Шаг | head | tail | head − tail по модулю 256 | Ячейка следующей записи | Ячейка следующего чтения |
|---|---|---|---|---|---|
| исходно | 250 | 243 | 7 | 250 & 15 = 10 | 243 & 15 = 3 |
| обработчик записал 9 байт | 3 | 243 | (3 − 243) mod 256 = 16, буфер полон | — | 3 |
| основной цикл прочитал 5 байт | 3 | 248 | (3 − 248) mod 256 = 11 | 3 | 248 & 15 = 8 |
В буфере 7 байт в ячейках 3–9. Обработчик дописывает 9 байт в ячейки 10–15 и 0–2, счётчик head проходит 255 и становится 259 − 256 = 3. Разность 16 означает «полон», и следующий байт rb_put() отклонит, хотя номера ячеек головы и хвоста совпали — как у пустого буфера; различает их только разность счётчиков. Основной цикл забирает 5 байт из ячеек 3–7, и в буфере остаётся 11.
Другой распространённый вариант — индексы, которые сразу заворачиваются маской, и одна всегда пустая ячейка, чтобы отличить полный буфер от пустого; так сделан приём UART по прерыванию в статье об интерфейсе UART. Для сообщений длиннее байта схема та же: сначала целиком скопировать структуру в ячейку, затем опубликовать индекс. Если писателей или читателей несколько — например, в очередь пишут два обработчика с разными приоритетами, — индексы уже нельзя менять без защиты, и rb_put() выполняют в критической секции.
Типичные ошибки
- Нет
volatileу переменной, общей с обработчиком: цикл ожидания зависает при включённой оптимизации и «чудесно» работает с-O0. - Многобайтная переменная без защиты на AVR: редкие и невоспроизводимые скачки значений вроде 511 вместо 255. На Cortex-M то же с
uint64_tи с несколькими связанными переменными. - Чтение-изменение-запись одной переменной или регистра в основной программе и в обработчике: изредка пропадают биты и события.
- Безусловное
sei()или__enable_irq()в конце критической секции, вызванной из другой критической секции или из обработчика: прерывания включаются раньше времени. Используйте сохранение и восстановление состояния. - Долгий обработчик: задержки, ожидание флагов, вычисления с плавающей точкой без FPU. Всё это время ждут прерывания того же и более низкого приоритета, а на AVR — все. Обработчик забирает данные и ставит флаг, остальное делает основной цикл. На STM32 отдельная ловушка —
HAL_Delayв обработчике, она разобрана в статье о прерываниях STM32. - Нереентерабельные функции в обработчике:
printf,malloc,strtokи подобные пользуются общим состоянием. Если обработчик прервал основную программу внутри такой функции и вызвал её снова, состояние испортится. Стандарт C для обработчиков сигналов разрешает вызывать из библиотеки толькоabort,_Exit,quick_exitиsignal(C11, 7.14.1.1). - Несброшенный флаг источника: обработчик вызывается бесконечно — у AVR так бывает с приёмом USART, если не прочитать UDR0. Если же флаг у STM32 сбрасывается последней командой обработчика, запись может не дойти до периферии к выходу, и обработчик вызовется лишний раз.
- Старый запрос при разрешении прерывания: флаг взвёлся, пока прерывание было запрещено, и обработчик срабатывает сразу после разрешения. Сбросьте флаг периферии и ожидание в NVIC перед
NVIC_EnableIRQ(). - Ошибка в имени обработчика: на AVR — перезапуск программы через
__vector_default(avr-gcc предупреждает об имени без__vector), на STM32 — бесконечный цикл обработчика по умолчанию из стартового файла. - Переполнение стека вложенными прерываниями: каждый уровень вытеснения добавляет кадр (32 байта и больше на Cortex-M плюс локальные переменные обработчика). Стек считают по худшему случаю — как это сделать с помощью map-файла и
-fstack-usage, рассказано в статье о скриптах компоновщика. breakвнутриATOMIC_BLOCK: он выходит из скрытого цикла макроса, а не из внешнего цикла программы.
Частые вопросы
Почему переменная, которую меняет прерывание, не обновляется в основном цикле?
Скорее всего, она объявлена без volatile. Тогда компилятор вправе прочитать её в регистр один раз и в цикле while (!flag) больше к памяти не обращаться: о прерывании он ничего не знает. Квалификатор volatile заставляет выполнять каждое чтение и каждую запись. Атомарности он не даёт: 16-битную переменную на AVR всё равно читают при запрещённых прерываниях, например в ATOMIC_BLOCK.
Нужно ли запрещать прерывания, чтобы прочитать uint32_t на STM32?
Для одного чтения выровненного 32-битного слова — нет: в ARMv7-M такое обращение однократно-атомарно, и половину старого и половину нового значения получить нельзя. Защита нужна для операций чтение-изменение-запись вроде counter++, для 64-битных переменных, которые читаются двумя словами, и когда несколько связанных переменных должны быть согласованы между собой. На 8-битном AVR защищать приходится уже любую переменную длиннее байта.
Чем ATOMIC_RESTORESTATE отличается от ATOMIC_FORCEON в avr-libc?
Оба варианта ATOMIC_BLOCK запрещают прерывания на входе в блок. ATOMIC_RESTORESTATE сохраняет регистр SREG и на выходе восстанавливает его, то есть прежнее состояние флага I: если прерывания были запрещены, они такими и останутся. ATOMIC_FORCEON на выходе всегда разрешает прерывания; он экономит пару команд и регистр, но годится, только если точно известно, что до блока прерывания были разрешены.
Можно ли вызывать printf из обработчика прерывания?
Лучше не надо. printf работает долго, на всё это время блокирует прерывания того же и более низкого приоритета, а внутри пользуется общим состоянием: буферами вывода, иногда malloc. Если основная программа в этот момент сама была внутри printf, состояние окажется испорченным. Стандарт C для обработчиков сигналов — ближайшего аналога — разрешает вызывать из библиотеки только abort, _Exit, quick_exit и signal. Обработчик кладёт данные в буфер, а печатает основной цикл.
Что лучше для критической секции на Cortex-M: __disable_irq() или BASEPRI?
__disable_irq() устанавливает PRIMASK и задерживает все прерывания с настраиваемым приоритетом, включая самые срочные. BASEPRI задерживает только прерывания с приоритетом не выше порога, а более важные продолжают обслуживаться, поэтому для данных, общих с низкоприоритетными обработчиками, он предпочтительнее. Но обработчики выше порога не должны трогать защищаемые данные. На Cortex-M0 и M0+ регистра BASEPRI нет, остаётся только PRIMASK.
Нужен ли __DMB() при работе с общими данными на Cortex-M?
Между обработчиком и основной программой на одном ядре — нет, ни на M3 и M4, ни на M7: ядро видит свои обращения в порядке программы, достаточно барьера для компилятора. Архитектура требует DMB при обмене с другими ведущими шины — перед запуском DMA по только что записанным данным и при обмене с другим ядром. На Cortex-M3 и M4 он и там избыточен (AN321), но нужен для переносимости. На Cortex-M7 с кэшем данных для DMA одного DMB мало: нужно ещё очистить или аннулировать строки кэша либо держать буфер в некэшируемой области.
Источники
- Microchip. ATmega48A/PA/88A/PA/168A/PA/328/P Data Sheet, DS40002061B, 2020 — 7.5 — стек растёт вниз, начальное значение SP — последний адрес SRAM; 7.7 — при входе в прерывание флаг I сбрасывается, RETI его устанавливает; флаги, взведённые при запрещённых прерываниях, запоминаются; после выхода из прерывания выполняется ещё одна команда основной программы, после SEI — следующая команда; SREG аппаратура не сохраняет; приоритет — по адресу вектора; реакция не меньше 4 тактов и 3 такта на переход; 16.3 — 16-битные регистры таймера через общий регистр TEMP, при обращении к ним из прерывания основная программа должна запрещать прерывания; 20.7.3 — прерывание приёма USART повторяется, пока не прочитан UDRn; 14.2.2 — запись 1 в PINxn переключает вывод
- AVR-LibC 2.3.2: <avr/interrupt.h>, <util/atomic.h>, <avr/sleep.h>, Interrupts (interrupts.dox), Problems with Reordering Code (reordering-code.dox) — ISR() — функция с атрибутом signal, ISR_NOBLOCK вставляет SEI в начало; sei() и cli() — одна команда с барьером памяти для компилятора; BADISR_vect, без обработчика прерывание ведёт к сбросу; ATOMIC_BLOCK, ATOMIC_RESTORESTATE и ATOMIC_FORCEON, реализация через цикл for и атрибут cleanup, требуется C99; пример с 16-битным счётчиком; проверка условия и сон без гонки; компилятор может перенести вычисления внутрь участка с запрещёнными прерываниями
- GCC 16.2 Manual: When is a Volatile Object Accessed?; Extended Asm (clobber «memory»); __atomic Builtins; AVR Attributes; ARM Attributes. GCC Manual: AVR Options. GCC Wiki: avr-gcc ABI — обращения к не-volatile объектам не упорядочены относительно volatile, volatile нельзя использовать как барьер — нужен asm volatile с clobber «memory»; встроенные функции __atomic и порядок памяти, вызов внешней функции, если нет подходящих команд; __atomic_signal_fence; атрибуты AVR signal, interrupt, noblock, no_gccisr; -mgas-isr-prologues (с GCC 8 при оптимизации SREG, R0 и R1 сохраняются, только если нужны) и -Wmisspelled-isr (по умолчанию); атрибут interrupt на ARMv7-M означает лишь допустимость стека, выровненного на 4; avr-gcc: регистры R18–R27, R30, R31 портятся вызовом, обработчик сохраняет все используемые регистры, R0 и R1
- Arm. Cortex-M3 Devices Generic User Guide, DUI 0552A, 2010; Cortex-M4 Devices Generic User Guide, DUI 0553B; Cortex-M0 Devices Generic User Guide, DUI 0497A — PRIMASK, FAULTMASK, BASEPRI; приоритет 0–255, меньшее число важнее, равный приоритет не вытесняет; вход в исключение — 8 слов в стеке, хвостовая цепочка, опоздавшее прерывание; LDREX/STREX, метка монитора снимается исключением; DMB, DSB, ISB; функции CMSIS; прерывание становится ожидающим и при запрещённом канале; пробуждение по WFI при PRIMASK = 1; кадр с S0–S15 и FPSCR у M4; у Cortex-M0 нет LDREX/STREX и BASEPRI
- Arm. ARMv7-M Architecture Reference Manual, DDI 0403E.e, 2021; Cortex-M3 Technical Reference Manual r2p1 (100165) — A3.5.3 — однократно-атомарны обращения к байту, к выровненному полуслову и слову, LDRD и LDM — последовательность слов, прерванная команда выполняется заново; A3.4.4 — монитор эксклюзивного доступа сбрасывается при входе в исключение и выходе из него; B1.5.6 — кадр 0x20 байт, с контекстом FPU 0x68; обработчик может быть обычной функцией AAPCS; A3.8.3 — при кэше данных DMA может прочитать из памяти устаревшие данные; C1.8.3 — счётчик тактов CYCCNT; TRM 1.6.3 — в r2p0 STKALIGN после сброса стал равен 1 (у r1p1 — 0); TRM 3.9.2 — до 12 тактов на вход, 12 на возврат, 6 на хвостовую цепочку, LDM и STM продолжаются с места прерывания
- Arm. Application Note 321: ARM Cortex-M Programming Guide to Memory Barrier Instructions, DAI 0321A, 2012 — у Cortex-M3 и M4 обращения к памяти завершаются в порядке программы, и DMB в однопроцессорной системе избыточен, но рекомендуется для переносимости; архитектурно DMB нужен перед запуском DMA и при обмене с другим процессором; после CPSID барьер не нужен; у Cortex-M0 и M0+ нет команд эксклюзивного доступа
- Arm. CMSIS 6.4.0: CMSIS/Core/Include/cmsis_gcc.h, m-profile/cmsis_gcc_m.h, core_cm3.h — __disable_irq() — cpsid i, __DMB() — dmb, оба с clobber «memory»; __get_PRIMASK, __set_PRIMASK, __set_BASEPRI, __set_BASEPRI_MAX; __LDREXW и __STREXW только при __ARM_FEATURE_LDREX; DWT->CYCCNT, DCB->DEMCR (CoreDebug — устаревший синоним)
- ISO/IEC 9899:201x (C11), Committee Draft N1570, April 2011 — 6.3.1.3 — преобразование в беззнаковый тип по модулю; 7.14.1.1p5 — обработчик сигнала может вызывать из стандартной библиотеки только abort, _Exit, quick_exit и signal
Нашли ошибку?
Смотрите также
- Программирование МКC для МК, битовые операции, фиксированная точка, регистры
- C для микроконтроллеровТипы, stdint, volatile, атомарность, регистры, константы во Flash
- Битовые операции в CМаски, сдвиги, установка и проверка битов, ловушки стандарта C
- Скрипт компоновщика GNU ldMEMORY, SECTIONS, VMA и LMA, KEEP, стек и куча, map-файл, переполнение
- Прерывания STM32Векторы и IRQn, приоритеты и HAL, EXTI и общие векторы, флаги, события, примеры
- Cortex-M3Регистры, карта памяти, bit-banding, NVIC, SysTick, MPU, старт, пример
- Приоритеты прерываний NVICPRIGROUP, вытеснение и подприоритет, NVIC_IPR, AIRCR, bit-band
- Архитектура AVRЯдро, конвейер, память, порты, прерывания, сброс, такты и сон
- UARTКадр 8N1, скорость и допуск, TX и RX, уровни, USB–UART, AVR и STM32