Узел учёта воды в котельной я собирал на Uno с Ethernet-шилдом: раз в минуту шилд ходил в API городского сервиса, забирал давление в магистрали, температуру и расход. Сервис отдавал подробный ответ почти на два килобайта: справочник точек, суточная статистика, история за последний час по минутам. Месяц узел работал, а потом начал перезагружаться — без записи в логе: тишина на пару секунд и снова онлайн. Классическая картина кончившейся ОЗУ.
Сначала я гонялся за призраком: грешил на Ethernet-шилд, перетыкал блок питания, перепрошивал по три раза. Диагноз поставила одна строка Serial.println(freeRam()) в начале loop(): за сутки число медленно сползало вниз, а потом плата уходила в перезапуск. Это была не поломка, а проектирование: я не считал память до того, как стал её тратить. С тех пор у меня правило — для любой платы с ОЗУ меньше четырёх килобайт бюджет считается до первого deserializeJson, а не после первой перезагрузки.
Считать байты на Uno приходится с самого старта: SRAM здесь 2048 байта, и это всё — глобальные переменные, стек, буферы Serial по 64 байта на вход и выход, служебные структуры Ethernet-шилда. Свободным, по моим замерам freeRam(), оставалось меньше полутора килобайт. А просил разбор куда больше: ответ на 1900 байт в String — это 1900 байт кучи, документ под него — ещё порядка двух тысяч, потому что каждый ключ и каждое значение живут в документе с накладными расходами. Итого почти четыре килобайта при двух доступных. Перезагрузка без сообщения — мягкий исход: так ведёт себя AVR, когда куча доходит до стека.
Фильтр из прошлого урока здесь не удобство, а необходимость: три нужных поля против двух килобайт обвязки. Разберём бюджет ОЗУ построже, посмотрим фильтр с массивом — взять первые N элементов, — честно обозначим границы, где фильтр бессилен, и покажем эффект в симуляторе — с реальными цифрами на выходе.
Бюджет ОЗУ: кто сколько съел
Считаю всегда в одном порядке. Сначала константа: SRAM платы — Uno 2048 байта, ESP8266 около 80 килобайт (в куче реально живёт 40–50, остальное у Wi-Fi-стека), ESP32 — сотни килобайт. Потом статика: глобальные переменные и буферы библиотек — их показывает компилятор при прошивке. Потом стек: на AVR он растёт с другого конца кучи и на простых скетчах занимает сотню-другую байт. Остаток — куча, где живут String и JsonDocument седьмой версии; в шестой документ заводили с фиксированной ёмкостью, и он сидел в статике. Разница видна при частых переподключениях: куча фрагментируется, статика нет, поэтому в v6 продолжительные сессии проходили мягче — если ёмкость была честно посчитана.
Дальше арифметика большого ответа. String на 1900 байт — это 1900 байт кучи сразу. Полный разбор в v6 требовал ёмкость примерно вдвое больше длины ответа: под каждый ключ, значение и служебный слот уходит своё место, поэтому ориентир «ёмкость в полтора-два раза больше строки» мы выводили ещё в первом уроке раздела. Итого 1900 плюс до 3800 — там, где всего два килобайта. На ESP8266 тот же ответ проходит с запасом, на ESP32 — вовсе незаметно; дисциплина фильтров приживается именно на маленьких платах.
Отдельно про String: она перевыделяет память при каждом росте, и на фрагментированной куче это второй по коварности расходчик после самого ответа. Сравнение подходов к склейке строк мы делали в уроке про String-конкатенацию; здесь достаточно знать: чем меньше перевыделений между приёмом и разбором, тем предсказуемее куча. Идеал — приём прямо в разбор, из потока, без промежуточных строк вообще.
Отдельный расход — двойная копия. Если ответ сначала складывается в String из HTTPClient, а потом отдаётся deserializeJson, он живёт в памяти дважды: как строка и как документ. Мы разбирали это на примере ESP32 в уроке про JSON по HTTP: разбор прямо из потока http.getStream() снимает вторую копию. Опция Filter работает и с потоком — связка «поток плюс фильтр» держит в памяти только отобранное.
| Плата | SRAM | Ответ 2 КБ без фильтра | С фильтром на 2–3 поля |
|---|---|---|---|
| Uno | 2048 байта | не влезает: строка плюс документ — около 4 КБ | влезает: документ на десятки байт |
| ESP8266 | около 80 КБ | впритык вместе с Wi-Fi-стеком | влезает с запасом |
| ESP32 | сотни КБ | незаметно | выигрыш в основном по времени разбора |
#include <ArduinoJson.h> // Ответ с почасовым прогнозом; на плате приходит по HTTP, здесь — строкаString packet = "{\"city\":\"Omsk\",\"tz\":18000,\"hourly\":[19.2,18.8,18.1,17.5,17.0,16.4,16.0,15.6,15.1,14.8],\"sunrise\":\"08:42\",\"sunset\":\"17:05\",\"uv\":1.2}"; JsonDocument filter; // v6: StaticJsonDocument<192> filter;JsonDocument doc; // v6: StaticJsonDocument<256> doc; void setup() { Serial.begin(9600); // Массив в фильтре работает лимитом: true по индексам 0..2 // оставит первые три элемента hourly filter["city"] = true; filter["hourly"][0] = true; filter["hourly"][1] = true; filter["hourly"][2] = true; DeserializationError err = deserializeJson(doc, packet, DeserializationOption::Filter(filter)); if (err) { Serial.print("Razbor sorvan: "); Serial.println(err.c_str()); return; } Serial.print("city="); Serial.println(doc["city"].as<const char*>()); for (int i = 0; i < 3; i++) { Serial.print("t"); Serial.print(i); Serial.print("="); Serial.println(doc["hourly"][i].as<float>()); }} void loop() {}Массивный фильтр: первые N элементов
Почасовой прогноз — массив из 48 чисел, а мне нужны ближайшие три. Фильтр с массивом решает ровно это: элементам массива в filter-документе проставляется true по индексам, которые надо сохранить, — filter["hourly"][0] = true; filter["hourly"][1] = true; filter["hourly"][2] = true; — и после разбора в doc["hourly"] лежат первые три значения. Старший из перечисленных индексов работает лимитом: всё, что дальше, библиотека пропускает, не тратя ни байта документа.
Фильтр-документ при этом остаётся крошечным: в нём только имена ключей и маркеры true. Строка ответа может весить килобайт, filter — несколько десятков байт. Поэтому в v6 ёмкость основного документа при фильтрации закладывают по фильтру, а не по ответу: схема результата известна заранее, и StaticJsonDocument<256> надёжно покрывает её на любом ответе этого сервиса. Для данных, которые уходят на экран или в журнал, фиксированная схема — ещё и гарантия, что столбцы не поедут из-за нового поля в API.
Проверьте на своём API: если сервис отдаёт историю по минутам, вам почти никогда не нужны все шестьдесят значений часа. Десять точек для графика, три для тревоги, одна для текущего показания — и каждый запрос с фильтром-лимитом держит документ в заранее известном размере, каким бы длинным ни был ответ. Это второй после памяти аргумент: предсказуемость. На Uno предсказуемость и экономия — синонимы. На моём узле воды документ после фильтра ужался со ста с лишним байт до тридцати с небольшим — этого хватило, чтобы перестать жить на грани.
Эффект в симуляторе
Движок книги опцию Filter не поддерживает, поэтому в сим-скетче фильтр заменён урезанной строкой: то, что оставил бы настоящий фильтр, вырезано заранее. Полный ответ разбирается в один документ, урезанный — во второй; measureJson печатает длину печати обоих, а доля вычисляется прямо в скетче. Это длина печати, не ОЗУ: на плате разница в памяти ещё сильнее из-за накладных расходов на структуру документа.
Чтение результата — точечной формой docSmall.hourly[0]: массив по индексу движок понимает, на плате это doc["hourly"][0]. Попробуйте добавить в урезанную строку четвёртый час — доля подрастёт на несколько процентов; на плате ту же правку делают строкой filter["hourly"][3] = true.
Цифры в скетче реальные: полный документ печатается на 136 символов, отобранный — на 41, доля выходит около тридцати процентов. На плате, где под каждый ключ уходит место сверх самой печати, отобранная версия окажется ещё меньше относительно полной. Отсюда и ориентир из таблицы: два-три поля на Uno — не компромисс, а норма.
- Добавить час. Вставьте в урезанную строку четвёртое число: small_bytes подрастёт на несколько байт, доля сместится вверх — фильтр расширили, документ потяжелел.
- Урезать сильнее. Оставьте в выборке только city: документ ужмётся до пары десятков байт — так выглядит разбор одного скаляра из килобайтного ответа.
- Сравнить чтения. Прочитайте docSmall.hourly[2] и docSmall.hourly[5]: пятого в выборке нет, чтение даст ноль — на плате фильтр оставил бы за кадром то же самое.
- На настоящей плате. Перенесите скетч с фильтром из code-блока на Uno и напечатайте freeRam() до и после разбора: разница покажет реальную цену полного и отобранного варианта.
- Бюджет Uno считают до кода: 2048 байта минус статика, стек и буферы библиотек; свободным остаётся меньше полутора килобайт
- Полный разбор стоит от полутора до двух ёмкостей ответа; приём в String добавляет ещё одну копию — поток снимает её
- Массив в фильтре — лимит: filter["hourly"][2] = true оставляет первые три элемента, остальное не копируется в документ
- Ёмкость doc при фильтрации закладывают по filter-схеме, а не по ответу: схема результата фиксирована
- Фильтр бессилен, когда приём целиком больше SRAM: нужен разбор из потока, чанки или плата побольше
- 1. Почему Uno с ответом на 1900 байт перезагружается, хотя «на ESP32 всё работает»?
- 2. Что именно экономит фильтр при разборе?
- 3. Ответ складывается в String из getString(), затем парсится. Сколько копий ответа живёт в памяти одновременно?
- 4. Сколько элементов массива hourly оставит фильтр с записью filter["hourly"][1] = true?
- 5. Ответ 2 КБ приходит целиком в String на плате с 40 КБ свободной кучи. Что выбрать?
Разбор чужих ответов теперь под контролем по обеим осям: состав — фильтром, объём — лимитом на массивах и потоком. Дальше повернём доску к себе: плата не только читает JSON, но и рассказывает о себе — и собранный насквозь свой документ экономит ту же RAM, только уже у того, кто читает ваши телеметрии. Как собрать его присваиванием doc["key"] = value, с этажами и массивами — следующий урок. Применить оба конца сразу удобно по сети: сборка документа и отправка в MQTT или телеметрия через HTTP POST — по строке serializeJson на отправку.
Частые вопросы по теме урока
- Почему Uno с ответом на 1900 байт перезагружается, хотя «на ESP32 всё работает»?
- Дело не в скорости, а в ОЗУ: две копии данных не помещаются в два килобайта. Перезагрузка без записи в лог — типичный исход встречи кучи со стеком на AVR. Проверьте freeRam() в начале loop(): сползающее число покажет проблему раньше перезагрузки. SRAM Uno 2048 байта: строка плюс документ требуют около 4 КБ — куча доходит до стека
- Что именно экономит фильтр при разборе?
- Парсер идёт по всей строке, канал не ужимается. Экономия — ёмкость документа под неперечисленные поля и работа по их размещению. Канал сэкономит только сам сервис — параметром запроса, если API умеет отдавать меньше полей. Память под документ и часть времени размещения значений
- Ответ складывается в String из getString(), затем парсится. Сколько копий ответа живёт в памяти одновременно?
- String и разобранный документ существуют параллельно. Разбор из потока http.getStream() убирает строковую копию. Две: строка и документ
- Сколько элементов массива hourly оставит фильтр с записью filter["hourly"][1] = true?
- Самый старший из перечисленных индексов — лимит: индексы 0 и 1 заполнены true, значит сохраняются первые два элемента. Первые два
- Ответ 2 КБ приходит целиком в String на плате с 40 КБ свободной кучи. Что выбрать?
- Строка 2 КБ плюс документ — порядка 4–6 КБ, свободно 40 КБ: полный разбор проходит. Фильтр здесь дал бы выигрыш во времени разбора, но не необходимость. Фильтр не нужен: памяти хватает на полный разбор