Поливалка в оранжерее считала обороты крыльчатки водосчётчика: геркон щёлкал на пине 2, attachInterrupt ловил каждый фронт, счётчик увеличивался — я видел это по вспышке светодиода из обработчика. А loop() раз в минуту печатал в монитор одно и то же: oboroty=0. За вечер я переписал скетч четырьмя способами: переносил переменную выше, менял тип, объявлял глобальной, дёргал её в setup() — а виновато было слово, которое я дважды пропустил, переписывая пример из туториала: volatile.
Механика подвоха такая. Arduino IDE компилирует скетчи с оптимизацией -Os — оптимизацией по размеру, и у неё есть полное право держать часто читаемую переменную не в памяти, а в регистре процессора. Компилятор не знает про прерывания: ваш обработчик для него — функция, которую никто не вызывает, значит, по его данным никто не ходит, значит, перечитывать память в loop() незачем. Прерывание меняет переменную за спиной компилятора — регистр честно держит старую копию, и loop() месяцами смотрит на ноль.
В этом уроке два этажа одной беды. Первый — volatile: слово, которое запрещает компилятору кешировать. Второй — атомарность, которую volatile не даёт: на Uno int занимает два байта, и обработчик может вклиниться посреди чтения. Разберём таблицу «без volatile — с volatile — с критической секцией», соберём счётчик в симуляторе и добьём граблю «работало до подключения библиотеки». Базу про прерывания здесь не повторяю — она в уроке про attachInterrupt.
Компилятор прячет переменную в регистр
Язык C++ разрешает компилятору всё, что не меняет видимого поведения программы. Для обычного кода это благодеяние: переменная, которую loop() читает десятки раз за проход, ложится в регистр, и каждая проверка стоит ноль обращений к памяти. Проблема всплывает ровно в момент, когда значение меняет не ваш код, а железо через обработчик: у регистра нет механизма узнать о визите ISR.
volatile перед типом — приказ, а не рекомендация: читать из памяти при каждом обращении и не перемещать значение. Он не делает переменную атомарной, не защищает от гонок и не ускоряет код — он делает ровно одно: лишает оптимизатор права на кеш. Именно поэтому его пропускают: в мигалке с кнопкой без него правда работает — до первой правки кода, после которой компилятор раскладывает регистры по-новому.
Тот же шаблон держит счёт тиков энкодера: в уроке про энкодер на прерываниях я считал щелчки с этим же словом в первой строке. Разница лишь в направлении счёта и дребезге, а контракт между ISR и loop() один и тот же.
Классическая демонстрация на настоящей плате — цикл ожидания: while (flag == 0) с флагом из обработчика. Без volatile компилятор выносит чтение за пределы цикла: условие сравнивает регистровую копию один раз, и скетч зависает навсегда, хотя обработчик щёлкает на глазах. С volatile цикл честно перечитывает память и отпускает. Именно поэтому ожидание события я всегда вписываю в неблокирующий loop() через проверку флага, а не в мёртвый while: первый ещё и кнопку читает, второй — тупик из одного действия.
volatile int tiki = 0; // меняется из ISR — без volatile не работает void naTik() { // обработчик: только счёт, ничего больше tiki = tiki + 1;} void setup() { pinMode(2, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(2), naTik, FALLING); Serial.begin(9600);} void loop() { int snimok; // копия для печати noInterrupts(); // критическая секция: 8-битный AVR snimok = tiki; // читает int двумя командами interrupts(); // прерывания снова включены Serial.print("n="); Serial.println(snimok); delay(1000);}Атомарность: int читается за две команды
ATmega328P — восьмибитное ядро. int занимает два байта, и чтение — две машинные команды: сначала один байт, потом второй, в каком порядке неважно, важно, что их два. Между этими командами обработчику ничего не стоит вклиниться — прерывание срабатывает между любыми командами основного кода.
Честный пример, на котором я это увидел: счётчик дорос до 255 и получает плюс один. В памяти было 0x00FF, становится 0x0100. Loop успел прочитать старший байт до ISR, младший — после, и собрал 0x01FF: пятьсот одиннадцать оборотов из двухсот пятидесяти шести. В другой раз наоборот — 0x0000, счётчик откатился в ноль. Значение выходит не «чуть неточным», а чужим: два байта из разных эпох, склеенные в одно число.
Лечение — критическая секция: noInterrupts() перед чтением, interrupts() сразу после. На время пары команд прерывания выключены: пара микросекунд для ISR кнопки — задержка, которую никто никогда не заметит. Правило размера: секция — несколько команд, ни одного вызова Serial, ни одного delay. Сама печать — вне секции, по уже снятой копии. Сборка числа из байтов через сдвиги и маски разобрана в уроке про битовые операции.
| Как объявлено и читается | Что видит loop() | Когда этого достаточно |
|---|---|---|
| int tiki; без volatile | старое значение из регистра — счётчик стоит на нуле | никогда: переменную трогает ISR — это неопределённое поведение |
| volatile int tiki; | свежее значение, но два байта могут порваться посреди чтения | счётчик до 255 или когда мелькнувший мусор не влияет на логику |
| volatile + noInterrupts()/interrupts() вокруг копии | свежее и целое значение целиком | мой выбор для всего, что длиннее одного байта: int, long, float |
В симуляторе пары noInterrupts()/interrupts() не видно и не почувствовать: движок не вытесняет loop посреди команды, и рваных чтений там не бывает. Привычку ставьте с первого скетча — настоящая плата вытеснит ваш код в любой момент, и не когда удобно вам.
Про размер данных всё решается байтом. byte, bool и char на AVR — один байт, одна машинная команда, атомарность достаётся даром: флагу flag = 1 хватает volatile byte и никакой критической секции. А вот long — четыре байта, float — тоже: без снимка под выключенными прерываниями никуда. Контракт один для всех входов прерывания, не только для INT0: PCINT ловит любой пин платы — в уроке про PCINT счётчики собираются точно так же.
Отдельный случай — millis() внутри обработчика: unsigned long, четыре байта, и сам вызов тянет за собой чтение счётчика таймера. Если в ISR позарез нужно время, снимайте его в loop(): обработчик только ставит флаг «событие было», а основной код сам смотрит на часы и решает, когда это было. Так заодно экономится время прерывания — четыре байта под выключенными прерываниями прямо в ISR, это то ещё развлечение.
И про архитектуру: из обработчика — флаг и счёт, всё остальное в loop(). Датчики, которые будят основной цикл, живут по тому же контракту — как в уроке про прерывание от датчика по I2C: ISR минимален, решения принимает loop() после того, как заметил флаг. Это же правило спасает millis-планировщик из урока про таймеры: длинный обработчик сдвигал бы все задачи.
- 1. Переменная меняется в attachInterrupt, а loop() всегда видит ноль. Первое подозрение?
- 2. Почему int на Uno можно прочитать «порванным», даже если он volatile?
- 3. Что делает noInterrupts() перед снятием копии счётчика?
- 4. Какие типы на AVR атомарны сами по себе?
- 5. Зачем loop() печатает копию snimok, а не саму volatile-переменную?
- Переменную, которую пишет ISR, объявляйте volatile в момент создания: без него loop() читает регистровую копию и молчит годами
- volatile запрещает только кеш: атомарность он не даёт — int на Uno читается двумя командами
- Копия под noInterrupts()/interrupts() — атомарный снимок; печать и всё медленное живут вне критической секции
- byte и bool атомарны даром; long, unsigned long и float — всегда через снимок
- «Работало без volatile» — везение, а не успех: любая правка кода пересобирает регистры и ломает молчаливый счётчик
Частые вопросы по теме урока
- Переменная меняется в attachInterrupt, а loop() всегда видит ноль. Первое подозрение?
- Оптимизация -Os прячет часто читаемую переменную в регистре, а о визитах ISR компилятор не знает. volatile запрещает кеш и возвращает loop() живое значение из памяти. у переменной нет volatile: компилятор держит копию в регистре
- Почему int на Uno можно прочитать «порванным», даже если он volatile?
- Два байта — две машинные команды. Между ними обработчик меняет переменную, и loop склеивает байты из разных эпох: 0x00FF к 0x0100 на глазах превращается в 0x01FF или 0x0000. int занимает два байта, а ядро восьмибитное — чтение идёт двумя командами, ISR может вклиниться между ними
- Что делает noInterrupts() перед снятием копии счётчика?
- Критическая секция на две-три команды делает чтение атомарным: ISR подождёт пару микросекунд — для кнопки это незаметно. Всё медленное, включая печать, живёт вне секции. на пару команд запрещает прерывания, чтобы чтение прошло целиком
- Какие типы на AVR атомарны сами по себе?
- Всё, что влезает в один байт, читается за одну команду — вклиниться некуда. Long и float тянут четыре байта, и их копируют под noInterrupts()/interrupts(). byte, bool, char: один байт — одна машинная команда
- Зачем loop() печатает копию snimok, а не саму volatile-переменную?
- Печать занимает много команд, и ISR может изменить переменную между двумя Serial.print. Снимок фиксирует значение на момент снятия — в мониторе видны честные числа без скачков. чтобы ISR не вклинился посреди печати и число не прыгнуло на глазах