Привет, Хабр!


Вот уже несколько лет все свои проекты для линейки микроконтроллеров 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-алгоритмах. Если это вам интересно — пишите в комментариях.