30 июня 2018 г.

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

Никакая реальная микроконтроллерная система невозможна без взаимодействия с периферией. Любые периферийные устройства так или иначе управляются микроконтроллером через его порты ввода вывода общего назначения. Конечно, любой компилятор для микроконтроллера из коробки предоставляет возможность управления портами: чтение, запись, настройка на ввод либо вывод и т.п., однако мне оказалось этого недостаточно. Хочется:
  • абстрагировать задачи, решаемые микроконтроллером от конкретных портов ввода вывода;
  • изменять биты портов, используемых задачей в одном месте (обычно функция main)(корень композиции) без изменения кода самой задачи;
  • было бы неплохо вообще отвязать задачи от конкретной серии микроконтроллера, в идеале, не теряя при этом производительности и читаемости исходного кода.
Стандартным способом чтения/записи в порты ввода вывода AVR микроконтроллера является использование конструкций вида
PORTB = 0x42;
auto x = PORTB;
Выражение PORTB является макросом препроцессора, разворачивающимся как
#define __SFR_OFFSET 0x20
#define _MMIO_BYTE(mem_addr) (*(volatile uint8_t *)(mem_addr))
#define _SFR_IO8(io_addr) _MMIO_BYTE((io_addr) + __SFR_OFFSET)
#define PORTB _SFR_IO8(0x18)
в разыменованный указатель вида:
#define PORTB (*(volatile uint8_t*)(0x38))
то есть запись или чтения порта ввода вывода микроконтроллера с точки зрения программирования представляется не более, чем запись или чтение оперативной памяти по фиксированному адресу. Это знание в дальнейшем позволит использовать адреса регистров ввода вывода как числовые значения вместо их мнемонических обозначений.

Может возникнуть вопрос, что означает магическое число __SFR_OFFSET равное 0x20 или 32 в десятичной системе счисления. Для этого достаточно открыть datasheet на странице с описанием структуры оперативной памяти:


Как видно IO регистры имеют свою адресацию, начинающуюся с 0x00 по 0x3F, проецирующуюся на адресное пространство оперативной памяти микроконтроллера со смещением равным __SFR_OFFSET или 0x20. Таким образом регистр ввода вывода с адресом 0x18 в пространстве адресов регистров ввода вывода в адресном пространстве оперативной памяти будет иметь адрес 0x38.

С точки зрения ООП лучше всего организовать работу с портами ввода вывода в виде шаблонного класса
template<typename Size, int PinAddress, int DirectionAddress, int PortAddress, int... Bits>
class Port;
с шаблонными параметрами:
  • Size - числовой тип, представляющий размер порта ввода вывода. Для AVR микроконтроллеров этот тип практически всегда является uint8_t, однако я все же вывел этот параметр как настраиваемый;
  • PinAddress - адрес регистра для чтения непосредственного значения на ножках порта микроконтроллера (регистр PINX). Также этот регистр позволяет выполнять инверсию битов порта без выполнения процедуры чтения/модификации/записи;
  • DirectionAddress - адрес регистра направления ввода или вывода порта микроконтроллера (DDRX). Этот регистр не участвует в изменении либо чтении значений порта, однако класс порта, помимо этого, позволяет настроить порт на ввод или на вывод;
  • PortAddress - адрес регистра, непосредственно представляющего порт ввода вывода микроконтроллера (PORTX);
  • Bits... - вариативный параметр шаблона, биты порта ввода вывода микроконтроллера, с которыми работает класс. В этой статье будет рассмотрены только варианты либо с одним битом порта, либо весь порт целиком, в таком случае этот параметр не должен быть указан.
Каким образом можно получить числовые адреса регистров PinAddress, DirectionAddress и PortAddress? Указывать непосредственные числовые значения - нецелесообразно, конкретные адреса портов  меняются от одной модели микроконтроллера к другой (например для ATMega8 адрес PORTB, спроецированный на оперативную память равен 0x38, а для ATiny88 адрес того же порта 0x25). К счастью, так как выражение PORTB является разыменованным указателем на область памяти с фиксированным адресом, этот адрес можно получить, применив операцию взятия адреса к этому макросу, например &PORTB. Такая операция может быть вычислена на этапе компиляции и подставлена в виде шаблонного параметра, правда, дополнительно придется выполнить приведение этого выражения к типу int.

