Представим, что у нас есть система из нескольких контроллеров, связанных общей шиной. Внезапно возникает задача добавить в эту систему новый узел с совершенно примитивной полезной нагрузкой, пусть это будет управление светодиодами. Целевой функционал настолько простой, что для него вычислительных ресурсов надо чуть больше нуля, но в то же время уже устоявшаяся шина и протокол требуют, чтобы новый узел поддерживал необходимый минимум, который исторически довольно дорогой. Получается так, что ставить нормальный микроконтроллер жалко, а совместимость обеспечить всё-таки нужно.
В этом материале мы попробуем впихнуть, на первый взгляд, невпихуемое.
Пекарь и аптекарь
Учитывая, что устройство должно быть совместимо с существующей шиной и протоколом, определим требования:
1. RPC. Устройство должно поддерживать небольшой набор обязательных команд: вернуть информацию о себе, перезагрузиться, отдать и принять настройки, принять обновление.
2. Обновление. Устройство должно иметь возможность обновления по шине, причем требуется резидентный загрузчик, образ пишется целиком, включая саму процедуру обновления. Отсюда сразу схема A/B.
3. JSON. Устройство должно поддерживать работу с JSON (RPC, настройки). При этом желательно иметь расширяемую реализацию, позволяющую без лишних сложностей добавить новую настройку, новую команду и т.д.
4. Настройки. Устройство должно поддерживать настройки, которые можно получать и задавать снаружи по RPC. Настройки должны переживать перезапуск.
5. Собственно полезная нагрузка. Для демонстрации возьмём то, что весит примерно нисколько, но вполне имеет право на существование в реальной железке: управление небольшим количеством светодиодов.
Железо
Для подготовки этой статьи я постарался выбрать максимально простой и дешёвый контроллер, но не совсем уж экзотику. Выбор пал на CH32V003: в своё время я покупал такие по 9 рублей, причём в розницу, так что, наверно, можно найти и дешевле.
Характеристики, важные для нас:
ядро RISC-V RV32EC (урезанный RV32I);
48 МГц (HSI 24 МГц через фиксированный PLL x2);
16 КБ flash, 2 КБ SRAM.
Серия V003 есть в нескольких корпусах, но Flash/SRAM одинаковые.
Распиновка
Вывод | Функция | Как используем |
|---|---|---|
1 | PD6 | USART1 RX |
2 | VSS | Питание |
3 | PA2 | RS485 DE |
4 | VDD | Питание |
5 | PC1 | Светодиод 0 |
6 | PC2 | Светодиод 1 |
7 | PC4 | Светодиод 2 |
8 | PD5 | USART1 TX |
Почему выводы именно такие? Хотелось, чтобы одна и та же прошивка работала на любом корпусе V003, включая самый маленький и дешёвый - восьминогий J4M6.
Разметка флеша
Учитывая предъявленные ранее требования, разметка памяти тоже, в целом, определена однозначно:
Секция | Смещение | Размер | Что расположено |
|---|---|---|---|
Загрузчик |
| 1 КБ | Начальный загрузчик |
Настройки |
| 1 КБ | Настройки |
APP0 |
| 7 КБ | Слот приложения 0 |
APP1 |
| 7 КБ | Слот приложения 1 |
Стирается флеш страницами по 1 КБ (или 64 байта в быстром режиме). Можно было бы нарезать память точнее. Кроме того, у ch32v003 есть 1920 байтов под bootloader, по-хорошему начальный загрузчик надо поместить в него. Будем считать, что оба факта - умышленный балласт, который в случае необходимости можно использовать для оптимизации.
Итого под приложение остаётся семь килобайтов. Не густо, но люди в своё время на Луну летали на 72 КБ, а у нас тут ни разу не rocket science.
Транспорт: бинарный кадр с JSON внутри
Так как в системе общая шина, для RPC нужна адресация, а ещё хотелось бы контрольную сумму для проверки принятого сообщения.
Будем считать (без особых объяснений, статья, в целом, и не про это), что на шине следующий формат сообщения:
dst_addr u8 | src_addr u8 | payload_size u16 (LE) | crc32 u32 (LE) | payload
Восемь байт заголовка, дальше payload. А вот payload уже JSON-RPC 2.0:
запрос {"jsonrpc":"2.0","method":"led.on","params":{"n":2},"id":1} ответ {"jsonrpc":"2.0","result":"OK","id":1} ошибка {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1} нотификация запрос без "id" — ответ не отправляется
Так выглядит на шине запрос целиком - мастер с адресом 1 просит устройство с адресом 4 зажечь светодиод:
04 01 3b 00 0f 85 23 cc {"jsonrpc":"2.0","method":"led.on","params":{"n":2},"id":1} │ │ └─┬─┘ └────┬────┘ │ │ │ └ CRC32 payload │ │ └ длина payload: 59 байт │ └ от кого └ кому
JSON-RPC пришлось несколько урезать в целях экономии места:
наличие ключа
jsonrpcв запросе не проверяетсяidтолько число (и помещается вu32)batch-запросы не поддерживаются
на невалидный JSON устройство не отвечает
JSON и рефлексия
JSON-слой построен на статической рефлексии - библиотека reflect. Она позволяет на этапе компиляции обойти поля типа и узнать их имена. Никакого RTTI, никаких виртуальных таблиц, никакого рантайма - только compile-timeвычисления.
Сериализация
За запись отвечает одна функция write_json. Тип значения она разбирает через if constexpr, так что нужная ветка выбирается при компиляции:
inline char* write_json(char* where, auto&& data) { using type = std::decay_t<decltype(data)>; if constexpr (std::is_fundamental_v<type>) { // у нас только числа } else if constexpr (is_std_array_v<type>) { // массив: [elem,elem,...], каждый элемент - снова write_json } else if constexpr (std::is_same_v<type, std::string_view>) { // строка в кавычках } else if constexpr (std::is_standard_layout_v<type>) { // структура: см. ниже } else { static_assert(false, "Cannot dump type"); } return where; }
Самая интересная ветка — структура, просто добавляем скобки, ключ в кавычках, значение:
*where++ = '{'; reflect::for_each([&](auto field) { *where++ = '"'; where = std::copy_n( reflect::member_name<field>(data).data(), reflect::member_name<field>(data).length(), where ); *where++ = '"'; *where++ = ':'; where = write_json(where, reflect::get<field>(data)); *where++ = ','; }, data); *(where - 1) = '}';
reflect::for_each разворачивается компилятором в последовательность операторов без циклов, member_name превращается в строковую константу, а значение каждого поля пишется тем же write_json, а при наличии типа, для которого ветки нет код просто просто не соберётся.
Несложно заметить, что в коде нет никакой проверки размера выходного буфера - такое допущение.
Десериализация
У десериализатора под капотом jsmn - довольно легковесный токенизатор на Си, как раз для embedded-систем.
Дальше снова рефлексия, обход объекта выглядит так:
inline void parse_object(const char* data, const jsmntok_t* object, auto&& out) { const jsmntok_t* key = object + 1; for (int i = 0; i < object->size; ++i) { const jsmntok_t* value = key + 1; auto parsed_key = std::string_view(&data[key->start], key->end - key->start); reflect::for_each([&](auto field) { if (reflect::member_name<field>(out) == parsed_key) parse_value(data, value, reflect::get<field>(out)); }, out); key = json_skip_value(value); } }
Внешний цикл идёт по рантайм-ключам (результат работы токенизатора). Внутренний for_each разворачивается в сравнение с именем каждого поля структуры. parse_value устроен почти как write_json: те же ветки if constexpr, а для вложенной структуры снова parse_object. json_skip_value переносит указатель на следующий ключ.
Есть и шаблонный вариант, удобный для использования (однако стоит понимать, что отсутствующие в JSON поля примут значения по умолчанию. Решается, например, оборачиванием в std::optional):
auto request = bomzhur::read_json<led_request_t>(params);
Для более ресурсного контроллера я бы выбрал glaze, там есть крутые легковесные heap-free фичи.
RPC
RPC-команда
Команда — это пара "имя + обработчик", и вся она живёт в типе:
bomzhur::rpc::rpc_method<"led.on", rpc_led_on>
Имя метода - строковый литерал (у меня это reflect::fixed_string). Никакой регистрации при старте и никакой таблицы в рантайме: команда определяется (и регистрируется) только на этапе компиляции.
А сам обработчик получается такой:
struct led_request_t { // номер вне диапазона по умолчанию, чтобы можно было проверить валидность запроса uint32_t n = bomzhur::leds::count; }; static request_result_t rpc_led_on(std::string_view params, std::span<char> response, unsigned& response_size) { auto request = bomzhur::read_json<led_request_t>(params); if (!bomzhur::leds::valid(request.n)) return request_result_t::invalid_params; bomzhur::leds::on(request.n); ... }
rpc_led_off точно такой же, только зовёт leds::off.
read_json<led_request_t>(params) - и всё, разбор параметров написан: это та самая десериализация из предыдущего раздела. Структура led_request_t одновременно является и описанием формата, и документацией.
В реализации парсера JSON сейчас не проверяется наличие или отсутствие ключа, в данном случае мы защищены некорректным значением по умолчанию, запрос честно отвалится с
-32602
Сервер
Сервер - тоже специализация шаблона. Адрес устройства и список команд - его параметры:
using server = bomzhur::rpc::rpc_server< config::rpc_device_address, bomzhur::rpc::rpc_method<"led.on", rpc_led_on>, bomzhur::rpc::rpc_method<"led.off", rpc_led_off> >;
Добавить команду - дописать строку rpc_method<"имя", обработчик>. Диспетчеризация, сравнение имён, формирование ответа и ошибок - всё шаблонное и не требует доработок при добавлении или удалении методов.
Диспетчеризация устроена так же, как обход структуры в JSON-слое:
methods_list::any([&](auto entry) { if (type_unbox<entry>::name == method) { result = type_unbox<entry>::handler(params, result_area, result_size); return true; } return false; });
Обходит эта конструкция список команд: any из Zhele - это fold-выражение (func(type_box<Ts>{}) || ...), и компилятор разворачивает его в цепочку сравнений с именами-константами. Это не очень эффективно в части размера секции кода (зато экономия RAM), такова стандартная плата за шаблоны.
Раз список команд известен при компиляции, при компиляции же можно детектировать (и запретить) дубликаты:
static_assert(methods_list::is_unique([](auto v1, auto v2) { return type_unbox<v1>::name == type_unbox<v2>::name; }), "Two handler for one method name!!!");
Пресеты команд
Раз минимальный набор команд фиксирован системой, обязательную часть удобно написать один раз и в перспективе переиспользовать. Так появились пресеты - наборы готовых RPC-методов:
template<settings_module _settings> struct settings { /* settings.get, settings.set */ }; template<firmware_updater _updater> struct ota { /* ota.begin, ota.write, ota.end */ }; template<std::add_pointer_t<void(uint32_t)> _deferred_reboot_cbk = nullptr> struct system { /* sys.getinfo, sys.reboot */ };
Каждый отдаёт наружу all_methods - список типов, который подмешивается в таблицу сервера одной строкой (можно и по одному):
using server = bomzhur::rpc::rpc_server< config::rpc_device_address, // Команды из пресета можно использовать по одной bomzhur::rpc::presets::settings<bomzhur::settings>::get_method, bomzhur::rpc::presets::settings<bomzhur::settings>::set_method, // А можно целиком bomzhur::rpc::presets::system<deferred_reboot>::all_methods, bomzhur::rpc::presets::ota<updater>::all_methods, // Прикладная часть bomzhur::rpc::rpc_method<"led.on", rpc_led_on>, bomzhur::rpc::rpc_method<"led.off", rpc_led_off> >;
Пресеты реализованы как параметризованные шаблоны с чётким интерфейсом, и этот интерфейс определён концептами. Например, пресет ota требует следующего интерфейса от модуля, который непосредственно работает со слотом приложения:
template<typename T> concept firmware_updater = requires(uint32_t size, uint32_t crc32, std::span<const uint8_t> chunk) { { T::begin(size, crc32) } -> std::same_as<bool>; { T::write_chunk(chunk) } -> std::same_as<bool>; { T::end() } -> std::same_as<bool>; };
Сервер принимает вперемешку и списки, и отдельные методы: внутри он раскрывает всё это в один плоский список типов.
Формирование полного списка
template<uint8_t _device_address, typename... _methods> class rpc_server { using methods_list = decltype((std::conditional_t< is_type_list<_methods>::value, _methods, Zhele::template_utils::type_list<_methods>>{} + ...)); ...
Fold-выражение оборачивает одиночный метод в список из одного элемента, а уже готовый список оставляет как есть, и всё конкатенируется в один.
Настройки
Всё описание настроек устройства выглядит так:
struct settings_t { struct example_settings_t { uint32_t val1 = config::default_example_value; } sec1; };
Каждое поле верхнего уровня - это секция, адресуемая по имени в settings.get / settings.set. Имя берётся прямо из имени поля структуры.
Теперь добавляем настройку. Дописываем одну строку:
struct example_settings_t { uint32_t val1 = config::default_example_value; uint32_t val2 = 100; // Всё, этого достаточно };
И дальше не делаем ничего. settings.get начинает её отдавать как надо, settings.set - принимать. Ни парсер, ни сериализатор, ни RPC-обработчики не знают, что что-то поменялось, просто при пересборке проекта reflect::for_each сгенерирует код для нового члена структуры.
Целая секция добавляется так же - одним полем в settings_t.
Расширение бесплатно, но не бесконечно. В общий буфер токенов должны влезать и весь дамп настроек при загрузке из флеша, и самый большой запрос settings.set, а сам дамп - в буфер передачи и в страницу флеша. Все эти пределы легко проверяются при компиляции: через рефлексию считается, сколько токенов и байт в худшем случае требует структура, остаётся проверить через static_assert.
Например, как отдаётся секция:
inline char* settings_base<...>::dump_section_to_json(std::string_view section, char* where) { char* end = nullptr; // нет такой секции reflect::for_each([&](auto field) { if (reflect::member_name<field>(_data) == section) { end = write_json(where, reflect::get<field>(_data)); } }, _data); return end; }
Хранение во флеше
Настройки хранятся просто текстом. Возможно, неоптимально, но для концепта пойдёт.
Кроме того, в текущем демо-коде отсутствует сохранение двух копий, то есть пропажа питания в момент записи настроек приведёт к их порче. В настоящем проекте такое упрощение, скорее всего, недопустимо.
Обновление
Тут стоит начать с того, как эту задачу решают, когда никто не жмётся - двухбанковый контроллер. Флеш там физически разделён на два банка, и контроллер умеет исполнять код из одного, пока пишем в другой, а потом переключить их местами одним битом в регистре. Приложение при этом собирается один раз: с точки зрения адресного пространства оно всегда лежит по одному и тому же базовому адресу, банки подменяются аппаратно. Это удобно, надёжно и стоит денег.
У CH32V003 (да и большинства других дешевых чипов) никаких банков нет. Поэтому примитивное переключение банков пришлось изобрести программно.
Схема
Флеш делится пополам на два слота приложения - APP0 и APP1. В любой момент один из них активен и исполняется, второй считается запасным и в него пишется обновление.
Проблема в том, что код привязан к адресу: приложение, слинкованное на 0x0800, не заработает по адресу 0x2400. Позиционно-независимый код эту привязку снял бы, но, честно признаюсь, я когда-то пытался разобраться с -fPIC сборкой и не осилил (точнее из коробки оно так и не заработало).
Поэтому приложение просто собирается дважды. Один и тот же исходный код, два таргета в CMake, два линкер-скрипта - они отличаются стартовым адресом региона FLASH:
/* app0.ld */ /* app1.ld */ FLASH : ORIGIN = 0x00000800 FLASH : ORIGIN = 0x00002400
На выходе два бинарника: один умеет исполняться только из первого слота, второй - только из второго.
Файл обновления
А дальше все банально. Файл обновления - это два бинарника, склеенные подряд. Никакого контейнера, заголовка или метаданных: сначала образ для APP0, следом образ для APP1.
Склейкой занимается postbuild-скрипт. Он же добивает каждую половину до размера слота, считает CRC32 образа и дописывает в хвост контрольную сумму и счётчик обновлений - вместе с инвертированной копией, это дешёвая проверка целостности самого счётчика.
Устройство получает такой файл целиком, чанками по RPC, а модуль обновления пишет только нужную половину, а лишнюю пропускает.
приложение исполняется из APP0 → пишем в APP1 → пропускаем первую половину файла, пишем вторую;
приложение исполняется из APP1 → пишем в APP0 → пропускаем вторую половину файла, пишем первую.
Мастеру при этом всё равно, что там на другом конце: он всегда шлёт один и тот же файл. Какую половину взять, устройство решает само.
Определить текущий APP довольно просто:
static unsigned get_current_app_number() { uint32_t this_method_address = reinterpret_cast<uint32_t>(get_current_app_number); return this_method_address < _second_app_begin ? 0 : 1; }
Функция берёт адрес самой себя и сравнивает с границей слотов. Ниже границы - значит мы APP0, выше - APP1.
Сам пропуск половины устроен так же примитивно: модуль считает принятые байты и до/после границы просто выходит из обработчика чанка, не трогая флеш.
Выбор слота при старте
Начальный загрузчик при каждом сбросе делает одно и то же: считает CRC32 обоих слотов, сверяет со значением в хвосте образа, проверяет счётчик обновлений против его инвертированной копии - и передаёт управление валидному слоту с бОльшим счётчиком.
Если валиден только один - идём в него. Если оба - в более свежий. Если ни одного - всё равно прыгаем в APP0, больше идти некуда.
Настоящего отката на прошлую версию, если новая прошла проверку, но нормально не стартует, нет, добавить вроде несложно - retained_memory в RAM.
Байты прошивки в JSON
А вот тут пришлось немного поплатиться за красоту протокола. Раз payload - это JSON (строка), бинарные данные надо чем-то кодировать. Взял base64.
Декодер написан без таблицы - символы считаются арифметически (экономим FLASH).
Правда, честный JSON всё-таки не влез, пришлось экономить: params у ota.write - не объект, а голая JSON-строка с base64, без обёртки {"data":"..."}.
Загрузчик на RISC-V
Передать управление на нужную инструкцию оказалось проще, чем на Cortex-M, например. Там надо переставить VTOR на таблицу векторов приложения. На QingKe таблицу векторов в mtvec прописывает вендорский стартап, так что загрузчику достаточно прыгнуть на начало слота:
__attribute__((naked, noreturn)) void start_application(uint32_t startup_address) { __asm volatile("jr a0"); }
Весь загрузчик - 624 байта из отведённого килобайта, большая часть - вендорский код: таблица векторов и обработчик handle_reset.
Полезная нагрузка
Объявляем виртуальный GPIO-порт из трех ножек, дальше вызов методов Zhele::PinList
using led_pins = Zhele::IO::PinList<Zhele::IO::Pc1, Zhele::IO::Pc2, Zhele::IO::Pc4>; static constexpr unsigned led_count = 3;
На всю полезную нагрузку вместе с двумя RPC-командами ушло около 300 байт. На обвязку - почти семь (про что и шла речь во введении).
Итог сборки (тулчейн из MounRiver Studio):
text data bss dec hex filenam 6916 92 1616 8624 21b0 bomzhur_app0.elf
FLASH - 7008 из 7156Б (98%). RAM - 1708 из 2048Б (83%). Из них 640Б (0x280) - стек, остальные 1068Б - статика. Честно, глубину стека не измерял, возможно накладывается в heap, что не страшно, на .bss вроде нет, тесты не падали).
Что в итоге
Хотелось бы подытожить парой основных мыслей:
1. Функциональный не значит дорогой. JSON, JSON-RPC, хранимые настройки, обновление по шине, переживающее обрыв, и резидентный загрузчик - всё это уместилось в 7 килобайт вместе с полезной нагрузкой. С допущениями, возможностью некорректным запросом всё сломать и, конечно, почти полным отсутствием свободного места - оно собирается и работает.
2. Оптимизированный не значит написанный на ассемблере. В прошивке практически нет каких-то специфичных для конкретного контроллера трюков, мало вендорского HAL (понятно, что доступ к периферии придётся обеспечить, но это, как правило, не такой большой набор функций). В данном случае периферия вообще через Zhele, а верхний слой (RPC, JSON, настройки, обновление) на 100% платформо-независимый.
Тем не менее, не претендую на полную универсальность, некоторые части кода для других чипов лучше сделать иначе:
Программный CRC32 здесь, потому что у V003 нет аппаратного блока, у многих чипов он присутствует.
Приём кадра по
IDLEс ручным управлением DE, у многих чипов есть настраиваемый таймаут и аппаратный драйв DE, у 003 такого нет. Ну и прием по IDLE - это формально некорректно, так как принято считать окончанием посылки паузу в 3.5 байта.
Послесловие
Конечно, это ни разу не production-код, а концепт того, как можно попробовать выжать максимум из слабого чипа, при этом сохранить высокий уровень абстракции, расширяемость и лёгкое портирование на другие микроконтроллеры. Считаю, что в целом это получилось, копеечный чип вместил себя довольно большой набор функций, при этом код получился весьма современным и легко расширяемым.
Для реального проекта я бы, конечно, выбрал хотя бы ch32v005/v006, stm32g030 - не сильно дороже, зато по FLASH/RAM вчетверо богаче.
Рабочий код можно взять в репо bomzhur.
