Низкоуровневый доступ
Существует два типа таймеров/счетчиков, типичных для большинства 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.