MyTetra Share
Делитесь знаниями!
С++, метапрограммирование и регистры микроконтроллера STM32
Время создания: 31.05.2022 21:40
Автор: Алексей AKA DeddXey
Текстовые метки: stm32, c++, метапрограммированиеdefine, определения, упрощение
Раздел: Компьютер - Аппаратное обеспечение - Микроконтроллеры ARM
Запись: xintrea/mytetra_syncro/master/base/16540224245ojh0wxi3n/text.html на raw.github.com

Вот уже несколько лет все свои проекты для линейки микроконтроллеров stm32 я делаю на C++.

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

Во избежание лишних вопросов: я использую связку QtCreator+gcc+gdb+openocd. Как с ней работать, описано многократно, поэтому не буду на этом останавливаться, а вот о своих подходах к работе с микроконтроллерами расскажу подробнее.

На нижнем уровне проекта, как правило, находятся драйвера периферии. С них и начнём.

Я стараюсь, по возможности, не связываться с монстрами вроде SPL, HAL и, прости господи, CubeMX, разве что допиливая за деньги чужие проекты. Сама идеология фреймворка "как для больших машин", на мой взгляд, там порочна. Задуманные гибкими и удобными в использовании (хотя кто-то и поспорит с этим) функции превращаются в достаточно большой и неоптимальный машинный код, где многие лишние операции выполняются на микроконтроллере в рантайме. Да, современные контроллеры мощны, но компьютер, на котором мы собираем проект гораздо мощнее, поэтому пусть он всё и делает.

Я определил для себя основные требования к реализации управления периферией:

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

Наверное, только ленивый не придумывал свой велосипед для работы с полями регистров. Это и сдвиг плюс логические операции а-ля CMSIS, и всяческие макросы, скрывающие эти же операции в своих запутанных недрах и хтонь в стиле библиотек от ST. Одно время я даже работал через битовые поля, пока, наконец, не придумал решение, с которым и хочу ознакомить вас.

Здесь и далее в качестве примера буду использовать так любимый китайцами STM32f103 и его Flash access control register, который там один, поэтому пример будет простым и коротким.



Сконфигурируем flash средствами CMSIS для работы ядра на 72МГц:

FLASH->ACR = (FLASH->ACR &

(~(FLASH_ACR_LATENCY_Msk

| FLASH_ACR_PRFTBE_Msk ))) // Плохо читаемое обнуление полей

| FLASH_ACR_LATENCY_1 // Сразу не скажешь, сколько это и чего

| FLASH_ACR_PRFTBE;

Не очень наглядно, не находите? Если через пару недель понадобится изменить код, придётся лезть в даташит и по-новому разбираться, где, что и как. Или писать везде подробные комментарии. Тем не менее, плюсом данного подхода является то, что сгенерированный машинный код очень компактный:

