Низкоуровневый доступ

Существует два типа таймеров/счетчиков, типичных для большинства 8-битных микроконтроллеров AVR: 8-битный (обычно Timer0) и 16-битный (Timer1). Дополнительные экземпляры практически идентичны, с одинаковым расположением регистров, поэтому имеет смысл представлять их с той же структурой. Структура должна быть параметризована адресами регистров и любыми индивидуальными различиями таймера/счетчика.

Зарегистрировать местоположения

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

struct Timer1 { 
  uint16_t TCCRA_addr = 0x80;
  // ... 
};

Это отлично подходит для оптимизации, но очень плохо для повторного использования. Теоретически разные микросхемы могут иметь регистры в разных местах, и другие экземпляры таймера (например, Timer3, Timer4, Timer5) должны быть представлены как несвязанные структуры. Это означает, что вы не сможете легко написать код, работающий с любым 16-битным таймером.

Я также рассматривал возможность использования шаблонов с расположением регистров в качестве аргументов шаблона:

template<uint16_t tccra_addr>
struct Timer16 { ... };

// Micro 1
Timer16<0x80> timer1;
Timer16<0x90> timer3;

// Some other micro
Timer16<0xB0> timer1;

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

Чтобы решить эту проблему, я могу поместить адреса регистров в виде структурных данных:

struct Timer16bit
{
  const uint16_t TCCRA_addr;
}; 

Timer16bit timer1 { 0x80 };

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

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

В итоге у меня получились следующие структуры. Доступ к большинству регистров таймера осуществляется с использованием базового адреса + смещения. Адреса прерываний указываются отдельно.

class AvrTimer16
{
public:
    struct Config {
        uint16_t base;
        uint16_t timskAddr;
        uint16_t tifrAddr;
        int      irqCompA;
    };

private:
    const Config &config;

// ...

public:
    constexpr AvrTimer16(const Config &config_) : config(config_) {}

    auto TCCRA() const { /* access TCCRA using config.base address */ 
}

Если все значения известны во время компиляции, вычисление адреса все равно оптимизируется:

void foo()
{
    AvrTimer16 t1(cfg1);
    t1.TCCRA().WGM10 = 3;
}
    r30,    0x0200
    r31,    0x0201
    ld      r24, Z
    ori     r24, 0x03       
    st      Z, r24
    ret

В других случаях это делается во время выполнения:

static AvrTimer16 t1(cfg1);

void foo(AvrTimer16 &tx)
{
    tx.TCCRA().WGM10 = 3;
}
    movw    r26, r24
    ld      r30, X+
    ld      r31, X
    ld      r0, Z+
    ld      r31, Z
    mov     r30, r0
    ld      r24, Z
    ori     r24, 0x03       
    st      Z, r24
    ret

Режимы таймера

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

И снова главной задачей было найти баланс между гибкостью и возможностью компилятора оптимизировать код. Это особенно актуально для выбора тактовой частоты и расчета частоты, поскольку вычисление необходимых значений регистров может быть дорогостоящим в вычислительном отношении, а аргументы часто известны во время компиляции. В конце концов я получил методы constexpr режима таймера, которые возвращают объект ComponentConfig. Объект содержит функцию конфигурации с максимально возможным количеством заранее рассчитанных значений, что потенциально исключает операции во время выполнения.

struct CTCMode {
    //...

