Вечер перед выездом: я комментировал строки отладочной печати в скетче метеостанции — двенадцать штук — и на седьмой понял, что одна забытая println завтра будет сыпать мусор в порт, куда планировал подключить преобразователь для журнала. Печати — главный инструмент со времён первого урока про отладку логики, и вырывать их руками из скетча — занятие с гарантией промаха: у меня примерно каждый седьмой выезд начинался с невыметенного мусора в журнале.
Балласт не только мусорный, но и платный. Уровневый логгер из урока про уровни логов отбирает строки на лету — но компилятор грузит в плату все строки, включая те, что никогда не печатаются. Каждая из них без обёртки F() лежит копией в оперативке — байты мы считали в уроке про макрос F(); а печати в цикле ещё и стоят времени: 30 символов при 9600 бод занимают около 31 мс, и loop столько же отстаёт.
Старое решение языка C — условная компиляция. Одна строка #define решает, чем станет вызов DBG(): настоящей печатью или пустышкой, которую компилятор вырезает вместе со строками и аргументами. В боевой сборке от отладки не остаётся ни байта — ни флеша, ни оперативки, ни времени в loop, и это не про самодисциплину, а про свойства препроцессора. Здесь соберём готовый набор макросов, посчитаем цену обеих сборок и найдём флагу сборки место, откуда он не теряется.
Что компилятор вырезает сам
Каркас — ветка #ifdef DBG_ON ... #else ... #endif. Флаг описан — макросы превращаются в печати; не описан — в пустые do {} while (0): компилятор выбрасывает вызов целиком, а с ним и аргументы. Отсюда первое правило: в DBG() прячем только просмотр. Счётчик, инкремент, чтение порта в аргументе отладочного макроса исчезнут в боевой сборке — и скетч начнёт жить другой жизнью: счёт собьётся, опрос датчика не случится. Отладка смотрит, но не трогает мир.
Обёртка do-while(0) — не украшение: она делает макрос безопасным после if без фигурных скобок. Голый println внутри #define, развёрнутый после if, съедает ветку else — компилятор ругается, а без ругани код ведёт себя странно. Скобки вокруг каждого аргумента — вторая страховка: DBG_MARK(t, a+b) разворачивается в Serial.print((a+b)) и считается предсказуемо. И запятая: выражение из двух частей внутри одного аргумента заворачивайте в лишние скобки, иначе макрос развалит его на два и откажется собирать.
Строки внутри макросов держим во флеше через F() — тот же механизм из урока про F(); в бою это неважно, там макросы вырезаны, а в отладочной сборке оперативка не тает. Метка времени печатается прямо в макросе: DBG_MS() выдаёт [dbg 12456], и поток событий легко соотносится с периодами работы узла. Почему строки тают в оперативке и как посчитать остаток RAM, мы разбирали в уроке про PROGMEM и память — здесь достаточно правила: F() вокруг каждого литерала в макросе.
Тела отладки прячьте в функции, а макросами оборачивайте только вызовы: если обернуть в #ifdef половину скетча, боевая версия рассыплется на ветвях и читать её станет мучительно — я собирал такой комбайн и потом полдня распутывал. Второй ход — расширить DBG_MS() номером строки: препроцессор разворачивает __LINE__ в строку вызова, и метка становится вида [dbg 12456:42] — видно не только когда, но и где. Третий — префикс модуля: DBG_METEO() против DBG_POMPA() экономят больше, чем кажется, когда журналов становится два.
// Отладочная сборка: строку #define DBG_ON 1 держим раскомментированной.// Боевая: закомментировать — и от отладки не останется ни байта.//#define DBG_ON 1 #ifdef DBG_ON #define DBG(x) do { Serial.println(x); } while (0) #define DBG_MARK(m,v) do { Serial.print((m)); Serial.print("="); Serial.println((v)); } while (0) #define DBG_MS() do { Serial.print("[dbg "); Serial.print(millis()); Serial.print("] "); } while (0) #define DBG_HEX(m,x) do { Serial.print((m)); Serial.print("=0x"); if ((x) < 16) Serial.print("0"); Serial.println((x), HEX); } while (0)#else #define DBG(x) do {} while (0) #define DBG_MARK(m,v) do {} while (0) #define DBG_MS() do {} while (0) #define DBG_HEX(m,x) do {} while (0)#endif const int LED = 12;const int POT = A0;const int BTN = 2;const int POROG = 800; void setup() { Serial.begin(9600); pinMode(LED, OUTPUT); pinMode(BTN, INPUT_PULLUP); DBG("start: porog=800");} void loop() { int uroven = analogRead(POT); DBG_MS(); DBG_MARK("uroven", uroven); // в бою исчезнет вместе с печатью if (uroven > POROG) { digitalWrite(LED, HIGH); DBG("porog probit: led=1"); } else { digitalWrite(LED, LOW); } if (digitalRead(BTN) == LOW) { DBG_MS(); DBG_HEX("kod knopki", 0x01); delay(300); } delay(100);}Сравните сборки: с флагом монитор сыплет строками с миллисекундными метками, без — молчит, как будто печатей никогда не было, и так оно и есть: строк в прошивке нет, вызовов нет, вычислений аргументов нет. Цена видна внизу IDE в строчке Global variables use: отладочная сборка тяжелее на сотни байтов строк, боевая — на ноль. В loop разница нагляднее: печать из 30 символов при 9600 бод занимает около 31 мс — арифметика байта на линии из урока про буфер UART, — и отладочный цикл отстаёт ровно на столько.
Мой порядок перед выездом простой и скучный, потому и рабочий: вечер на столе — с флагом; перед прошивкой в поле строку флага закомментировать, собрать, посмотреть на размер внизу IDE, залить. Пропущенный боевой прогон — та самая история с мусором в журнале: телеметрия уехала в поле вместе с дебагом, и таблица опять решето. Скучная строка в чек-листе стоит десяти минут тишины в порту.
Отдельная история — скорость порта в отладочной сборке. Строки сыплются пачками, и на 9600 монитор за ними не успевает: буфер приёма на компьютере растёт, цикл тормозит. Я поднимаю отладочный порт до 115200 — аппаратный UART на Uno держит её стабильно, и печати перестают давить на loop; в боевой сборке скорость не важна, порт молчит. Уровень DBG_VERBOSE заведите сразу: обычный DBG для событий, VERBOSE для построчного потока значений — переключение одним флагом, без новых веток.
Где живёт флаг сборки
В Arduino IDE у скетча нет своего места для ключей компилятора: флаг -D туда не дотянуть без правки platform.txt, а правка глобального конфига IDE ломается при первом обновлении — я на этом собрал грабли и больше не хожу. Поэтому в IDE я описываю флаг первой строкой скетча: //#define DBG_ON 1, и включаю его, раскомментировав. Примитивно, зато флаг виден в диффе и не теряется между машинами.
В PlatformIO флаг живёт в platformio.ini: build_flags = -D DBG_ON — и среды делаются двумя секциями [env:debug] и [env:release] с общим кодом. Сборка без флага — release, с флагом — debug, и переключение не трогает исходник. Это тот же механизм, что у любых ключей -D препроцессору: флаг передаётся компилятору из командной строки, скетч остаётся чистым.
Второй флаг добавляйте по мере нужды, а не заранее: DBG_ON — события и пороги, DBG_VERBOSE — построчный поток значений. Две ветки #ifdef, четыре макроса — и вы различаете «что случилось» от «как оно тикало», не трогая код. Третий этаж, журналы с датой и графиком, живёт на компьютере в Processing и pyserial — туда уезжают те же строки, и таблица растёт сама.
Рантайм-переключатель — ложный друг: команда debug on из урока про текстовое меню включает болтливость, но строки при этом остаются в плате и память платят постоянно. Уровни логов и #ifdef — не конкуренты, а два этажа: уровни отбирают, что печатать прямо сейчас, #ifdef решает, будет ли отладка в сборке вообще. Я оставляю в бою INFO-строки за F(), весь DEBUG живёт за флагом сборки и в бой не ездит.
| Вариант печати | Флеш | RAM | Время в loop |
|---|---|---|---|
| Serial.println со строкой без F(), в бою | строка и вызов | копия строки в ОЗУ | ~31 мс на 30 символов при 9600 |
| DBG-макрос, отладочная сборка | строки и вызовы | ноль: литералы за F() | то же ~31 мс на строку |
| DBG-макрос, боевая сборка | ноль байтов | ноль | ноль: вызов вырезан |
| Уровни логов, DEBUG молчит | строки в плате | строки без F() — в ОЗУ | проверка уровня на каждый вызов |
Эфир сборки в симуляторе заменён переключателем: команды dbg on и dbg off двигают флаг, поток и тишина сравниваются на глаз. В настоящем скетче выбор делает #ifdef при сборке — здесь то же решение показано моделью на переменной, и это честная оговорка: компилятор вырезает печати целиком, а флаг в рантайме лишь молчит о них.
- 1. Что делает компилятор с вызовом DBG() в боевой сборке, где макрос — пустышка?
- 2. Строка лога без обёртки F() на 30 символов съедает в оперативке...
- 3. Зачем в макросе обёртка do-while(0)?
- 4. Флаг -D DBG_ON в PlatformIO задаётся...
- 5. Уровни логов и #ifdef DEBUG — как соотносятся?
- #ifdef DBG_ON решает при сборке: печати или пустышка — в бою не остаётся ни байтов флеша, ни RAM, ни вызовов
- Макросы в обёртке do-while(0) со скобками вокруг аргументов: безопасно после if и с выражениями
- В DBG() — только просмотр: побочные эффекты исчезнут в боевой сборке и сломают логику
- В Arduino IDE флаг — первой строкой скетча; в PlatformIO — build_flags = -D DBG_ON и две среды
- Боевой прогон сборки перед выездом: вырезанная отладка — то, что легко забыть и дорого искать
Частые вопросы по теме урока
- Что делает компилятор с вызовом DBG() в боевой сборке, где макрос — пустышка?
- Тело do {} while (0) пустое, компилятор выбрасывает вызов целиком — и аргументы вместе с ним: строк нет, времени нет. Вырезает вызов вместе с аргументами
- Строка лога без обёртки F() на 30 символов съедает в оперативке...
- Без F() литерал копируется в оперативку целиком: 30 символов — 30 байтов. Обёртка F() держит строку во флеше. 30 байтов
- Зачем в макросе обёртка do-while(0)?
- Развёрнутый после if голый println съедает ветку else; do-while(0) превращает макрос в самостоятельный оператор. Делает макрос безопасным после if без скобок
- Флаг -D DBG_ON в PlatformIO задаётся...
- build_flags = -D DBG_ON передаёт флаг компилятору; исходник остаётся чистым, переключение — выбором среды сборки. В platformio.ini через build_flags, отдельно для сред debug и release
- Уровни логов и #ifdef DEBUG — как соотносятся?
- Уровни — про выбор в момент печати, #ifdef — про наличие отладки в прошивке. У меня в бою INFO живёт, DEBUG ездит только за флагом. Два этажа: уровни отбирают строки на лету, #ifdef решает, есть ли отладка в сборке