Как стать автором
Обновить

Использование шаблонного метапрограммирования для микроконтроллеров AVR

Время на прочтение 21 мин
Количество просмотров 26K

AVR


Микроконтроллеры AVR фирмы Atmel хорошо знакомы разработчикам и не нуждаются в особом представлении. Эти устройства используют модифицированную гарвардскую архитектуру и демонстрируют приличную производительность при низком энергопотреблении. Видимо не будет преувеличением сказать, что сегодняшней популярностью AVR во многом обязан проекту Arduino.

Arduino


Arduino — это открытая платформа для прототипирования. В настоящее время доступно богатое разнообразие различных плат Arduino и дополнительных устройств. Простое для освоения подмножество языка программирования C вместе с богатым набором библиотек, создаваемых энтузиастами со всего света, позволяют создавать любые приложения для решения практически неограниченного числа задач. Как профессионал, так и новичок в программировании имеет возможность быстро проверить любую идею или создать прототип будущего устройства в кратчайшие сроки. Однако вряд-ли кто-то будет использовать ПО Arduino для реальных проектов. Основная причина — неэффективность результирующего кода [8]. Стремление к универсальности и простоте инструментария Arduino не позволяет в полной мере использовать потенциал микроконтроллера AVR, его производительность и естественные возможности параллелелизма.

Подходы к разработке встраиваемого ПО


Старая школа


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

Новая школа


Люди, воспитанные в эпоху объектов, склонны в каждой сущности видеть объект. Классы являются прекрасным примером повторно используемого кода. Использование классов поощряет разработчика к достижению лучшей структуры кода и продуманного распределения ответственности между компонентами. Правильно написанный объектно-ориентированный код является легким для понимания и поддержки. К недостаткам кода, написанного с использованием C++, часто относят его производительность. Объектно-ориентированные возможности языка являются его безусловным преимуществом, однако за это часто приходится платить. Автоматическая генерация методов, неявное создание временных объектов могут привести к ощутимому снижению производительности эффективности (спасибо ProstoTyoma) результирующего кода [7]. Разработка эффективного кода на C++ является своего рода искусством.

Шаблоны С++


Одной из сильнейших сторон C++ является механизм шаблонов. Основная идея — возможность обобщенного определения поведения кода без явного указания используемых сущностей. В качестве очевидного примера использования шаблонов можно привести стандартную библиотеку шаблонов. STL предоставляет три основных типа сущностей — контейнеры, алгоритмы и итераторы. Обобщенные контейнеры позволяют задать требуемые типы хранимых данных в точке использования. Алгоритмы ничего не знают о контейнерах; связь алгоритмов и контейнеров осуществляется через механизм итераторов. Таким образом, STL демонстрирует удивительную гибкость и позволяет решать бесконечное число практических задач.

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

Идею шаблонов проще всего показать на примере:

Предположим, нам нужна функция min для работы с целыми числами. Очевидным решением C-программиста будет нечто вроде:
int min(int a, int b)
{
    return (a < b) ? a : b;
}

Если аналогичная функция понадобится для работы с плавающей точкой, придется написать еще одну функцию:
float min(float a, float b)
{
    return (a < b) ? a : b;
}

Для каждого нового типа потребуется новая функция.
Для C++ программиста задача решается написанием примерно следующего шаблона:
template<typename T>
T min(T a, T b)
{
    return (a < b) ? a : b;
}


В данном случае тип используемых значений не указывается явно, вместо этого мы используем обозначение T, которое присутствует в определении шаблона с ключевым словом typename. Для шаблонных функций (а также методов класса) компилятор способен самостоятельно вывести (deduce) требуемый тип параметра на основании типов передаваемых значений. В случае, если в данную функцию min будет передана пара параметров с различными типами, компилятор вполне обоснованно выскажет свое недовольство. При этом, если передача параметров разных типов выполнена намеренно, есть возможность помочь компилятору, явно указав тип шаблонного параметра при вызове функции:
    float float_variable = 3.141;
    int    integer_variable = 3;

    int   result = min<int>(float_variable, integer_variable);

