Выжимаем все соки из ch32v003

Представим, что у нас есть система из нескольких контроллеров, связанных общей шиной. Внезапно возникает задача добавить в эту систему новый узел с совершенно примитивной полезной нагрузкой, пусть это будет управление светодиодами. Целевой функционал настолько простой, что для него вычислительных ресурсов надо чуть больше нуля, но в то же время уже устоявшаяся шина и протокол требуют, чтобы новый узел поддерживал необходимый минимум, который исторически довольно дорогой. Получается так, что ставить нормальный микроконтроллер жалко, а совместимость обеспечить всё-таки нужно.

В этом материале мы попробуем впихнуть, на первый взгляд, невпихуемое.

Пекарь и аптекарь

Учитывая, что устройство должно быть совместимо с существующей шиной и протоколом, определим требования:

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.

Разметка флеша

Учитывая предъявленные ранее требования, разметка памяти тоже, в целом, определена однозначно:

Секция

Смещение

Размер

Что расположено

Загрузчик

0x0000

1 КБ

Начальный загрузчик

Настройки

0x0400

1 КБ

Настройки

APP0

0x0800

7 КБ

Слот приложения 0

APP1

0x2400

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; if constexpr (std::is_fundamental_v) { // у нас только числа } else if constexpr (is_std_array_v) { // массив: [elem,elem,...], каждый элемент - снова write_json } else if constexpr (std::is_same_v) { // строка в кавычках } else if constexpr (std::is_standard_layout_v) { // структура: см. ниже } else { static_assert(false, "Cannot dump type"); } return where;}

Самая интересная ветка — структура, просто добавляем скобки, ключ в кавычках, значение:

*where++ = '{';reflect::for_each([&](auto field) { *where++ = '"'; where = std::copy_n( reflect::member_name(data).data(), reflect::member_name(data).length(), where ); *where++ = '"'; *where++ = ':'; where = write_json(where, reflect::get(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 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(out) == parsed_key) parse_value(data, value, reflect::get(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(params);

Для более ресурсного контроллера я бы выбрал glaze, там есть крутые легковесные heap-free фичи.

RPC

RPC-команда

Команда — это пара «имя + обработчик», и вся она живёт в типе:

bomzhur::rpc::rpc_method

Имя метода — строковый литерал (у меня это 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 response, unsigned& response_size) { auto request = bomzhur::read_json(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(params) — и всё, разбор параметров написан: это та самая десериализация из предыдущего раздела. Структура led_request_t одновременно является и описанием формата, и документацией.

В реализации парсера JSON сейчас не проверяется наличие или отсутствие ключа, в данном случае мы защищены некорректным значением по умолчанию, запрос честно отвалится с -32602

Сервер

Сервер — тоже специализация шаблона. Адрес устройства и список команд — его параметры:

using server = bomzhur::rpc::rpc_server< config::rpc_device_address, bomzhur::rpc::rpc_method, bomzhur::rpc::rpc_method>;

Добавить команду — дописать строку rpc_method. Диспетчеризация, сравнение имён, формирование ответа и ошибок — всё шаблонное и не требует доработок при добавлении или удалении методов.

Диспетчеризация устроена так же, как обход структуры в JSON-слое:

methods_list::any([&](auto entry) { if (type_unbox::name == method) { result = type_unbox::handler(params, result_area, result_size); return true; } return false;});

Обходит эта конструкция список команд: any из Zhele — это fold-выражение (func(type_box{}) || ...), и компилятор разворачивает его в цепочку сравнений с именами-константами. Это не очень эффективно в части размера секции кода (зато экономия RAM), такова стандартная плата за шаблоны.

Раз список команд известен при компиляции, при компиляции же можно детектировать (и запретить) дубликаты:

static_assert(methods_list::is_unique([](auto v1, auto v2) { return type_unbox::name == type_unbox::name;}), "Two handler for one method name!!!");

Пресеты команд

Раз минимальный набор команд фиксирован системой, обязательную часть удобно написать один раз и в перспективе переиспользовать. Так появились пресеты — наборы готовых RPC-методов:

templatestruct settings { /* settings.get, settings.set */ };templatestruct ota { /* ota.begin, ota.write, ota.end */ };template<std::add_pointer_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::get_method, bomzhur::rpc::presets::settings::set_method, // А можно целиком bomzhur::rpc::presets::system::all_methods, bomzhur::rpc::presets::ota::all_methods, // Прикладная часть bomzhur::rpc::rpc_method, bomzhur::rpc::rpc_method>;

Пресеты реализованы как параметризованные шаблоны с чётким интерфейсом, и этот интерфейс определён концептами. Например, пресет ota требует следующего интерфейса от модуля, который непосредственно работает со слотом приложения:

templateconcept firmware_updater = requires(uint32_t size, uint32_t crc32, std::span chunk) { { T::begin(size, crc32) } -> std::same_as; { T::write_chunk(chunk) } -> std::same_as; { T::end() } -> std::same_as;};

Сервер принимает вперемешку и списки, и отдельные методы: внутри он раскрывает всё это в один плоский список типов.

Формирование полного списка

templateclass rpc_server { using methods_list = decltype((std::conditional_t< is_type_list::value, _methods, Zhele::template_utils::type_list>{} + ...));...

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(_data) == section) { end = write_json(where, reflect::get(_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(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;static constexpr unsigned led_count = 3;

На всю полезную нагрузку вместе с двумя RPC-командами ушло около 300 байт. На обвязку — почти семь (про что и шла речь во введении).

Итог сборки (тулчейн из MounRiver Studio):

text data bss dec hex filenam6916 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% платформо-независимый.

Тем не менее, не претендую на полную универсальность, некоторые части кода для других чипов лучше сделать иначе:

  1. Программный CRC32 здесь, потому что у V003 нет аппаратного блока, у многих чипов он присутствует.

  2. Приём кадра по IDLE с ручным управлением DE, у многих чипов есть настраиваемый таймаут и аппаратный драйв DE, у 003 такого нет. Ну и прием по IDLE — это формально некорректно, так как принято считать окончанием посылки паузу в 3.5 байта.

Послесловие

Конечно, это ни разу не production-код, а концепт того, как можно попробовать выжать максимум из слабого чипа, при этом сохранить высокий уровень абстракции, расширяемость и лёгкое портирование на другие микроконтроллеры. Считаю, что в целом это получилось, копеечный чип вместил себя довольно большой набор функций, при этом код получился весьма современным и легко расширяемым.

Для реального проекта я бы, конечно, выбрал хотя бы ch32v005/v006, stm32g030 — не сильно дороже, зато по FLASH/RAM вчетверо богаче.

Рабочий код можно взять в репо bomzhur.

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев