Продвинутый курс Arduino · Секреты платы
Урок 3. Мгновенные события: прерывания
Плата занята анимацией, а удары по столу всё равно считаются — все до одного. Знакомимся с прерываниями: событие само врывается в программу. В финале — «чёрный ящик» для посылки.
Что понадобится
- Контроллер Uno и кабель USB
- Макетная плата
- Датчик вибрации SW-420
- Светодиод и резистор 470 Ом
- Зуммер
- Провода
Все детали есть в обучающем наборе, по которому идёт курс.
Весь базовый курс мы жили по одному правилу: хочешь заметить событие — почаще смотри на датчик. Кнопку опрашивали в каждом проходе loop(), и пока проходы шустрые, всё работало. Но стоит плате заняться чем-то долгим — анимацией, мелодией, экраном, — и короткое событие проскакивает незамеченным. Постучали по столу, пока плата «моргала» светодиодом? Значит, не постучали.
Сегодня вы узнаете второй способ — прерывания. Событие перестанет ждать, пока на него посмотрят: оно само ворвётся в программу, чем бы та ни занималась. В финале соберём «чёрный ящик» для посылки: он ловит каждый толчок, помнит их число даже без питания и ругается сиреной на тряску.
Почему опрос пропускает удары
Датчик вибрации из шестнадцатого урока «звенит» при ударе всего несколько миллисекунд. Если в этот момент программа сидела в delay(15) — событие исчезло без следа. Можно, конечно, писать все программы без единого delay, на чистом millis()… но есть путь честнее: поручить дежурство самой плате.
Собираем схему
Датчик вибрации, светодиод, зуммер — всё старые знакомые. Одна важная деталь: датчик встаёт на вывод 2, и это не случайность.
Выводы 2 и 3 — особенные. Только они на плате Uno умеют будить обработчик прерывания. Остальные выводы на такое не способны — это зашито в сам контроллер. Поэтому если в проекте есть «мгновенное» событие, выводы 2 и 3 берегите именно для него.
Шаг 1. Считаем опросом — и честно проигрываем
Сначала соберём «плохую» версию, чтобы своими глазами увидеть проблему. Плата занята важным делом — дышит светодиодом, — а на датчик смотрит один раз за цикл.
// Продвинутый курс, урок 3. Шаг 1: считаем удары опросом. Плохо считаем.
const int SENSOR = 2; // датчик вибрации
const int LED = 10; // светодиод «плата занята делом»
int hits = 0; // сколько ударов насчитали
void setup() {
Serial.begin(9600);
pinMode(SENSOR, INPUT_PULLUP);
pinMode(LED, OUTPUT);
Serial.println("Стучите по столу — я считаю удары.");
}
void loop() {
// Плата занята «важным делом»: плавно разгорается и гаснет светодиод.
// Всё это время она НЕ смотрит на датчик.
for (int b = 0; b <= 255; b = b + 5) {
analogWrite(LED, b);
delay(15);
}
for (int b = 255; b >= 0; b = b - 5) {
analogWrite(LED, b);
delay(15);
}
// И только теперь — один быстрый взгляд на датчик.
if (digitalRead(SENSOR) == HIGH) { // в покое датчик замкнут, при ударе размыкается
hits = hits + 1;
Serial.print("Удар! Всего: ");
Serial.println(hits);
}
}
Постучите по столу. Иногда удар засчитается — если повезёт попасть в тот миг, когда программа освободилась. Но большинство ударов пропадает: цикл дыхания занимает почти полторы секунды, а взгляд на датчик — доли миллисекунды. Вратарь, который смотрит на ворота раз в полторы секунды, — плохой вратарь.
Кстати, вспомните: датчик вибрации в покое замкнут, сигнал на выводе — LOW. Удар размыкает его на мгновение, и сигнал прыгает вверх. Этот прыжок нам сейчас пригодится.
Шаг 2. Прерывание: событие врывается само
Знакомьтесь — attachInterrupt. Читается строка так: «плата, как только сигнал на выводе 2 прыгнет вверх — бросай всё и выполни функцию onKnock».
// Продвинутый курс, урок 3. Шаг 2: удары считает прерывание.
const int SENSOR = 2; // прерывания на Uno живут только на выводах 2 и 3
const int LED = 10;
volatile int hits = 0; // переменную трогает обработчик — нужна пометка volatile
void setup() {
Serial.begin(9600);
pinMode(SENSOR, INPUT_PULLUP);
pinMode(LED, OUTPUT);
// «Плата, при каждом скачке сигнала вверх на выводе 2 — бросай всё
// и выполняй функцию onKnock». RISING — потому что в покое датчик
// замкнут (LOW), а удар его размыкает (сигнал прыгает вверх).
attachInterrupt(digitalPinToInterrupt(SENSOR), onKnock, RISING);
Serial.println("Стучите — теперь я не пропущу ни одного удара.");
}
// Обработчик: максимально короткий. Ни delay, ни печати — только пометка.
void onKnock() {
hits = hits + 1;
}
int shown = 0; // сколько ударов мы уже показали в мониторе
void loop() {
// Плата по-прежнему занята анимацией...
for (int b = 0; b <= 255; b = b + 5) {
analogWrite(LED, b);
delay(15);
}
for (int b = 255; b >= 0; b = b - 5) {
analogWrite(LED, b);
delay(15);
}
// ...а печатает в свободную минуту — всё, что накопилось.
if (hits != shown) {
shown = hits;
Serial.print("Ударов: ");
Serial.println(shown);
}
}
Разберём новые слова. digitalPinToInterrupt(2) — переводчик: превращает номер вывода в номер прерывания, чтобы не путаться. RISING — «на прыжке вверх»: наш датчик в покое держит LOW, значит удар — это подъём сигнала. Бывают ещё FALLING — на спаде, и CHANGE — на любом изменении.
Функция onKnock — обработчик. Обратите внимание, какой он короткий: одна строчка. Это закон жанра: обработчик врывается в программу посреди любого дела, поэтому он обязан отработать молниеносно и уйти. Никаких delay, никакой печати в порт — только пометить событие и исчезнуть. Всю тяжёлую работу — печать, звук, запись — делает loop(), когда освободится.
Слово volatile обязательно. Переменная hits живёт в двух мирах: её меняет обработчик и читает loop(). Без пометки volatile компилятор, оптимизируя программу, может решить, что переменная «не меняется», и спрятать её в свой карман — тогда loop() будет вечно видеть старое значение. volatile запрещает такие фокусы: «эту переменную каждый раз читай по-настоящему». Правило простое: трогаете переменную в обработчике — помечайте её volatile.
Загрузите и постучите. Плата всё так же занята дыханием светодиода — но теперь не пропускает ни одного удара: счёт растёт, даже когда программа сидит в delay.
Шаг 3. Дребезг возвращается
Присмотритесь к числам: один хороший удар прибавляет к счёту сразу 5–20. Знакомый почерк? Это дребезг из одиннадцатого урока: пружинка внутри датчика звенит, и каждое колебание — отдельный «прыжок вверх», отдельное прерывание. Опрос дребезга не видел — он просто не успевал, — а прерывание видит всё.
// Продвинутый курс, урок 3. Шаг 3: один удар — одно срабатывание.
const int SENSOR = 2;
const int LED = 10;
volatile int hits = 0;
volatile unsigned long lastKnock = 0; // момент последнего засчитанного удара
void setup() {
Serial.begin(9600);
pinMode(SENSOR, INPUT_PULLUP);
pinMode(LED, OUTPUT);
attachInterrupt(digitalPinToInterrupt(SENSOR), onKnock, RISING);
Serial.println("Считаю удары, не поддаваясь дребезгу.");
}
void onKnock() {
// millis() в обработчике можно ЧИТАТЬ (часы просто замирают на время
// обработки). Если после прошлого удара не прошло 150 мс — это ещё
// дрожит тот же самый удар, не считаем его заново.
if (millis() - lastKnock < 150) {
return;
}
lastKnock = millis();
hits = hits + 1;
}
int shown = 0;
void loop() {
for (int b = 0; b <= 255; b = b + 5) {
analogWrite(LED, b);
delay(15);
}
for (int b = 255; b >= 0; b = b - 5) {
analogWrite(LED, b);
delay(15);
}
if (hits != shown) {
shown = hits;
Serial.print("Ударов: ");
Serial.println(shown);
}
}
Лекарство то же, что и для кнопки, — окно тишины: после засчитанного удара 150 миллисекунд не считать ничего. Тонкость в том, где стоит проверка — прямо в обработчике. И тут надо знать один секрет: внутри обработчика часы millis() замирают. Читать их можно — они показывают момент удара, — но тикать они продолжат только после выхода. Для нашего окна тишины этого вполне достаточно.
Шаг 4. Стук-метр
Теперь loop() полностью свободен — пусть занимается отчётами. Посчитаем интенсивность: сколько ударов пришлось на каждые десять секунд.
// Продвинутый курс, урок 3. Шаг 4: отчёт каждые десять секунд.
const int SENSOR = 2;
volatile int hits = 0;
volatile unsigned long lastKnock = 0;
unsigned long windowStart = 0; // когда началась текущая десятисекундка
void setup() {
Serial.begin(9600);
pinMode(SENSOR, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(SENSOR), onKnock, RISING);
Serial.println("Считаю удары порциями по 10 секунд.");
windowStart = millis();
}
void onKnock() {
if (millis() - lastKnock < 150) {
return;
}
lastKnock = millis();
hits = hits + 1;
}
void loop() {
if (millis() - windowStart >= 10000) {
windowStart = millis();
Serial.print("За 10 секунд ударов: ");
Serial.println(hits);
hits = 0; // новая десятисекундка — счёт заново
}
}
Это разделение труда — главный узор всех программ с прерываниями: обработчик копит, loop() разбирает. Обработчик — кладовщик, который молча ставит галочки; loop — начальник, который в удобное время подводит итоги.
Шаг 5. Проект: чёрный ящик для посылки
Соединим всё, что умеет продвинутый курс. Представьте: вы отправляете хрупкую посылку и хотите знать, как с ней обращались в дороге. Прерывание ловит каждый толчок, вечная память из первого урока хранит их число без всякого питания, а команда с компьютера из второго урока начинает новую поездку.
// Продвинутый курс, урок 3. Шаг 5: сторож посылки с чёрным ящиком.
// Прерывание ловит толчки, EEPROM помнит их число даже без питания,
// сирена ругается на тряску. Урок 1 и урок 3 работают вместе.
#include <EEPROM.h>
const int SENSOR = 2;
const int LED = 10;
const int BUZZER = 12;
const int ADDR_HITS = 40; // ящики 40-41: число толчков (int)
volatile int hits = 0; // толчки с момента включения
volatile unsigned long lastKnock = 0;
int savedHits = 0; // что уже записано в вечную память
void setup() {
Serial.begin(9600);
pinMode(SENSOR, INPUT_PULLUP);
pinMode(LED, OUTPUT);
// Чёрный ящик: докладываем, что происходило с посылкой раньше.
int before;
EEPROM.get(ADDR_HITS, before);
if (before == -1) {
before = 0; // свежая память — толчков ещё не записывали
}
Serial.print("Толчков за прошлые поездки: ");
Serial.println(before);
Serial.println("Команда 0 — обнулить счёт и начать новую поездку.");
hits = before; // продолжаем счёт с прежнего места
savedHits = before;
attachInterrupt(digitalPinToInterrupt(SENSOR), onKnock, RISING);
}
void onKnock() {
if (millis() - lastKnock < 150) {
return;
}
lastKnock = millis();
hits = hits + 1; // в обработчике — ТОЛЬКО пометка
}
void loop() {
// Всю тяжёлую работу делает loop, а не обработчик.
if (hits != savedHits) {
savedHits = hits;
EEPROM.put(ADDR_HITS, savedHits); // запись по событию — ресурс цел
Serial.print("Толчок! Всего: ");
Serial.println(savedHits);
digitalWrite(LED, HIGH); // вспышка и короткая сирена
tone(BUZZER, 700, 300);
delay(300);
digitalWrite(LED, LOW);
}
// Команда с компьютера: 0 — начать новую поездку (привет, урок 2).
if (Serial.available() > 0) {
char c = Serial.read();
if (c == '0') {
hits = 0;
savedHits = 0;
EEPROM.put(ADDR_HITS, 0);
Serial.println("Счёт обнулён. Счастливого пути!");
}
}
}
Посмотрите, как аккуратно распределены роли. Обработчик — только hits + 1. Запись в EEPROM, печать, сирена — всё в loop(): он замечает, что счётчик изменился, и делает тяжёлую работу. И записи в вечную память случаются по событию — ресурс памяти в безопасности, как мы договаривались в первом уроке.
Испытание чёрного ящика: постучите три раза, выдерните USB, вставьте обратно и откройте монитор. Плата доложит: «Толчков за прошлые поездки: 3». Память пережила «дорогу». Команда 0 — и ящик готов к новой посылке.
Частые проблемы
- Счёт не растёт совсем. Датчик обязан стоять на выводе 2 (или 3 — тогда поменяйте номер и в скетче). На других выводах attachInterrupt работать не будет.
- Один удар — плюс двадцать. Это дребезг: проверьте, что окно тишины из третьего шага на месте, а датчик стоит под углом и не болтается в макетке.
- Счёт растёт сам, без ударов. Датчик слишком чувствителен в вертикальном положении или стол дрожит. Увеличьте окно тишины до 300 мс и наклоните датчик сильнее.
- Программа зависла после ваших правок. Загляните в обработчик: не появились ли там
delay,toneилиSerial.print? Им туда нельзя — печать в порт изнутри обработчика может заморозить плату намертво.
Задания для тренировки
- РазминкаПусть при каждом засчитанном толчке светодиод коротко вспыхивает — но включает и выключает его
loop(), а не обработчик. - ПосложнееДобавьте «шкалу строгости»: если за десять секунд толчков больше пяти — длинная сирена и надпись «С посылкой обращаются ужасно!». Стук-метр из четвёртого шага уже почти всё умеет.
- Со звёздочкой ⭐Переведите датчик Холла из семнадцатого урока на «мгновенные рельсы»: магнит на спице колеса, датчик на выводе 3 — и у вас велокомпьютер, который не пропускает ни одного оборота. Подсказка: у аналогового датчика сигнал плавный, поэтому сравните его с порогом в
loop()… или почитайте про режимCHANGEи цифровые датчики Холла — а честнее всего собрать «оборотомер» на герконовом принципе: датчик наклона тоже замыкатель.
Что вы теперь умеете
- объяснять, чем опрос отличается от прерывания и когда какой способ честнее;
- подключать мгновенные события:
attachInterrupt,digitalPinToInterrupt, режимыRISING/FALLING/CHANGE; - писать правильные обработчики: коротко, без delay и печати, с
volatile; - гасить дребезг окном тишины прямо в обработчике;
- строить «чёрные ящики»: прерывание копит, loop разбирает, EEPROM помнит.
Блок «Секреты платы» закрыт: вечная память, разговор с компьютером и мгновенные события — всё это пряталось в контроллере с самого первого урока. Дальше — блок «Незнакомые детали»: достанем со дна коробки набора то, что ещё ни разу не побывало в макетке. Начнём с загадочных бочонков — конденсаторов.
Частые вопросы
Почему прерывания есть только на выводах 2 и 3?
Так устроен сам контроллер ATmega328P: аппаратные «звонки» INT0 и INT1 физически подведены только к этим двум ножкам. У других плат выводов с прерываниями больше, поэтому в чужих проектах вы встретите и иные номера — сверяйтесь с описанием своей платы.
Сколько времени можно сидеть в обработчике?
Чем меньше, тем лучше: пока выполняется обработчик, замирают millis(), не отправляется Serial и не ловятся другие события. Хороший обработчик укладывается в микросекунды — изменить переменную, запомнить время. Всё, что дольше пары строк, отдавайте loop().
А кнопку можно повесить на прерывание?
Можно, и это частый приём. Но у кнопки дребезг ещё злее, чем у датчика вибрации, поэтому окно тишины из этого урока обязательно. Для обычных кнопок в неторопливых программах хватает опроса — прерывание берегите для событий, которые нельзя проспать.