или в зависимости от того, что вам нужно:
    float result = min<float>(float_variable, integer_variable);

Объявленная таким образом функция, может работать с любым типом данных, единственным условием является наличие операции "<" (меньше), определенной для данного типа. Это очень напоминает поведение языков с динамической типизацией, однако есть принципиальное отличие. В языках наподобие Питона функция может существовать в единственном экземпляре. Будучи языком со строгой статической (спасибо 0xd34df00d) типизацией, C++ потребует отдельного экземпляра функции для каждого использованного с ней типа. Здесь мы целиком полагаемся на компилятор, который выполняет всю эту работу за нас и создаёт необходимый объектный код для каждого использованного типа.

Очень удобно, но именно это обстоятельство может являться причиной еще одной проблемы шаблонов — раздувания кода (code bloat). Данная конкретная функция не является проблемой, поскольку имеет малый размер и является очевидным кандидатом на встраивание (inlining). Однако наличие в коде множества по разному параметризированных экземпляров шаблонных классов, имеющих объемные методы, действительно может приводить к значительному разрастанию кода, создавая таким образом реальную проблему. Тодд Вельдхузен приводит рекомендации [5], позволяющие этого избежать.

Meta programming


В 1994 году на заседании комитета по стандартизации C++, Эрвин Унрух впервые продемонстрировал возможность выполнения вычислений на этапе компиляции. В процессе компиляции, представленный им код, выводил серию диагностических сообщений, содержавших значения ряда простых чисел. Дальнейшие исследования показали, что возможности выполнения математических действий обладают вычислительной полнотой [6]: действительно, имеется возможность использовать арифметические операции, организовывать циклы через использование рекурсии, а также ветвления через использование специализаций.

Было подмечено некоторое подобие между шаблонами и привычными функциями времени исполнения [3].

Параметры шаблонов играют роль параметров обычных функций, а вложенные типы и константные значения перечислимых типов являются аналогами возвращаемых значений. В качестве параметров метафункций и возвращаемых значений могут использоваться те же самые сущности, которые могут быть параметрами шаблонов, к ним относятся:
— константные значения перечислимых типов,
— типы, а также
— указатели

1. Наиболее простой и понятный случай — использование значений перечислимых типов


Приведенная ниже метафункция возводит значение BASE в степень PWR.

template  // первичный шаблон
<
    unsigned PWR,
    unsigned BASE = 10 // параметры шаблонных классов 
                       // могут иметь значения по умолчанию
>
struct power
{
    enum{value = BASE * power<PWR-1,BASE>::value};
};


template<unsigned BASE>  // специализация шаблона
struct power<0,BASE>
{
    enum{value = 1};
};


Как видно, для вычисления результата шаблон power вызывает себя рекурсивно с модифицированным значением PWR. Для предотвращения бесконечной рекурсии необходима специализация, в данном случае для нулевого значения PWR.

Пример использования:
unsigned KILO   = power<3,10>::value; // переменной KILO будет присвоено значение 1000
unsigned MEGA   = power<6,10>::value; // переменной MEGA будет присвоено значение 1000000
unsigned kBytes = power<10,2>::value; // переменной kBytes будет присвоено значение 1024


2. Вычисления над типами


Представим, что нам необходимо передать в функцию входной параметр, имеющий тип ValueType. Мы бы хотели, чтобы оптимальный способ передачи параметра (по константной ссылке либо по значению) выбирался автоматически, в зависимости от целевой платформы и размера конкретного типа параметра.

template
<
    typename ValueType
>
struct PARAM
{
    typedef typename type_selector<
			(sizeof(ValueType*) < sizeof(ValueType)),
                        const ValueType&,
                        ValueType
    		>::type type;
};