Вот как будет выглядеть описание класса для работы с портом B ввода вывода:
using PortB = Port<uint8_t, (int)&PINB, (int)&DDRB, (int)&PORTB>;
А вот так описание класса для работы с отдельным битом порта ввода вывода (в данном случае второй бит порта B):
using PortB2 = Port<uint8_t, (int)&PINB, (int)&DDRB, (int)&PORTB, 2>;
Естественно, можно сгенерировать описания для всех битов всех возможных портов и вынести их в общую библиотеку и не дублировать эти объявления в каждом проекте.
Заметьте, что список бит в определении шаблона является вариативным так, что если не указан ни один бит, класс работает со всем портом, как с единым целым; а если указан только один бит - только с указанным битом, не изменяя остальные биты порта. Случай с указанием нескольких битов в вариативном шаблоне будет разобран в следующей части статьи, посвященной созданию объединения нескольких битов нескольких портов как единого класса.

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

Реализации классов не представляют никакой сложности. Они содержат набор константных выражений, и таких статических методов:
  • static void set() - все биты (или указанный бит) порта устанавливаются в 1;
  • static void clear() - все биты (или указанный бит) порта устанавливаются в 0;
  • static void toggle() - все биты (или указанный бит) порта инвертируются (для этого используется запись в регистр PINX);
  • static void write(Size value) - в порт одномоментно заносится указанное значение. Если используется реализация с указанием конкретного бита порта, то этому биту присваивается нулевой бит указанного значения;
  • static Size read() - чтение значения на ножках порта. Если используется реализация с указанием конкретного бита порта, то результатом является 1 в случае, если значение бита на ножке порта является логической единицей и 0 в противном случае;
  • static void output() - этот и два следующих метода позволяют настроить порт на ввод или на вывод. Метод output перенастраивает порт на вывод. Его требуется единожды вызвать перед вызовом методов, изменяющих значение порта ввода вывода;
  • static void pullUp() - настраивает порт на ввод с подтягивающим резистором;
  • static void highImpedance() - настраивает порт в высокоимпедансное или Z-состояние.
Все реализации методов статические и не требуют создания экземпляра класса.
/*
Представляет порт ввода вывода
*/
template<typename Size, int PinAddress, int DirectionAddress, int PortAddress, int... Bits>
struct Port;

/*
Представляет выбранный бит порта ввода вывода
*/
template<typename Size, int PinAddress, int DirectionAddress, int PortAddress, int Bit>
struct Port<Size, PinAddress, DirectionAddress, PortAddress, Bit> final
{
public:
    // Тип, определяющий размер текущего порта ввода вывода
    using size = Size;

public:
    // Адрес регистра порта ввода вывода
    static constexpr auto address = PortAddress;

    // Адрес регистра ножек порта ввода вывода
    static constexpr auto pinAddress = PinAddress;

    // Адрес регистра направления порта ввода вывода
    static constexpr auto directionAddress = DirectionAddress;

    // Адрес регистра порта ввода вывода
    static constexpr auto portAddress = PortAddress;

    // Выбранный бит порта ввода вывода
    static constexpr auto bit = Bit;

private:
    // Указатель на регистр ножек порта ввода вывода
    static constexpr auto pinReg = (volatile Size*)PinAddress;

    // Указатель на регистр направления порта ввода вывода
    static constexpr auto directionReg = (volatile Size*)DirectionAddress;

    // Указатель на регистр порта ввода вывода
    static constexpr auto portReg = (volatile Size*)PortAddress;

    // Маска, соответствующая выбранному биту порта ввода вывода
    static constexpr auto mask = 1 << Bit;

public:
    // Устанавливает выбранный бит порта ввода вывода равным 1
    static void set() { *portReg |= mask; }

    // Устанавливает выбранный бит порта ввода вывода равным 0
    static void clear() { *portReg &= ~mask; }

    // Инвертирует выбранный бит порта ввода вывода
    static void toggle() { *portReg ^= mask; }

    // Выполняет запись младшего бита значения в выбранный бит порта ввода вывода
    static void write(Size value) { *portReg = (*portReg & ~mask) | ((value << Bit) & mask); }

