Термостат для балконного ящика с рассадой я писал в четыре руки: сам и я же через месяц, дорабатывая ночной режим. Глобальных переменных было шесть: celi, gisterezis, rezhim, avariya, podogrev, poslednayaTemp. С работающим скетчем они жили мирно, пока я не начал добавлять функции: в одном месте порог сравнивался с rezhim == 2, в другом — с голой двойкой, в третьем я перепутал gisterezis с полупорогом. Нагрев не выключался, и два часа с распечаткой скетча я вёлся за багом, который ловится компилятором бесплатно, если состояния у нас не числа, а имена.
Порядок в скетче длиннее трёхсот строк приносят две вещи, и обе — из базового C++. struct собирает настройки в один тип: на целевую температуру, гистерезис и режим — одно объявление, одно место в памяти, один способ передать их функции. enum заменяет магические числа состояний словами: SPIT, SLEDIT, TRIVOIGA — и опечатка в слове становится ошибкой сборки, а не вечерней охотой. Обе вещи не стоят ни байта на плате: типы живут только до компиляции, в памяти остаются те же числа и те же переменные — за порядок вы платите только вниманием при написании. Автоматы на именованных состояниях мы уже трогали в уроке про светофор — там автомат был на числах, здесь та же логика взрослеет до типов.
Урок завершает тройку про взрослый скетч: PROGMEM-таблицы из предыдущего урока держат данные во флеше, передача без копий приносит порядок в функции, а struct и enum — в том, что осталось. В конце — симулятор: движок struct и enum не исполняет, поэтому в макетке состояния собраны из именованных констант, а на плату вы возьмёте версию с типами.
struct: настройки одним куском
Объявление структуры — чертёж нового типа: struct Nastroyki { int porog; int viderzhka; int tiho; }; — и теперь Nastroyki можно объявлять как обычный тип, а поля заполнять списком: Nastroyki n = {500, 4, 100}. Доступ к полям через точку: n.porog. Польза не в красивом синтаксисе, а в единственности: у порога теперь ровно одно место, и функция, которая хочет настройки, получает их одним аргументом — по ссылке из урока про указатели, без копий и без шести параметров в заголовке.
Про размер стоит знать одну особенность AVR: выравнивания полей на нем нет, sizeof структуры равен сумме размеров полей. Три int — ровно 6 байт, и это число я проверяю через sizeof(Nastroyki) перед любой записью в EEPROM: шесть байт — шесть ячеек, и ничего не наезжает на соседние данные. На ESP32 поля выравниваются по границам, и тот же тип может весить больше — переносите структуру между платами как двоичный blob с осторожностью, лучше с подписью-версией внутри.
Главная связка — энергонезависимость: EEPROM.put(0, n) пишет структуру целиком, EEPROM.get(0, n) читает обратно, приёмы разобраны в уроке про put и get. Вместо шести EEPROM.update по разным адресам — одна команда и один адрес, и настройки переезжают через перепрошивку без потерь. Для долго живущего скетча я завожу struct Nastroyki первым делом, до первой глобальной переменной настройки — потом переучиваться больно.
struct Nastroyki { int porog; // порог срабатывания, единицы АЦП int viderzhka; // замеров подряд выше порога int tiho; // зона отпуска: porog - tiho}; enum Status { SPIT, SLEDIT, TRIVOIGA }; Nastroyki n = {500, 4, 100};Status sost = SPIT;int schet = 0; void perehod(Status novyi) { // все переходы в одном месте sost = novyi; schet = 0; if (novyi == TRIVOIGA) { digitalWrite(12, HIGH); tone(8, 880, 200); } else { digitalWrite(12, LOW); }} void setup() { pinMode(12, OUTPUT); pinMode(2, INPUT_PULLUP); Serial.begin(9600); Serial.print("razmer nastroyek: "); Serial.println(sizeof(Nastroyki)); // на AVR ровно 6} void loop() { int syr = analogRead(A0); if (sost == SPIT && digitalRead(2) == LOW) { perehod(SLEDIT); delay(300); } if (sost == SLEDIT) { if (syr > n.porog) { schet = schet + 1; } else { schet = 0; } if (schet >= n.viderzhka) { perehod(TRIVOIGA); } } if (sost == TRIVOIGA && syr < n.porog - n.tiho) { perehod(SPIT); } delay(5);}enum: состояния по именам
enum Status { SPIT, SLEDIT, TRIVOIGA } — объявление типа с фиксированным набором значений. По умолчанию они нумеруются с нуля: SPIT — 0, SLEDIT — 1, TRIVOIGA — 2, и можно задать свои числа явно. Переменная Status sost принимает только именованные значения, и вот здесь таится главная выгода: если в switch вы напишете SPET вместо SPIT, компилятор откажется собирать скетч — неизвестное имя. С int-константами та же опечатка прошла бы незамеченной: 3 — тоже число, пусть и не то.
Второй подарок — switch по enum: компилятор с ключом -Wswitch напоминает о состояниях, для которых нет ветки. Я добавил в автомат режим OZHIDANIE и забыл про него в switch — сборка указала строку раньше, чем я успел бы поймать это на железе. Не отключайте такие подсказки: они бесплатный второй ревьюер, который не устаёт.
Каркас, к которому я пришёл на трёх термостатах: все переходы — в одной функции perehod(Status novyi), там же светодиод, зуммер и печать в Serial; loop() только решает, когда переходить, и читает датчики. Если переходов больше трёх, такая связка окупается за первый вечер — логика выхода из состояния живёт в одном месте, и новые состояния добавляются одной веткой. Для многостадийных устройств дальше по смыслу стоит планировщик задач на millis: автомат решает что делать, планировщик — когда.
| Инструмент | Объявление | Опечатка в значении | Моё правило |
|---|---|---|---|
| int-константы | const int SPIT = 0; | не ловится: 0, 1, 2 — любые числа | модель для симулятора, движок enum не исполняет |
| enum | enum Status { SPIT, SLEDIT }; | ловится: SPET — неизвестное имя | состояния автомата на плате |
| #define | #define SPIT 0 | не ловится, к тому же без типа и области видимости | не беру: enum и const int чище |
struct масштабируется дальше — в массив профилей. Мой полив имел три режима: рассада, цветник, отпуск; вместо трёх наборов переменных жил массив Nastroyki profili[3], и кнопка выбирала индекс: profili[nomer].porog. Три структуры по 6 байт — 18 байт, один адрес для EEPROM.put и одна переменная номера вместо переприсваивания шести полей по всему скетчу. Смена профиля становится событием, которое видно в одном месте, а не рассыпается по коду — ровно то же свойство, за которое мы ценим perehod() у автомата.
Мелочь для отладки, которая потом переезжает в каждый мой скетч: enum нельзя напечатать в Serial напрямую — print не знает, как превратить Status в текст. Выручает функция-переводчик imya(Status s) со switch по каждому значению: три строки на состояние, зато в логе видно perehod=TRIVOIGA, а не perehod=2. Если у вас несколько автоматов, у каждого — свой enum со своими именами: общий список состояний для насоса и подсветки превращает код обратно в магические числа, только теперь ещё и с чужими подписями.
Движок симулятора не исполняет ни struct, ни enum — в его грамматике их нет. Поэтому в макетке ниже состояния объявлены именованными константами int, а настройки — тремя отдельными переменными с пометкой, что на плате это поля структуры. Логика при этом точная, один в один с code-блоком: переходы через функцию perehod, выдержка в замерах, зона отпуска ниже порога. В Serial печатается имя состояния из строкового массива — модель enum-имён: в реальном скетче вместо IMENA[sost] вы бы печатали сам Status.
Сюжет тот же, что у термостата: потенциометр A0 — уровень сигнала, кнопка D2 будит автомат из спячки, четыре замера подряд выше порога — тревога, зуммер и светодиод, падение сигнала в зону отпуска — возврат в сон. Числа подобраны под единицы АЦП: порог 500 — половина шкалы, зона отпуска в сто единиц ниже — запас против дребезга сигнала на границе. Попробуйте нарочно держать сигнал чуть выше порога с провалами: выдержка обнуляется на каждом провале, и тревога не срабатывает — это и есть гистерезис с выдержкой, а не просто сравнение.
- struct собирает настройки в один тип: одно объявление, одна инициализация, передача по ссылке без копий
- На AVR sizeof структуры — сумма полей без выравнивания: три int — ровно 6 байт; на ESP32 поля выравниваются
- enum — тип с именованными значениями от нуля: опечатка в состоянии — ошибка компиляции, у int-констант она проходит молча
- Каркас автомата: все переходы в одной perehod(), loop только решает когда; switch по enum ловит забытые состояния
- Настройки в EEPROM — struct через put/get одним куском; новые состояния enum добавляйте в конец списка
- 1. struct Nastroyki { int a; int b; int c; }; — сколько байт занимает на Uno?
- 2. Зачем передавать struct в функцию по ссылке, а не по значению?
- 3. В switch по enum написали SPET вместо SPIT. Что произойдёт?
- 4. С каких значений enum Status { SPIT, SLEDIT, TRIVOIGA } нумеруется по умолчанию?
- 5. Почему новые состояния enum добавляют в конец списка, если структура хранится в EEPROM?
Частые вопросы по теме урока
- struct Nastroyki { int a; int b; int c; }; — сколько байт занимает на Uno?
- На AVR поля не выравниваются: sizeof равен сумме полей, три int по два байта — ровно 6. Проверяйте sizeof перед записью в EEPROM. 6 — int по два байта, выравнивания на AVR нет
- Зачем передавать struct в функцию по ссылке, а не по значению?
- По значению копируются все байты на каждый вызов, и изменения умирают с копией. Ссылка даёт функции оригинал: экономия тактов и честные изменения. ноль копий байтов и функция меняет оригинал, а не двойника
- В switch по enum написали SPET вместо SPIT. Что произойдёт?
- enum вводит имена в область видимости типа: неизвестное имя не соберётся. С int-константами та же опечатка проскочила бы как обычное число. ошибка компиляции: неизвестное имя
- С каких значений enum Status { SPIT, SLEDIT, TRIVOIGA } нумеруется по умолчанию?
- По умолчанию первый элемент — 0, дальше по единице: SPIT — 0, SLEDIT — 1, TRIVOIGA — 2. Свои числа задают явно, но совместимость с EEPROM при этом страдает. с нуля
- Почему новые состояния enum добавляют в конец списка, если структура хранится в EEPROM?
- Нумерация идёт по порядку: новое состояние в середине сдвигает последующие номера, и прочитанный из EEPROM байт 2 окажется совсем не тем режимом. вставка в середину сдвинет номера, и старые сохранённые байты будут значить другое