Шаблон type_selector, использованный внутри нашей метафункции, описан у многих авторов [например 3, 4] и может выглядеть следующим образом:

template // первичный шаблон
<
    bool CONDITION, // логическое условие
    typename TYPE0, // тип, который будет выбран в случае, если условие истинно
    typename TYPE1  // тип, который будет выбран в случае, если условие ложно
>
struct type_selector
{
    typedef TYPE0 type;
};

// специализация шаблона для значения CONDITION == false
template<typename TYPE0,typename TYPE1>
struct type_selector<0,TYPE0,TYPE1>
{
    typedef TYPE1 type;
};


В зависимости от значения условия CONDITION, шаблон type_selector выбирает либо TYPE0 (CONDITION == true), либо TYPE1 (CONDITION == false). В качестве условия в данном случае мы используем логическое выражение: sizeof(ValueType) > sizeof(ValueType*). Для примера, если параметр имеет тип uint32_t, мы используем следующее определение для нашей функции:

    void function(typename PARAM<uint32_t>::type value){...}


В данном случае компилятор требует указания ключевого слова typename перед обращением к шаблону, поскольку используемый для передачи параметра тип является вложенным (nested). Подобное объявление/определение функции выглядит несколько громоздко, тем не менее, поставленная задача решена: — на 32 и 64-битной платформах параметр будет передаваться по значению, а например, в случае компиляции под микроконтроллер AVR, где размер адреса равен двум байтам, параметр будет передаваться по константной ссылке.

3. Указатели в качестве шаблонных параметров


Предположим, внутри некоторого кода нам необходимо выполнить вызов callback — функции, тип которой определен как:

    typedef void (*CALLBACK_FUNCTION_TYPE)(); // Тип callback функции


Теперь, определив наш код в виде шаблона:

// В качестве шаблонного параметра используем объект cb_func, 
// имеющий тип CALLBACK_FUNCTION_TYPE
template<CALLBACK_FUNCTION_TYPE cb_func> 
void some_code(...)
{
    ...
    cb_func(); // Вызов функции внутри нашего кода
    ...
}


мы можем передать требуемую функцию при вызове нашего кода следующим образом:

    some_code<&our_callback_function>(...);


Поскольку адрес функции our_callback_function известен во время компиляции, она может быть успешно встроена (inlined) компилятором [5]. О влиянии встраивания функций на размер и эффективность кода можно прочитать [7] — три главы этой книги целиком посвящены вопросам встраивания функций. В своей статье [5] Тодд Вельдхузен демонстрирует очень интересные примеры использования метафункций, в том числе разворачивание циклов на примере функции dotproduct для умножения матриц, вычисление тригонометрических констант для алгоритмов Быстрого Преобразования Фурье путем суммирования ряда. Здесь важно понимать, что во время исполнения все эти действия имеют нулевую стоимость, поскольку выполняются на этапе компиляции.

Дизайн


Когда речь заходит о коде, который предполагается использовать неоднократно, на первый план выходит вопрос об интерфейсе. Важность хорошо определенного интерфейса неоднократно обсуждалась в Сети. Набор требований, традиционно предъявляемых к хорошему интерфейсу включает в себя правильно определенные абстракции, сокрытие деталей реализации, минимальность и достаточность, удобство использования, сложность или невозможность неправильного использования и прочие [9]. При использовании метапрограммирования, некоторые из требований, могут быть реализованы лишь большими усилиями, а некоторые возможно не могут быть реализованы вовсе. Тот факт, что используемое в нашем случае свойство языка было обнаружено случайно, объясняет причину довольно неуклюжего синтаксиса. Это не добавляет удобства при разработке метапрограммного кода и использовании интерфейсов, основанных на шаблонах. Невозможность спецификации диагностических сообщений при компиляции, делают контроль за правильностью использования кода труднореализуемым, хотя некоторые попытки изменений в этом направлении уже предприняты, например Static assertions в библиотеке boost и новых стандартах языка.

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

