Узел котельной жил год без хлопот: температура раз в минуту, свой командный топик, тишина в логах. Потом я подвесил на него вторую подписку — на сводку погодной станции, где соседний ESP32 выкладывал JSON на четыреста байтов: давление, влажность, заряд батареи. Callback молчал. Проверки стандартные ничего не дали: пинг до брокера шёл, client.connected() показывал единицу, свои команды приходили как ни в чём не бывало. Сводки не было неделю — пока я случайно не сократил её на сто байтов, и она вдруг появилась.
Вскрылось то, о чём я не думал, когда читал про буфер библиотеки: буфер у PubSubClient один на всё — и на отправку, и на приём, и по умолчанию в нём 256 байтов. Как поднять его через setBufferSize и почему publish возвращает ноль на длинном discovery, я разбирал в уроке про setBufferSize — там исходящая сторона и механика вызова. Здесь копаю глубже: что происходит со входящим пакетом длиннее буфера, сколько на самом деле свободной RAM у ESP8266 и куда она утекает, пока вы поднимаете лимиты.
И мнение, к которому я пришёл после пары вечеров с ESP.getFreeHeap(): на Uno MQTT с длинными JSON не живёт вообще — только короткие поля. Дальше числа с условиями, таблица потолков по платформам и приёмы ужимки, после которых буфер часто поднимать не нужно. Отдельно покажу, где живёт сам стек MQTT-библиотеки: его вклад в RAM обидно мал по сравнению с сетевым этажом под ним. Всё измерено на D1 mini, Uno R3 с W5100 и ESP32 DevKit — в бою, а не в даташите.
Что библиотека делает со входящим длиннее буфера
Свежие версии библиотеки поступают со входящим жёстко: пакет длиннее буфера отбрасывается целиком, callback не вызывается, и — главное — соединение не рвётся. connected() честно возвращает единицу, PINGREQ и PINGRESP ходят по расписанию, брокер уверен, что доставка состоялась: с его стороны пакет уехал, подписка на месте. Никакой печати в Serial, никакого кода возврата — тишина в callback при полностью живой связи. В старых версиях до callback доезжал обрубок, первые байты без хвоста, и разбор такого JSON честно падал. Оба варианта хуже падения платы: перезагрузка видна из окна, а молчаливая дыра в данных не видна месяц.
Защита на моей стороне — две строки в callback. Первая: печатать length для каждого входящего, чисел хватает, чтобы заметить аномалию. Вторая: до разбора проверять длину по белому списку и чужое выбрасывать — если payload длиннее шестидесяти четырёх байтов, это не моя команда, а чей-то JSON, и грызть его на маленькой плате смысла нет. Приём payload без завершающего нуля и аккуратное копирование в свой буфер — материал урока про callback и команды, здесь не повторяю.
Вторая половина ловушки — самообман: буфер подняли, короткие сообщения приходят, кажется, что проблема решена. Она ушла в ожидание: следующая сводка в час пик, с длинными значениями, добьёт потолок через неделю. Поэтому проверка длины живёт в callback постоянно, а не в первые дни после запуска.
#include <ESP8266WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const char* WIFI_SSID = "HomeNet";const char* WIFI_PASS = "12345678";const char* BROKER = "192.168.1.50"; void callback(char* topic, byte* payload, unsigned int length) { Serial.print("-> "); Serial.print(topic); Serial.print(" length="); Serial.println(length); // первая линия обороны: сколько пришло if (length > 64) { // белый список длины: не моя команда Serial.println("[!] dlinnee 64 - propusk"); return; } char buf[65]; memcpy(buf, payload, length); buf[length] = 0; // payload без нуля - добавляем сами Serial.println(buf);} void setup() { Serial.begin(115200); WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(300); } Serial.print("heap posle WiFi: "); Serial.println(ESP.getFreeHeap()); // у моего D1 mini: 41000-46000 client.setServer(BROKER, 1883); client.setBufferSize(512); // ДО connect: по умолчанию 256 Serial.print("bufSize: "); Serial.println(client.getBufferSize()); if (client.connect("kotel-esp")) { client.subscribe("home/kotel/set"); Serial.print("heap posle subscribe: "); Serial.println(ESP.getFreeHeap()); }} void loop() { if (!client.connected()) { if (client.connect("kotel-esp")) { client.subscribe("home/kotel/set"); } delay(3000); return; } client.loop();}Числа с моего D1 mini, свежее ядро. ESP.getFreeHeap() при старте скетча — около пятидесяти тысяч байтов: из восьмидесяти, что есть у чипа, система и загрузчик забрали своё до первой строки кода. После подключения к точке доступа — сорок один-сорок шесть тысяч, зависит от роутера и канала. После connect к брокеру и трёх подписок — минус ещё несколько сотен. Сокет WiFiClient при этом держит приёмный и передающий буферы из той же кучи, сотни байтов на коннект, и появляются они не в момент объявления объекта, а при первом реальном обмене.
Отсюда правило ужимающегося: мерить heap до и после каждой вехи — Wi-Fi, connect, первая публикация, поднятие буфера. Три печати ESP.getFreeHeap() в setup стоят ноль памяти и отвечают на вопрос, который без них решается гаданием. Если после вехи отвал заметнее ожидаемого — ищите, что выросло: String в loop фрагментирует кучу так, что свободные килобайты формально есть, а непрерывного куска под буфер нет — это состояние разобрано в уроке про PROGMEM и память.
Отдельно про сам стек MQTT-библиотеки: PubSubClient ест смешно — объект на десятки байтов, буфер и два-три указателя. Всё остальное живёт этажом ниже: lwIP держит управляющие блоки TCP на каждый сокет, Wi-Fi-стек — таблицы сопряжения с точкой доступа, и это уже не килобайты, а половина кучи. Поэтому «облегчить» MQTT-часть почти невозможно: вес узла определяет сеть, а не протокол. Реальный рычаг — не плодить соединений: один client, один сокет, и не держать параллельный HTTPClient ради красивого кода — я видел прошивку, где второй сетевой клиент съедал три килобайта и не добавил ни одной функции.
Сколько RAM на самом деле
Маркетинговая цифра и живая — разные: восемьдесят килобайтов ESP8266 в даташите превращаются в сорок с лишним до первой строки моего кода. На Uno два килобайта SRAM — и это до шилда: W5100 держит приёмные буферы в той же памяти, и «два килобайта» местами означают «меньше килобайта под переменные». Поэтому я говорю про потолок payload, а не про размер буфера: буфер — число в константе, потолок — то, что реально доживёт до отправки рядом с соседями по памяти.
Съедателей, о которых забывают, трое. String в цикле — куски кучи, которые не отдаются. Сокетные буферы WiFiClient — выделяются при первом обмене. И TLS: контекст BearSSL на ESP8266 отъедает порядка двадцати килобайтов, и это не опция «включу на всякий случай». Сумма этих статей определяет таблицу ниже.
| Платформа | Свободная RAM по факту | Реальный потолок payload |
|---|---|---|
| Uno / Nano, 2 КБ SRAM | меньше килобайта под скетч с шилдом | короткие поля, 100-120 байтов; длинный JSON не гоняю |
| ESP8266, D1 mini | 80 КБ кучи, после Wi-Fi 41-46 КБ | 512-1024 комфортно; ужимка ключей дешевле второго килобайта |
| ESP8266 + TLS | минус 20 КБ на контекст BearSSL | буфер не выше 512, payload короткий |
| ESP32 | около 300 КБ free heap | 1024 и выше без размышлений |
Мнение, за которое мне прилетало в комментариях: на Uno MQTT с длинными JSON не живёт — только короткие поля. Не «работает нестабильно», не «рискованно»: не живёт. Два килобайта SRAM, буферу я готов отдать максимум 512 — четверть всей памяти, W5100 держит свои буферы в той же куче, документ ArduinoJson на 768 байтов хочет своего. Сумма съедает всё, и плата перезагружается по переполнению стека на первом же длинном сообщении — обычно ночью, когда сводка внезапно толще обычного. Короткие поля вида t=21 — да; discovery на 380 байтов — конфиг пишется руками один раз и на плату не грузится.
На ESP8266 потолок выше, но и соседи прожорливее. Мои рабочие точки: буфер 512 закрывает телеметрию и команды с запасом вдвое; 1024 — только когда в payload реально едет документ на 600-700 байтов, и первым вопросом к такому документу будет «зачем». Ужимка ключей — stat_t вместо state_topic, t вместо temperature — экономит десятки байтов; арифметика поднятого буфера короткая: 512 стоит 256 байтов сверху дефолта, 1024 — семьсот шестьдесят восемь, на ESP8266 это 0,6-1,7% кучи — незаметно, пока рядом не появляется TLS или пара String в loop. measureJson из урока про оценку памяти документа замеряет длину будущего payload до отправки, а сырые короткие сообщения против JSON-конвертов — выбор из урока про payload. Буфер, который не нужно поднимать, — лучший буфер.
Соглашусь с оппонентом в одном: если payload у вас по определению короче сотни байтов — весь этот урок не про вас, живите с дефолтными 256. Противоположный край — TLS: WiFiClientSecure откусывает двадцать килобайтов, и после него буфер 1024 уже не роскошь, а подушка, которой может не хватить. На ESP32 три сотни килобайтов free heap делают вопрос скучным — и я искренне рад, когда вопрос буфера скучный: значит, память не думает за меня.
- 1. Входящее сообщение длиннее буфера PubSubClient. Что произойдёт в свежих версиях?
- 2. Сколько показывает ESP.getFreeHeap() на типичном D1 mini сразу после подключения Wi-Fi?
- 3. Когда вызывать client.setBufferSize(512)?
- 4. Почему на Uno с Ethernet-шилдом я не публикую длинный discovery-JSON?
- 5. Буфер 1024 и включённый TLS на ESP8266 — что с памятью?
- Буфер PubSubClient один на приём и передачу, 256 по умолчанию: входящее длиннее отбрасывается молча, connected() жив.
- setBufferSize — до первого connect; каждый лишний килобайт буфера берётся из той же кучи, что и всё остальное.
- ESP8266: 80 КБ кучи всего, после Wi-Fi свободно 41-46 КБ; TLS откусывает ещё около 20 КБ.
- Мои потолки payload: Uno — короткие поля 100-120 байтов, ESP8266 — 512-1024, ESP32 — килобайты без боли.
- Диагностика без отладчика: ESP.getFreeHeap() по вехам, getBufferSize(), печать length в callback.
Частые вопросы по теме урока
- Входящее сообщение длиннее буфера PubSubClient. Что произойдёт в свежих версиях?
- Библиотека молча выбрасывает пакет: callback не вызывается, connected() показывает живое соединение. В старых версиях доезжал обрубок. Пакет отбросят целиком, соединение останется живым
- Сколько показывает ESP.getFreeHeap() на типичном D1 mini сразу после подключения Wi-Fi?
- Из 80 КБ кучи система и Wi-Fi-стек забирают половину до первой строки вашего кода: реально свободно 41-46 тысяч байтов. 41-46 КБ
- Когда вызывать client.setBufferSize(512)?
- Буфер подбирается под соединение: подмена после подключения вступает в силу только со следующего reconnect. До первого connect
- Почему на Uno с Ethernet-шилдом я не публикую длинный discovery-JSON?
- На Uno короткие поля — единственный режим: сумма буфера, документа и шилдовых буферов съедает два килобайта на глазах. 2 КБ SRAM: буфер 512 уже четверть памяти, а шилд ест ту же кучу
- Буфер 1024 и включённый TLS на ESP8266 — что с памятью?
- Контекст BearSSL берёт порядка двадцати килобайтов из той же кучи: после TLS буфер 1024 — подушка, которой может не хватить. TLS отъест около 20 КБ кучи — может не хватить