Типобезопасная работа с регистрами без оверхеда на С++17: value-based метапрограммирование

в 19:36, , рубрики: c++, c++17, mcu, метапрограммирование, программирование микроконтроллеров

С++, благодаря своей строгой типизации, может помочь программисту на этапе компиляции. На хабре уже довольно много статьей, описывающих как, используя типы, добиться этого, и это прекрасно. Но во всех, что я читал, есть один изъян. Сравним с++ подход и си подход с использованием CMSIS, привычный в мире программирования микроконтроллеров:

some_stream.set (Direction::to_periph)    SOME_STREAM->CR |= DMA_SxCR_DIR_0
   .inc_memory()                                          |  DMA_SxCR_MINC_Msk
   .size_memory (DataSize::word16)                        |  DMA_SxCR_MSIZE_0
   .size_periph (DataSize::word16)                        |  DMA_SxCR_PSIZE_0
   .enable_transfer_complete_interrupt();                 |  DMA_SxCR_TCIE_Msk;

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

Сначала приведу пример, как бы я хотел, чтобы это выглядело. Желательно, чтобы это не сильно отличалось от уже привычного с++ подхода.

some_stream.set(
     dma_stream::direction::to_periph
   , dma_stream::inc_memory
   , dma_stream::memory_size::byte16
   , dma_stream::periph_size::byte16
   , dma_stream::transfer_complete_interrupt::enable
);

Каждый параметр в методе set — отдельный тип, по которому можно понять, в какой регистр надо записать значение, а значит во время компиляции можно оптимизировать обращение к регистрам. Метод вариадический, поэтому аргументов может быть любое количество, но при этом должна присутствовать проверка, что все аргументы относятся к данной периферии.

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

Решать задачу буду абстрактно, не для конкретной периферии. Итак, есть несколько полей регистров, условно запишу их в качестве перечислений.

enum struct Enum1 { _0, _1, _2, _3 };
enum struct Enum2 { _0, _1, _2, _3 };
enum struct Enum3 { _0, _1, _2, _3, _4 };
enum struct Enum4 { _0, _1, _2, _3 };

Первые три будут относиться к одной периферии, четвертое к другой. Таким образом, если вписать значение четвёртого перечисления в метод первой периферии, должна быть ошибка компиляции, желательно понятная. Так же первые 2 перечисления будут относиться к одному регистру, третье к другому.

Поскольку значения перечислений не хранят в себе ничего, кроме собственно значений, нужен дополнительный тип, который будет хранить, к примеру, маску, чтобы определять, в какую часть регистра это перечисление будет записываться.

struct Enum1_traits { static constexpr std::size_t mask = 0b00111; };
struct Enum2_traits { static constexpr std::size_t mask = 0b11000; };
struct Enum3_traits { static constexpr std::size_t mask = 0b00111; };
struct Enum4_traits { static constexpr std::size_t mask = 0b00111; };

Осталось связать эти 2 типа. Тут пригодится фишка уже 20 стандарта, но она довольно тривиальна и можно реализовать её самому.

template <class T> struct type_identity { using type = T; };

// получить значение типа
constexpr auto some_type = type_identity<Some_type>{};

// достать тип из значения типа
using some_type_t = typename decltype(some_type)::type;
#define TYPE(type_identity) typename decltype(type_identity)::type

Суть в том, что можно из любого типа сделать значение и передать его в функцию в качестве аргумента. Это главный кирпичик value-based подхода в метапрограммировании, в котором надо стараться передавать информацию о типе через значения, а не в качестве параметра шаблона. Тут я определил макрос, но являюсь противником их в с++. Но он позволяет далее писать меньше. Далее приведу связывающую перечисление и его свойства функцию и ещё один макрос, позволяющий уменьшить количество копипасты.

constexpr auto traits(type_identity<Enum1>) {
    return type_identity<Enum1_traits>{};
}

#define MAKE_TRAITS_WITH_MASK(enum, mask_) struct enum##_traits { 
    static constexpr std::size_t mask = mask_; 
}; 
constexpr auto traits(type_identity<enum>) { 
    return type_identity<enum##_traits>{}; 
}

Необходимо поля связать с соответствующими регистрами. Я выбрал связь через наследование, поскольку в стандарте уже есть метафункция std::is_base_of, которая позволит определять связи между полями и регистрами уже в обобщенном виде. Наследоваться от перечислений нельзя, поэтому наследуемся от их свойств.

struct Register1 : Enum1_traits, Enum2_traits {
   static constexpr std::size_t offset = 0x0;
};

Адрес, где находится регистр, хранится в виде смещения относительно начала периферии.