Удобным подходом для построения интерфейса является дизайн с использованием классов стратегий, описанный в [1, 2]. Идея очень проста. Часть реализуемого функционала делегируется внешним классам (стратегиям), которые используются в качестве шаблонных параметров. При необходимости изменить поведение, просто выбирается другая стратегия. Это очень напоминает использование параметров обычных (runtime) функций, где благодаря параметрам, мы имеем возможность получать различные результаты при передаче в функцию различных значений аргументов. Функция с жестко закодированными значениями аргументов, всегда будет возвращать один и тот же результат, что не имеет особого смысла. В качестве шаблонных параметров (стратегий) могут использоваться типы (классы) с полноценной функциональностью. Это обеспечивает возможность параметризации алгоритма в точке использования путем указания стратегий с требуемым поведением в качестве аргументов шаблона. Это дает новый уровень гибкости и обобщенности.

Рассмотрим пример реализации интерфейса устройства USART (универсальный синхронно-асинхронный приемопередатчик), входящего в состав типичного контроллера AVR

enum USART_ID // Идентификатор устройства
{
    USART0,
    USART1,
    USART2,
    USART3,
};


enum BAUD_RATE // Скорость обмена
{
    BR_2400 = 2400,
    ...
    BR_921600 = 921600,
    BR_CUSTOM = CUSTOM_BAUD_RATE
};


// Класс стратегия - Параметры обмена (формат кадра)
template
<
    BAUD_RATE baud = BR_9600,                // Скорость обмена бод (enum)
    DATA_BITS data_bits = DATA_BITS_8,       // Количество бит данных в кадре (enum)
    PARITY parity = NO_PARITY,               // Паритет (enum)
    STOP_BITS stop_bits = STOP_1,            // Стоповые биты  (enum)
    ...
>
struct FRAME_CONTROL;


Строгая типизация C++ потребует в качестве параметров указания значений, точно соответствующих объявленным типам данных. Например, для указания скорости обмена могут быть выбраны лишь те значения, которые объявлены в перечислении BAUD_RATE. Если потребуется какое-то особое (не стандартное) значение скорости, можно использовать значение BR_CUSTOM, предварительно объявив макроопределение CUSTOM_BAUD_RATE с требуемым значением скорости передачи данных.

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

template
<
    USART_ID id,                              // Идентификатор устройства (enum)
    class usart_ctrl  = FRAME_CONTROL<>,      // Параметры обмена - (стратегия) - структура FRAME_CONTROL
    class receiver    = USART_RECEIVER<>,     // Параметры приемника - (стратегия) - структура USART_RECEIVER
    class transmitter = USART_TRANSMITTER<>   // Параметры передатчика - (стратегия) - структура USART_TRANSMITTER
>
struct USART
{
    static void inline init(){...}
    static size_type send(const uint8_t* data, size_type data_size){...}
    static size_type print(const char* fmt, ...){...}
    static size_type _vprintf(const char* fmt, va_list ap){...}
    ...
    static void USART_UDRE_handler(){...}
};


Здесь для краткости, определения многих перечислений и структур опущено. Для использования в реальном коде мы включаем заголовочный файл с описанием всех этих структур при помощи директивы include и определяем требуемые параметры для нашего устройства:

#define SEND_BUFFER_SIZE 32
#define RECV_BUFFER_SIZE 16


typedef USART<
            USART0,
            FRAME_CONTROL<BR_921600>,
            RECEIVER_DISABLED,
            USART_TRANSMITTER<SEND_BUFFER_SIZE>
            > 
          usart_0; // Устройство USART0, скорость 921600 бод, 8N1, приемник не используется, буфер передатчика 32 байта