    static constexpr auto configureSquareWave(unsigned long ioFreq, 
                                              float freq)
    {
        const auto clock = findClock(ioFreq, freq);
        const auto valid = clock.prescaler != 0;
        const auto ocrValue = valid ? 
            getOcr(ioFreq, clock.prescaler, freq) : 0;

        auto c = [=](const AvrTimer16 &obj) {
            obj.writeWgm(AvrTimer16::WaveformGenerationMode::CtcToOcr);
            obj.TCCRB().CS = clock.value;
            obj.OCRA() = ocrValue;
            obj.TCCRA().COMA = CompareOuputMode::Toggle;
            
            return true;
        };

        return ComponentConfig<decltype(c)> {valid, c};
    }

В приведенном выше примере наиболее затратными в вычислительном отношении операциями являются findClock() и getOcr(), которые вычисляют значения прескалера тактовой частоты и значения OCR для запрошенной частоты. Однако, поскольку оба эти метода, а также configure() являются constexpr, вычисления можно выполнить во время компиляции.

Фактическая операция записи в регистр, очевидно, не может быть выполнена во время компиляции, поэтому она возвращается как функция. Затем он помещается в объект ComponentConfig, который обеспечивает обработку ошибок. В идеале следует использовать std::optional или исключения, но они недоступны для цели AVR (насколько мне известно).

template <class T> struct ComponentConfig {
    bool isValid;
    T    configFunc;

    operator bool() const { return isValid; }
};

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

auto outputSquareWaveOptimized() -> void
{
    using CTC = AvrTimer16::CTCMode;

    constexpr auto cfg1 = CTC::configureSquareWave(F_CPU, 99300400);
    static_assert(cfg1.isValid == false);

    constexpr auto cfg2 = CTC::configureSquareWave(F_CPU, 0.0001f);
    static_assert(cfg2.isValid == false);

    constexpr auto cfg3 = CTC::configureSquareWave(F_CPU, 300);
    static_assert(cfg3.isValid);

    constexpr auto t3 = Board::makeTimer16(Timer16::Timer3);
    t3.apply(cfg3);
}

В полученном ассемблерном коде нет вычислений частоты во время выполнения, поскольку все значения были предварительно рассчитаны функциями constexpr.

;; Dump of assembler code for function outputSquareWaveOptimized():
ldi     r26,    0x91    ; 145
ldi     r27,    0x00    ; 0
ld      r24,    X
andi    r24,    0xE7    ; 231
ori     r24,    0x08    ; 8
st      X,      r24
ldi     r30,    0x90    ; 144
ldi     r31,    0x00    ; 0
ld      r24,    Z
andi    r24,    0xFC    ; 252
st      Z,      r24
ld      r24,    X
andi    r24,    0xF8    ; 248
ori     r24,    0x01    ; 1
st      X,      r24
ldi     r24,    0x29    ; 41
ldi     r25,    0x68    ; 104
sts     0x0099, r25
sts     0x0098, r24
ld      r24,    Z
andi    r24,    0x3F    ; 63
ori     r24,    0x40    ; 64
st      Z,      r24
ret

Интерфейс высокого уровня

Помимо низкоуровневого интерфейса, Liquid обеспечивает абстракцию определенных функций более высокого уровня, которые можно реализовать с помощью таймера/счетчика. В настоящее время это ШИМ, прямоугольная волна и периодическое прерывание. Эти интерфейсы могут быть реализованы как 16-битными таймерами, так и 8-битными таймерами, которые будут представлены разными классами. Должна быть возможность смешивать две реализации в одной программе, поэтому я решил использовать виртуальные базовые классы для API. Другими словами, мне хотелось бы иметь возможность написать функцию, которая принимает объект Pwm и вызывает его с аргументом, подкрепленным 16-битным или 8-битным таймером.

Эти API находятся на один уровень выше ранее описанных классов, и я готов пожертвовать снижением производительности ради дополнительной гибкости.

class Pwm
{
public:
    virtual ~Pwm() = default;
    
    virtual auto configure(unsigned long fCpu, 
                           unsigned long min, 
                           unsigned long max) -> bool = 0;
    virtual auto setDutyCycle(float dutyCycle) -> void = 0;
};

class SquareWave
{
public:
    virtual ~SquareWave() = default;

    virtual auto configure(unsigned long fCpu, 
                           float freq) -> bool = 0;
    virtual auto setFrequency(unsigned long fCpu, 
                              float freq) -> bool = 0;
};


class Timer
{
public:
    virtual ~Timer() = default;

    virtual auto enablePeriodicInterrupt(unsigned long fCpu, 
        float freq, const IrqHandler &handler) -> bool = 0;
    virtual auto disablePeriodicInterrupt() -> void = 0;
    virtual auto stop() -> void = 0;
};

Конкретные реализации используют объект AvrTimer16 и функции из соответствующей структуры режима таймера. К сожалению, поскольку на данный момент я использую C++17, я не могу использовать constexpr с виртуальными функциями. Я рассмотрю этот вариант, если/когда переведу проект на C++20.

class PwmImpl : public Pwm
{
public:
    PwmImpl(AvrTimer16 timer_, CompareOutputChannel channel_) 
      : timer(timer_), channel(channel_) {}

    virtual ~PwmImpl() = default;

    auto setDutyCycle(float dutyCycle) -> void override
    {
        auto config = AvrTimer16::FastPwmMode::setDutyCycle(
            dutyCycle, channel);
        timer.apply(config);
    }

    auto configure(unsigned long fCpu, 
         unsigned long min, unsigned long max) -> bool override
    {
        auto config = AvrTimer16::FastPwmMode::configure(
            channel, fCpu, min, max);
        if (!config) return false;
        timer.apply(config);
        return true;
    }

private:
    AvrTimer16                 timer;
    const CompareOutputChannel channel;
};  

Вот пример использования API высокого уровня. Код ниже настраивает выход ШИМ на выводе D12 с частотой от 10 до 40 кГц и рабочим циклом 75%.

auto pwm1 = Board::makePwm(Board::Gpio::D12);
pwm1.configure(F_CPU, 10000, 40000);
pwm1.setDutyCycle(0.75f);

О частотах ШИМ

В приведенном выше примере частота ШИМ настраивается путем указания желаемого диапазона, а не точного значения. Это связано с тем, что при использовании аппаратного модуля таймера для генерации ШИМ-сигнала частота определяется прескалером тактового сигнала и разрешением таймера, поэтому он обеспечивает лишь ограниченный выбор значений. Точная частота ШИМ может быть достигнута путем использования прерываний для переключения выходного контакта в программном обеспечении. Однако в большинстве случаев нет необходимости точно контролировать частоту при использовании ШИМ, и я предпочитаю сохранить циклы ЦП и обработку прерываний для других задач.

Оставшаяся работа

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

  • Реализации ШИМ, прямоугольной волны и периодических прерываний с использованием 8-битных таймеров/счетчиков
  • Различные разрешения в режимах 16-битного таймера

Код, описанный в этой статье, является частью Project Liquid.

Проект на GitHub
AvrTimer16.h
TimerDemo.cpp

Первоначально опубликовано на https://chasingfolly.com.