Привет, Хабр!
Это первая статья из планируемого цикла про мой объектно‑ориентированный подход к написанию кода для микроконтроллера stm32 (а именно камня stm32f303vc на отладочной плате stm32f3discovery) с использованием функций языка вплоть до стандарта c++20. Но обо всём по порядку.
Но в самом начале я хочу выразить огромную благодарность ребятам с MicroTechnics, статьи которых были очень полезны для погружения в работу с микроконтроллерами. Да и, собственно говоря, одна из их статей и стала вдохновением для моей архитектуры, которой будут посвящены несколько следующих работ. А теперь можем начинать.
Зачем это всё?
Первый вопрос, который стоит осветить: «зачем вообще заморачиваться, если есть условный CubeMX, в графическом интерфейсе которого всё протыкал, а он сам сгенерировал весь код для настройки и инициализации аппаратных составляющих контроллера?»
Во‑первых, я хотел лучше изучить возможности великого и могучего C++ и разобраться в низкоуровневом коде для работы с камнем, который предоставляет сам производитель в виде готовых библиотек (SPL, HAL, LL от компании STMicroelectronics).
Ну а во‑вторых, потому что захотел и была возможность. Заодно получился неплохой пет проект, которым я очень доволен и про который готов рассказывать добровольным (не всегда) слушателям. Не b2b ai стартап, но всё же.
Я ни в коем случае не отрицаю удобство инструментов от st, но считаю нужным разбираться не только в пользовательской части кода, но и в настройке аппаратной части железа.
Содержание
Постановка задачи
Перед решением определённой задачи необходимо чётко обозначить для чего мы делаем тот или иной модуль, а также необходимый набор методов и полей классов для удобного их использования из других частей кода.
В этой статье будет рассмотрена организация пакетов данных для их отправки по интерфейсам связи (virtual com port или usart), модификации и расширения.
Главным требованием, которое я поставил — класс для работы с пакетом данных должен легко преобразовываться в uint8_t* ptrBuffer, uint8_t length, чтобы его можно было без проблем использовать в функциях с сигнатурой как в примере ниже:hw_config.c
uint32_t CDC_Send_DATA(uint8_t *ptrBuffer, uint8_t Send_length);
Базовый класс и специализированные пакеты данных
Для начала стоит описать структуру пакетов. Она довольно тривиальна и может быть усовершенствована/изменена при необходимости. Её схема представлена ниже:
┌──────────────────────────────┬──────────────┬───────────────┐ │ Заголовок пакета данных │ Тело пакета │ Контрольная │ ├───────────┬─────────┬────────┤ данных │ сумма │ │ Начало │ Байт │ Длина ├──────────────┬───────────────┤ │ заголовка │ формата │ данных │ │ │ ├───────────┬─────────┬────────┤ N байт │ 1 байт │ │ 2 байта │ 1 байт │ 1 байт │ │ │ └───────────┴─────────┴────────┴──────────────┴───────────────┘
В целом, это простой бинарный пакет, который используется у меня на работе, так что я решил работать именно с ним. Однако, по этому же принципу можно реализовать любой другой прикладной протокол, например тот же modbus, так что главное уловить идею.
Для того, чтобы во всех местах использовать общий тип пакетов данных, используется базовый класс BasePackage, у которого 2 protected и 2 public поля, дефолтные (почти) конструктор с деструктором и метод для расчёта контрольной суммы, который можно переопределить в наследнике в случае необходимости:
class BasePackage{ protected: static constexpr uint8_t HeaderFirstByte = 0x7E; static constexpr uint8_t HeaderSecondByte = 0xE7; public: uint8_t *data_ptr; uint8_t len; BasePackage(): data_ptr(nullptr), len(0) {} ~BasePackage() = default; protected: uint8_t CountControlSum(){ uint16_t crc = 0; // Исключаем последний байт (собственную контрольную сумму) for (uint8_t i = 0; i < len - 1; i++){ crc += data_ptr[i]; } return static_cast<uint8_t>(crc); } };
Байты заголовка являются общими для всех всех классов‑наследников в рамках одного проекта, что позволяет довольно просто изменять их в одном месте при миграции кода.
Ну а поля data_ptr и len, как несложно догадаться, необходимы для требования, которое было озвучено ранее. А вот их определение происходит в наследниках, но об этом далее.
Для типизации пакета данных необходимо использовать наследников BasePackage, в которых уже и будет детальная реализация пакета данных. В качестве примера будет использоваться PackageImuData, в котором будет закодированы данные акселерометра LSM303DLHC и гироскопа L3GD20, которые расположены прямо на отладочной плате. Собственно, данные этих датчиков имеют тип TriaxialData, который представляет из себя класс с именованными координатами и методами для использования класса в математических выражениях.
Сигнатура TriaxialData
class TriaxialData{ public: float x_coord; float y_coord; float z_coord; // ------------------------------ // Конструкторы и деструктор // ------------------------------ TriaxialData(): x_coord(0), y_coord(0), z_coord(0) {} TriaxialData(float val): x_coord(val), y_coord(val), z_coord(val) {} TriaxialData(float _x, float _y, float _z): x_coord(_x), y_coord(_y), z_coord(_z) {} TriaxialData(const TriaxialData& triaxial_data): x_coord(triaxial_data.x_coord), y_coord(triaxial_data.y_coord), z_coord(triaxial_data.z_coord) {} ~TriaxialData() = default; // ------------------------------ // Перегрузка операторов // ------------------------------ TriaxialData& operator=(const TriaxialData& other) {...} float& operator[](int index) {...} const float& operator[](int index) const {...} TriaxialData operator+(const TriaxialData& other) const {...} TriaxialData operator-(const TriaxialData& other) const {...} TriaxialData operator*(float scalar) const {...} TriaxialData operator*(const TriaxialData& other) const {...} TriaxialData operator/(float scalar) const {...} TriaxialData operator/(const TriaxialData& other) const {...} TriaxialData& operator+=(const TriaxialData& other) {...} TriaxialData& operator-=(const TriaxialData& other) {...} TriaxialData& operator*=(float scalar) {...} TriaxialData& operator*=(const TriaxialData& other) {...} TriaxialData& operator/=(float scalar) {...} TriaxialData& operator/=(const TriaxialData& other) {...} TriaxialData calc_sqrt() const{...} bool operator==(const TriaxialData& other) const {...} bool operator!=(const TriaxialData& other) const {...} };
Но вернёмся к PackageImuData.
Для того, чтобы не плодить внутри этого класса множество сеттеров я решил сохранять указатели на внешние данные внутри класса сразу при инициализации класса. Для этого помечаем конструктор без параметров удалённым (чтобы выстрелить себе в ногу с помощью nullptr было немного сложнее) и оставим разрешённым только конструктор с указателями на используемые данные:
class PackageImuData: public BasePackage{ private: static constexpr uint8_t DataFormat = 0x01; ///< Байт формата пакета uint32_t* package_num_ptr; ///< Указатель на внешний счётчик пакетов TriaxialData* acc_data_ptr; ///< Указатель на внешние данные акселерометра TriaxialData* gyro_data_ptr; ///< Указатель на внешние данные гироскопа public: PackageImuData() = delete; PackageImuData( uint32_t* _package_num_ptr, TriaxialData* _acc_data_ptr, TriaxialData* _gyro_data_ptr ): package_num_ptr(_package_num_ptr), acc_data_ptr(_acc_data_ptr), gyro_data_ptr(_gyro_data_ptr){...} };
Таким образом класс PackageImuData ничего не знает про методы обновления данных (привет SOLID), что позволяет при инициализации экземпляра PackageImuData менять указатели данных на нужные в конкретный момент разработки.
Например, изначально в пакете данных мы отправляли исходные данные датчиков:
main.cpp:
// Счётчик тиков таймера volatile uint32_t tick_counter = 0; // Встроенный гироскоп STM_CppLib::L3GD20 sensor_L3GD20; // Встроенный акселерометр STM_CppLib::LSM303DLHC sensor_LSM303DLHC; // Пакет данных Packages::PackageImuData data_package( &tick_counter, &sensor_LSM303DLHC.acc_data, &sensor_L3GD20.gyro_data );
Но в ходе развития проекта добавили фильтр Калмана (у меня он находится в файле ./user/inc/Filters/SimpleKalmanFilter.hpp), который хранит в себе отфильтрованные значения, которые теперь и необходимо отправлять. Для этого нужно всего лишь изменить указатели при инициализации следующим образом:
main.cpp:
SimpleKalmanFilter<TriaxialData> acc_filter(...); SimpleKalmanFilter<TriaxialData> gyro_filter(...); Packages::PackageImuData data_package( &tick_counter, &acc_filter.filtered_value, &gyro_filter.filtered_value );
А внутри PackageImuData ничего менять не надо.
Внутренняя структура
Однако мало сохранить указатели на внешние данные, необходимо их грамотно сохранить внутрь класса и структурировать под описанную ранее схему пакета данных. Тут на помощь приходит ещё одно поле класса, а именно внутренняя структура, в которой и будут хранится данные:
struct package_body_t { uint8_t header[4] = {BasePackage::HeaderFirstByte, BasePackage::HeaderSecondByte, DataFormat, 0}; uint32_t package_num = 0; TriaxialData acc_data, gyro_data; uint8_t control_sum = 0; } package_body;
Но если оставить структуру в таком виде, то можно неприятно удивиться несовпадению ожидаемого и фактического размеров структуры, ведь компилятор для оптимизации скорости доступа к полям структуры и для соблюдения требований к выравниванию отдельных её элементов спокойно может добавить (и добавит) так называемые «заполнители» или «паддинги». Если для Вас новость (как это было и для меня), что структура вида
struct package_t{ uint8_t a; float b; uint16_t c; };
имеет размер вообще не sizeof(uint8_t) + sizeof(float) + sizeof(uint16_t) = 1 + 4 + 2 = 7 байт, а все 12, то почитайте про выравнивание структур (например тут и тут).
Именно из‑за этого обязательно нужно добавить директиву препроцессора #pragma:
#pragma pack(1) struct package_body_t{...} package_body; #pragma pack()
Тогда дополнительные отступы внутри структуры не будут добавлены компилятором, и мы получим структуру, размер которой совпадёт с суммой размеров её полей, что весьма нужно при дальнейшем декодировании полученных пакетов.
А для обновления полей структуры используется метод UpdateData который просто сохраняет текущее значение по указателям в package_body и обновляет значение контрольной суммы:
void UpdateData() { package_body.package_num = *package_num_ptr; package_body.acc_data = *acc_data_ptr; package_body.gyro_data = *gyro_data_ptr; package_body.control_sum = CountControlSum(); }
При такой организации данные внутри пакета обновляются все вместе в определённый момент выполнения программы, что уменьшает вероятность отправки битых пакетов, то есть пакетов, у которых обновилась только часть данных, а другая часть всё ещё содержит устаревшие значения.
Преобразование к uint8_t*
Теперь можно перейти к небольшому финту ушами, который происходит в конструкторе PackageImuData, а именно к тому, как заполняются data_ptr, len и длина полезных данных в пакете (напомню, это четвёртый байт заголовка package_body).
Начнём с конца. Длина полезных данных в пакете — это всего лишь длина всего пакета за исключением заголовка и контрольной суммы. И в конструкторе это запишется как
package_body.header[3] = sizeof(package_body) - sizeof(package_body.header) - sizeof(package_body.control_sum);
Я настоятельно не рекомендую задавать длину данных, как сумму всех элементов, ведь в таком случае при добавлении новых полей в структуру нужно будет также изменять конструктор, что увеличивает количество мест, которые можно забыть изменить при изменении отправляемых данных.
Поле len, как несложно догадаться, задаётся через sizeof(package_body), а вот data_ptr задаётся чуть сложнее, а именно через reinterpret_cast к указателю на uint8_t от адреса package_body:
data_ptr = reinterpret_cast<uint8_t*>(&package_body);
Вот полный код конструктора, для более удобного представления:
/** * @brief Конструктор с указателями на внешние данные. * @param _package_num_ptr Указатель на объект uint32_t счётчика пакетов. * @param _acc_data_ptr Указатель на объект TriaxialData для акселерометра. * @param _gyro_data_ptr Указатель на объект TriaxialData для гироскопа. * @note Переданные указатели должны оставаться валидными на всём * протяжении использования объекта PackageImuData. */ PackageImuData( uint32_t* _package_num_ptr, TriaxialData* _acc_data_ptr, TriaxialData* _gyro_data_ptr ): package_num_ptr(_package_num_ptr), acc_data_ptr(_acc_data_ptr), gyro_data_ptr(_gyro_data_ptr){ // Последним байтом заголовка необходимо задать длину полезных данных: package_body.header[3] = sizeof(package_body) - sizeof(package_body.header) - sizeof(package_body.control_sum); // Общая длина пакета len = sizeof(package_body); // Указатель на начало пакета data_ptr = reinterpret_cast<uint8_t*>(&package_body); }
По итогу, получается универсальный конструктор для классов‑наследников BasePackage, тело которого можно просто копипастить в другие проекты с минимальным количеством изменений.
Порядок добавления новых полей в имеющийся пакет или создания нового пакета данных
Тут будет краткая инструкция для модификации/изменения классов для описания пакетов данных. А точнее, последовательность действий:
При необходимости измените формат посылки в поле static constexpr uint8_t DataFormat;
Уберите или добавьте нужные указатели на внешние данные;
Измените внутреннюю структуру package_body_t;
Отредактируйте конструктор и метод UpdateData;
При инициализации экземпляра прокиньте в конструктор нужные параметры.
И на этом всё! Как по мне, довольно удобно и при этом необходимо вносить минимальные изменения в уже имеющийся код.
Заключение
Вот такая архитектура пакетов данных у меня получилась. Вообще не утверждаю, что это самый лучший вариант, но какое‑то место под солнцем он может иметь. Тем не менее, буду рад Вашим предложениям и замечаниям в комментариях. Но одно могу сказать точно — это сильно лучше, чем работать с
uint8_t data_buffer[N];
в котором нужно вручную заполнять каждый байт (говорю по личному опыту).
Понятное дело, что при создании программ на ПК этот подход может быть не самым оптимальным, но для микроконтроллеров, где память может быть весьма ограничена, а её динамическое выделение вообще крайне нежелательно, это может быть удобно и эффективно.
Файлы с исходным кодом находятся тут: BasePackage.hpp, PackageImuData.
А вот ссылка на репозиторий с проектом: https://github.com/r3m4k/ImuDataSender.
В целом, это проект будет освещён во всех последующих статьях с объяснением тех или иных моментов, с подробностями реализации и некоторым количеством остроумных (а может и не очень) комментариев от меня.
А сейчас вы можете самостоятельно изучить проект, однако я всё же постараюсь рассказать про него более интересно, чем сухая документация к нему, так что ждите новых материалов)
Обнял, связь
