Поливалка на моём балконе честно проработала месяц, а сломалась — в самый жаркий день, когда меня не было дома. Скетч у неё был дотошный: каждые двести миллисекунд печатал в порт строку вида v=412 nasos=0. За сутки таких строк набегает около четырёхсот тысяч, и монитор порта превращается в конвейер одинакового текста. Тем вечером я листал историю назад в поисках момента, когда насос должен был включиться и не включился, — глаз по строке в секунду, и через сорок минут я поймал себя на мысли, что не ищу проблему, а сортирую мусор. Строки не ранжированы: обычный опрос датчика, лёгкая странность и настоящая катастрофа выглядят в них совершенно одинаково.
И это не только моя беда. Сорок строк в минуту — пока всё работает, каждая выглядит полезной. Но тем вечером мне была нужна одна-единственная строка, та, где датчик замолчал, — и я не мог отличить её от сотни соседних. У строк нет веса: рутинный дебаг, предупреждение и ошибка стоят в одной очереди с равными правами. Данные в них были честные: датчик действительно опрашивался, насос действительно был выключен. Проблема была в подаче. Листать такое можно всю ночь — я и листал, с третьего часа и до подъёма.
Лекарство старше Arduino на десятилетия: уровни логов. В серверном syslog у сообщений есть severity, в Android — LogCat с метками, в консоли браузера — console.debug, console.info, console.warn и console.error, и вы наверняка видели, как там жёлтое и красное отсекается от рутины одним взглядом. В этом уроке переносим тот же порядок в скетч: единый формат строки — метка уровня, метка времени из millis(), текст, — три функции-обёртки вместо голого Serial.print и правило двойного подтверждения, чтобы ложное срабатывание оставалось строкой warn, а не сиреной. Печатать в порт мы научились в первом уроке про монитор порта; теперь научимся печатать так, чтобы журнал через месяц не было стыдно листать.
Четыре уровня: кто когда говорит
Уровней в больших системах бывает от шести до восьми — у syslog целая лестница от Emergency до Debug. Для скетча на Uno хватает четырёх, и запоминать их лучше не по номерам, а по одному вопросу: что устройство делает в момент, когда произносит эту строку? Ничего — это info. Готовится — warn. Действует — error. А ничего и вообще зря печатал — debug.
Debug — внутренности: значения переменных, входы в функции, сырые коды датчиков. Такие строки нужны, пока вы чините, и мешают, когда починили: их отключает первой же строка-константа. Info — нормальный ход: старт, нажатая кнопка, контрольный замер раз в пять-десять секунд. Warn — подозрение: датчик первый раз ответил ниже порога, но ещё не подтвердил; устройство работает, но уже считает. Error — сбой подтверждён, и скетч реагирует: зажигает красный светодиод, глушит насос, уходит в резервный режим.
Ключевая развилка — между warn и error. Я поначалу писал всё в одну кучу: что-то странное — и сразу ОШИБКА, большими буквами. Через неделю красного в журнале стало столько, что глаз перестал цепляться. Тогда появилось правило двойного подтверждения: первый странный опрос даёт warn и счётчик, второй подряд — error и действие. Ложное срабатывание живёт в журнале одной строкой warn и строкой info про восстановление, а сирена включается только по-настоящему.
Второй вопрос, на который отвечает формат, — когда это случилось. Метка времени из millis() внутри строки экономит вечер: строка [W] t=4127s говорит не только «подозрение», но и «на четыре тысячи сто двадцать седьмой секунде после старта», а если скетч печатал и состояние насоса, вы сопоставляете два события по числам, а не по памяти. Метка уровня стоит в начале строки не для красоты: монитор Arduino IDE не умеет ни фильтровать, ни искать по журналу, зато весь лог копируется в файл — и одна команда grep по [E] достаёт все ошибки из сорока тысяч строк. Я разбираю ночные логи именно так: сначала [E], потом [W] вокруг них, а рутину [I] читаю, только если оба уровня ничего не объяснили.
И фильтр — в скетче, а не в мониторе. Монитор порта на Uno строки прятать не умеет, зато умеет скетч: константа UROVEN_LOGA, и сообщение ниже уровня даже не отправляется в порт. На 9600 бод это не педантичность: символ стоит около миллисекунды, и невычеркнутый debug в горячем цикле платит за место в журнале реальным временем loop. К числам вернёмся в граблях, а карта уровней — в таблице.
| Уровень | Метка | Когда печатать | Что делает устройство |
|---|---|---|---|
| debug | [D] | внутренности: значения переменных, входы в функции — только на время отладки | ничего: строка нужна вам, не плате |
| info | [I] | старт, событие кнопки, контрольный замер раз в 5–10 с | ничего: обычная работа |
| warn | [W] | подозрение: первый опрос ниже порога, странная пауза в ответе | готовится: считает повторные опросы, мигает |
| error | [E] | подтверждённый сбой: два опроса подряд ниже порога | действует: красный светодиод, резервный режим |
void setup() { Serial.begin(9600); pinMode(2, INPUT_PULLUP);} void loop() { int v = analogRead(A0); int knopka = digitalRead(2); Serial.print("v="); Serial.print(v); Serial.print(" knopka="); Serial.print(knopka); Serial.print(" millis="); Serial.println(millis()); delay(100);} // Десять строк в секунду, около 28 символов каждая —// 36 000 строк в час. И все с равными правами.Грабли: канал, шум и дорогие строки
Первая грабля — арифметическая. На 9600 бод при кадре 8N1 на байт уходит десять бит, выходит около 960 символов в секунду — почти миллисекунда на символ. Строка в тридцать символов занимает около тридцати мс: печатать её каждые десять нельзя, TX-буфер Uno на 64 байта переполняется, Serial.print превращается из «отдать и забыть» в «подождать места», и loop растягивается до скорости канала. У меня контрольный цикл с тридцатисимвольной печатью раз в 10 мс съедал около двух третей времени. На 115200 лёгче — 11,5 КБ в секунду, — но тонна debug в горячем цикле и там ощутима. При этом печать раз в двести мс, как у поливалки, брала около тринадцати процентов канала — это нормально: проблема не в том, что печатать нельзя, а в том, что печатать много и одинаково.
Вторая грабля — уровни без системы. Пометить всё [E], «чтобы заметнее», — самая человеческая ошибка, и я её сделал: через неделю красного в журнале стало столько, что он перестал быть сигналом. Рабочая пропорция у моей поливалки получилась такая: info — единицы строк в минуту, warn — единицы в день, error — считанные разы за сезон, и каждый — с действием.
Третья грабля — платная, и мы её уже проходили. Склейка v= со String(v) перед каждым println заводит объект в куче: символы плюс служебные поля, а после освобождения — дырка. В уроке про макрос F() мы только что выгоняли литералы из оперативки — и я же зазываю их обратно самодельным логгером. Лечение простое: печатать по частям, Serial.print("v=") и следом Serial.print(v), — строка не заводится вовсе, а формат от этого не страдает. HEX- и BIN-добавки к меткам ставятся тем же вторым аргументом печати, что мы разобрали в прошлом уроке про системы счисления.
Практика: мини-логгер с двойным подтверждением
Формат строки собирается тремя кусками: метка уровня в квадратных скобках, метка времени и текст. Время берём из millis(), делённого на тысячу, — в секундах: сам таймер на millis() вместо delay() разобран в отдельном уроке, здесь он вписан в маленькую функцию stamp(). Печать — по частям, без единого String: Serial.print("[I] "), stamp(), пробел, сообщение.
Дальше — функции-обёртки, прямое продолжение урока про свои функции: logInfo, logWarn и logError принимают готовый текст и добавляют метку с уровнем. Формат живёт в одном месте: захотели HEX-метку millis() из прошлого урока — правите stamp(), а не сорок вызовов по всему скетчу. В полной версии из скетча ниже есть и logDebug с фильтром UROVEN_LOGA: сообщение ниже уровня обрывается голым return, даже не начав печататься.
Логика уровней — обычные ветки if/else из урока про условия. Опрос раз в секунду: значение ниже порога — поднимаем счётчик странных опросов; первый такой — warn, второй подряд — error, красный светодиод, зелёный гаснет. Значение вернулось, а счётчик был поднят — печатаем info про выздоровление и сбрасываем счётчик. Каждые пять секунд в норме — контрольная строка info, чтобы журнал дышал. В симуляторе ниже — компактная версия: ползунок A0 изображает капризный датчик, светодиоды на пинах 7 и 8 — реакцию устройства.
const int UROVEN_LOGA = 1; // 0 — всё, 1 — без debug, 2 — только warn и error void stamp() { Serial.print("t="); Serial.print(millis() / 1000); Serial.print("s");} void logDebug(String msg) { if (UROVEN_LOGA > 0) return; // строка даже не уйдёт в порт Serial.print("[D] "); stamp(); Serial.print(" "); Serial.println(msg);} void logInfo(String msg) { if (UROVEN_LOGA > 1) return; Serial.print("[I] "); stamp(); Serial.print(" "); Serial.println(msg);} void logWarn(String msg) { if (UROVEN_LOGA > 2) return; Serial.print("[W] "); stamp(); Serial.print(" "); Serial.println(msg);} void logError(String msg) { // ошибки не фильтруются никогда Serial.print("[E] "); stamp(); Serial.print(" "); Serial.println(msg);} void setup() { Serial.begin(9600); logDebug("эта строка видна только с UROVEN_LOGA=0"); logInfo("старт");} void loop() { static unsigned long last = 0; if (millis() - last >= 5000) { last = millis(); logInfo("heartbeat"); }}- Запустите симулятор, не трогая ползунок, и посчитайте строки журнала за первые десять секунд. Прикиньте, сколько их было бы при печати раз в 100 мс, как в хаос-скетче, — и сколько миллисекунд в секунду съедала бы такая печать на 9600 бод.
- Уроните ползунок A0 на секунду и отпустите: найдите в журнале пару из [W] и следом [I] ozdorovel. Это ложное срабатывание — попробуйте отыскать его глазами в хаос-логе из начала урока и сравните усилия.
- Утопите ползунок на три опроса: [W] должен смениться [E] со счётчиком oprosov, красный светодиод — загореться. Отпустите, найдите строку восстановления и сверьте oprosov= с числом опросов в секунду.
- Перенесите фильтр UROVEN_LOGA из полной версии в симуляторный скетч и поставьте 2: контрольные строки [I] должны исчезнуть, а [W] и [E] — остаться.
- Замените в stamp() десятичную метку на Serial.print(millis(), HEX) из прошлого урока и сравните ширину строк журнала; помните, что millis() переполняется через ~49,7 суток.
- Единый формат строки — метка уровня, метка времени из millis(), текст: строку проблемы видно глазами за секунды даже в тысячах строк
- Четыре уровня: debug — внутренности, отключается фильтром; info — старт и события; warn — подозрение без подтверждения; error — подтверждённый сбой, после которого устройство действует
- На 9600 бод символ стоит около 1 мс, TX-буфер — 64 байта: спам-печать в горячем loop() растягивает цикл до скорости канала
- Двойное подтверждение: первый опрос ниже порога — warn, повторный подряд — error; ложное срабатывание остаётся одной строкой warn, а не сиреной
- Строки лога печатайте по частям через Serial.print: String-склейка каждый опрос — это объекты в куче на 2048 байт RAM и молчаливая смерть скетча через сутки
- 1. Первый опрос датчика дал значение ниже порога. Что напечатает скетч с двойным подтверждением из урока?
- 2. Почему печать тридцати символов каждые 10 мс на 9600 бод тормозит скетч?
- 3. Где правильное место для фильтра уровней?
- 4. Что не так со склейкой String("v=") + String(v), собираемой перед каждым опросом?
Частые вопросы по теме урока
- Первый опрос датчика дал значение ниже порога. Что напечатает скетч с двойным подтверждением из урока?
- Первое чтение ниже порога — только подозрение: датчик мог чихнуть разово. Второй опрос подряд переводит журнал в [E] и включает реакцию устройства. [W]: подозрение, ошибка подтвердится только вторым опросом подряд
- Почему печать тридцати символов каждые 10 мс на 9600 бод тормозит скетч?
- 9600 бод при кадре 8N1 — десять бит на байт, около 960 символов в секунду. Когда печать требует больше, буфер на 64 байта переполняется, и loop живёт со скоростью канала. Символ стоит около 1 мс: 30 символов — это 30 мс, буфер TX на 64 байта переполняется, и print начинает ждать места
- Где правильное место для фильтра уровней?
- Фильтр на этапе печати выгоднее всего: ненужная строка не тратит ни миллисекунд loop, ни бодов. Монитор порта на Uno фильтровать не умеет вовсе. В скетче: сообщение ниже UROVEN_LOGA даже не уходит в порт — экономия и времени loop, и канала
- Что не так со склейкой String("v=") + String(v), собираемой перед каждым опросом?
- Печати хватает Serial.print по частям: число уходит прямо из переменной. String-объекты в loop() множатся и фрагментируют кучу — об этом был урок про макрос F(). Каждая склейка заводит объект в куче: на Uno с её 2048 байтами RAM в loop() это путь к фрагментации и молчаливой смерти