124. volatile, const и атомарность: прерывания и C11
Прошивка почти всегда живёт в двух потоках сразу: основная программа
и прерывания. Данные между ними — флаги, счётчики, буферы — самое хрупкое
место: код, правильный построчно, теряет события и зависает. Три слова
Си решают три разные задачи, и их постоянно путают: volatile — «читай
память каждый раз», атомарность — «делай за один неделимый шаг», const —
«не меняй, а заодно положи во флеш-память». Раздел показывает каждое
на опыте: на компьютере — с сигналом и потоками, на плате — в командах,
которые выдаёт armclang.
Опирается на прерывания (раздел 7) и неопределённое поведение (раздел 123).
Как это называется в документации
Оригинал Перевод volatile изменчивый: каждое обращение — к памяти atomic operation атомарная операция: неделимая read-modify-write чтение — изменение — запись race condition гонка critical section критическая секция exclusive access ( LDREX/STREX)эксклюзивный доступ lock-free без блокировок memory barrier ( DMB,DSB)барьер памяти memory order порядок памяти compare-and-swap сравнить и обменять sig_atomic_tтип, который обработчик сигнала пишет целиком
124.1 Флаг из прерывания: зачем volatile
Типичный цикл ожидания: прерывание ставит флаг, основная программа ждёт.
while (!flag_plain) { } /* флаг прочитан один раз: цикл без чтения */
Компилятор видит функцию целиком и не видит прерывания. Для него флаг
внутри цикла никто не меняет — значит, прочитать его можно один раз.
На компьютере это ровно так и вышло: флаг ставит обработчик сигнала
(аналог прерывания), но цикл, собранный с -O2, так и не заметил записи —
программу снял тайм-аут.
На плате armclang поступил ещё решительнее: превратил всю функцию ожидания
в одну команду BX lr. Цикл с неконстантным условием и без побочных
действий — без volatile, атомарных операций и ввода-вывода — по C11
можно считать завершающимся (вечного for (;;) { } это не касается),
и компилятор его выбросил — функция возвращается сразу,
не дождавшись. Ошибка тем опаснее, что прибор не висит, а просто
идёт дальше раньше времени.
С volatile каждый проход читает память заново:
while (!flag_volatile) { } /* чтение памяти на каждом проходе */
и на компьютере программа вышла сразу после сигнала, а на плате в цикле
видны команды LDR.
124.2 Что volatile обещает и чего нет
volatile обещает одно: каждое обращение в тексте — это обращение к памяти,
в том же порядке относительно других volatile. Поэтому им помечены все
регистры периферии в заголовках CMSIS (__IO), флаги прерываний
и переменные, которые меняет DMA.
Чего volatile не обещает:
- неделимости —
volatile-счётчик++по-прежнему три команды; - порядка относительно обычных переменных — компилятор вправе переставить невolatile-запись через volatile;
- порядка на уровне шины — это делают барьеры (124.6).
Опыт это показывает прямо: два потока по миллиону прибавлений к
volatile-счётчику потеряли часть прибавлений, а атомарный счётчик дал
ровно 2 000 000.
124.3 Гонка: чтение — изменение — запись
events++; /* LDR, ADDS, STR — между ними может войти прерывание */
armclang делает из этого три команды: LDR (прочитать), ADDS (прибавить),
STR (записать). Если прерывание придёт после LDR и само прибавит к тому
же счётчику, основная программа затем запишет старое значение плюс один —
и событие прерывания исчезнет. Ошибка редкая, зависит от момента прихода
прерывания и не воспроизводится на отладчике — классическая «иногда».
Правило: всё, что одновременно меняют основная программа и прерывание, меняется неделимо. Способов три.
124.4 Критическая секция: запрет прерываний
Самый простой и универсальный: на время чтения-изменения-записи запретить прерывания и восстановить прежнее состояние.
__asm volatile("mrs %0, primask" : "=r"(primask));
__asm volatile("cpsid i" ::: "memory");
events++;
__asm volatile("msr primask, %0" ::"r"(primask) : "memory");
Важно восстанавливать прежнее значение PRIMASK, а не просто
разрешать прерывания: функцию могут вызвать уже внутри другой критической
секции. "memory" в ассемблерной вставке — барьер для компилятора: он не
перенесёт обращения к памяти через запрет. Секции должны быть короткими:
пока они длятся, задерживается любое прерывание (раздел 7 — задержка входа).
124.5 Атомарные операции C11
С C11 есть stdatomic.h, и компилятор сам выбирает, как сделать операцию
неделимой:
atomic_fetch_add_explicit(&events_atomic, 1u, memory_order_relaxed);
На Cortex-M3 и M4 armclang собрал это в петлю LDREX/STREX: эксклюзивное
чтение, прибавление, попытка записи; если между ними кто-то вмешался, STREX
не пишет и петля повторяется. Прерывания при этом не запрещаются.
На Cortex-M0 эксклюзивных команд нет, и та же строка превращается в вызов
библиотеки __sync_fetch_and_add_4 — а компилятор предупреждает, что
операция не «без блокировок». На M0 атомарность — это та же критическая
секция, только спрятанная в библиотеке, и её реализацию должна дать среда.
Полезные операции сверх прибавления — обмен (atomic_exchange) для простой
блокировки, atomic_fetch_or для флагов нескольких прерываний в одном слове
и «сравнить и обменять» (atomic_compare_exchange_strong) для очередей
без блокировок. Все они возвращают прежнее значение — это и позволяет
понять, кто успел первым.
124.6 Барьеры: порядок для DMA и второго ядра
Бывает, что важен порядок записей, которые видит не процессор, а шина: данные для DMA должны лечь в память до того, как DMA запустят.
dma_buf[0] = 0xA5;
atomic_thread_fence(memory_order_seq_cst); /* превращается в DMB */
dma_start = 1;
atomic_thread_fence на всех трёх ядрах стал командой DMB: все обращения
к памяти до неё завершены до обращений после. В CMSIS то же самое — __DMB();
__DSB() дополнительно ждёт завершения, __ISB() сбрасывает конвейер после
смены настроек ядра (раздел 7). У Cortex-M3/M4 с простой шиной порядок
записей обычно сохраняется и без барьера. На M7 (F7, H7) с включённым
кэшем данных одного барьера мало: записанные байты могут остаться в кэше,
а DMA читает ОЗУ напрямую и отправит старые. Перед запуском передачи буфер
выталкивают из кэша — SCB_CleanDCache_by_Addr, после приёма кэш по
буферу сбрасывают — SCB_InvalidateDCache_by_Addr; сам буфер выравнивают
по 32 байта (строка кэша) и делают кратным 32. Другой путь — положить
буферы DMA в область, которую MPU помечает некэшируемой.
124.7 const: не только «нельзя менять»
const на глобальной таблице меняет, где она лежит. В объектном файле:
const uint8_t table_const[256]— в секции только для чтения, то есть во флеш-памяти, и ОЗУ не занимает;uint8_t table_ram[256]безconst— в.data: 256 байт во флеш-памяти хранят начальные значения, и ещё 256 байт ОЗУ стартовый код заполняет при запуске (раздел 11).
На микроконтроллере с 20 КБ ОЗУ таблица синусов или шрифт без const
съедают память впустую. Правило: всё, что не меняется, — const.
Для регистров встречается сочетание volatile const — «только читаем,
но значение меняется само»: так описаны регистры только для чтения
(__I в CMSIS).
124.8 Где что писать
| Задача | Средство |
|---|---|
| флаг «событие было» от прерывания | volatile, запись одного слова |
| счётчик, который меняют обе стороны | атомарная операция или критическая секция |
| несколько полей, изменяемых вместе | критическая секция |
| порядок записи перед запуском DMA | барьер DMB; на M7 с кэшем — ещё очистка кэша |
| регистр периферии | volatile (уже в заголовке) |
| неизменная таблица | const — во флеш-память |
Одиночное чтение или запись выровненного 32-битного слова на Cortex-M
неделимы сами по себе — поэтому флаг из одного слова достаточно пометить
volatile. Всё, что длиннее или состоит из чтения и записи, — нет.
124.9 В RTOS то же самое
Во FreeRTOS между задачами действуют те же правила, только прерыванием
становится планировщик. Готовые средства — очереди, семафоры и
taskENTER_CRITICAL (разделы 26–30) — внутри устроены так же:
критическая секция или эксклюзивные команды. Свои «очереди на флагах»
без атомарности — источник тех же гонок.
taskENTER_CRITICAL на M3/M4 не ставит PRIMASK, а поднимает BASEPRI:
прерывания с приоритетом выше configMAX_SYSCALL_INTERRUPT_PRIORITY идут и во
время секции, и функций FreeRTOS в них вызывать нельзя. Тот же приём годится
и для своих секций вместо полного запрета прерываний (124.4), когда срочное
прерывание нельзя задерживать.
124.10 Грабли
Цикл ожидания флага завершается сразу или висит. Флаг без volatile:
компилятор прочитал его один раз или выбросил цикл. volatile.
Счётчик событий иногда отстаёт. ++ из основной программы и из
прерывания: три команды, между ними прерывание. Атомарная операция или
критическая секция.
После критической секции прерывания включились там, где не должны.
Секция разрешила их безусловно вместо восстановления PRIMASK.
На M0 не собирается атомарная операция — нет __sync_fetch_and_add_4.
У M0 нет эксклюзивных команд; нужна реализация через запрет прерываний.
DMA отправил старые данные. Запуск переставлен раньше записи буфера —
барьер DMB между ними. На M7 с кэшем данных буфер ещё и не вытолкнут
из кэша: SCB_CleanDCache_by_Addr перед запуском.
ОЗУ кончилось на таблицах. Таблицы без const копируются в ОЗУ.
124.11 Итоги
volatile— «читай память каждый раз»: без него флаг из обработчика не дождался, а armclang превратил цикл ожидания вBX lr.volatileне делает операцию неделимой: два потока потеряли часть прибавлений, атомарный счётчик дал ровно 2 000 000.events++—LDR,ADDS,STR; неделимо — критическая секция с восстановлениемPRIMASKилиatomic_fetch_add(LDREX/STREXна M3/M4,__sync_fetch_and_add_4на M0).- Порядок для DMA — барьер:
atomic_thread_fenceсталDMB; на M7 с кэшем данных буфер ещё выталкивают из кэша перед запуском DMA. constуводит таблицу во флеш-память: без него 256 байт ещё и в ОЗУ.
Полные исходники
vol124_arm.c — ожидание флага, общий счётчик программы и прерывания, барьер перед DMA и const-таблица — для сборки под Cortex-M.
/* Раздел 124: volatile, const и атомарность на Cortex-M — armclang для F103 и F407
* (и для M0 — сравнить атомарность). _verify/c124_check.py смотрит в объектный файл:
* во что превращается ожидание флага, прибавление к счётчику, барьер и таблица. */
#include <stdatomic.h>
#include <stdint.h>
/* ── ожидание флага, который ставит прерывание ─────────────────────────── */
int flag_plain;
volatile int flag_volatile;
void wait_plain(void)
{
while (!flag_plain) { } /* флаг прочитан один раз: цикл без чтения */
}
void wait_volatile(void)
{
while (!flag_volatile) { } /* чтение памяти на каждом проходе */
}
/* ── общий счётчик основной программы и прерывания ─────────────────────── */
volatile uint32_t events;
atomic_uint events_atomic;
void count_plain(void)
{
events++; /* LDR, ADDS, STR — между ними может войти прерывание */
}
void count_atomic(void)
{
atomic_fetch_add_explicit(&events_atomic, 1u, memory_order_relaxed);
}
/* Критическая секция: запрет прерываний на время чтения-изменения-записи. */
void count_masked(void)
{
uint32_t primask;
__asm volatile("mrs %0, primask" : "=r"(primask));
__asm volatile("cpsid i" ::: "memory");
events++;
__asm volatile("msr primask, %0" ::"r"(primask) : "memory");
}
/* ── барьер: данные для DMA записаны до запуска передачи ───────────────── */
uint8_t dma_buf[16];
volatile uint32_t dma_start;
void start_dma(void)
{
dma_buf[0] = 0xA5;
atomic_thread_fence(memory_order_seq_cst); /* превращается в DMB */
dma_start = 1;
}
/* ── const: таблица остаётся во флеш-памяти, без const — ещё и копия в ОЗУ ─ */
const uint8_t table_const[256] = { 1, 2, 3 };
uint8_t table_ram[256] = { 1, 2, 3 };
uint8_t lookup(unsigned i)
{
return (uint8_t)(table_const[i & 255u] + table_ram[i & 255u]);
}
Дальше: раздел 125 «Объявления и указатели на функции» — как читать
void (*const handlers[])(void) и строить таблицы обработчиков.