Советы для улучшения качества embedded-кода на C++

Дисклеймер: этот пост ни коим образом не является общим описанием embedded-разработки. Это небольшая хитрость, которая улучшает качество программ при работе с железом.

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

Мотивация этой статьи достаточно проста: вокруг полно (просто до безобразия) некачественного embedded-кода. Этому есть ряд причин:

  1. У разработчиков отсутствует опыт и образование в области программной инженерии. Зачастую embedded-программистами становятся люди с образованием из области электро- и радиотехники, которым во время обучения преподают либо “чистый” C, либо C с классами (советую посмотреть доклад на эту тему).
  2. Сложность отладки. Большая часть embedded-систем достаточно слаба с точки зрения вычислительных мощностей и имеет весьма ограниченные возможности отладки (порой, они даже отсутствуют). Это не является проблемой само по себе, но может привести к большому количеству костылей, лишь бы работало, а также к индусскому коду.
  3. Экзотические архитектуры (с байтом в 24 или 32 бита) и зоопарк компиляторов. Раньше это были, в основном, кастомные решения, однако сейчас производители процессоров стремятся использовать GCC и LLVM в виде базы для построения тулчейна. Это приводит к проблемам при переиспользовании кода и замедляет принятие новых стандартов.

Ближе к делу, наша задача состоит в превращении подобного кода (CubeMX, STM32):

void SystemInit_ExtMemCtl(void)
{
    __IO uint32_t tmp = 0x00;

    register uint32_t tmpreg = 0, timeout = 0xFFFF;
    register __IO uint32_t index;
    RCC->AHB1ENR |= 0x000001F8;
    tmp = READ_BIT(RCC->AHB1ENR, RCC_AHB1ENR_GPIOCEN);
    GPIOD->AFR[0] = 0x00CCC0CC;
    GPIOD->AFR[1] = 0xCCCCCCCC;
    GPIOD->MODER = 0xAAAA0A8A;
    GPIOD->OSPEEDR = 0xFFFF0FCF;
    GPIOD->OTYPER = 0x00000000;
    GPIOD->PUPDR = 0x00000000;
    GPIOE->AFR[0] = 0xC00CC0CC;
    GPIOE->AFR[1] = 0xCCCCCCCC;
    GPIOE->MODER = 0xAAAA828A;
    GPIOE->OSPEEDR = 0xFFFFC3CF;
    GPIOE->OTYPER = 0x00000000;
    GPIOE->PUPDR = 0x00000000;
    // ...
    /* Delay */
    for (index = 0; index<1000; index++);
    // ...
    (void)(tmp);
}

Во что-то вроде этого:

void SystemInit_ExtMemCtl() {
    rcc.Init();
    gpio.Init();
}

Почему это вообще проблема?

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

Две мысли от умных людей помогут нам сделать этот код лучше:

All problems in computer science can be solved by another level of indirection

David J. Wheeler

C++ is a zero-cost abstraction language

Bjarne Stroustrup

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

abstraction

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

bbp2

Отлично, теперь у нас есть смутное представление о микросхеме. Наша задача состоит в том, чтобы заставить блок DDC(digital down converter, очень полезная штука, часто используется в аппаратной цифровой обработке сигналов) работать так, как мы хотим. Упрощённая структурная схема приведена ниже:

ddc

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

register

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

  1. Множественной работой с битовыми полями
  2. Возможностью смены значения регистра вне программы (аппаратное изменение)

К сожалению, большинство embedded-библиотек и кода работают с регистрами следующим образом:

*(volatile std::uint32_t*)reg_name = val;