Перед тем, как описывать периферию, необходимо рассказать о списке типов в value-based метапрограммировании. Это довольно простая структура, которая позволяет сохранить несколько типов и передать их по значению. Немного напоминает type_identity, но для нескольких типов.

template <class...Ts> struct type_pack{};

using empty_pack = type_pack<>;

Для этого списка можно реализовать множество constexpr функций. Их реализация значительно проще в понимании, чем знаменитые списки типов Александреску (библиотека Loki). Далее будут примеры.

Вторым важным свойством периферии должна быть возможность расположить ее как по конкретному адресу (в микроконтроллере), так и передать адрес динамически для тестов. Поэтому структура периферии будет шаблонной, и в качестве параметра принимать тип, который в поле value будет хранить конкретный адрес периферии. Параметр шаблона будем определять из конструктора. Ну и метод set, о котором говорилось ранее.

template<class Address>
struct Periph1 {
   Periph1(Address) {}

   static constexpr auto registers = type_pack<Register1, Register2>{};

   template<class...Ts>
   static constexpr void set(Ts...args) {
       ::set(registers, Address::value, args...);
   }
};

Всё, что делает метод set — вызывает свободную функцию, передавая в неё всю необходимую для обобщенного алгоритма информацию.

Приведу примеры типов, предоставляющих адрес на периферию.

// статический адрес микроконтроллера
struct Address { static constexpr std::size_t value = SOME_PERIPH_BASE; };

// динамический адрес для тестов, передается через конструктор
struct Address {
   static inline std::size_t value;
   template<class Pointer>
   Address(Pointer address) { value = reinterpret_cast<std::size_t>(address); }
};

Вся информацию для обобщенного алгоритма подготовлена, осталось его реализовать. Приведу текст этой функции.

template<class...Registers, class...Args>
constexpr void set(type_pack<Registers...> registers, std::size_t address, Args...args) {
   // из аргументов достаем их свойства и упаковываем, используя value based подход
   constexpr auto args_traits = make_type_pack(traits(type_identity<Args>{})...);

   // и теперь можно проверить все ли свойства аргументов являются базовыми для данной периферии
   static_assert(all_of(args_traits, [](auto arg){
       return (std::is_base_of_v<TYPE(arg), Registers> || ...);
   }), "one of arguments in set method don`t belong to periph type");

   // определяем список регистров, в которые надо записывать данные
   constexpr auto registers_for_write = filter(registers, [](auto reg){
       return any_of(args_traits, [](auto arg){
           // как без захвата в эту лямбду пoпало значение reg?
           return std::is_base_of_v<TYPE(arg), TYPE(reg)>;
       });
   });

   // определяем значения в каждом регистре и пишем по его адресу
   foreach(registers_for_write, [=](auto reg){
       auto value = register_value(reg, args...);
       auto offset = decltype(reg)::type::offset;
       write(address + offset, value);
   });
};

Реализация функции, которая преобразует аргументы (конкретные поля регистров) в type_pack, довольно тривиальна. Напомню, что многоточие у шаблонного списка типов раскрывает список типов через запятую.

template <class...Ts>
constexpr auto make_type_pack(type_identity<Ts>...) {
   return type_pack<Ts...>{};
}

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

template <class F, class...Ts>
constexpr auto all_of(type_pack<Ts...>, F f) {
   return (f(type_identity<Ts>{}) and ...);
}

Тут впервые применено выражение развёртки 17 стандарта. Именно это нововведение сильно упростило жизнь тем, кто увлекается метапрограммированием. В данном примере применяется функция f для каждого из типов в списке Ts, преобразуя его к type_identity, а результат каждого вызова собирается по И.

Внутри static_assert применён этот алгоритм. В лямбду по очереди передается args_traits, обернутый в type_identity. Внутри лямбды используется стандартная метафункция std::is_base_of, но поскольку регистров может быть не один, используется выражение развёртки, чтобы выполнить ее для каждого из регистров по логике ИЛИ. В результате, если найдётся хоть один аргумент, свойства которого не являются базовым хотя бы для одного регистра, сработает static assert и выведет понятное сообщение об ошибке. По нему легко понять в каком именно месте ошибка (передан не тот аргумент в метод set) и исправить её.

Очень похожа и реализация алгоритма any_of, которая понадобится далее:

template <class F, class...Ts>
constexpr auto any_of(type_pack<Ts...>, F f) {
   return (f(type_identity<Ts>{}) or ...);
}

Следующая задача обобщенного алгоритма — определить, в какие регистры надо будет произвести запись. Для этого надо исходный список регистров отфильтровать и оставить только те, к которым есть аргументы в нашей функции. Нужен алгоритм filter, который возьмёт исходный type_pack, применит функцию предикат для каждого типа из списка, и добавит его в новый список, если предикат вернет true.

