Представим, что у нас есть система из нескольких контроллеров, связанных общей шиной. Внезапно возникает задача добавить в эту систему новый узел с совершенно примитивной полезной нагрузкой, пусть это будет управление светодиодами. Целевой функционал настолько простой, что для него вычислительных ресурсов надо чуть больше нуля, но в то же время уже устоявшаяся шина и протокол требуют, чтобы новый узел поддерживал необходимый минимум, который исторически довольно дорогой. Получается так, что ставить нормальный микроконтроллер жалко, а совместимость обеспечить всё-таки нужно.
В этом материале мы попробуем впихнуть, на первый взгляд, невпихуемое.
Пекарь и аптекарь
Учитывая, что устройство должно быть совместимо с существующей шиной и протоколом, определим требования:
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.elfFLASH - 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.

Комментарии 0