Логгер на даче писал в журнал на SD-карте имена сенсоров вместе с показаниями: «bme280, 21.4», «ds18b20, 19.9». Имена я доставал из документа привычным чтением — const char* name = doc["name"]; — и передавал прямо в функцию записи на карту. Месяц всё работало безупречно, потому что пакет разбирался один раз при включении и жил в глобальном документе до самого конца. Потом мне понадобилось менять список сенсоров без перепрошивки, и я добавил перечитывание пакета раз в десять минут. Через сутки в журнале началась абракадабра: вместо bme280 — обрывки нового ответа, куски ключей, иногда пустые строки на месте имён.
Данные в документе были верные, показания тоже — врвало только имя. Вскрытие заняло вечер: const char* из документа — это не копия строки, а адрес внутри неё. Перечитывание перезаписало пул документа, старые байты ушли под новые данные, а мои указатели продолжали показывать на то же место — где теперь лежало что-то другое. Библиотека не могла меня предупредить: с её точки зрения всё честно, строка принадлежит документу, а не мне.
В этом уроке разберём, где физически живут строки после разбора, какие три события их убивают, как снять копию тремя способами — String, фиксированный буфер, strdup — и сколько каждый стоит в байтах. Механику чтения со страховками мы уже проходили в уроке про оператор | и containsKey — там я вскользь упомянул висящий указатель; здесь добираемся до дна, потому что на живом узле эта тема стоит не часов отладки, а поездок на дачу.
Куда указывает doc['name']
ArduinoJson не хранит строки там, где их нашёл в исходном тексте пакета. При разборе библиотека копирует текст в свой пул — ту же область памяти, где лежат слоты ключей и чисел, мы разбирали её устройство в уроке про JsonDocument. Чтение doc["name"] возвращает const char* — адрес внутри этого пула. Пока документ жив и не переписан, адрес честный: строку можно печатать, сравнивать, передавать в функции. Но владелец — документ, а не ваша переменная: указатель даёт доступ, но не гарантию.
Строку убивают три события, и все три в обычном скетче случаются регулярно. Первое — doc.clear(): пул освобождён целиком, адреса умерли все сразу. Второе — повторный deserializeJson в тот же документ: перед разбором библиотека сбрасывает пул сама, мы это проверяли вживую в уроке про первый deserializeJson — и это самый коварный случай, потому что выглядит безобидно: «ну я же просто новый пакет разобрал». Третье — выход локального документа из области видимости: JsonDocument doc; объявленный внутри функции, умирает на закрывающей скобке, и все адреса, которые вы из него вынесли, повисают в воздухе.
Отсюда правило владения, которое я теперь пишу даже в комментариях к коду: строка из документа живёт ровно до следующего события в жизни документа. Нужна дольше — снимайте копию в момент разбора, не откладывая на потом. Отложите — получите мой дачный журнал с обрывками вместо имён, причём через сутки после того, как вы уехали.
#include <ArduinoJson.h> JsonDocument doc;char city[24]; // копия №1: фиксированный буфер, куча не участвуетchar* devName = NULL; // копия №2: strdup, живёт в куче void setup() { Serial.begin(9600); DeserializationError err = deserializeJson(doc, "{\"city\":\"Sochi\",\"device\":\"meteo1\",\"temp\":21.4}"); if (err) return; // Способ 1: strncpy в заранее выделенный буфер. // Терминатор ставим вручную: strncpy его не гарантирует. strncpy(city, doc["city"] | "?", sizeof(city) - 1); city[sizeof(city) - 1] = '\0'; // Способ 2: strdup — выделит strlen + 1 байт и скопирует сам. devName = strdup(doc["device"] | ""); doc.clear(); // пул можно чистить: копии уже живут своей жизнью Serial.print("city="); Serial.println(city); Serial.print("device="); Serial.println(devName ? devName : "-"); // Когда имя больше не нужно: free(devName); devName = NULL;} void loop() {}strdup: механика и цена
strdup(s) делает ровно две вещи: выделяет в куче strlen(s) + 1 байт — под текст и терминатор — и копирует строку туда. Возвращает указатель на копию, которая больше никак не связана с документом: чистите doc, переразбирайте, выходите из функции — копия стоит на месте. Это функция из стандартной библиотеки Си, на Arduino она доступна из коробки вместе с free(), которой копию положено возвращать куче, когда она стала не нужна.
Цена измеряется байтами и дисциплиной. Строка «meteo1» стоит 7 байт — шесть символов и терминатор; ничего, пока free() не забыт. Забытый strdup — утечка по расписанию: узел, который strdup-ит имя города раз в минуту без освобождения, съедает 1440 строк в сутки по 10–15 байт — около 19 КБ в сутки. На Uno с её 2 КБ SRAM куча умирает за пару часов, и я однажды собрал именно такой скетч — историю свежести имен в логе он рассказывал два часа, потом молчал навсегда. На ESP32 с сотнями килобайт кучи те же 19 КБ в сутки приведут к перезапуску через пару недель непрерывной работы — дольше, но так же гарантированно.
Моё мнение с условием: strdup я пишу, когда строк может быть много и они разной длины — списки имён, история сообщений. Для одного-двух полей конфига выгоднее фиксированный буфер: место известно на компиляции, куча не участвует вовсе, утечать нечему. А String(city) — путь короткий и удобный, но каждый повторный присвоение String перевыделяет память в куче; в уроке про сравнение String-склейки мы считали, чем это кончается на Uno при жизни скетча месяцами. Для единственного присвоения при старте — нормально, для цикла раз в минуту — нет.
Как поймать утечку до того, как она поймает вас. На ESP32 печатайте ESP.getFreeHeap() раз в минуту в лог: падение с 300 тысяч байт на старте до 280 тысяч к вечеру рисует траекторию, по которой утечка видна за день, а не за месяц. На Uno счётчика кучи нет, зато есть характерное поведение: скетч, отработавший два часа и начавший молчать без причины, почти всегда кончил в куче — фрагментация или полное опустошение выглядят снаружи одинаково. Второй приём — дисциплина пары: каждый strdup в коде получает свой free() до выхода из той же ветки, а пар в скетче должно быть так мало, чтобы проверить их глазами за минуту. Пять пар — проверяемо, двадцать — уже миграция на буферы.
| Способ | Цена в памяти | Когда беру | Главный риск |
|---|---|---|---|
| String(city) | strlen+1 в куче; повторные присвоения перевыделяют | единичная копия при старте, отладка, ESP32 | фрагментация кучи на Uno при частых переприсвоениях |
| char buf[24] + strncpy | ноль кучи: место занято на компиляции всегда | конфиги, имена устройств, поля с понятным потолком длины | переполнение буфера — нужна ручная проверка размера |
| strdup() | strlen+1 из кучи сейчас, free() потом | много строк разной длины: списки, история | забытый free() — утечка по расписанию |
Правило владения строкой
Всё сказанное сворачивается в один вопрос, который я задаю себе у каждого чтения строки из документа: сколько эта строка проживёт? Если она нужна внутри той же ветки кода — напечатали, сравнили, отдали на экран — указатель легален и копировать нечего. Если она переживёт паузу между пакетами — журнал, экран после перерисовки, имя узла в MQTT-топике — копия обязательна, и снимать её надо в момент разбора, пока документ ещё держит оригинал.
Компромиссный вариант для конфигов, которые читаются один раз при включении: глобальный документ плюс глобальные указатели, как в самом первом скетче этого раздела. Это работает ровно до тех пор, пока документ никто не трогает; я ставлю рядом комментарий «doc дальше не переразбирать» — и в моём опыте нарушают его дважды: сам автор через полгода и коллега через месяц. Копия дешевле поездки на дачу.
В симуляторе ниже механика показана честно, с одной оговоркой: в движке книги любая строка — копия, поэтому подвисание указателя не показать, он всегда честно напечатает старое значение. Зато разница «ключ исчез из документа, копия осталась» видна вживую: смотрите, как doc.city меняется на новый город, doc.temp обнуляется вместе с исчезнувшим ключом, а kopiya_city держит первоначальное значение сколько угодно пакетов.
И одно предупреждение про симулятор и плату сразу: копия в моём примере — String, а не char-массив, потому что в симе строковые методы и присвоения работают одинаково. Перенося скетч на Uno, вы можете оставить String — для копии, снятой один раз при старте, это безопасно, — но последний эксперимент из четвёрки ниже предлагает заменить её на буфер: разница между двумя способами останется только на настоящей плате, где куча видна по поведению, а не по учебнику.
- Смените копию. Поменяйте пакет в loop на {"city":"Omsk","wind":4.2} и запустите: doc.city станет Omsk, kopiya_city останется Sochi — копия не зависит от перезаписей документа.
- Уберите копию. Удалите строку kopiyaCity = doc.city; и попытайтесь после второго пакета напечатать старый город из документа: его там больше нет, чтение по исчезнувшему ключу даст ноль — на плате вместо этого был бы мусор из нового пакета.
- Участите пакеты. Поменяйте 3000 в условии на 1000: данные пойдут каждые десять виртуальных секунд — как в реальном узле с опросом раз в минуту, только быстрее; копия всё равно не дрогнет.
- На настоящей плате. Перенесите логику на Uno: kopiyaCity замените на char buf[16] со strncpy и терминатором, добавьте doc.clear() сразу после копии и убедитесь, что печать buf не изменилась — копия живёт своей жизнью.
- const char* из документа — адрес в его пуле: doc.clear(), повторный deserializeJson и выход локального doc из функции убивают строку
- Копия снимается в момент разбора: String(city) — просто, char-буфер со strncpy — без кучи, strdup() — ровно по размеру строки
- strdup() берёт strlen+1 байт из кучи и требует free(): strdup раз в минуту без освобождения — около 19 КБ в сутки
- strncpy не гарантирует терминатор: buf[sizeof(buf)-1] = '\0' вручную или snprintf(buf, sizeof(buf), "%s", src)
- Правило владения: строка нужна дольше паузы между пакетами — копия; используется в той же ветке — указатель легален
- В симуляторе строки — копии по устройству движка, но исчезновение ключей и живучесть копии показываются честно
- 1. Где физически лежит строка, на которую указывает const char* после чтения doc["name"]?
- 2. Сколько байт выделит strdup для строки из 11 символов?
- 3. В каком случае копировать строку из документа не обязательно?
- 4. Что станет со String-копией имени после повторного deserializeJson в тот же документ?
- 5. Чем опасен strncpy в маленький буфер, если не добавить терминатор вручную?
Домашнее задание с платой: возьмите любой свой скетч, где строки из документа переживают цикл loop, найдите их честным вопросом «сколько проживёт?» и переведите на буфер или strdup с free(). Проверьте себя удвоением пакета: разберите в тот же doc второй JSON и убедитесь, что копии напечатались без изменений. А где строки приходят не готовой пачкой, а по одному символу из порта — следующая тема: соберём JSON из потока Serial и сделаем разбор, который не боится медленного соседа, — JSON через UART.
Частые вопросы по теме урока
- Где физически лежит строка, на которую указывает const char* после чтения doc["name"]?
- Библиотека копирует текст в пул документа при разборе, и чтение отдаёт адрес внутри него. Владелец строки — документ. В пуле самого JsonDocument
- Сколько байт выделит strdup для строки из 11 символов?
- strdup выделяет strlen + 1 байт и копирует строку вместе с терминатором. Возврат памяти — free(), иначе утечка. 12: одиннадцать на текст и один на терминатор
- В каком случае копировать строку из документа не обязательно?
- Пока обращение случилось до следующего события в жизни документа, указатель честен. Копия нужна, когда строка переживёт паузу между пакетами. Если строка используется в той же ветке кода сразу после разбора
- Что станет со String-копией имени после повторного deserializeJson в тот же документ?
- String хранит собственную копию текста. Именно поэтому копирование — единственный способ сохранить строку на свой век. Копия останется прежней — это независимая строка
- Чем опасен strncpy в маленький буфер, если не добавить терминатор вручную?
- При заполнении буфера целиком strncpy не оставляет места под терминатор. Отсюда пара: strncpy + buf[размер-1] = '\0'. Буфер может остаться без \0, и печать уйдёт читать память за ним