5 декабря 2018 г.


На данный момент функционал работы с портами довольно примитивен: есть возможность обработки либо целого порта, либо отдельного его бита. Хотелось бы иметь возможность объединить несколько бит разных портов и работать с ними как с единым целым. Например, объявив виртуальный порт как
using MySet = PortSet<PortA7, PortB1, PortA5, PortA4>;
выполнять все те операции, которые были описаны в предыдущей части: set, clear, write и т.п.

Запись значения в такой виртуальный порт выполняется с учетом порядкового номера бита виртуального порта. Таким образом, запись в вышеобъявленный виртуальный порт логически эквивалентна записи в PortA7 третьего бита, в PortB1 второго, в PortA5 первого и в PortA4 нулевого:




Чтение выполняется аналогично. Считываемое значение формируется в соответствии с порядком бит, заданных при объявлении порта. Так, чтение из вышеобъявленного виртуального порта логически эквивалентно тому, что третий бит числа будет прочитан из PortA7, второй из PortB1, первый из PortA5 и нулевой из PortA4:


Естественно, что реализация виртуального порта в виде последовательного выполнения операций чтения или записи для каждого отдельного бита не особо эффективна, особенно для относительно большой группы бит портов ввода вывода, последовательно расположенных на одном физическом порту. Кстати, такая конфигурация группировки - большой массив соседних ножек, используется довольно часто, в отличие от физически разряженных, но логически объединенных в один виртуальный порт ножек микроконтроллера.

Например, для того же виртуального порта PortSet<PortA7, PortB1, PortA5, PortA4>, состоящего из четырех бит, наивная реализация методов чтения или записи будет включать четыре операции чтения или записи порта - по одной на каждый бит виртуального порта. В то же время можно заметить, что запись в этот порт может быть выполнена всего лишь двумя операциями:
  • в порт A, применив маску 0b1011 к исходному значению и выполнив побитовый сдвиг полученного значения влево на 4
  • в порт B, применив маску 0b0100 к исходному значению и выполнив побитовый сдвиг полученного значения вправо на 1

Особенно важным требованием к виртуальному порту является минимальный оверхед по сравнению с ручной реализацией. Поэтому хотелось бы перенести на этап компиляции все расчеты, которые можно выполнить на этапе компиляции, благо c++ позволяет такие извращения. Для этого я применяю boost.mpl - библиотеку метапрограммирования, входящую в состав boost.

Итак, объявление класса для работы с объединением нескольких бит порта:
template<typename... Ports>
class PortSet;
Объявление содержит шаблон с переменным числом параметров. Эта возможность появилась в c++11 и доступна практически во всех известных мне компиляторах. Шаблон виртуального порта может принимать неограниченное число бит портов ввода вывода, но следует заметить, что по умолчанию списки типов библиотеки boost.mpl, которые я использую в вычислениях, ограничены 20 элементами, хотя, это значение может быть изменено, а сам алгоритм группировки бит портов ввода вывода не содержит никаких ограничений на количество этих бит.

Далее я привожу основную логику алгоритма записи в виртуальный порт. Каждый шаг алгоритма сопровождается промежуточным результатом на примере виртуального порта PortSet<PortA7, PortB1, PortA5, PortA4>. Алгоритм чтения практически идентичен алгоритму записи, за исключением последнего шага.

Обобщенно, алгоритм записи в виртуальный порт состоит в следующем:
  • вычисление ключей для группировки бит. Ключом является пара - адрес порта ввода вывода и "смещение" - разница между номером бита в физическом порту и его порядковым номером в виртуальном. Метафункция, выполняющая получение ключа группировки на основе одного бита порта T и порядкового номера бита в виртуальном порту Index:
struct select_key_by_offset
{
    template<typename T, typename Index>
    struct apply : mpl::identity<
        tuples::tuple<mpl::int_<T::address>, mpl::int_<T::bit - Index::value>>> { };
};
то есть, ключом группировки является кортеж из двух элементов: адрес порта ввода вывода, к которому относится бит и смещение. Смещение будет необходимо для группировки бит, которые могут быть записаны или прочитаны за одну операцию. Например, для виртуального порта PortSet<PortA7, PortB1, PortA5, PortA4> на данном шаге алгоритма получится такой список ключей группировки (напомню, что индексация битов осуществляется от младшего бита к старшему):
tuple<&PORTA, 7 - 3> = tuple<&PORTA, +4>
tuple<&PORTB, 1 - 2> = tuple<&PORTB, -1>
tuple<&PORTA, 5 - 1> = tuple<&PORTA, +4>
tuple<&PORTA, 4 - 0> = tuple<&PORTA, +4>
легко заметить, что в результате получилось две уникальные группы: порт A со смещением +4 и порт B со смещением -1.
  • подготовка для группировки бит виртуального порта выполняется не самой простой метафункцией:
template<typename Seq, typename Op>
struct to_map<Seq, Op> : mpl::fold<
    typename mpl::transform<typename mpl::reverse<Seq>::type, mpl::range_c<int, 0, mpl::size<Seq>::value>, mpl::pair<mpl::_1, mpl::_2>>::type,
    mpl::map0<>,
    mpl::insert<mpl::_1, mpl::pair<mpl::apply_wrap2<Op, mpl::first<mpl::_2>, mpl::second<mpl::_2>>, mpl::first<mpl::_2>>>> { };