В этом коде берётся адрес регистра, который хранится в переменной reg_name (или даже #define-константе), приводит к volatile указателю, разыменовывает его и записывает значение. Это плохо по ряду причин:

  1. Код сложно читать и поддерживать
  2. Практически отсутствует возможность для инкапсуляции и повышения уровня абстракции
  3. При наличии множества регистров код обычно превращается в простыню (больше того, copy-paste на радость статическому анализатору)

Однако, существует альтернатива. Рассматриваемый регистр может быть представлен в виде структуры:

struct DeviceSetup {
    enum class TableType : std::uint32_t {
        inphase = 0,
        quadrature,
        table
    };

    std::uint32_t input_source : 8;
    TableType table_type : 4;
    std::uint32_t reserved : 20;
};

В случае, если внутренние поля требуют пересчёта, он может быть скрыт и выполнен в методах GetFoo() и SetFoo(). К слову, я очень рад появлению этого предложения по разрешению инициализации по умолчанию для битовых полей.

Следующим шагом является расположение объекта структуры в соответствующее место в памяти. В зависимости от предпочтений и стиля кода, это может быть реализовано через расположение указателя, ссылки, либо используя placement new:

auto device_registers_ptr =
        reinterpret_cast<DeviceSetup*>(DeviceControlAddress);

auto &device_registers_ref =
        *reinterpret_cast<DeviceSetup*>(DeviceControlAddress);

auto device_registers_placement =
        new (reinterpret_cast<DeviceSetup*>(DeviceControlAddress)) DeviceSetup;

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

Существует способ сказать компилятору, что значение может быть изменено, это делается при помощи volatile. Это ключевое слово зачастую используется неправильно и получило репутацию, сравнимую с goto. Но оно было введено именно для подобных ситуаций (вот тут можно ознакомиться).

volatile auto device_registers_ptr =
        reinterpret_cast<DeviceSetup*>(DeviceControlAddress);

volatile auto &device_registers_ref =
        *reinterpret_cast<DeviceSetup*>(DeviceControlAddress);

volatile auto device_registers_placement =
        new (reinterpret_cast<DeviceSetup*>(DeviceControlAddress)) DeviceSetup;

Теперь мы можем быть уверены, что каждая операция чтения будет произведена.

N.B. Доступ к регистрам, которые модифицируются извне, может быть рассмотрен как многопоточное приложение. Следовательно, стоит рассмотреть возможность использования std::atomic<T*> вместо volatile T*. К сожалению, используемые нами копиляторы полностью не поддерживают C++11 (NM SDK выполнен по стандарту C++98 с полным отсутствием STL, ARM Compiler 5 поддерживает C++11 на уровне синтаксиса, но STL осталась от старой версии компилятора), поэтому я не могу протестировать это решение в реальных условиях. Однако, compiler explorer демонстрирует многообещающий дизассемблер: ссылка.

Отлично, с регистрами всё, пора добавить новый уровень абстракции: давайте настроим устройство. В большинстве случаев, устройство может быть представлено как набор регистров или других устройств:

struct Mixer {
    NCO nco;
    MixerInput mixer_input;
    // ...
};

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

NCO& GetNCO() {
    return nco;
}

Повышаем уровень абстракции и создаём канал, состоящий из устройств:

struct Channel{
    Mixer mixer;
    Normalizer normalizer;
    Downsampler downsampler;
    PackUnit pack_unit;
};

И, наконец-то, весь блок может быть представлен как массив каналов и ряд управляющих регистров:

struct DDC {
    std::array<Channel, number_of_channels> channels;

    struct ControlRegisters {
        // ...
    } control_registers;
};

Это всё называется уровнем абстракций над аппаратурой (HAL). Этот уровень предоставляет программный интерфейс взаимодействия с железом. Самая удобная вещь в применении HAL заключается в возможности подмены реальной аппаратурой на ПК-модель. Это отдельная тема для разговора и другой статьи. Вкратце, преимущества моделей:

  1. Модели позволяют разработчикам писать программы без непосредственной работы с аппаратурой. Это очень полезно, так как позволяет разрабатывать ПО в процессе изготовления микросхем или плат и, тем самым, уменьшая время вывода продукта на рынок
  2. Реализация моделей позволяет улучшить понимание аппаратуры, над которой трудится программист
  3. Модели позволяют отлаживать программы в post-mortem режиме. Для этого собирается дамп памяти, либо некая другая информация и модель запускается с этими данными.

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


© 2022. All rights reserved.

Powered by Hydejack v9.1.6