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

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

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

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

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

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

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

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

Послесловие

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

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

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