коллекция Seq на входе этой метафункции - исходный список бит виртуального порта ввода вывода, Op - метафункция select_key_by_offset, описанная на предыдущем шаге алгоритма, результатом метафункции является список пар, содержащих ключ группировки бита порта ввода вывода и непосредственно сам бит порта ввода вывода. В результате получится структура примерно следующего содержания:
map< /* Ключ группировки *//* Бит */
    pair<tuple<&PORTA, +4>, PortA7>,   
    pair<tuple<&PORTB, -1>, PortB1>,   
    pair<tuple<&PORTA, +4>, PortA5>,   
    pair<tuple<&PORTA, +4>, PortA4>>
  • затем полученные пары группируются по своим ключам:
template<typename T>
struct select_bit : mpl::int_<T::second::bit> { };

template<typename Seq, typename Key>
struct build_by_key : mpl::copy_if<Seq,
    boost::is_same<mpl::first<mpl::_1>, Key>,
    mpl::inserter<mpl::vector0<>, mpl::push_back<mpl::_1, select_bit<mpl::_2>>>> { };

template<typename Seq>
struct select_groups : mpl::fold<
    typename mpl::fold<Seq, mpl::set0<>, mpl::insert<mpl::_1, mpl::first<mpl::_2>>>::type,
    mpl::map0<>,
    mpl::insert<mpl::_1, mpl::pair<mpl::_2, build_by_key<Seq, mpl::_2>>>> { };
коллекция Seq на входе метафункции select_groups - результат, полученный на предыдущем шаге алгоритма. Эта метафункция выполняет группировку битов виртуального порта, используя ключи группировки. boost::mpl::set используется для обеспечения уникальности ключей группировки, метафункция build_by_key выбирает из исходной коллекции Seq только элементы, соответствующие ключу группировки Key.

Результатом выполнения этого шага алгоритма станет структура:
map< /* Ключ группировки *//* Сгруппированный список бит */
    pair<tuple<&PORTA, +4>, vector<7, 5, 4>>,
    pair<tuple<&PORTB, -1>, vector<1>>>
каждая строка этого списка может быть представлена как отдельная операция записи или чтения порта. До этого момента все вычисления выполнялись на этапе компиляции, однако, для выполнения чтения либо записи порта придется перейти к написанию операций времени выполнения. boost::mpl::for_each для каждого элемента метасписка выполняет переданную ей функцию. То, что нужно. Но, прежде, чем перейти к финальной реализации этих методов, осталось вычислить маску, которая будет использована для выполнения операции чтения-модификации-записи.

Вычисление маски для любого списка бит достаточно примитивно:
template<typename Seq>
struct mask : mpl::fold<Seq, mpl::int_<0>, mpl::bitor_<mpl::_1, mpl::shift_left<mpl::int_<1>, mpl::_2>>> { };
эта метафункция - ничем не примечательная функция свертки с начальным значением равным нулю и последовательным применением операции побитового ИЛИ с операндом, соответствующим маске отдельного бита. Так, для списка vector<7, 5, 4> результатом станет значение 0b10110000 - число с битами, установленными в единицу в битовых позициях с порядковыми номерами 7, 5 и 4.

Для выполнения функции boost::mpl::for_each требуется передать в качестве параметра экземпляр класса-функции с переопределенным оператором "()". Для метода, выполняющего запись, определение такого класса-функции будет выглядеть приблизительно так:
struct write_function
{
    write_function(size value)
        : _value(value) { }
   
    // Этот метод выполнится для каждой группы бит
    // Тип T - пара (к примеру, pair<tuple<&PORTA, +4>, vector<7, 5, 4>>)
    template<typename T>
    void operator()(T)
    {
        // Маска, которая будет применена для выполнения операции чтение-модификация-запись
        constexpr auto mask = mask<boost::mpl::second<T>::type>::type::value;

        // Вычисляется адрес регистра
        constexpr auto address = boost::tuples::get<boost::mpl::first<T>::type, 0>::value;

        // Вычисляется значение смещения для операции побитового сдвига
        constexpr auto shift = boost::tuples::get<boost::mpl::first<T>::type, 1>::value;

        // Сам порт ввода вывода
        constexpr auto reg = (volatile uint8_t*)address;

        *reg = (*reg & ~mask) | (shift_left(_value, shift) & mask);
    }

    // Выполняет сдвиг влево при положительном значении параметра сдвига,
    // либо вправо, если значение параметра сдвига отрицательно
    size shift_left(size value, int shift)
    {
        return shift >= 0 ? value << shift : value >> -shift;
    }

private:
    size _value;
};
код на строке 23 выполняет запись в порт ввода вывода после выполнения группировок и вычислений смещений и масок - именно то, ради чего затевалась вся идея с виртуальными портами ввода вывода. Если принять pair<tuple<&PORTA, +4>, vector<7, 5, 4>> в качестве параметра типа T, выполняемое действие преобразуется в операцию вида
PORTA = (PORTA & 0b01001111) | ((_value << 4) & 0b10110000)
а pair<tuple<&PORTB, -1>, vector<1>> в операцию вида
PORTB = (PORTB & 0b11111101) | ((_value >> 1) & 0b00000010)
Несложно убедиться, что это эти выражения в точности соответствует заявленным в самом начале статьи действиям, предназначенным для записи в виртуальный порт. Все маски и смещения без особого труда были вычислены компилятором, для заданной конфигурации виртуального порта найден оптимальный способ группировки бит для уменьшения количества операций чтения-модификации-записи.

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

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

P.S.
Для моего текущего проекта аппаратного Pomodoro таймера очень полезной возможностью оказалась простота изменения конфигурации виртуального порта. Даже не сосчитать, сколько раз я перетрассировывал плату, прежде чем пришел к окончательному варианту. Из-за изменения трассировки менялось назначение физических портов ввода вывода, но виртуальный порт позволил программной части проекта крайне легко подстроиться под изменения, связанные с аппаратной частью проекта.