typedef USART<
            USART1,
            FRAME_CONTROL<BR_9600, DATA_BITS_7, EVEN_PARITY, STOP_2>
            USART_RECEIVER<RECV_BUFFER_SIZE>,
            USART_TRANSMITTER<SEND_BUFFER_SIZE>
            > 
            usart_1; // Устройство USART1, 9600-7E2, буфер приемника 16 байт, буфер передатчика 32 байта




typedef TWI<400000> I2C; // TWI - интерфейс 400 kHz


Итак, структура USART принимает четыре шаблонных параметра:
— идентификатор устройства — позволяет работать с любым из четырех имеющихся устройств (только Mega256, для младших чипов следует использовать USART0)
— стратегию usart_ctrl с единственной реализацией — FRAME_CONTROL — для спецификации параметров обмена (см. выше).
— стратегию receiver — приемник, для которого имеется всего две реализации — USART_RECEIVER, позволяющая задать требуемые параметры приемника (указать размер буфера и управлять прерыванием) и RECEIVER_DISABLED, позволяющая отключить приемник при необходимости
— стратегию transmitter — параметры передатчика с реализациями USART_TRANSMITTER (размер буфера, управление прерываниями) и TRANSMITTER_DISABLED, которая запрещает передатчик и соответствующие прерывания.

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

Далее инициализируем устройство:

    usart_0::init();
    I2C::init();


Здесь следует обратить внимание на необычный синтаксис вызова методов. Вместо привычного оператора “.” — ссылка на член структуры (structure reference), здесь использован оператор “::” — раскрытие области видимости (scope resolution). Дело в том, что все методы класса USART (а также TWI) определены как статические и здесь мы работаем не с объектами, а с типами. Это позволяет избежать накладных расходов на конструирование и разрушение объекта, а кроме того, явно отражает синглтон-подобную природу устройства. Это вовсе не означает, что мы полностью отказываемся от обычных объектов в пользу использования типов и их статических членов. Скорее всего в коде будет присутствовать множество привычных объектов, однако если говорить о структурах для управления аппаратными компонентами, данный подход имеет больше смысла.

Генерируемый при этом ассемблер (для Mega256) выглядит примерно следующим образом:

000000ba <_Z10usart_initv>:
  ba:        10 92 c4 00         sts        0x00C4, r1
  be:        10 92 c5 00         sts        0x00C5, r1
  c2:        10 92 c0 00         sts        0x00C0, r1
  c6:        88 e2               ldi        r24, 0x28        ; 40
  c8:        80 93 c1 00         sts        0x00C1, r24
  cc:        86 e0               ldi        r24, 0x06        ; 6
  ce:        80 93 c2 00         sts        0x00C2, r24
  d2:        10 92 26 01         sts        0x0126, r1
  d6:        08 95               ret


000000d8 <_Z8twi_initv>:
  d8:        8c e0               ldi        r24, 0x0C        ; 12
  da:        80 93 b8 00         sts        0x00B8, r24
  de:        10 92 b9 00         sts        0x00B9, r1
  e2:        85 e4               ldi        r24, 0x45        ; 69
  e4:        80 93 bc 00         sts        0x00BC, r24
  e8:        10 92 03 01         sts        0x0103, r1
  ec:        08 95               ret



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

Еще один пример


Если мы разрабатываем свой протокол обмена, его объявление (с использованием стратегий) может выглядеть следующим образом:

template
<
    class transport,
    class params = PROTO_PARAMETERS<...> // Какие-то параметры нашего протокола
    ...
>
struct SUPER_DUPPER_EXCHANGE_PROTOCOL;


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

typedef SUPER_DUPPER_EXCHANGE_PROTOCOL<usart_0, PROTO_PARAMETERS<>, ...> PROTO_SERIAL;


При желании мы сможем использовать тот же самый протокол с другим устройством, например SPI или TWI, то есть:

typedef SUPER_DUPPER_EXCHANGE_PROTOCOL<TWI<200000>, PROTO_PARAMETERS<>, ...> PROTO_TWI;


