Панель шлёт плате команды пачкой: три строки за секунду — включить, выключить, спросить состояние. Плата честно отвечает на каждую, и однажды заказчик попросил в ответе эхо: номер команды, которую плата выполняет. С этого скромного желания начинается арифметика памяти: разобрать запрос и собрать ответ в одном документе можно — но по строгому порядку действий, а иногда нельзя вовсе, потому что строки прибывают быстрее, чем ответ успевает уйти.
Приём команд из монитора я разбирал в уроке про JSON-команды — там диспетчер по ключу cmd и вежливые ветки отказа; формы ответов с кодами — в уроке про эхо-протокол. Новое здесь — память: сколько документов держать одновременно и где заканчивается один документ на две роли.
План такой: конвейер одного документа и его правило порядка; сценарии, где без второго документа не обойтись; арифметика SRAM Uno на пару документов; предохранители от переполнения — и всё это живьём в симуляторе.
Один документ на обе роли
Конвейер из одного документа выглядит так: deserializeJson положил запрос в doc, вы тут же вытащили значения в обычные переменные — op, id, val, — вызвали doc.clear() и собрали в том же doc ответ. Память платит за одну ёмкость, а не за две. Ловушка сидит в слове «тут же»: значения вынимаются до clear, потому что после него чтение по ключу молча возвращает ноль — та же механика молчаливого нуля, что при первом разборе. Правило clear() перед новым разбором при этом не отменяется: в v6 пул без очистки тихо кончается, ключи прошлых строк копятся и уезжают в ответ лишним хвостом.
В седьмой версии добавился нюанс: deserializeJson стартует с чистого листа и выкидывает прошлый состав сам. Казалось бы, забота, — но представьте конвейер, где ответ собирается в том же документе и ещё не напечатан: приехавшая следующая строка сотрёт его без всякого clear. Буфер UART при 9600 бод вмещает 64 байта — команды в тридцать-сорок байт копятся пачкой, и разбор второй строки случается раньше, чем первый ответ покинул плату; механика буфера разобрана в уроке про буфер UART. Привычку clear() я всё равно держу: половина чужих скетчей живёт в v6.
Когда второго не миновать
Первый случай — корреляция: ответ обязан дожить до печати, пока запросы прибывают. Второй — сквозной проход, когда ответ строится из полей запроса: эхо id, повтор значения, отметка millis приёма. Вытащить три значения в переменные и собрать ответ из них можно, но стоит запросу стать структурой с этажами, как вытащивать придётся половину документа — и проще дать ответу собственную ёмкость. Третий случай — два независимых потребителя: журнал разбирается по строкам, телеметрия собирается по таймеру, и общий документ превращает их в гонку, где победитель выбирается случайностью тайминга.
Оба пути имеют цену, поэтому граница у меня простая: если ответ собирается из горстки значений — один документ и правило порядка; если запрос должен пережить печать ответа целиком — второй документ. Когда партнёр шлёт больше памяти, чем есть на плате, спор уже не о документах: разбор частями разобран в уроке про чанки. Ёмкости для обеих ролей считаются до прошивки, а не после первой перезагрузки — методика в уроке про расчёт ёмкости.
#include <ArduinoJson.h> DynamicJsonDocument req(384); // v7: JsonDocument req;DynamicJsonDocument resp(256); // v7: JsonDocument resp; void setup() { Serial.begin(9600); Serial.println("gotov: {\"cmd\":\"led\",\"val\":1,\"id\":7}");} void loop() { if (Serial.available()) { String line = Serial.readStringUntil(10); DeserializationError err = deserializeJson(req, line); if (err) { Serial.println("paket ne razobralas: otvet ne sobiraem"); return; } // Шаг 1: вытащить из запроса все нужные значения, пока он жив int id = req["id"] | 0; String cmd = req["cmd"] | "none"; // Шаг 2: ответ — второй документ; запрос доживёт до конца печати resp.clear(); resp["ok"] = (cmd == "led") ? 1 : 0; resp["id"] = id; if (cmd == "led") resp["val"] = req["val"] | 0; // Шаг 3: предохранитель v7 — флаг переполнения при сборке if (resp.overflowed()) { Serial.println("otvet ne vlez: szimte schemu ili emkost resp"); } else { serializeJson(resp, Serial); Serial.println(); } }}Разбор скетча короткий. В req ложится запрос, в resp — ответ; op, val и id вытащены из печати документа по имени ключа — поимённое чтение в движке не эмулировано, и это помечено комментарием. На настоящей плате те же три значения — это req["op"], req["val"], req["id"] без всяких подстрок. Светодиод исполняет op 1 и op 2, ручка A0 прикладывается к ответу командой op 3: фоновая телеметрия и диалог команд не мешают друг другу, потому что живут в разных документах.
Эхо id в ответе стоит двух полей и пары байт: {"ok":1,"id":7,"echo":200} — это 26 байт, при 9600 бод такой ответ уезжает за 27 миллисекунд. Цена становится заметной, когда в ответ вписывают копию всего запроса: ёмкость resp тогда считается под обе структуры, и половина пула живёт про запас. Эхо ключевых полей — компромисс, который держит протокол отлаживаемым и не раздувает схему.
Арифметика SRAM Uno: всего 2048 байта, из них 128 сидят в двух буферах Serial по 64, десятки — в глобальных переменных, сотни — под стек на самом глубоком вызове. Пара req 384 + resp 256 — это 640 байт, четверть бюджета, и влезает она только потому, что остальное скромно. В v6 оба документа были бы StaticJsonDocument: место в статической области занято с момента запуска и навсегда — зато куча не фрагментируется. В v7 локальный JsonDocument внутри функции живёт до выхода из области видимости, и правило получается простое: самый большой документ — самый короткоживущий.
Предохранители: после сборки measureJson печатает длину ответа — в симуляторе она видна под каждой репликой, на плате та же цифра в журнале служит бесплатным монитором схемы. В v7 есть и прямой флаг: resp.overflowed() возвращает true, если при сборке не хватило места; в v6 переполнение вылезало тихой обрезкой, и ловили его сверкой размера с ожиданием. Если флаг сработал — ответ не печатается вовсе: половина ответа хуже короткой реплики об отказе.
Что выбрать под задачу
Три раскладки из практики. Диспетчеру команд хватает одного документа: ответы короткие, из вытащенных переменных, и триада «вытащил — clear — собрал» держит память в одной ёмкости. Шлюзу с эхом и журналом нужны два глобальных: они живут весь сеанс и платят местом постоянно. Узлу, который отвечает раз в минуту, достаточно локального документа прямо в ветке приёма. Миграция с одного расклада на другой — это пара строк объявлений, пока имена документов не менялись; смена имён разъезжается по всему скетчу за минуты, так что выбирайте до первой прошивки, а не после.
Темп диалога тоже входит в расклад. Ответ в 60 байт при 9600 бод едет 60 миллисекунд, и панель, шлющая три команды в секунду, забивает канал целиком — диалог с одним документом в таких темпах почти наверняка ловит стирание несохранённого ответа. Медленный датчик с опросом раз в десять секунд спокойно живёт на одном документе годами. Скорость канала — не деталь, а часть выбора: посчитайте строки в секунду прежде, чем считать байты.
| Расклад | Документы | Цена и следствие |
|---|---|---|
| Один документ на обе роли | req и resp по очереди | минимум памяти; запрос эхом целиком не уедет |
| Два глобальных документа | живут весь сеанс | 640 байта занято постоянно: в v6 это статика |
| Локальные внутри функции | живут до выхода из ветки | куча освобождается сама в v7; в v6 — Dynamic и фрагментация |
| Разбор из потока плюс один документ | строковой копии пакета нет | партнёр шлёт больше памяти, чем есть на плате |
- 1. Один документ обслуживает и разбор запроса, и сборку ответа. Правильный порядок?
- 2. В v7 ответ собирали в doc и не успели напечатать, приехала следующая строка, вызвали deserializeJson. Что стало с ответом?
- 3. Где живут байты двух StaticJsonDocument в v6 на Uno?
- 4. resp.overflowed() вернул true после сборки ответа. Что печатаем?
- 5. Пара req 384 + resp 256 плюс буферы Serial — сколько это от бюджета Uno?
- Один документ на обе роли живёт по триаде: вытащил значения — clear — собрал ответ — напечатал до следующего разбора
- deserializeJson в v7 стартует с чистого листа: несохранённый ответ умирает при первом же приезде новой строки
- Второй документ нужен, когда ответ обязан пережить прибытие строк или запрос — структура, а не горстка значений
- Бюджет Uno: req 384 + resp 256 = 640 байта плюс 128 буферов Serial из 2048; v6-статика платит всегда, v7-локал умирает с областью видимости
- Предохранители: measureJson в журнале и overflowed() в v7 до печати ответа
Частые вопросы по теме урока
- Один документ обслуживает и разбор запроса, и сборку ответа. Правильный порядок?
- После clear чтение по ключу молча вернёт ноль: значения вынимаются из живого документа. Ответ собирается в очищенный пул и печатается до приёма следующей строки — иначе v7 сотрёт его при новом deserializeJson. Разобрал — вытащил значения — clear — собрал ответ
- В v7 ответ собирали в doc и не успели напечатать, приехала следующая строка, вызвали deserializeJson. Что стало с ответом?
- deserializeJson в v7 выкидывает прошлый состав сам. Ответ обязан уходить из платы до следующего разбора — или жить во втором документе, пока не напечатан. Умер: седьмая версия стартует разбор с чистого листа
- Где живут байты двух StaticJsonDocument в v6 на Uno?
- Static вырезает ёмкость из статики на этапе сборки: место занято всегда, зато куча не фрагментируется. Оборотная сторона — память платит даже за простой. В статической области: занято с запуска и всегда
- resp.overflowed() вернул true после сборки ответа. Что печатаем?
- Флаг v7 честно показывает, что сборка сорвалась. Печатать обрезанный ответ — отправлять партнёру ложь; меняется ёмкость или схема, и только потом ответ уходит. Ничего: короткую реплику про переполнение — половина ответа хуже отказа
- Пара req 384 + resp 256 плюс буферы Serial — сколько это от бюджета Uno?
- 640 плюс 128 — 768 байта из 2048, и это ещё до глобальных переменных и стека. Бюджет считается до первого deserializeJson, а не после первой перезагрузки. Около четверти: 640 байта документов и 128 на Serial из 2048