0x80001ec 04 4a ldr r2, [pc, #16] ; (0x8000200 <main()+20>)

0x80001ee <+ 2> 13 68 ldr r3, [r2, #0]

0x80001f0 <+ 4> 23 f0 17 03 bic.w r3, r3, #23

0x80001f4 <+ 8> 43 f0 12 03 orr.w r3, r3, #18

0x80001f8 <+ 12> 13 60 str r3, [r2, #0]

Как видим, всё по классике: чтение-модификация-запись. Можно ли получить такой код из более понятного программного текста?

Можно. В этом нам помогут шаблоны и метапрограммирование.

Прежде всего, необходимо сделать описание периферии. В моей концепции набор регистров каждого периферийного устройства представляет собой структуру. Для flash с его одним регистром это будет выглядеть так:

struct Regs {

uint32_t ACR;

// Здесь могли бы быть ещё регистры

};

Для описания полей регистра используется следующее:

struct ACR {

constexpr static uint8_t LATENCY[]{ 0, 3 };

constexpr static uint8_t HLFCYA[]{ 3, 1 };

constexpr static uint8_t PRFTBE[]{ 4, 1 };

constexpr static uint8_t PRFTBS[]{ 5, 1 };

};

Здесь первое число в каждом массиве — это смещение поля, а второе — его длина. Для автоматического получения подобных структур на Python на коленке был написан парсер SVD файлов.

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

constexpr static uint32_t base = 0x40022000; // Базовый адрес периферийного устройства

INLINE constexpr static volatile Regs* rg()

{

return reinterpret_cast<volatile Regs*>(base);

}

Здесь base задаётся отдельным выражением, потому что в некоторых случаях (если, например, у нас несколько одинаковых устройств, например, таймеров) оно может быть и параметром шаблона. Об этом я расскажу в других статьях.

Макрос INLINE определён как

#ifndef INLINE

#define INLINE __attribute__((__always_inline__)) inline

#endif

Это как раз тот случай, когда макрос оправдан. Запись становится более короткой, а в дальнейшем, при использовании компилятора отличного от gcc его можно будет условно переопределить.

Функция конфигурирования flash выглядит следующим образом:

INLINE static void setLatency(Flash::Latency latency, bool prefetchBufferEnable = false)

{

setRegister(rg()->ACR,

ACR::LATENCY, static_cast<uint8_t>(latency),

ACR::PRFTBE, prefetchBufferEnable

);

}

На мой взгляд, всё достаточно удобочитаемо: как сам интерфейс функции, так и её код. Входное значение latency жёстко типизировано, попытка записать туда произвольное число приведёт к ошибке. Приведение типа static_cast<uint8_t>(latency) обязательно, иначе компилятор будет ругаться, несмотря на то, что Latency объявлено как:

enum class Latency : uint8_t {

zeroWaitState = 0b000,

oneWaitState = 0b001,

twoWaitStates = 0b010

};

Вызов функции вида

Flash::setLatency(Flash::Latency::twoWaitStates, true);

компилируется в следующий машинный код:

0x80001ec 04 4a ldr r2, [pc, #16] ; (0x8000200 <main()+20>)

0x80001ee <+ 2> 13 68 ldr r3, [r2, #0]

0x80001f0 <+ 4> 23 f0 17 03 bic.w r3, r3, #23

0x80001f4 <+ 8> 43 f0 12 03 orr.w r3, r3, #18

0x80001f8 <+ 12> 13 60 str r3, [r2, #0]

Попробуйте найти отличие от реализации на CMSIS: всё происходит за один цикл чтения-модификации-записи, причём все константы вычисляются на этапе компиляции.

Какими средствами это достигается? Добро пожаловать под капот функции setRegister. Выглядит она следующим образом:

template<typename T, typename V, typename... Args>

INLINE constexpr static void setRegister(volatile uint32_t& reg,

const T field,

const V value,

const Args... args)

{

uint32_t mask = setMaskR(field, value, args...);

uint32_t val = setBitsR(field, value, args...);


reg = (reg & (~mask)) | val;

}

Ей передаётся ссылка на регистр (число uint32_t) и произвольное количество пар поле — значение. Далее при помощи вспомогательных функций конструируется маска для обнуления полей и число для записи в них.

Вспомогательные функции имеют вид:

template<typename V, typename T>

INLINE constexpr static uint32_t setBitsR(T field, V val)

{

return (val << (field[0]));

}


template<typename V, typename T, typename... Args>

INLINE constexpr static uint32_t setBitsR(T field, V val, Args... args)

{

return (val << (field[0])) | setBitsR(args...);

}


template<typename V, typename T>

INLINE constexpr static uint32_t setMaskR(T field, V val)

{

return ((((1 << field[1]) - 1) << field[0]));

}


template<typename V, typename T, typename... Args>

INLINE constexpr static uint32_t setMaskR(T field, V val, Args... args)

{

return ((((1 << field[1]) - 1) << field[0])) | setMaskR(args...);

}

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

Зачем используются вспомогательные функции и почему сразу не написать рекурсивную функцию setRegister? Я сначала так и сделал и получил очень неприятный эффект. Предположим, нам нужно подёргать туда-сюда какой-то бит — типичная задача при программировании микроконтроллеров. Однако, компилятор умный, он видит, что мы последовательно меняем одно и то же значение, и как бы мы его не отмечали как volatile, он оставляет только операцию, выполняющую последнюю модификацию. К сожалению, код такой реализации у меня не сохранился, поэтому прошу поверить мне на слово или попробовать проверить это самостоятельно.

В моей текущей реализации этот эффект отсутствует. В этом можно убедиться:

Flash::setLatency(Flash::Latency::twoWaitStates, true);

Flash::setLatency(Flash::Latency::oneWaitState, true);

компилируется в два цикла чтение-модификация-запись:

0x80001ec 07 4a ldr r2, [pc, #28] ; (0x800020c <main()+32>)

0x80001ee <+ 2> 13 68 ldr r3, [r2, #0]

0x80001f0 <+ 4> 23 f0 17 03 bic.w r3, r3, #23

0x80001f4 <+ 8> 43 f0 12 03 orr.w r3, r3, #18

0x80001f8 <+ 12> 13 60 str r3, [r2, #0] ; пишем раз

0x80001fa <+ 14> 13 68 ldr r3, [r2, #0]

0x80001fc <+ 16> 23 f0 17 03 bic.w r3, r3, #23

0x8000200 <+ 20> 43 f0 11 03 orr.w r3, r3, #17

0x8000204 <+ 24> 13 60 str r3, [r2, #0] ; пишем два


Функция чтения поля регистра достаточно тривиальна:

template<typename T>

INLINE constexpr static uint32_t getRegField(volatile uint32_t& reg,

const T field)

{

uint32_t mask = (((1 << field[1]) - 1) << field[0]);

return ((reg & mask) >> field[0]);

}

За один вызов функции считывается одно поле регистра.

Ниже — реализованная при её помощи сервисная функция:

INLINE static bool getLatencyPrefetch()

{

return getRegField(rg()->ACR,

ACR::LATENCY,

ACR::PRFTBE);

}

Полностью класс для управления flash будет выглядеть следующим образом:

struct Flash {

constexpr static uint32_t base = 0x40022000; //< Базовый адрес периферии


struct ACR {

constexpr static uint8_t LATENCY[]{ 0, 3 };

constexpr static uint8_t HLFCYA[]{ 3, 1 };

constexpr static uint8_t PRFTBE[]{ 4, 1 };

constexpr static uint8_t PRFTBS[]{ 5, 1 };

};


enum class Latency : uint8_t {

zeroWaitState = 0b000,

oneWaitState = 0b001,

twoWaitStates = 0b010

};


INLINE constexpr static volatile Regs* rg()

{

return reinterpret_cast<volatile Regs*>(base);

}


INLINE static void setLatency(Flash::Latency latency, bool prefetchBufferEnable = false)

{

Utility::setRegister(rg()->ACR,

ACR::LATENCY, static_cast<uint8_t>(latency),

ACR::PRFTBE, prefetchBufferEnable

);

}

INLINE static bool getLatencyPrefetch()

{

return getRegField(rg()->ACR,

ACR::LATENCY,

ACR::PRFTBE);

}

};

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

Для работы с регистрами портов ввода-вывода я использую иной механизм, о нём будет в следующей статье.

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


Так же в этом разделе:
 
MyTetra Share v.0.59
Яндекс индекс цитирования