Метеоузел на ESP32 опрашивал соседний контроллер раз в десять секунд и отдавал JSON локальному дашборду. Первые сутки всё было хорошо. На третьи разбор стал падать с NoMemory при том же ответе, а перезагрузка лечила ровно на сутки. Я вычитывал память печатью строк, пока не вывел свободную кучу в тот же журнал: за сутки она таяла, а крупнейший непрерывный блок таял втрое быстрее. Свободных сотен килобайт было полно — просто ни один кусок не был достаточно велик, чтобы принять очередной пул.
Причина сидела в моей аккуратности наоборот: документ я создавал внутри функции разбора — локальный, красивый, умирающий при выходе. Каждые десять секунд куча получала пару malloc/free, а между ними в освободившиеся куски вгрызались String, HTTP-клиент и WiFi. По отдельности каждый поток был безупречен, вместе — пила, распилившая кучу на ломти. Хуже всего, что курс падения рос со временем: чем сильнее изрезана куча, тем меньший кусок находил аллокатор под пул — и тем чаще он резал новые дыры. Как собрать и разобрать ответ по-настоящему, показано в уроке про JSON по HTTP на ESP32; здесь вторая половина истории: куда девается куча после разбора.
План: три стратегии жизни документа — локальный в каждой итерации, глобальный с clear() и глобальный без него; цена каждой в операциях аллокатора; замер парой ESP.getFreeHeap() и ESP.getMaxAllocHeap(); и правило, которым я разруливаю выбор за секунду — документ живёт столько, сколько данные. Симулятор покажет обе цифры модельными счётчиками: кучу движок не считает, зато протечку старых ключей покажет по-настоящему.
Три стратегии жизни документа
Локальный документ в каждой итерации — любимец учебников: JsonDocument doc; внутри функции разбора, память ушла вместе с областью видимости, ничего не протекает. На седьмой версии пул растёт под данные сам, на шестой DynamicJsonDocument(768) держит пул фиксированным. Плата платит за аккуратность парой malloc/free на каждый цикл. Один цикл в десять секунд — три тысячи пар в сутки, и каждая — окно, в которое соседи по куче успевают вгрызнуться в освободившийся кусок. Сам по себе локальный документ не виноват: беда начинается, когда в те же окна лезет всё остальное.
Глобальный документ с clear() в начале разбора выделяет пул один раз и дальше переиспользует его: куча платит единожды, итерации ходят по одному месту. clear() здесь не косметика — он стирает прежние ключи; без него документ превращается в протечку наоборот: данные прибывают, старые не уходят. Порядок clear, разбор, печать мы проговаривали в уроке про два документа запрос-ответ — там про очередность; здесь про экономику.
Выбор делаю по правилу: документ живёт столько, сколько данные. Данные на один HTTP-ответ — глобальный документ с clear(), разбор в millis-цикле. Данные на весь сеанс — кольцевой буфер показаний, словарь команд — глобальный без clear вовсе, ключи там живут годами. Локальный в итерации оставляю коду, который зовётся раз в минуты и реже: разбор команды из монитора, конфиг при старте.
#include <WiFi.h>#include <HTTPClient.h>#include <ArduinoJson.h> JsonDocument doc; // v6: DynamicJsonDocument(768) doc;unsigned long lastAsk = 0;int iter = 0; void setup() { Serial.begin(115200); WiFi.begin("HomeNet", "parol"); while (WiFi.status() != WL_CONNECTED) { delay(300); Serial.print("."); } Serial.println();} void loop() { if (millis() - lastAsk < 10000) return; // цикл без delay lastAsk = millis(); iter++; HTTPClient http; http.begin("http://192.168.1.10/meteo"); int code = http.GET(); if (code == 200) { String body = http.getString(); doc.clear(); // старые ключи не протекут DeserializationError err = deserializeJson(doc, body); if (!err) { float t = doc["temp"] | 0.0; Serial.print("iter "); Serial.print(iter); Serial.print(" t="); Serial.print(t, 1); Serial.print(" json="); Serial.println(measureJson(doc)); } else { Serial.println(err.c_str()); } } else { Serial.print("http "); Serial.println(code); } http.end(); if (iter % 50 == 0) { // раз в 500 секунд — взгляд на кучу Serial.print("heap: svobodno "); Serial.print(ESP.getFreeHeap()); Serial.print(", krupnejshij blok "); Serial.println(ESP.getMaxAllocHeap()); }}Пара ESP.getFreeHeap() и ESP.getMaxAllocHeap() читается как диагноз, а не как две случайные цифры. Первая — сумма свободных байт, вторая — крупнейший непрерывный кусок, и разрыв между ними и есть фрагментация: на живом узле с WiFi я ловил 76 КБ свободных против 61 КБ крупнейшего блока — после суток HTTP-опроса раз в десять секунд. Именно второй цифрой советуют задавать размер пула: пул от getMaxAllocHeap честнее пул от getFreeHeap, который обещает больше, чем реально выделится. Та же картина на форумах и в issue HTTPClient: свободной памяти много, а Update.begin или новый запрос падают — непрерывного куска нужной длины нет.
Замер, который ставит точку, стоит одну строку в millis-цикле: печать обеих цифр каждые пятьдесят итераций и сутки узла на столе. Тревога — не когда тает getFreeHeap, а когда тает крупнейший блок: опустился ниже удвоенного пула документа — разбор скоро начнёт задыхаться. Строки String в том же цикле вгрызаются тем же способом, о чём разобрано в уроке про строки и копии; WiFi-клиент берёт память из той же кучи транзитом, и его пики видны в минимальной куче ESP.getMinFreeHeap().
Седьмая версия упростила выбор: JsonDocument сам подрастает под данные, и глобальному экземпляру с clear() почти всегда хватает места, занятого в первый же раз. Ёмкость всё равно считают заранее — урок про расчёт ёмкости даёт арифметику пула под NoMemory-тест, а урок про JsonDocument v7 показывает, чем v7-документ отличается от DynamicJsonDocument под капотом. Правило с v7 не изменилось: документ глобальный, clear() первой строкой разбора, локальные — для редких вызовов. Нюанс для ответов с большим разбросом длины: v7-документ, подросший под разовый пик, не отдаёт память обратно, поэтому для пиковых ответов держат отдельный документ, который живёт только на время пика и умирает целиком, вместе со своим пулом.
Что считать тревогой
Порог, на котором я поднимаю флаг: крупнейший блок ниже удвоенного пула документа. Дальше каждый новый сосед по куче — HTTP, экран, String — откусывает от того же остатка, и первый же пик трафика добьёт разбор. Второй маркер — дрожание: getFreeHeap после итерации плавает на килобайты от цикла к циклу, значит в цикле кто-то аллоцирует; ищите локальные документы и String-склейки раньше, чем WiFi.
Поиск конкретного источника я веду бисекцией: печать freeHeap до и после подозрительной функции в одной итерации. Половина функций — разрыв нулевой, половина — валит сотни байт за вызов; делим список подозреваемых пополам, три-четыре раунда, и виновник найден с точностью до строки. WiFi я исключаю сразу: его буферы скетчу не принадлежат, ступеньки от него — норма, лечится не кодом, а пониманием.
Цикл без delay здесь не прихоть, а условие честного замера: блокирующий delay прячет пики аллокаций и растягивает сутки теста в неделю. Millis-каркас разобран в уроке про millis вместо delay, а точный размер пакета до отправки или разбора знает урок про measureJson — с ним пул подбирается под фактический JSON, а не на глаз.
| Стратегия | Цена за итерацию | Риск, который я вижу вживую |
|---|---|---|
| Локальный JsonDocument в итерации | пара malloc/free на каждый цикл; на ESP32 — тысячи за сутки | дыры от соседей по куче: freeHeap стоит, maxAllocHeap тает |
| Локальный DynamicJsonDocument(768) (v6) | тот же malloc/free, пул фиксирован | 768 байт — крупный кусок, который аллокатору труднее найти после недели работы |
| Глобальный и clear() в начале разбора | одна аллокация на всё время работы | забытый clear(): старые ключи протекают в ответ |
| Глобальный без clear() | нуль аллокаций | JSON пухнет, ответы врут прежними полями — баг, который виден не сразу |
Кучу движок не считает, поэтому обе цифры в симуляторе — модельные счётчики: svobodno и krupnejshij blok. Модель показывает разницу стратегий: локальный документ в каждой итерации роняет крупнейший блок на модельные шестнадцать байт за цикл — не метрика с платы, а множитель для наглядности, — глобальный платит один раз. А вот протечку ключей движок показывает по-настоящему: режим без clear() оставляет в документе поля прошлых пакетов, и печать JSON врёт на ваших глазах.
Что попробовать. Ручка A0 выбирает режим: крайнее левое — локальный документ в каждой итерации, середина — глобальный с clear(), крайнее правое — глобальный без clear. Пакеты приходят по очереди от двух устройств с разными ключами: насос со state и h, вентилятор с rpm. В правом режиме после второго пакета в ответе живут поля обоих — measureJson показывает прирост в байтах. Светодиод вспыхивает на каждой итерации.
Режим меняется на лету, и в этом половина пользы: подвиньте ручку в середине сеанса и смотрите на счётчики. Переход с локального на глобальный останавливает счётчик malloc/free, но украденное уже не вернуть — krupnejshij blok стоит на месте. Переход в режим без clear() сначала выглядит выгодой — нулевые аллокации, — а через две итерации печать показывает смесь ключей, и цена этой выгоды налицо.
- 1. Локальный JsonDocument создаётся в каждой итерации цикла раз в 10 секунд. Что тает быстрее всего на ESP32 через сутки?
- 2. В ответе вентилятора прилетело поле state со значением on. Какой документ так врёт?
- 3. Какую цифру честнее использовать для задания размера пула на ESP32?
- 4. Пакеты ходят по очереди: у насоса поля state и h, у вентилятора rpm. Через 20 итераций без clear() что вернёт doc["state"]?
- 5. Документ нужен на один HTTP-ответ, разбор — раз в 10 секунд. Что выберу?
- Локальный документ в итерации — пара malloc/free на цикл; на ESP32 это тысячи циклов в сутки и дыры от соседей по куче
- Разрыв ESP.getFreeHeap() и ESP.getMaxAllocHeap() — метрика фрагментации: тает не сумма, а крупнейший непрерывный блок
- Глобальный документ с clear() в начале разбора платит аллокацией один раз; clear стоит до deserializeJson
- Забытый clear() не ругается: старые ключи протекают в ответы, JSON пухнет, данные врут
- Правило: документ живёт столько, сколько данные — ответ, сеанс или вечность
Частые вопросы по теме урока
- Локальный JsonDocument создаётся в каждой итерации цикла раз в 10 секунд. Что тает быстрее всего на ESP32 через сутки?
- malloc/free возвращают байты, но соседи по куче вгрызаются в освободившиеся куски: сумма стоит на месте, крупнейший блок тает. ESP.getMaxAllocHeap(): дыры от соседей по куче режут крупнейший непрерывный кусок
- В ответе вентилятора прилетело поле state со значением on. Какой документ так врёт?
- Без clear() прежние ключи остаются в документе, и serializeJson печатает смесь двух пакетов. Глобальный, у которого забыли clear() перед разбором
- Какую цифру честнее использовать для задания размера пула на ESP32?
- Аллокация проходит только в непрерывном куске; getFreeHeap обещает больше, чем реально выделится. ESP.getMaxAllocHeap() — крупнейший непрерывный блок
- Пакеты ходят по очереди: у насоса поля state и h, у вентилятора rpm. Через 20 итераций без clear() что вернёт doc["state"]?
- Ключи не удаляются сами: serializeJson покажет 58 байт вместо 40, а поле state будет жить в документе вечно. Значение on: ключ остался в документе с пакета насоса
- Документ нужен на один HTTP-ответ, разбор — раз в 10 секунд. Что выберу?
- Данные живут один ответ — документ переиспользуется: одна аллокация на всё время работы, ключи стирает clear(). Глобальный с clear() в начале разбора