    // Выполняет чтение выбранного бита порта ввода вывода в младший бит возвращаемого значения
    static Size read() { return (*portReg & mask) >> Bit; }

    // Устанавливает режим ввода с подтягивающим резистором для выбранного бита порта ввода вывода
    static void pullUp() { *directionReg &= ~mask, *portReg |= mask; }

    // Устанавливает режим ввода с высокоимпедансным состоянием для выбранного бита порта ввода вывода
    static void highImpedance() { *directionReg &= ~mask, *portReg &= ~mask; }

    // Устанавливает режим вывода для выбранного бита порта ввода вывода
    static void output() { *directionReg |= mask; }
};

/*
Представляет все биты порта ввода вывода
*/
template<typename Size, int PinAddress, int DirectionAddress, int PortAddress>
struct Port<Size, PinAddress, DirectionAddress, PortAddress> final
{
public:
    // Тип, определяющий размер текущего порта ввода вывода
    using size = Size;

public:
    // Адрес регистра порта ввода вывода
    static constexpr auto address = PortAddress;

    // Адрес регистра ножек порта ввода вывода
    static constexpr auto pinAddress = PinAddress;

    // Адрес регистра направления порта ввода вывода
    static constexpr auto directionAddress = DirectionAddress;

    // Адрес регистра порта ввода вывода
    static constexpr auto portAddress = PortAddress;

private:    
    // Указатель на регистр ножек порта ввода вывода
    static constexpr auto pinReg = (volatile Size*)PinAddress;

    // Указатель на регистр направления порта ввода вывода
    static constexpr auto directionReg = (volatile Size*)DirectionAddress;

    // Указатель на регистр порта ввода вывода
    static constexpr auto portReg = (volatile Size*)PortAddress;

    // Маска, соответствующая всем битам порта ввода вывода
    static constexpr auto mask = ~(Size)0;

public:
    // Устанавливает все биты порта ввода вывода равным 1
    static void set() { *portReg = mask; }

    // Устанавливает все биты порта ввода вывода равным 0
    static void clear() { *portReg = ~mask; }

    // Инвертирует все биты порта ввода вывода
    static void toggle() { *portReg ^= mask; }

    // Выполняет запись значения в порт ввода вывода
    static void write(Size value) { *portReg = value; }

    // Выполняет чтение порта ввода вывода в возвращаемое значение
    static Size read() { return *portReg; }

    // Устанавливает режим ввода с подтягивающим резистором для всех битов порта ввода вывода
    static void pullUp() { *directionReg = ~mask, *portReg = mask; }

    // Устанавливает режим ввода с высокоимпедансным состоянием для всех битов порта ввода вывода
    static void highImpedance() { *directionReg = ~mask, *portReg = ~mask; }

    // Устанавливает режим вывода для всех битов порта ввода вывода
    static void output() { *directionReg = mask; }
};
Ну и напоследок, пример использования в классической мигалке светодиодом:
#include <avr/io.h>
#include <util/delay.h>

#include <sdk/Ports.h>   // Файл с описанием портов ввода вывода

template<typename Port>  // Порт передается как шаблонный параметр функции
void BlinkLed(void)      // Выполняемая задача. Не имеет представления, с каким конкретно портом она работает
{                        // Это даже может быть не сам порт, а заглушка для тестирования

    Port::output();      // Настройка порта на вывод. Необходимо, так как задача меняет состояние порта

    while (true)
    {
        Port::toggle();  // Переключение ножки в обратное логическое состояние
        _delay_ms(1000);
    }
}

int main(void)
{                        // Решение о конкретных портах и битах принимается здесь, в корне приложения,
    BlinkLed<PortB2>();  // абстрагируя от этого выполняемые задачи
}
Как видно из примера, задача мигания светодиодом не только абстрагирована от конкретной ножки микроконтроллера, но и практически от самой модели микроконтроллера. Причем, сгенерированный компилятором код не содержит никаких лишних инструкций: вызовы методов класса порта ввода вывода встроились в тело задачи. Единственное оставшееся знание - процедура задержки, однако это уже не является целью этой статьи.

В следующей части я расскажу о создании "виртуальных" портов, объединяющих отдельные биты разных портов в единое целое.