Узел приёма на веранде держал журнал событий: имена узлов-отправителей я доставал из пакета через const char* и складывал в лог. Документ был глобальный, разбор — в функции, где приёмный String собирался заново на каждом пакете: 46 байт телеметрии, восемь символов имени. Неделю всё было отлично, потом в журнале появилась каша: половина имени от старого узла, половина от нового, а однажды — бессмысленный набор символов. Я сутки подозревал радиомодуль в порче данных, пока не понял: данные портил я сам, точнее — моя привычка хранить const char* дольше, чем живёт строка, на которую он смотрит.
Дело в том, что при разборе ArduinoJson не копирует строковые значения. Библиотека кладёт в документ указатель и длину — ссылку на кусок входного текста. Это называется zero-copy: разбор летит, потому что под каждое значение не выделяется память, а пул документа растёт от числа элементов, а не от длины строк. Цена экономии — правило владения: входной текст обязан жить дольше, чем вы пользуетесь документом.
Экономия измерима. Разбор пакета из двадцати строковых значений без zero-copy — это двадцать выделений памяти под копии: malloc на каждое и фрагментация от каждого освобождения; с zero-copy — ноль выделений на строки, только слоты структуры. Отсюда и следствие, которое связывает урок с замером размера: при разборе вес документа зависит от числа элементов, а не от длины строк — пакет со строкой описания на 200 символов и пакет с коротким именем стоят в пуле одинаково, пока строки остаются ссылками в источник.
В этом уроке разберём три способа забрать строку из документа — const char*, String и char-буфер, — у каждого свой владелец, своя цена и своя граница применимости: немедленное использование, копия в куче и копия без кучи. И покажем в симуляторе главный ритуал этого дела: копировать значения до того, как буфер приёма ушёл на следующий пакет.
const char*: быстро, но чужое
Чтение const char* name = doc["node"]; не выделяет ни байта: указатель смотрит внутрь входной строки, туда, где лежит значение между кавычками. Печатать в Serial, сравнивать, принимать решение — можно сразу и мгновенно. Хранить до вечера — нельзя: срок жизни указателя равен сроку жизни источника. Три сценария, где это стреляет: источник — локальный String функции, а документ глобальный и живёт дальше; буфер приёма пересобирается на каждом цикле — так работает и приём по ограничителю из урока про чтение строк по Serial; документ держат «на потом», а разбирают по приходу каждого пакета.
Есть и честное исключение в пользу копии: если в значении встретились экранированные символы — \n, \" и соседи, — библиотека копирует строку в свой пул, потому что распакованный текст не совпадает по длине с исходным. А при записи в документ строки копируются всегда: doc["node"] = "kotelnaya"; кладёт собственную копию в пул документа. Разбор и запись — разные миры владения, и путать их не стоит. Откуда берётся пул и как он растёт — мы разбирали в уроке про JsonDocument седьмой версии.
Моё правило простое: const char* — для «здесь и сейчас». Прочитал, напечатал, сравнил с порогом, принял решение — и отпустил. Всё, что должно пережить текущий пакет, копируется сразу, и способ копирования выбирается по цене. Сравнение строки из документа — тоже «здесь и сейчас»: strcmp(name, "kotelnaya") == 0 читает чужой кусок, но ничего не сохраняет; проверить ключ до чтения умеет containsKey — мы это делали в уроке про чтение полей.
#include <ArduinoJson.h> JsonDocument doc; // глобальный — живёт весь скетч void setup() { Serial.begin(9600); // Источник — локальный String: умрёт в конце setup(). // Пока разбор и копирование в одной функции — это безопасно. String packet = "{\"node\":\"kotelnaya\",\"temp\":21.4}"; DeserializationError err = deserializeJson(doc, packet); if (err) { Serial.println("razbor sorvan"); return; } // 1. const char* — без копии. Указатель смотрит ВНУТРЬ packet: // годен, пока жив этот String, — здесь используем сразу. const char* name = doc["node"]; Serial.print("sejchas: "); Serial.println(name); // 2. String — своя копия в куче. Владеете, но на Uno помните // про цену выделений: строка в цикле, живущем месяцами, — риск. String nameCopy = doc["node"]; Serial.print("kopiya String: "); Serial.println(nameCopy); // 3. char-буфер фиксированной длины — никакой кучи. char nameBuf[16]; strncpy(nameBuf, doc["node"], sizeof(nameBuf) - 1); nameBuf[sizeof(nameBuf) - 1] = '\0'; Serial.print("kopiya v bufer: "); Serial.println(nameBuf);} void loop() {}String и буфер: кто платит кучей
String nameCopy = doc["node"]; делает то, чего не делает разбор, — снимает собственную копию в куче. Теперь строка ваша навсегда: источник можно переиспользовать, документ очистить, пакет забыть. Цена — выделение в куче, и здесь всё решает плата: на ESP32 с сотнями килобайт копии не считаются, на Uno с 2 КБ каждая String в цикле скетча, живущего месяцами, — тот же realloc-риск, который мы разбирали в уроке про сборку JSON без склейки: свободные байты остаются, но рвутся на лоскуты. Есть и тихий симптом: когда куче тесно, копия не удаётся, и String остаётся пустой — не исключение, а молча пустая строка, так что после копии на Uno полезно проверить length().
Компромисс с условием — char-буфер фиксированной длины: strncpy в buf[16], ноль-терминатор руками, никакой кучи вообще. Годится, когда верхняя граница длины известна: имя узла до 15 символов, команда до 8, город до 20. Когда граница неизвестна — текст сообщения, длинная строка чужого ответа, — берите String и не ломайте голову. На ESP32/ESP8266 есть и промежуточный лаконичный вариант: char* copy = strdup(doc["node"]); — одна строка вместо трёх; на AVR-платах strdup может не оказаться в libc, там надёжнее strncpy вручную.
Копии нужны и при отправке: тело HTTP-запроса или сообщение MQTT собираются из данных документа в собственный String задолго до того, как буфер приёма переиспользуется, — как в уроке про HTTP POST на ESP32, где тело собирается в String с честным Content-Length. Логика одна: всё, что уходит наружу, строится из копий, а не из чужих кусков.
Сводка выбора — в таблице ниже; она стоит того, чтобы повесить её в голове рядом с правилом «копируй сразу».
| Способ | Владение | Куча | Когда выбирать |
|---|---|---|---|
| const char* = doc["k"] | Указатель в источник | Нет | Прочитать, напечатать, решить — сейчас |
| String s = doc["k"] | Своя копия | Да | Длина неизвестна, ESP32, редкие события |
| strncpy в char-буфер | Своя копия | Нет | Граница длины известна, Uno, живой цикл |
Симулятор: копии против перезаписанного буфера
Честная оговорка: движок симулятора хранит строки в своей памяти и указателей не показывает — повиснуть в симе нельзя, да и незачем. Зато можно проверить сам ритуал, который спасает на плате: разобрали пакет — сразу переписали нужные значения в String-переменные — и только потом буфер приёма ушёл на следующий пакет. Копия целая, документ уже про новый пакет, и журнал пишется чисто. Порядок строк в печати показывает сам ритуал: разбор, копии, и лишь потом второй разбор.
На настоящей плате ровно этот момент рождает строки-призраки из note выше: непрокопированное место из первого пакета после перезаписи буфера выглядит как половинка старого сообщения. В симуляторе оба пакета честно проходят через один и тот же String-буфер — смотрите на печати. Заодно закрепите привычку печатать значения парами имя=значение: если после второго разбора city1 печатает не то, что положили, копия не была сделана.
- Поменять порядок. Перенесите String city1 = doc.city; после перезаписи packet вторым пакетом — в журнале окажется уже Kazan: копия снимает текущее значение, а не прошлое.
- Ужать буфер. В code-блоке замените nameBuf[16] на nameBuf[6] и запустите на плате: имя обрежется до пяти символов с ноль-терминатором — граница длины работает, но её надо выбирать с запасом.
- Сравнить владельцев. Добавьте String city3 = doc.city; после второго разбора и напечатайте city1 и city3: две независимые копии — изменение одной не трогает другую.
- На настоящей плате. Разбирайте пакет из функции с локальным String и копируйте значения внутрь неё, а журнал ведите по копиям: ни одной строки-призрака за вечер.
- При разборе строковые значения не копируются: doc["k"] — указатель и длина в кусок входного текста; вход обязан жить дольше документа
- Копия появляется в двух случаях: в значении есть экранированные символы — \n, \"; или строку записывают в документ присвоением
- const char* — для немедленного использования; String — своя копия в куче; strncpy в буфер — копия без кучи при известной границе длины
- Документ глобальный плюс разбор с локальным String — рецепт строк-призраков: копируйте значения в той же функции, где жив источник
- На ESP32 копии бесплатны по ощущению, на Uno 2 КБ каждая String в месячном цикле — риск фрагментации
- 1. Что физически хранит doc["node"] после разбора строки String?
- 2. Когда при разборе ArduinoJson всё же копирует строковое значение в свой пул?
- 3. Источником разбора служит локальный String функции, а документ глобальный. Что с const char* из документа после выхода из функции?
- 4. На Uno значение ключа не длиннее 15 символов и копируется на каждый пакет в живущем месяцами цикле. Какой способ выбрать?
- 5. Что происходит со строкой при записи в документ: doc["node"] = "kotelnaya";
Тройка замкнулась: коды разбора говорят, что сломалось, measureJson говорит, сколько это весит, а правила владения строками — кто за какой байт отвечает. Правило на память: const char* — занял и вернул, String и буфер — забрал навсегда; способ копирования выбирается платой и длиной строки, а сам факт копирования — всегда. Дальше по разделу — фильтры при разборе больших ответов и связка с брокером: собранный здесь пакет в уроке про MQTT на ESP32 уходит в топик одной строкой serializeJson. Домашнее задание с платой: примите два пакета подряд по Serial от соседнего узла, копируйте значения в char-буферы до приёма следующего — и полистайте свой журнал на предмет каши: теперь вы знаете, откуда она берётся.
Частые вопросы по теме урока
- Что физически хранит doc["node"] после разбора строки String?
- Разбор делает zero-copy: библиотека экономит память и скорость, храня ссылку на кусок входной строки. Отсюда правило: источник жив дольше документа, а всё, что нужно сохранить, копируется сразу. Указатель и длину в кусок входного текста
- Когда при разборе ArduinoJson всё же копирует строковое значение в свой пул?
- Распакованный текст не совпадает по длине с экранированным, поэтому такие значения копируются в пул. Остальные строки остаются ссылками в источник. Когда в значении есть экранированные символы вроде \n
- Источником разбора служит локальный String функции, а документ глобальный. Что с const char* из документа после выхода из функции?
- Локальный String умирает в конце функции, его память уходит, а указатель в документе смотрит в освободившиеся байты. Копируйте значения в той же функции. Указатель продолжает указывать на освободившуюся память
- На Uno значение ключа не длиннее 15 символов и копируется на каждый пакет в живущем месяцами цикле. Какой способ выбрать?
- Граница длины известна — буфер без кучи надёжнее всего для месячного цикла: ни одного выделения, ни одного перевыделения; strncpy при усечении сам не добавит терминатор — его ставят руками. strncpy в char-буфер фиксированной длины
- Что происходит со строкой при записи в документ: doc["node"] = "kotelnaya";
- Запись кладёт собственную копию в пул документа — разбор и запись различаются по владению. Документ отвечает за свои записанные значения, даже если источник строки давно умер. Библиотека копирует строку в пул документа