Метеостанция на Uno опрашивала BME280 и раз в минуту отдавала данные в Serial — loop пробегал за четыре миллисекунды, и я позволил себе разбор ответа API на двести с лишним байт прямо между делами. Через неделю заметил: кнопка листания экрана стала ловить каждое третье нажатие, а записи в логе пошли с пропусками по полминуты. Разбор съедал свои миллисекунды в каждом проходе, и millis-планировщик из урока про millis вместо delay начал их проглатывать вместе с моими нажатиями. С тех пор цену разбора я меряю так же честно, как ток ленты или цену delay — цифрой с условием, а не ощущением.
Ничего экзотического в замере нет, но два грабли я собрал заранее. Первая: одиночный разбор в миллисекундах не разглядеть — результат слишком короткий, и millis() покажет то ноль, то единицу, что бессмысленно. Вторая: замер с печатью внутри цикла — подлог, потому что печать на 9600 бод стоит миллисекунду на символ и примешивается к дельте. Честная методика ниже, и она одинаково работает на Uno, на ESP32 и в симуляторе этой книги, а пакеты для замера возьмём плоские, как в уроке про первый разбор с нуля.
Разложим бюджет на пальцах. Loop станции делал четыре задачи: опрос датчика по шине, обновление экрана, опрос кнопки, минутная передача данных. Планировщик раздавал им миллисекунды, и каждой хватало. Разбор на две миллисекунды в каждом проходе — половина бюджета: кнопка опрашивается вдвое реже, экран перерисовывается с запозданием, а всё потому, что одна задача тихо оттяпала себе кусок. Цена в микросекундах нужна не для красоты — она решает, влезет ли разбор в loop вообще или ему нужен отдельный таймер.
У micros() есть свой предел, и о нём надо знать заранее: счётчик переполняется примерно раз в семьдесят минут работы, и дельта через границу переполнения получится чушь. Для серий замера в секунды это не проблема — просто не ставьте длинный прогон на ночь. millis() терпит почти пятьдесят суток, но его разрешение в тысячу раз грубее. Для замера разбора мой выбор — micros() и короткие серии: сто повторов на Uno занимают доли секунды, до переполнения не добираются. И держите источник замера неизменным между прогонами: сравнивать строку-константу с чтением с карточки — бессмыслица.
Как мерить честно: серия и микро
Методика: millis() до, сто повторов deserializeJson, millis() после, разницу делим на сто. Получаются микросекунды на разбор — ровно та валюта, в которой loop тратит бюджет между своими задачами. Для коротких замеров берите micros(): на Uno разрешение четыре микросекунды, миллисекундный millis() такой факт просто не увидит. Важна и честность источника: строка-константа во flash и строка с SD-карты — два разных замера, и сравнивать их между собой бессмысленно, о втором будет ниже.
В сим-скетче ниже замерщик уже стоит: три пакета на три, двенадцать и тридцать полей, миллисекунды до и после разбора. И вот честная оговорка, которую я обязан поставить в начало урока: движок книги не списывает виртуальные миллисекунды за чистые вычисления, поэтому дельта на экране будет ноль для любого пакета. Это не подгонка и не баг — техника замера в симе та же, что на плате, а настоящие числа даёт только живая плата. Если дельта вдруг покажется не нулевой, а подозрительно круглой вроде 480 — это квантование кадров движка, а не цена разбора: тяжёлые скетчи исполняются кадрами, и граница кадра иногда попадает внутрь замера.
- Раздуйте пакет. Замените paket(30) на paket(100): строка вырастет в разы, дельта в симе не сдвинется — на плате этот же эксперимент растянет замер пропорционально размеру.
- Пересчитайте на ESP32. Возьмите расчётную строку с пятью микросекундами на байт и поделите на сто: так выглядит цена того же пакета при 240 МГц.
- Ужмите ключи. Переименуйте k в p: пакет похудеет на один байт за поле — на Uno каждый байт стоит микросекунды разбора, а в эфире ещё и миллисекунду передачи на 9600.
- Поймайте молчаливый ноль. Обратитесь после разбора p1 к doc.k5: в пакете из трёх полей его нет, чтение даст ноль без единой жалобы — та самая ловушка отсутствующего ключа.
Что говорят цифры живой платы
Живые ориентиры такие. Uno при 16 МГц разбирает плоский пакет в двести байт примерно за одну-две миллисекунды — на байт выходит порядка пяти-десяти микросекунд. ESP32 при 240 МГц перемалывает тот же пакет за десятки микросекунд — примерно в сто раз быстрее, и на фоне беспроводного стека этим можно пренебречь. Отсюда моё правило: на Uno следить за пакетами свыше пары сотен байт и за разбором в каждом проходе loop, на ESP32 не считать микросекунды вовсе, пока что-то не подтормаживает. Сериализация стоит того же порядка, что и разбор: документ собирается обратно в текст за сопоставимое время.
Две цифры из практики, которые переворачивают картину. Первая: печать в Serial на 9600 бод стоит около миллисекунды на символ, и красивый serializeJsonPretty на шестьдесят байт уедет в порт за полсотни миллисекунд — медленнее самого разбора в десятки раз. Отладочные печати в рабочем loop — дорогое удовольствие, их место в setup или в редких событиях. Вторая: главный тормоз разбора почти никогда не парсер, а источник. Файл с SD-карты отдаёт байты по одному, каждый вызов read() — свои микросекунды, и на килобайте набирается десятки миллисекунд; сеть отдаёт пакетами с паузами. Лечится буфером между источником и парсером: библиотека StreamUtils кладёт ReadBufferingStream между файлом и deserializeJson, и на карточках, читающих по байту, замер ускоряется в разы.
Сериализация имеет и свою цену памяти: текст, который вы печатаете, сначала где-то живёт. Печать прямо в Serial обходится без промежуточной строки — байты уходят в порт по мере сборки, куча не растёт. А вот сборка в String на Uno уже заметна: строка в сотню байт — сотня байт кучи, плюс перевыделения при росте. Моё правило: в рабочем скетче serializeJson получает приёмник-буфер или порт, String остаётся прототипам в setup — про цены приёмников подробно говорил урок про сериализацию.
#include <ArduinoJson.h> DynamicJsonDocument doc(768);const char* paket = "{\"node\":\"kotelnaya\",\"temp\":21.4,\"humi\":58," "\"wind\":3.5,\"ok\":true,\"batt\":87}"; void setup() { Serial.begin(9600); // Серия из ста разборов: одиночный разбор в микросекунды // миллисекундным millis() не разглядеть const int RAZ = 100; unsigned long t0 = micros(); for (int i = 0; i < RAZ; i++) { deserializeJson(doc, paket); } unsigned long d = micros() - t0; Serial.print("razbor mks: "); Serial.println(d / RAZ); // Фильтр: разрешён один ключ, остальное парсер пропускает JsonDocument filter; filter["temp"] = true; deserializeJson(doc, paket, DeserializationOption::Filter(filter)); Serial.print("temp: "); Serial.println(doc["temp"].as<float>());} void loop() {}Рычаги: меньше документ — дешевле жизнь
Самый дешёвый рычаг — не гнаться за скоростью парсера, а уменьшить работу. Из ответа API вам нужны температура и город, а приходит прогноз на неделю с ветрами и ультрафиолетом: фильтр DeserializationOption::Filter разрешает нужные ключи, и парсер пропускает остальные ещё на лету — экономятся и микросекунды, и память документа, о которой подробно говорил урок про JsonDocument. На Uno это не эстетика, а выживание: документ, пережатый под маленький пакет, не упрётся в потолок кучи посреди лета.
Числа на фильтре выглядят так. Ответ с прогнозом на неделю занимает пятьсот байт текста; после фильтра с тремя разрешёнными ключами документ весит тридцать-сорок — экономия на порядок и в памяти, и во времени разбора, потому что парсер честно пропускает лишнее, а не тащит его в документ. Важно и то, что фильтр не меняет скетч дальше: разрешённые ключи читаются теми же командами по именам. Самый выгодный рефакторинг в разделе — две строки кода, минус девяносто процентов работы.
Второй рычаг — сам формат пакета. Плоские ключи разбираются быстрее матрёшки: каждый вложенный объект — ещё один проход парсера и ещё кусок памяти под промежуточную структуру. Короткие имена ключей экономят и память документа, и эфир, если пакет уходит по воздуху: длинный заголовок на скорости 9600 стоит миллисекунду на символ. И третий рычаг — не дублируйте память: getString() с сети копирует весь ответ в кучу целиком, потом парсер строит документ поверх — на ESP32 с сотнями килобайт это незаметно, на Uno с двумя килобайтами самоубийство. Разбор из потока вместо строки я подробно разбирал в уроке про JSON по HTTP — там же про цены HTTP-стека.
| Действие | Платформа и условие | Порядок цены |
|---|---|---|
| Разбор пакета в 200 байт | Uno, 16 МГц, плоский пакет | 1-2 мс, порядка 5-10 мкс на байт |
| Тот же пакет | ESP32, 240 МГц | Десятки мкс — на фоне сети незаметно |
| Печать пакета в Serial | 9600 бод, 60 байт | Около 60 мс — печать дороже разбора в десятки раз |
| Чтение источника | SD-карта без буфера, каждый read() по байту | Микросекунды на байт; килобайт — десятки мс ожидания |
- 1. Пакет в 60 байт на Uno: что дороже по времени?
- 2. Как замерить разбор, длящийся сотни микросекунд, миллисекундным millis()?
- 3. Почему дельта в сим-скетче урока нулевая даже для большого пакета?
- 4. DeserializationOption::Filter делает что?
- 5. Ответ API читают с SD-карты, каждый read() отдаёт байт. Что даст самый заметный прирост?
- Замеряют серией: millis() до, сто разборов, millis() после, разницу на сто — одиночный разбор в миллисекундах не виден
- Uno 16 МГц: двести байт примерно за 1-2 мс; ESP32 240 МГц: десятки мкс — за пакетами следим на Uno
- Печать на 9600 бод стоит миллисекунду на символ и бьёт разбор в десятки раз — отладочные печати из рабочего loop убираем
- Тормозит чаще источник, а не парсер: SD и сеть лечатся буфером вроде ReadBufferingStream
- Фильтр DeserializationOption::Filter пропускает лишние ключи на лету — экономия и времени, и памяти документа
Частые вопросы по теме урока
- Пакет в 60 байт на Uno: что дороже по времени?
- Печать на 9600 бод стоит около миллисекунды на символ — шестьдесят миллисекунд. Разбор шестидесяти байтов на Uno — порядка полумиллисекунды: печать дороже в десятки раз. печать в Serial на 9600 бод
- Как замерить разбор, длящийся сотни микросекунд, миллисекундным millis()?
- Одиночный разбор короче разрешения millis(). Серия из ста повторов накопит дельту в миллисекунды, и деление на сто вернёт цену одного разбора в микросекундах. серией: до, сто повторов, после, разницу на сто
- Почему дельта в сим-скетче урока нулевая даже для большого пакета?
- Виртуальные миллисекунды движок начисляет за задержки вроде delay и HTTP-пауз, а не за вычисления. На плате те же строки замера дают настоящие микросекунды. движок не тратит виртуальное время на чистые вычисления
- DeserializationOption::Filter делает что?
- Фильтр — документ с разрешёнными ключами: парсер пропускает всё остальное ещё на лету, экономя и время разбора, и память итогового документа. разбирает только разрешённые ключи
- Ответ API читают с SD-карты, каждый read() отдаёт байт. Что даст самый заметный прирост?
- Тормозит источник, а не парсер: каждый read() стоит микросекунды. ReadBufferingStream из StreamUtils закрывает эту цену одним большим чтением. буфер между файлом и парсером