Для классов-стратегий отсутствуют какие-либо дополнительные ограничения, например требование наследования от общего предка. Единственным требованием к типу, используемому в качестве транспорта, является наличие методов (например send и receive) с требуемой сигнатурой.

Может быть определено любое необходимое количество стратегий, каждая из которых должна нести ответственность за определенный аспект функциональности, обеспечивая таким образом их ортогональность [2].

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

Определив таким образом требуемые нам типы, используем их в коде следующим образом:

PROTO_SERIAL::send(data, size); //отправка блока данных data на устройство usart_0
PROTO_TWI::send(data, size);    //отправка блока данных data на TWI интерфейс


Отладка


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

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

Отладка встраиваемого (embedded) кода, в свою очередь, может стать кошмаром для разработчика, в особенности при отсутствии специального оборудования. В таком случае, единственным выходом является метод грубой силы — вставка отладочных сообщений.

Предположим, мы отлаживаем класс DEVICE, имеющий следующий интерфейс:

template
<
    class params = DEVICE_SETTINGS<...>,
    class dbg = NO_DEBUG
>
struct DEVICE
{
    static uint8_t some_method(uint8_t parameter)
    {
        dbg::print("%s:%d\n", __FUNCTION__, parameter);
        ....
        dbg:: print("retval:%d\n", retval);
        return retval;
    }
};


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

typedef DEVICE_SETTINGS<...>               DEV_SETTINGS;  // используем typedef для компактности
typedef DEVICE<DEV_SETTINGS, AVR_DEBUG<usart_0> >  device; // объявление типа нашего устройства


Видно, что в качестве параметра dbg мы используем некий шаблон AVR_DEBUG, параметризированный типом usart_0. Если посмотреть на определение AVR_DEBUG, мы увидим примерно следующий код:

template
<
    class SENDER
>
struct AVR_DEBUG
{
    ...    
    static void print(const char* fmt, ...)
    {
        va_list ap;
        va_start(ap, fmt);
        uint8_t retval = SENDER::_vprintf(fmt, ap);
        va_end(ap);
    }
};


Фактически, это означает, что в процессе выполнения приложения вызов dbg::print будет приводить к вызову метода print класса AVR_DEBUG<usart_0>, который в свою очередь вызывает метод _vprintf класса usart_0. Таким образом, в процессе отладки мы направляем требуемую отладочную информацию на последовательный интерфейс, заданный параметром usart_0.

По завершении отладки кода нашего устройства мы заменяем строку объявления следующим образом:

    typedef DEVICE<DEV_SETTINGS, NO_DEBUG>  device;


или проще, используем значение по умолчанию NO_DEBUG для параметра dbg:

    typedef DEVICE<DEV_SETTINGS>  device;


Реализация функции print класса NO_DEBUG имеет пустое тело и может выглядеть следующим образом:

struct NO_DEBUG
{
    ...
    static void inline print(const char* , ...){}
};


Здесь мы опять полагаемся на компилятор, который успешно выполняет встраивание (inlining) и удаление тел пустых функций. При сборке релизной версии кода, весь неиспользуемый код будет удален.
Таким образом, мы имеем механизм, позволяющий управлять выводом отладочной информации.

Unit-tests


В своих документах Atmel рекомендует использовать типы данных, минимально допустимого для хранения данных размера. Рекомендация опирается на анализ размера результирующего кода, позволяет снизить затраты памяти, используемой приложением и подразумевает использование типов, размеры которых не зависят от платформы. А это в свою очередь способствует созданию кода, имеющего лучшую переносимость (portability). В идеале мы можем получить возможность запускать наш код на PC, что при разработке позволяет использовать юнит-тесты. Значительная часть кода, предназначенного для микроконтроллера, может быть проверена на тестах задолго до его загрузки на контроллер.