template <class F, class...Ts>
constexpr auto filter(type_pack<Ts...>, F f) {
   auto filter_one = [](auto v, auto f) {
       using T = typename decltype(v)::type;
       if constexpr (f(v))
           return type_pack<T>{};
       else
           return empty_pack{};
   };
   return (empty_pack{} + ... + filter_one(type_identity<Ts>{}, f));
}

Вначале описана лямбда, которая выполняет функцию предикат над одним типом и возвращает type_pack с ним, если предикат вернул true, или пустой type_pack, если предикат вернул false. Тут помогает еще одна новая фишка последних плюсов — constexpr if. Её суть в том, что в результирующем коде остается только одна ветка if, вторая выбрасывается. А поскольку в разных ветках возвращаются разные типы, без constexpr была бы ошибка компиляции. Результат выполнения этой лямбды для каждого типа из списка конкатенируется в один результирующий type_pack, опять благодаря выражению развертки. Не хватает перегрузки оператора сложения для type_pack. Его реализация также довольно проста:

template <class...Ts, class...Us>
constexpr auto operator+ (type_pack<Ts...>, type_pack<Us...>) {
   return type_pack<Ts..., Us...>{};
}

Применяя новый алгоритм над списком регистров, в новом списке оставлются лишь те, в которые надо записать переданные аргументы.

Следующий алгоритм, который понадобится это foreach. Он просто применяет функцию к каждому типу из списка, оборачивая его в type_identity. Тут в выражении развертки применяется оператор запятая, которая выполняет все действия, описанные через запятую и возвращает результат последнего действия.

template <class F, class...Ts>
constexpr void foreach(type_pack<Ts...>, F f) {
   (f(type_identity<Ts>{}), ...);
}

Функция позволяет добраться до каждого из регистров, куда надо писать. В лямбде вычисляется значение для записи в регистр, определяется адрес, куда необходимо записать, и непосредственно запись в регистр.

Для того, чтобы вычислить значение одного регистра, вычисляется значение для каждого аргумента, к которому относится этот регистр, и объединяется результат по ИЛИ.

template<class Register, class...Args>
constexpr std::size_t register_value(type_identity<Register> reg, Args...args) {
   return (arg_value(reg, args) | ...);
}

Вычисление значения для конкретного поля должно выполняться только для аргументов, от которых этот регистр наследуется. Для аргумента достаем из его свойства маску, по маске определяем смещение значения внутри регистра.

template<class Register, class Arg>
constexpr std::size_t arg_value(type_identity<Register>, Arg arg) {
    constexpr auto arg_traits = traits(type_identity<Arg>{});
    // значение вычисляется только, если аргумент соотвествует регистру
    if constexpr (not std::is_base_of_v<TYPE(arg_traits), Register>)
        return 0;

    constexpr auto mask = decltype(arg_traits)::type::mask;
    constexpr auto arg_shift = shift(mask);
    return static_cast<std::size_t>(arg) << arg_shift;
}

Алгоритм, определяющий смещение по маске, можно написать самому, но я воспользовался уже существующей builtin функцией.

constexpr auto shift(std::size_t mask) {
   return __builtin_ffs(mask) - 1;
}

Осталась последняя функция, которая пишет значение по конкретному адресу.

inline void write(std::size_t address, std::size_t v) {
   *reinterpret_cast<std::size_t*>(address) |= v;
}

Для проверки выполнения задачи написан небольшой тест:

// место, где будет периферия
volatile std::size_t arr[3];

int main() {
    // необходимо передать адрес динамически (для тестов)
    // поскольку адрес динамический, то эту часть можно выполнить не на микроконтроллере
    auto address = Address{arr};
    auto mock_periph = Periph1{address};
    // значение 1 в первый регистр без смещения
    // значение 3 в первый регистр со смещением на 3
    // значение 4 во второй регистр без смещения
    // итого в первом регистре 0b00011001 (25)
    //      во втором регистре 0b00000100 (4)
    mock_periph.set(Enum1::_1, Enum2::_3, Enum3::_4); // all ok
    // mock_periph.set(Enum4::_0);                       // must be compilation error
}

Всё тут написанное объединил вместе и скомпилировал в godbolt. Там любой может поэксперементировать с подходом. Видно, что поставленная цель выполнена: нет лишних обращений к памяти. Значение, которое необходимо записать в регистры, вычисляется на этапе компиляции:

main:
  mov QWORD PTR Address::value[rip], OFFSET FLAT:arr
  or QWORD PTR arr[rip], 25
  or QWORD PTR arr[rip+8], 4
  mov eax, 0
  ret

Автор: slonegd

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js