Первая версия моего светофора жила на #define — как во всех примерах из сети: #define ZELENOE 4000, #define MIG 250. Промах вскрылся, когда я захотел утроенный жёлтый: написал MIG * 3 и ожидал 750 мс, а получил 1000. Подстановка превращает MIG * 3 в 250 + 250 * 3, приоритет умножения делает своё дело — и лишние 250 мс жёлтый честно отгорает. Препроцессор не компилирует: он переписывает текст до компилятора, и ни типов, ни скобок, ни проверок в его мире нет.
Здесь разложим по полкам четыре инструмента именованных значений: #define, const, constexpr и enum. Покажу, где константа живёт на AVR — во flash или в RAM, — почему компилятор часто выкидывает const из памяти вовсе, и как enum ловит опечатки в состояниях ещё при сборке. Финал — таблица-шпаргалка и светофор, у которого длительности, состояния и имена собраны на const и constexpr. Основы автоматов на именованных состояниях уже расписаны в уроке про светофор — здесь речь про то, чем каждое число объявлено.
#define: подстановка до компилятора
#define MIG 250 — договорённость препроцессора: каждое вхождение слова MIG заменяется литералом 250. Типа нет, области видимости нет: имя действует от строки объявления до конца файла, и компилятор его не видит — в сообщении о несовместимости будет 250, а не MIG. Отсюда два правила. Первое: любое выражение в макросе — только в скобках, #define MIG (250+250), иначе приоритет операций рассыпает его, как в моей мигалке. Второе: макросы с аргументами вида #define KVAD(x) x*x на скетчах лучше не заводить — KVAD(2+3) разворачивается в 2+3*2+3, и лечится это лишь скобками вокруг каждой буквы, после которых проще написать обычную функцию.
Зато у #define есть ниша, где он до сих пор безальтернативен: условная компиляция. #ifdef ARDUINO_AVR_UNO под разные платы, #include-гарды в библиотеках, выбор пинов под железо — всё это препроцессорная песочница, и const её не заменит. И сама по себе привычка миллионов примеров с #define PIN_LED 12 не вредна: для одиночного числа макрос и const в работе неотличимы. Разница вскрывается на первом же выражении без скобок, первом конфликте имён с библиотекой — макрос не уважает пространства имён — и первом непонятном сообщении сборки, где вместо вашего имени красуется развёрнутый текст.
enum Faza { ZELENYI, ZHELTYI, KRASNYI }; // длительности фаз — именованные константы: правка в одном местеconst unsigned long ZELENOE = 4000; // мсconst unsigned long ZHELTOE = 2000;const unsigned long KRASNOE = 6000; constexpr int VSEGO_FAZ = 3; // размер считает компиляторconst unsigned long Dlit[VSEGO_FAZ] = {ZELENOE, ZHELTOE, KRASNOE};const char* Imena[VSEGO_FAZ] = {"ZELENYI", "ZHELTYI", "KRASNYI"}; const int LED = 12;Faza faza = ZELENYI;unsigned long fazaS = 0; void vFazu(Faza novaya) { faza = novaya; fazaS = millis(); if (faza == ZELENYI) digitalWrite(LED, HIGH); if (faza == KRASNYI) digitalWrite(LED, LOW); Serial.println(Imena[faza]); // жёлтый дёргает led в loop()} void setup() { pinMode(LED, OUTPUT); Serial.begin(9600); fazaS = millis(); digitalWrite(LED, HIGH); Serial.println(Imena[faza]);} void loop() { unsigned long proshlo = millis() - fazaS; if (faza == ZHELTYI) { digitalWrite(LED, (proshlo / 250) % 2 == 0 ? HIGH : LOW); } if (proshlo >= Dlit[faza]) { vFazu(faza == ZELENYI ? ZHELTYI : (faza == ZHELTYI ? KRASNYI : ZELENYI)); }}Разбор листинга. enum Faza — тип из трёх имён с нумерацией от нуля; переменная faza хранит обычное число, но опечатка ZELANUY вместо ZELENYI валит сборку: неизвестное имя. С int-константами та же промашка прошла бы незаметно — три тоже число. Длительности — const unsigned long: тип на месте, компилятор проверяет каждый присвоенный литерал, а заодно и то, что вы не передадите туда отрицательное. Обращение Dlit[faza] выбирает длительность текущей фазы — таблица и состояния живут одной жизнью.
Про литералы, потому что тип константы виден именно в них. 4000 влезает в знаковый int с запасом до 32767, а 60000 — уже нет: без суффикса UL компилятор честно предупредит о переполнении, а кое-какие старые версии ядра — молча усекут. Системы счисления литералов — 0x1F, 0b1010 и обычный десятичный — разобраны в уроке про bin, hex и dec; для битов регистров там же пригодится запись 0b. Мой практический приём: у всех таймерных констант суффикс UL по умолчанию — unsigned long под millis() и его переполнение на 49,7 сутки разобраны в отдельном уроке, и суффикс снимает целый класс молчаливых усечений ещё до сборки.
constexpr int VSEGO_FAZ = 3 — самый молодой инструмент тройки: constexpr требует вычислить значение при сборке, и массив получает честный константный размер, а не число-литерал, вписанное в двух местах. Услуга оказалась взаимной: поменяете таблицу фаз — constexpr-счётчик... точнее, размеры массивов — останется согласован. Разницу с const легко запомнить так: const обещает не менять значение после инициализации, constexpr обещает знать его до прошивки платы.
constexpr пришёл в C++11, и AVR-ядро Arduino IDE с версии 1.6.6 собирает скетчи с ключом -std=gnu++11 — на любой свежей IDE он доступен без плясок. На плате он бесплатен: значение вычисляется компилятором и вшивается в команды, ни такта, ни байта на само вычисление не тратится. Мой ходовой приём — constexpr-функция пересчёта единиц: msSek(4) вместо 4000 читается быстрее, чем magic-число, а на скорость не влияет. Единицы секунд в длительностях и размеры таблиц — территория constexpr; всё, что должно быть известно до прошивки, — тоже.
Границы у constexpr честные: initializer должен вычисляться при сборке, переменная с него не меняется вовсе, и список значений для массивов — только литералы и другие constexpr. Это не беда, а дисциплина: если значение вдруг должно перестраиваться на ходу, оно не константа, и прятать его за constexpr не нужно.
Мой маркер выбора простой. Значение настраивается при прошивке и не меняется на ходу — constexpr: длительности, размеры, пересчёт единиц. Значение читается из настроек или датчика — const, и точка. Имя состояния — enum. Макрос остаётся для условной компиляции и ничего больше. По этой раскладке в моих скетчах #define встречается один-два раза на файл — и каждый раз это #ifdef под плату, а не константа.
| Инструмент | Тип | Во что превращается | Имя в сообщениях сборки |
|---|---|---|---|
| #define | нет — текст | подстановка до компилятора, выражения без скобок ломают приоритет | нет: компилятор видит развёрнутый текст |
| const | есть | RAM-ячейка, если берут адрес; иначе число вшито в команды | да |
| constexpr | есть | вычисление при сборке, в память не попадает без адреса | да |
| enum | псевдотип целых | числа 0, 1, 2... — сами имена в памяти не живут | да: неизвестное имя валит сборку |
const на AVR: RAM, immediate и PROGMEM
На AVR глобальная const-переменная без оговорок — это RAM: ядро копирует инициализированные значения из flash при старте, как обычные глобальные. Но маленькую const с известным значением, адрес которой никто не берёт, компилятор выбрасывает из памяти и вшивает число прямо в команды — immediate. const int LED = 12 из листинга выше не занимает ни байта RAM: значение известно при сборке, адреса нет. Проверка на материализацию простая: возьмите адрес — const int* p = &LED; — и ячейка немедленно появится в RAM, потому что на адрес нужно что-то указывать.
Массив Imena из трёх указателей — уже материал: сами указатели (по два байта) и строковые литералы под ними на AVR живут в RAM, и это честные два-три десятка байт. Для гарантированного flash большие таблицы и строки переносят в PROGMEM — приёмы и подводные камни чтения разобраны в уроке про PROGMEM. Бюджет RAM после подключения таблиц проверяйте freeRam из урока про утечки — константы почти никогда не виноваты в его падении, в отличие от строк и Serial-логов.
И не путайте const-локальные с глобальными: константа внутри функции существует только во время её работы и компилятор вообще кладёт её в регистр или стек. Глобальная const — про всю жизнь скетча от reset() до отключения питания. Разница заметна в сообщениях сборки и в том, откуда константу видно: локальную не увидит соседняя функция, и это хорошо — чем уже область видимости, тем меньше шансов, что её тронут чужой правкой.
- #define — текстовая подстановка до компилятора: без типа и области видимости; выражения — только в скобках
- const — типизированное имя: маленькие константы без адреса компилятор вшивает в команды, взятый адрес материализует ячейку в RAM
- constexpr требует вычислить значение при сборке: размеры массивов и пересчёт единиц бесплатны на плате; доступен с C++11, AVR-ядро собирает с gnu++11
- enum — имена состояний без байта RAM; опечатка в имени валит сборку, int-константа ту же промашку пропускает
- Большие таблицы и строки — во flash через PROGMEM; бюджет RAM проверяйте freeRam после каждого подключения таблиц
- 1. Что даст #define MIG 250+250 при вычислении MIG * 3?
- 2. Где на Uno живёт глобальная const int POROG = 500, адрес которой никто не берёт?
- 3. Чем constexpr принципиально отличается от const?
- 4. Почему опечатка ZELANUY вместо ZELENYI в switch по enum ловится при сборке?
- 5. Что даёт PROGMEM для массива строк?
Частые вопросы по теме урока
- Что даст #define MIG 250+250 при вычислении MIG * 3?
- Препроцессор подставляет текст без скобок: 250+250*3 по приоритету операций равно 1000. Лечение — скобки вокруг выражения или переезд на const. 1000: подстановка даёт 250+250*3
- Где на Uno живёт глобальная const int POROG = 500, адрес которой никто не берёт?
- Маленькую const с известным значением и без адреса компилятор выбрасывает из памяти и вшивает как immediate. Взятый адрес материализует ячейку в RAM. компилятор вшивает число в команды, RAM не тратится
- Чем constexpr принципиально отличается от const?
- const — обещание не менять после инициализации; constexpr — обещание, что значение известно компилятору до прошивки: из него можно задавать размеры массивов. constexpr требует вычислить значение при сборке, const лишь запрещает менять после инициализации
- Почему опечатка ZELANUY вместо ZELENYI в switch по enum ловится при сборке?
- enum — тип с фиксированным набором имён: неизвестное имя — неизвестный идентификатор, сборка падает. С int-константами та же промашка прошла бы незамеченной. компилятор знает список имён типа — неизвестного имени для него нет
- Что даёт PROGMEM для массива строк?
- Без PROGMEM строковые литералы на AVR копируются в RAM при старте. PROGMEM оставляет их во flash — чтение идёт специальными командами, но RAM свободна. гарантирует, что строки останутся во flash и не съедят RAM