Если вернуться к приведенному выше примеру протокола, в качестве транспорта может быть использован специально написанный класс (mock/fake), который позволит имитировать получение пакетов с произвольным содержимым, что упростит отладку разрабатываемого протокола.

Развивая эту идею дальше, мы получаем возможность отладки практически любого кода на PC.
Множество периферийных устройств, с которыми приходится иметь дело, управляются контроллером через внешние интерфейсы (TWI, SPI...) и не имеют непосредственного отношения к той или иной платформе. Речь идет о различного рода сенсорах, часах реального времени, ЖК дисплеях и т.д. Код для управления подобной периферией в идеале должен быть способен исполняться на любой платформе. Это определяет важность переносимости (портируемости) кода.

Для отладки подобного кода, было бы очень удобно подключить устройство непосредственно к компьютеру разработчика. К сожалению, требуемый для устройства интерфейс может физически отсутствовать на компьютере. В таком случае мы можем использовать микроконтроллер в качестве адаптера между устройством и стандартным последовательным интерфейсом (COM или USB), который имеется на любом компьютере. Дизайн с использованием стратегий позволяет параметризировать отлаживаемый код таким образом, чтобы перенаправить управляющий поток на стандартный последовательный интерфейс. Микроконтроллер, подключенный одновременно к последовательному интерфейсу и к отлаживаемому устройству, программируется однократно на все время отладки и служит транслятором между двумя интерфейсами. Это дает возможность исполнять код непосредственно на компьютере разработчика, предоставляя полный контроль над этим кодом.

После завершения отладки готовый код может быть собран под требуемую платформу и загружен на соответствующий микроконтроллер. Данный прием успешно использовался при отладке кода для работы с SD card через SPI интерфейс (контроллер выполнял код адаптера serial to SPI), а также часов реального времени и датчиков инерционной навигации, использующих TWI интерфейс (контроллер выполнял функцию адаптера serial to TWI).

По приведенной ссылке [avr_meta] вы можете загрузить примеры реально используемого кода. Он разрабатывался с использованием avr8-gnu-toolchain-3.4.5.1522-linux, в качестве юнит-тест фреймворка использован TUT (C++ Template Unit Test Framework). Код разрабатывался для собственных нужд и еще требует большого количества работы. Однако мы нашли возможным опубликовать его в надежде, что для кого-то он может оказаться полезным:
bin                               Пара скриптов, используемых для генерации кода.

avr_adc                           Код для AVR ADC – Analog to Digital Converter
avr_debug                         Шаблон AVR_DEBUG
avr_interrupt/ext_int_control     Код для управления внешними прерываниями AVR
avr_interrupt/pin_ch_int_control  Код для управления Pin Change прерываний AVR
   
avr_misc                          вспомогательные функции
avr_pin                           Код для управления портами AVR
avr_power_mgmt                    Код для управления энергосбережением AVR
avr_spi                           Код для управления AVR SPI – Serial Peripheral Interface
avr_twi                           Код для управления AVR 2-wire Serial Interface (только серверная часть)
avr_usart                         Код для управления AVR USART (только асинхронные операции)

container/bit_field               Шаблонная реализация bit field
container/circular_buffer         Циклический буфер

event_driven                      Классы для событийно управляемого поведения

meta                              Шаблонные метафункции
misc                              вспомогательные функции

state/led_blinker                 Светодиодный индикатор режимов (состояний)
state/state_machine               Шаблонная реализация конечного автомата
state/switch_case                 Шаблон switch case


Заключение


Использование объектно-ориентированных возможностей языка C++ позволяет улучшить структуру, читаемость и понятность кода. Классы являются прекрасным воплощением идеи повторного использования кода.

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

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

Случайно открытые возможности C++, которые продемонстрировал в 1994 году Эрвин Унрух, не входили в первоначальный замысел создателей языка, однако вызвали бурный интерес многих разработчиков. Возможность выполнения вычислений на этапе компиляции предоставляет разработчику новый уровень обобщенности кода и эффективности его исполнения. В настоящее время этот механизм хорошо известен C++ программистам и воплощен во множестве известных библиотек, таких, как Blitz++ или boost::MPL.

Фактически, в рамках одного языка программирования имеется возможность управлять как поведением кода во время его исполнения, так и процессом генерации того же кода еще на этапе компиляции. В одной языковой конструкции (в шаблонной функции) могут одновременно присутствовать сущности как с динамическим связыванием (параметры функции), так и со статическим связыванием (параметры шаблона). Тодд Вельдхузен называет C++ двухуровневым языком (two-level language).

Применение метапрограммирования позволяет значительно повысить эффективность исполнения и иногда уменьшить объем генерируемого кода, за счет принятия решений во время компиляции. Наличие в проекте параметров, которые не изменяются во время исполнения кода (являются константами времени компиляции) — хорошая возможность для оптимизации за счет использования метапрограммирования. Любые значения, которые могут быть вычислены на этапе компиляции, а также ветвления, управляемые константными параметрами, все это хорошие кандидаты для оптимизации. Другими словами, мы часто можем ускорить выполнение программы, за счет увеличения времени компиляции. В статье [10] приведены результаты измерения производительности метапрограммного кода, работающего на AVR и его сравнение с традиционно разработанным на базе библиотек Atmel кодом.

Разработка метапрограммного кода является довольно трудоемким и длительным процессом и вряд-ли целесообразна для разовых проектов. Однако, если речь идет о разработке библиотеки, то усилия оправдываются ожиданием, что такая долговременная инвестиция принесет свою пользу при каждом повторном использовании [3].

Хорошая переносимость (portability) программного кода означает меньший объем работы при большем результате и во всех случаях является преимуществом. Если вернуться к примеру с протоколом, то портируемость здесь является ключевым условием. Разработка отдельной реализации протокола для каждой стороны взаимодействия имеет мало смысла. Значительно лучше, если поставляя устройство, вы сможете предоставить клиенту и определение протокола в виде соответствующего кода, тем самым, значительно облегчая для него разработку управляющего программного обеспечения под нужную ему платформу.

Судя по небольшому количеству публикаций, шаблоны вообще и метапрограммирование в частности не очень востребованы в мире встраиваемого ПО, хотя именно здесь возможности метапрограммирования могут принести существенную выгоду. Они позволяет использовать традиционные для объектно-ориентированного программирования приемы и при этом обеспечить эффективность, присущую вручную написанному на C и ASM коду.

Литература


1. David Vandevoorde and Nicolai M. Josuttis. C++ Templates: The Complete Guide
2. Andrei Alexandrescu. C++ Design: Generic Programming and Design Patterns Applied
3. David Abrahams and Aleksey Gurtovoy, C++ Template Metaprogramming: Concepts, Tools, and Techniques from Boost and Beyond
4. Davide Di Gennaro. Advanced C++ metaprogramming
5. Todd Veldhuizen. Techniques for Scientific C++. Indiana University Computer Science Technical Report #542
6. Todd L. Veldhuizen. C++ Templates are Turing Complete (2003).
7. Dov Bulka and David Mayhew. Efficient C++. Performance Programming Techniques. Addison-Wesley 2000.
8. Dale Wheat. Arduino Internals. Apress.
9. Martin Reddy. API Design for C++. 2011 Morgan Kaufmann Publishers
10. Christoph Steup, Michael Schulze, Jorg Kaiser. Exploiting Template-Metaprogramming for Highly Adaptable Device Drivers – a Case Study on CANARY an AVR CAN-Driver. Department for Distributed Systems Universitat Magdeburg
11. Материалы XXIV съезда КПСС. “Об усилении мер по повышению эффективности отраслей народного хозяйства. Надзор за соблюдением эффективности встраиваемого ПО”.
Теги:
Хабы:
+19
Комментарии 42
Комментарии Комментарии 42

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн