Если вы читаете эту статью, вы, вероятно, работали с таймерами в системах RTOS или POSIX для создания событий по времени. Однако работа с аппаратными таймерами менее распространена, но это важный аспект разработки встраиваемых систем. Это вторая статья из серии, посвященной TinyRTOS, и она продолжает закладывать основу для реализации POSIX-совместимой операционной системы.

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

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

Периферийный таймер/счетчик

Аппаратный таймер — это периферийное устройство, встроенное в компьютер и используемое для измерения времени. Он работает, генерируя периодический сигнал, который можно использовать для запуска других событий или выполнения определенных действий. Аппаратные таймеры часто используются в приложениях реального времени, таких как управление синхронизацией управления двигателем, измерение времени между двумя событиями или генерация сигнала широтно-импульсной модуляции (ШИМ) для управления яркостью светодиода.

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

На этом графике показано увеличение таймера с 0 до n — 1, где n — его размер, то есть количество различных значений, которые он может подсчитать. Размер обычно описывается битами, которые он использует для хранения. Например, 8-битный таймер считает от 0 до 255, а его размер (n) равен 256.

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

Частота тактов таймера обычно достигается за счет уменьшения частоты ЦП за счет уменьшения масштаба, что создает более длительный интервал между каждым тактом. Регулируя коэффициент масштабирования, можно настроить частоту тактов таймера в соответствии с конкретными требованиями приложения.

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

Таймеры ATmega328p

Сегодня мы будем использовать встроенный в ATmega328p Таймер/Счетчик, который подключен к внутренним часам микроконтроллера, и будем генерировать прерывания при определенных событиях. ATmega328p имеет три таймера с одинаковыми базовыми функциями, хотя Таймер/Счетчик 0 и 2 являются 8-разрядными, а Таймер/Счетчик 1 является 16-разрядным и имеет дополнительный внешний вход.

С этими новыми концепциями мы можем пойти дальше и написать простую программу, которая устанавливает таймер с прерыванием.

Таймеры/счетчики ATmega328p поставляются с несколькими отображаемыми в память регистрами для контроля и управления модулями таймеров:

  • TCRnA, TCRnB и TCRnC: регистры управления таймером, которые регулируют параметры таймера, включая режим генерации волны, режим вывода GPIO и предварительный делитель.
  • TCNTn: Счетчик таймера отслеживает количество
  • OCRnA, OCRnB: Регистр сравнения вывода, содержит значение для сравнения, если мы настроим сравнение совпадения вывода.
  • TIMSKn: Регистр маски прерывания таймера, используемый для включения/отключения различных источников прерывания таймера.
  • TIFRn: регистр флагов таймера отслеживает ожидающие прерывания

Обратите внимание, что n обозначает 0, 1 или 2 в зависимости от используемого нами таймера. Кроме того, TCRnC доступен только на таймере 1, так как он управляет входными параметрами. Однако все регистры имеют совместимые макеты, что может быть полезно для логической абстракции.

Разрешение таймера, прескалер и другие настройки

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

  • Нормальный режим генерации волн: Многократный счет от 0 до n — 1
  • Режим вывода: нет выхода GPIO
  • Выходное значение сравнения: 0
  • Прерывания переполнения и сравнения совпадений: отключены

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

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

+-----------+-------+-------------+--------------+
| Prescaler | Step  | Max (8-bit) | Max (16-bit) |
+-----------+-------+-------------+--------------+
|         1 |     1 |         256 |       65,536 |
|         8 |     8 |       2,048 |      524,288 |
|        64 |    64 |      16,384 |    4,194,304 |
|       256 |   256 |      65,536 |   16,777,216 |
|     1,024 | 1,024 |     262,144 |   67,108,864 |
+-----------+-------+-------------+--------------+

Предполагая, что частота процессора составляет 16 МГц, эти значения переводятся в следующие точные длительности:

+-----------+---------+-------------+--------------+
| Prescaler |  Step   | Max (8-bit) | Max (16-bit) |
+-----------+---------+-------------+--------------+
|         1 | 62.5 ns |       16 µs |     4.096 ms |
|         8 |  500 ns |      128 µs |    32.768 ms |
|        64 |    4 µs |    1.024 ms |   262.144 ms |
|       256 |   16 µs |    4.096 ms |   1.048576 s |
|     1,024 |   64 µs |   16.384 ms |   4.194304 s |
+-----------+---------+-------------+--------------+

Как правило, мы хотим выбрать таймер и прескалер, отвечающие требованиям с максимальной точностью. При этом полностью исключается 8-битный таймер, а также прескалеры 1, 8 и 64 16-битного таймера (их максимальная продолжительность составляет менее одной секунды). Это оставляет нам 256 и 1024 16-битного таймера, и для достижения большей точности (наименьший шаг) мы выбираем наименьший предварительный делитель, поэтому наша точность будет 16 мкс.

Теперь мы можем пойти дальше и закодировать наш выбор. Проверив таблицу данных, нам нужно установить бит OCIE1A в TIMSK1, чтобы включить прерывание сравнения вывода, записать продолжительность в тактах таймера в OCR1A и установить предварительный делитель в битах CS10, CS11 и CS12 в TCCR1B. Такты таймера можно рассчитать по следующей формуле: такты = длительность · F_CPU / пределитель. Более того, биты предварительного делителя (выбор тактовой частоты, CS) непрерывны и упорядочены, поэтому все, что нам нужно сделать, это записать 3-битное число, начинающееся с CS10.

При всем при этом получаем следующее:

#include <avr/io.h>

OCR1A = 1 * F_CPU / 256;     // Set output compare value
TIMSK1 |= 1 << OCIE1A;       // Enable output match interrupt
TCCR1B |= 4 << CS10;         // Start the timer

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

#include <avr/io.h>

typedef enum {
  COUNTER0CS_NONE = 0,
  COUNTER0CS_1,
  COUNTER0CS_8,
  COUNTER0CS_64,
  COUNTER0CS_256,
  COUNTER0CS_1024,
} counter0clock_select_t;

OCR1A = 1 * F_CPU / 256;             // Set output compare value
TIMSK1 |= 1 << OCIE1A;               // Enable output match interrupt
TCCR1B |= COUNTER0CS_256 << CS10;  // Start the timer

прерывания

Наконец, мы должны определить функцию ISR. Набор инструментов AVR предоставляет макросы ISR и TIMER1_COMPA_vect, что значительно упрощает процесс по сравнению, например, с ARM.

#include <stdio.h>
#include <avr/interrupt.h>

// Function that will execute on output match compare A
ISR(TIMER1_COMPA_vect) {
  printf("Hello from ISR!\n");
}

Давайте посмотрим, что именно это делает, запустив препроцессор на этом фрагменте кода, который мы сохранили в файле. Мы запускаем avr-gcc -mmcu=atmega328p timer_counter.c -E, чтобы узнать:

void __vector_11(void) __attribute__((signal, used, externally_visible));
void __vector_11(void) {
  printf("Hello from ISR!\n");
}

Функция ISR использует имя __vector_11, связанное с вектором прерывания, соответствующим сравнению совпадения выходных данных таймера/счетчика 1 A. Когда установлено прерывание, микроконтроллер AVR прерывает текущую выполняемую программу и переходит к соответствующей функции ISR.

Кроме того, __attribute__ используется для указания следующих атрибутов функции:

  • signal: Добавляет пролог прерывания и эпилог, которые будут сохранять и восстанавливать состояние ЦП (например, регистры, указатель стека), предотвращая затирание ISR регистров, используемых прерванным кодом.
  • used: Сообщает компилятору, что функция должна быть включена в двоичный файл, даже если она не вызывается явно; это имеет смысл для ISR, поскольку они не вызываются в коде.
  • externally_visible:: Делает функцию доступной для вызова извне модуля, в котором она определена, гарантируя, что функции могут быть доступны и выполнены системой аппаратных прерываний при возникновении события прерывания.

Наконец, мы можем объединить и добавить в наш файл main.c все из предыдущего урока.

#include <avr/io.h>
#include <avr/interrupt.h>
#include <stdio.h>

#include "serial_io.h"

typedef enum {
  COUNTER0CS_NONE = 0,
  COUNTER0CS_1,
  COUNTER0CS_8,
  COUNTER0CS_64,
  COUNTER0CS_256,
  COUNTER0CS_1024,
} counter0clock_select_t;


// Function that will execute on output match compare A
ISR(TIMER1_COMPA_vect) { printf("Hello from ISR!\n"); }

int main(void) {
  serial_io_init();
  printf("Starting TinyRTOS...");
  sei();                                       // Enable interrupts
  unsigned duration = 1;

  OCR1A = duration * F_CPU / 256;       // Set output compare value
  TIMSK1 |= 1 << OCIE1A;                // Enable output match interrupt
  TCCR1B |= COUNTER0CS_256 << CS10;   // Start the timer

  while (1) continue;                          // Wait forever

  return 0;
}

Глядя на разборку с Object File Dumper

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

make build/main.elf
avr-objdump -d build/main.elf > build/main.s

Команда avr-objdump с опцией -S генерирует дизассемблированный объектный код, который представляет собой ассемблерный код, соответствующий машинному коду в файле main.elf. Изучаем сгенерированный main.s и видим это в начале файла:

00000000 <__vectors>:
   0: 0c 94 34 00  jmp 0x68 ; 0x68 <__ctors_end>
   4: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
   8: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
   c: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  10: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  14: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  18: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  1c: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  20: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  24: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  28: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  2c: 0c 94 53 00  jmp 0xa6 ; 0xa6 <__vector_11>
  30: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  34: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  38: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  3c: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  40: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  44: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  48: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  4c: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  50: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  54: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  58: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  5c: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  60: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>
  64: 0c 94 51 00  jmp 0xa2 ; 0xa2 <__bad_interrupt>

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

В случае совпадения выходных данных Таймера/Счетчика 1 при сравнении А ЦП автоматически переходит к ячейке в таблице векторов, связанной с вектором 11 (адрес 0x2C); то есть ISR(TIMER1_COMPA_vect).

Давайте теперь рассмотрим дизассемблирование __vector_11, которое также генерируется objdump:

000000a6 <__vector_11>:
  a6:   1f 92           push    r1
  a8:   0f 92           push    r0
  aa:   0f b6           in      r0, 0x3f        ; 63
  ac:   0f 92           push    r0
  ae:   11 24           eor     r1, r1
  b0:   2f 93           push    r18
  b2:   3f 93           push    r19
  b4:   4f 93           push    r20
  b6:   5f 93           push    r21
  b8:   6f 93           push    r22
  ba:   7f 93           push    r23
  bc:   8f 93           push    r24
  be:   9f 93           push    r25
  c0:   af 93           push    r26
  c2:   bf 93           push    r27
  c4:   ef 93           push    r30
  c6:   ff 93           push    r31
  c8:   cf 93           push    r28
  ca:   df 93           push    r29
  cc:   cd b7           in      r28, 0x3d       ; 61
  ce:   de b7           in      r29, 0x3e       ; 62
  d0:   8e e0           ldi     r24, 0x0E       ; 14
  d2:   91 e0           ldi     r25, 0x01       ; 1
  d4:   0e 94 6d 01     call    0x2da   ; 0x2da <puts>
  d8:   00 00           nop
  da:   df 91           pop     r29
  dc:   cf 91           pop     r28
  de:   ff 91           pop     r31
  e0:   ef 91           pop     r30
  e2:   bf 91           pop     r27
  e4:   af 91           pop     r26
  e6:   9f 91           pop     r25
  e8:   8f 91           pop     r24
  ea:   7f 91           pop     r23
  ec:   6f 91           pop     r22
  ee:   5f 91           pop     r21
  f0:   4f 91           pop     r20
  f2:   3f 91           pop     r19
  f4:   2f 91           pop     r18
  f6:   0f 90           pop     r0
  f8:   0f be           out     0x3f, r0        ; 63
  fa:   0f 90           pop     r0
  fc:   1f 90           pop     r1
  fe:   18 95           reti

Код начинается с сохранения текущего состояния ЦП в стек с использованием инструкций push и in для помещения в стек нескольких регистров ЦП. Эта часть называется эпилогом функции и обеспечивает сохранение состояния процессора перед выполнением ISR.

Затем ISR загружает непосредственные значения 0x0E и 0x01 в регистры r24 и r25 с помощью инструкции ldi. Это аргументы для puts, которые компилятор выбрал вместо printf, поскольку мы просто печатаем строковый литерал. Функция puts вызывается с помощью файла call. Если вас интересует загруженный адрес 0x010E, это соответствует указателю в сегменте данных, в котором хранятся инициализированные данные.

Это подтверждается командой:

avr-objdump -s -j .data build/main.elf

Что производит:

Contents of section .data:
 800100 00000002 00000000 db000000 00004865  ..............He
 800110 6c6c6f20 66726f6d 20495352 21005374  llo from ISR!.St
 800120 61727469 6e672054 696e7952 544f532e  arting TinyRTOS.
 800130 2e2e0000                             ....

; 0x48 is the ascii code of 'H', which is in 0x80010E
; 0x00 is the ascii code of the null terminator '\0'

Когда мы пишем строковый литерал в вызове функции, компилятор создает для него переменную и сохраняет ее адрес. В случае puts компилятор создал переменную по адресу 0x010E (0x80 — это префикс .data), которая содержит строку Hello from the ISR!\n\0 (включая нулевой терминатор!), а затем передает 0x010E в puts.

После возврата из функции puts ISR восстанавливает состояние ЦП из стека с помощью инструкции pop, а затем возвращается из прерывания с помощью инструкции reti. Инструкция reti используется для возврата из процедуры обработки прерывания и восстановления предыдущего состояния прерывания, включая глобальный флаг прерывания.

На этом мы закончим анализ дизассемблирования, так как дальнейший анализ может быстро превратиться в кроличью нору, углубляя нас в тонкости архитектуры AVR, что не является основной темой этой статьи. Тем не менее, базовое понимание языка ассемблера и исполнения ЦП может дать ценную информацию о внутренней работе компьютеров, и этот уровень понимания необходим для разработчиков встраиваемых систем. Итак, в следующий раз, когда вы столкнетесь с чем-то интригующим (или разочаровывающим), не стесняйтесь исследовать больше с objdump!

Наконец, мы можем проверить это, запустив make upload display. Если все пойдет хорошо, мы увидим это на экране.

Starting TinyRTOS...
Hello from ISR!

Однако… насколько мы уверены, что это сработало? Он подождал одну секунду? Конечно, мы можем замерить его внешне, и, вероятно, будет ощущение, что прошла ровно одна секунда. Но это указывает на то, что в нашей программе чего-то не хватает: возможности измерять время — часы.

Реализация часов ISO C

Поддержка времени обеспечивается самим языком C, рукой <time.h>. ISO C определяет функцию clock():

Функция clock определяет используемое процессорное время. […] Функция clock возвращает наилучшее приближение реализации к процессорному времени, используемому программой с начала определяемой реализацией эры, связанной только с вызовом программы. Чтобы определить время в секундах, значение, возвращаемое функцией clock, нужно разделить на значение макроса CLOCKS_PER_SEC. Если используемое процессорное время недоступно, функция возвращает значение (clock_t)(−1). Если значение не может быть представлено, функция возвращает неопределенное значение.

clock() — это основная ссылка на точный хронометраж в стандарте C, и его реализация обычно обеспечивается операционной системой. В данном случае это мы. Мы стремимся реализовать эту функцию, и мы сделаем это с помощью таймера/счетчика AVR.

Стратегии хронометража

Одним из разумных и эффективных вариантов было бы использование таймера с прерыванием переполнения, при котором счетчик переполнения увеличивается. Таким образом, функция clock() может возвращать количество тактов таймера с момента ее установки: overflow_count · timer_size + timer_count,где:

  • overflow_count – количество переполнений, произошедших с момента его установки,
  • timer_size — размер таймера, например. 256, если мы используем 8-битный таймер, и
  • timer_count – текущее значение счетчика таймера, например число от 0 до 255 для 8-битного таймера.

Мы можем быстро поместить это в такой код:

#include <avr/io.h>

#define COUNTER0_SIZE (1 << 8)

static unsigned overflow_count = 0;

ISR(TIMER0_OVF_vect) { overflow_count++; }

clock_t clock(void) { return overflow_count + COUNTER0_SIZE + TCNT0; }

Этот подход имеет относительно низкие накладные расходы на прерывания, обеспечивая при этом высокую точность тактовой частоты. Мы используем то, что таймер продолжает считать между переполнениями бесплатно для ЦП, обеспечивая при этом точность часов таймера. Например, чтобы поддерживать часы с 8-битным таймером, мы будем прерывать ЦП каждые 256 тактов.

Однако у этого подхода есть один фатальный недостаток. Если определенное нами прерывание не единственное в программе, возникнут конфликты прерываний. На данный момент есть два варианта:

  • Отключить прерывания во время выполнения ISR; таким образом, прерывания обслуживаются в порядке очереди
  • Вложенные прерывания: Держите прерывания включенными во время ISR и позволяйте им прерывать друг друга в зависимости от приоритета.

Чтобы проанализировать первый вариант, давайте представим это: другое прерывание (например, USART) выдается до того, как таймер часов переполнится (TIMER0_OVF_vect). Естественно, мы пропустим это прерывание, и overflow_count будет ниже ожидаемого. Однако TCNT0 продолжает увеличиваться и переполняется с 255 до 0 (т. е. уменьшается). Что произойдет, если в этот момент будет вызван clock()? Правильно: функция clock() вернет значение не только меньшее, чем ожидалось, но и значение, которое уменьшилось по сравнению с предыдущими вызовами. Программа отправилась в прошлое! Было бы ненадежно создавать приложения, требующие знания времени.

Остаются только вложенные прерывания. Мы должны гарантировать, что прерывание часов всегда имеет достаточный приоритет для немедленного обслуживания и что возвращаемое значение clock() является надежным и согласованным. Тем не менее, вложенные прерывания могут значительно увеличить сложность системы, усложнив ее проектирование, отладку и обслуживание. Поток выполнения становится сложнее предсказать, что приводит к неожиданному поведению и трудно обнаруживаемым ошибкам.

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

Тактовый период, служебные прерывания и проблемы с округлением

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

  1. Задержка: период часов определяет точность времени для нашей системы; в результате службы, полагающиеся на него, могут столкнуться с ошибками округления в своих сроках.
  2. Потребляемая мощность: часы будут использовать ЦП при каждом переполнении; если период слишком короткий, при обновлении часов будет потребляться значительное количество энергии.
  3. Накладные расходы на прерывание: если период таймера слишком короткий, это может привести к тому, что ЦП будет чрезмерно занят обновлением часов.
  4. Переполнение часов. Подобно любому целочисленному типу, тип clock_t имеет фиксированную ширину и подвержен целочисленному переполнению; более длительный период времени потребует больше времени для переполнения

В этом упражнении мы будем использовать 8-битный таймер с предварительным делителем 8, который, судя по таблицам в предыдущем разделе, дает одно прерывание каждые 2048 циклов ЦП и около 128 мкс тактового периода. Мы можем вернуться к этому и скорректировать, если это необходимо.

#include <time.h>
#include <avr/io.h>

#define CLOCK_SELECT (COUNTER_0_CS_8)

typedef enum {
  COUNTER_0_CS_NONE = 0,
  COUNTER_0_CS_1,
  COUNTER_0_CS_8,
  COUNTER_0_CS_64,
  COUNTER_0_CS_256,
  COUNTER_0_CS_1024,
} counter_0_clock_select_t;

void clock_start(void) {
  TIMSK0 |= 1 << TOIE0;             // Enable overflow interrupt
  TCCR0B |= CLOCK_SELECT << CS00;   // Start the timer
}

static clock_t clock_overflow_count = 0;

ISR(TIMER0_OVF_vect) { clock_overflow_count++; }

clock_t clock(void) { return clock_overflow_count; }

Свяжите часы ISO C с нашим исходным кодом

Набор инструментов AVR включает реализацию libc, которая содержит множество функций, определенных стандартом ISO. Однако, как мы видели, некоторые функции зависят от системы. Если мы посмотрим на <time.h>, мы обнаружим это:

/* We have to provide clock_t / CLOCKS_PER_SEC so that libstdc++-v3 can
   be built.  We define CLOCKS_PER_SEC via a symbol _CLOCKS_PER_SEC_
   so that the user can provide the value on the link line, which should
   result in little or no run-time overhead compared with a constant.  */
typedef unsigned long clock_t;
extern char* _CLOCKS_PER_SEC_;
#define CLOCKS_PER_SEC ((clock_t) _CLOCKS_PER_SEC_)
extern clock_t clock(void);

Мы должны определить _CLOCKS_PER_SEC_ и clock в нашем коде, и clock_t нам дано. Функция clock — это та функция, которую мы только что определили и собираемся протестировать. Переменная _CLOCKS_PER_SEC_ — это тактовая частота, которая рассчитывается по следующей формуле:

частота = F_CPU / (предделитель · размер_таймера) = 16 000 000 / (8 · 256) = 7 812,5

К сожалению, _CLOCKS_PER_SEC_ приводится к clock_t, поэтому мы должны добавить 0,5 такта/с (512 мкс/с) к нашей ошибке точности. С другой стороны, _CLOCKS_PER_SEC_ определяется как char*, поэтому мы знаем, что в нашем случае мы не сможем хранить значения больше, чем UINTPTR_MAX, 65535. Наше значение 7812, мы в безопасности.

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

libc/time.h

#ifndef __TIME_H__
#define __TIME_H__

#include <time.h>

#define CLOCK_SELECT (COUNTER_0_CS_8)

typedef enum {
  COUNTER_0_CS_NONE = 0,
  COUNTER_0_CS_1,
  COUNTER_0_CS_8,
  COUNTER_0_CS_64,
  COUNTER_0_CS_256,
  COUNTER_0_CS_1024,
} counter_0_clock_select_t;

void clock_start(void);

clock_t clock(void);

#endif  // __TIME_H__

libc/time.c

#include "libc/time.h"

#include <avr/interrupt.h>
#include <avr/io.h>

#define COUNTER_0_SIZE (1U << 8)
#define CLOCK_PRESCALER (COUNTER_0_PRESCALERS[CLOCK_SELECT])

static const unsigned COUNTER_0_PRESCALERS[] = {0, 1, 8, 64, 256, 1024};

char* _CLOCKS_PER_SEC_ = (char*)0;

static clock_t clock_overflow_count = 0;

void clock_start(void) {
  _CLOCKS_PER_SEC_ =
      (char*)(uintptr_t)(F_CPU / (CLOCK_PRESCALER * COUNTER_0_SIZE));

  TIMSK0 |= 1 << TOIE0;            // Enable overflow interrupt
  TCCR0B |= CLOCK_SELECT << CS00;  // Start the timer
}

ISR(TIMER0_OVF_vect) { clock_overflow_count++; }

clock_t clock(void) { return clock_overflow_count; }

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

Давайте обновим main.c и посмотрим, работает ли это!

#include <avr/interrupt.h>
#include <avr/io.h>
#include <stdio.h>

#include "libc/time.h"
#include "serial_io.h"

typedef enum {
  COUNTER0CS_NONE = 0,
  COUNTER0CS_1,
  COUNTER0CS_8,
  COUNTER0CS_64,
  COUNTER0CS_256,
  COUNTER0CS_1024,
} counter0cs_t;

ISR(TIMER1_COMPA_vect) {
  printf("Hello from ISR! time = %lu\n", clock() / CLOCKS_PER_SEC);
}

int main(void) {
  serial_io_init();
  sei();
  printf("Starting TinyRTOS...\n");
  clock_start();
  printf("%lu\n", CLOCKS_PER_SEC);
  unsigned duration = 1;

  OCR1A = duration * F_CPU / 256;      // Set output compare value
  TIMSK1 |= 1 << OCIE1A;               // Enable output match interrupt
  TCCR1B |= COUNTER0CS_256 << CS10;  // Start the timer

  while (1) continue;  // Wait forever

  return 0;
}

Мы запускаем нашу команду make upload display для получения логов:

Starting TinyRTOS...
Hello from ISR! time = 1
Hello from ISR! time = 2
Hello from ISR! time = 3

Часы теперь выглядят и ощущаются естественно, считая пошагово секунда за секундой!

Мы запускаем его вместе с секундомером, чтобы увидеть, насколько он точен, и обнаруживаем, что у него значительный перекос часов, равный 2 секундам в минуту, то есть 3,3%. Это немного больше, чем мы ожидали…

Накладные расходы на прерывания и рассогласование часов

Принятый нами теоретический сдвиг часов равен точности часов (128 мкс за любой период) плюс ошибка округления при преобразовании единиц измерения (512 мкс/с). Для периода в 1 минуту это должно дать абсолютную ошибку 128 мкс + 512 мкс/с × 60 с = 30 848 мкс = 0,030848 с, то есть 0,051% относительной ошибки. Это было бы приемлемо, тем более, что большая часть ошибок возникает из-за преобразования единиц измерения, которое не влияет на время.

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

Мы определили два прерывания, одно для TIMER0_OVF_vect, которое обновляет статическую переменную, и одно для TIMER1_COMPA_vect, которое выводит время на экран. Проблема в том, что printf — медленная функция. Хотя это и незаметно для человеческого глаза, время ожидания на несколько порядков превышает тактовый период. Каждый раз, когда вызывается printf, вызывается usart_transmit для каждого печатаемого символа.

Вот что у нас было в usart.c:

void usart_transmit(char c) {
  while (!(UCSR0A & (1 << UDRE0))) continue;  // Busy wait on TX ready
  UDR0 = c;                                   // Send character
}

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

// in usart.h

extern uint32_t iterations;
extern size_t calls;

// in usart.c

uint32_t iterations = 0;
size_t calls = 0;

void usart_transmit(char c) {
  while (!(UCSR0A & (1 << UDRE0))) iterations++;
  calls++;
  UDR0 = c;  // Send character
}

// in main.c

#include "usart.h"

ISR(TIMER1_COMPA_vect) {
  printf("Hello from ISR! time = %u\n", (uint8_t)(clock() / CLOCKS_PER_SEC));
  printf("average = %u\n", (unsigned)(iterations / calls));
}

Мы перестраиваем, загружаем и снова отображаем и обнаруживаем, что среднее число итераций стабилизируется на уровне 920. Что еще хуже, каждая итерация цикла в while (!(UCSR0A & (1 << UDRE0))) iterations++; не занимает ровно один цикл.

Как мы видели ранее, нам не нужно очень глубоко разбираться в ассемблере, чтобы понять, что происходит, поэтому мы снова запускаем avr-objdump, чтобы увидеть дизассемблирование нового usart_transmit.

00000216 <usart_transmit>:
 216: 90 91 c0 00  lds r25, 0x00C0 ; 0x8000c0 <__TEXT_REGION_LENGTH__+0x7f80c0>
 21a: 95 fd        sbrc r25, 5
 21c: 18 c0        rjmp .+48      ; 0x24e <usart_transmit+0x38>
 21e: 40 91 5a 01  lds r20, 0x015A ; 0x80015a <iterations>
 222: 50 91 5b 01  lds r21, 0x015B ; 0x80015b <iterations+0x1>
 226: 60 91 5c 01  lds r22, 0x015C ; 0x80015c <iterations+0x2>
 22a: 70 91 5d 01  lds r23, 0x015D ; 0x80015d <iterations+0x3>
 22e: 4f 5f        subi r20, 0xFF ; 255
 230: 5f 4f        sbci r21, 0xFF ; 255
 232: 6f 4f        sbci r22, 0xFF ; 255
 234: 7f 4f        sbci r23, 0xFF ; 255
 236: 40 93 5a 01  sts 0x015A, r20 ; 0x80015a <iterations>
 23a: 50 93 5b 01  sts 0x015B, r21 ; 0x80015b <iterations+0x1>
 23e: 60 93 5c 01  sts 0x015C, r22 ; 0x80015c <iterations+0x2>
 242: 70 93 5d 01  sts 0x015D, r23 ; 0x80015d <iterations+0x3>
 246: 90 91 c0 00  lds r25, 0x00C0 ; 0x8000c0 <__TEXT_REGION_LENGTH__+0x7f80c0>
 24a: 95 ff        sbrs r25, 5
 24c: f0 cf        rjmp .-32      ; 0x22e <usart_transmit+0x18>
 24e: 20 91 58 01  lds r18, 0x0158 ; 0x800158 <calls>
 252: 30 91 59 01  lds r19, 0x0159 ; 0x800159 <calls+0x1>
 256: 2f 5f        subi r18, 0xFF ; 255
 258: 3f 4f        sbci r19, 0xFF ; 255
 25a: 30 93 59 01  sts 0x0159, r19 ; 0x800159 <calls+0x1>
 25e: 20 93 58 01  sts 0x0158, r18 ; 0x800158 <calls>
 262: 80 93 c6 00  sts 0x00C6, r24 ; 0x8000c6 <__TEXT_REGION_LENGTH__+0x7f80c6>
 266: 08 95        ret

Мы обратим внимание на следующее:

  • Инструкция перехода (rjmp), которая сообщает ЦП, к какой инструкции перейти дальше,
  • sbrc, который указывает ЦП пропускать или не выполнять следующую инструкцию в зависимости от условия, и
  • инструкция возврата (ret), которая сигнализирует об окончании функции и позволяет процессору вернуться к вызывающей программе.

Они полезны, потому что изменяют поток выполнения ЦП, в то время как мы можем предположить, что остальные инструкции будут выполняться постепенно.

Мы видим, что первое изменение в потоке выполнения — это переход к 0x24E. Это соответствует ветви, где выполняется условие while, и процессор полностью пропускает цикл while. Другая ветвь, которая пропускает инструкцию rjmp, соответствует невыполненному условию while. Ниже по этой ветке мы видим еще одну инструкцию rjmp, возвращающуюся к 0x22E, что соответствует продолжению итерации (очень похоже на C continue). Пропуск этого rjmp соответствует выходу из цикла (эквивалентно break). Обратите внимание, что rjmp всегда предшествует инструкция sbrc, которая определяет, следует ли пропускать инструкцию rjmp.

Мы хотим измерить количество циклов ЦП от начала цикла (0x22E) до его продолжения (0x24E), поскольку этот участок будет повторяться до тех пор, пока в очереди USART не появится свободное место.

 22e: 4f 5f        subi r20, 0xFF ; 255
 230: 5f 4f        sbci r21, 0xFF ; 255
 232: 6f 4f        sbci r22, 0xFF ; 255
 234: 7f 4f        sbci r23, 0xFF ; 255
 236: 40 93 5a 01  sts 0x015A, r20 ; 0x80015a <iterations>
 23a: 50 93 5b 01  sts 0x015B, r21 ; 0x80015b <iterations+0x1>
 23e: 60 93 5c 01  sts 0x015C, r22 ; 0x80015c <iterations+0x2>
 242: 70 93 5d 01  sts 0x015D, r23 ; 0x80015d <iterations+0x3>
 246: 90 91 c0 00  lds r25, 0x00C0 ; 0x8000c0 <__TEXT_REGION_LENGTH__+0x7f80c0>
 24a: 95 ff        sbrs r25, 5
 24c: f0 cf        rjmp .-32      ; 0x22e <usart_transmit+0x18>
 24e: 20 91 58 01  lds r18, 0x0158 ; 0x800158 <calls>

Не слишком разбираясь в том, что именно делают инструкции, мы подсчитываем количество байтов, которое занимает каждая инструкция. Проверив таблицу данных, мы видим, что каждый цикл занимает два байта инструкции. Исключая команду, которая выходит из цикла, мы считаем 32 байта, следовательно, 16 циклов. Это происходит для каждого символа, а в Hello from ISR! time = %u\naverage_iterations = %u\n 39 символов (считая %u).

Складывая все это вместе, мы получаем:

16 циклов / итерация × 920 итераций / символов × 39 символов = 574 080 циклы

Мы можем преобразовать это в секунды с помощью F_CPU:

574 080 циклов / 16 000 000 циклов / секунда = 0,03588 секунды

И это происходит каждую секунду, поэтому расхождение часов составляет 3,6%, что очень близко к тому, что мы только что наблюдали! Это удивительно.

Мы можем уменьшить эти накладные расходы на прерывание, напечатав только число, что значительно уменьшит количество символов.

ISR(TIMER1_COMPA_vect) {
  printf("%u\n", (uint8_t)(clock() / CLOCKS_PER_SEC));
}

Пробуем еще раз и обнаруживаем, что часы идут очень точно!

Однако в целом не рекомендуется вызывать printf из ISR, поскольку это блокирующая функция. Если необходимо, мы можем использовать буферизованную очередь USART с поддержкой прерываний или хранить журналы в буфере, который мы распечатываем позже из основной процедуры. Это лишь некоторые примеры общих стратегий, например, в случае отладочных прерываний.

Инкапсуляция таймера/счетчика

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

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

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

Вот части, которыми будет управлять наш драйвер:

  • Настройте его режим генерации волны
  • Настройте его выходные модули сравнения совпадений
  • Настройте его прерывания
  • Запустите таймер/счетчик

Мы сделаем это для Таймера/Счетчика 1, который является наиболее полным, и обобщим наш код на остальные таймеры. Кроме того, мы назовем этот модуль counter, чтобы отличить его от будущих модулей программного таймера, подчеркнув его низкоуровневые характеристики.

Режим генерации волны

Режим генерации волны относится к способу, которым таймер генерирует форму выходного сигнала, обычно в виде периодического сигнала с определенной частотой и рабочим циклом. Режим генерации волны определяет метод, используемый для генерации формы волны, включая такие параметры, как переключение контакта, установка выходного регистра сравнения или использование методов широтно-импульсной модуляции (ШИМ).

Нас интересуют только эти два режима:

  • Обычный: считать от 0 до размера счетчика (например, 65536)
  • Clear Timer on Compare (CTC): сброс таймера на ноль, когда он достигает заданного выходного значения сравнения.

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

typedef enum {
  TIMER_COUNTER0WGM_NORMAL = 0,
  TIMER_COUNTER0WGM_CTC_OCR = 4,
} timer_counter0wgm_t;

timer_counter0wgm_t wave_gen_mode;

// set wave generation mode
TCCR1A = (((wave_gen_mode >> 0) & 1u) << WGM10) |
         (((wave_gen_mode >> 1) & 1u) << WGM11);
TCCR1B = (((wave_gen_mode >> 2) & 1u) << WGM12) |
         (((wave_gen_mode >> 3) & 1u) << WGM13);

Выходные модули сравнения совпадений

Все таймеры/счетчики имеют два выходных модуля сравнения совпадений (A и B). Они используются для установки значений, с которыми счетчик будет сравнивать свое значение, и установит флаг прерывания сравнения соответствия при совпадении.

Для каждого модуля есть три настройки:

  • Режим: поведение вывода GPIO при сравнении совпадений, мы будем рассматривать только 0, соответствующий неиспользованию GPIO (обычный режим).
  • Значение для сравнения и подтверждения совпадения
  • Обратный вызов: функция, которая будет вызываться из соответствующего ISR.

Собираем все вместе (только модуль А)

typedef enum {
  TIMER_COUNTER_OUTPUT_MODE_NORMAL = 0,
} timer_counter_output_mode_t;

static void (*counter0output_a_on_match)(void) = NULL;
static void (*counter0output_b_on_match)(void) = NULL;
static void (*counter0on_overflow)(void) = NULL;
static timer_counter_output_mode_t counter0output_a_mode = 0;

ISR(TIMER0_OVF_vect) {
    counter0output_a_on_match();
}

void (*callback)(void);
timer_counter_output_mode_t mode;
uint16_t value;

counter0output_a_on_match = callback;  // Set on-match callback
TCCR1A |= mode << COM0A0;                // Set output mode
OCR1A = value;                           // Set compare value
TIMSK1 |= 1 << OCIE0A;                   // Enable match interrupt

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

typedef enum {
  TIMER_COUNTER_OUTPUT_MODE_NORMAL = 0,
} timer_counter_output_mode_t;

typedef void (*counter_cb_t)(void);

typedef struct {
  counter_cb_t output_a_on_match;
  counter_cb_t output_b_on_match;
  counter_cb_t on_overflow;
} counter_t;

static counter_t counter_1 = {0};

ISR(TIMER0_OVF_vect) {
    counter_1.output_a_on_match();
}

counter_cb_t output_a_on_match;
timer_counter_output_mode_t mode;
uint16_t value;

if (output_a_on_match) {
  counter_1.output_a_on_match = output_a_on_match;  // Set on-match callback
  TCCR1A |= mode << COM0A0;                         // Set output mode
  OCR1A = value;                                    // Set compare value
  TIMSK1 |= 1 << OCIE0A;                            // Enable match interrupt
} else {
  counter_1.output_a_on_match = NULL;
}

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

if (overflow) {
  counter_1.on_overflow = on_overflow;         // Set on-overflow callback
  TIMSK0 |= 1 << TOIE0;                        // Enable overflow interrupt
} else {
  counter_1.on_overflow = NULL;
}

Выбор часов (предделитель)

Установка прескалера запускает счетчик, что мы и делаем в конце настройки. Как обсуждалось ранее, мы различаем предварительный делитель и выбор часов, даже если они указывают на одну и ту же концепцию. Например, чтобы установить предварительный делитель 64 на Таймере/Счетчике 1, нам нужно установить значение 3 в поле выбора часов (CS) TCCR1B.

// in counter.h

extern unsigned counter0prescalers[];
extern counter0prescalers_size;

// in counter.c

typedef enum {
  COUNTER0CS_NONE = 0,
  COUNTER0CS_1,
  COUNTER0CS_8,
  COUNTER0CS_64,
  COUNTER0CS_256,
  COUNTER0CS_1024,
} counter0clock_select_t;

unsigned counter0prescalers[] = {0, 1, 8, 64, 256, 1024};
size_t counter0prescalers_size = counter_prescalers / counter_prescalers[0];

counter0prescaler_t clock_select;

TCCR1B |= clock_select << CS00;

Условия гонки в таймере/счетчике

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

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

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

TCCR1B = 0;    // stop the timer
TIMSK1 = 0;    // disable interrupts
TIFR1 = 0xFF;  // clear pending interrupts
TCNT1 = 0;     // reset counter

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

Собираем все вместе

counter.h

#ifndef __COUNTER_H__
#define __COUNTER_H__

#define COUNTER_0_SIZE (1 << 8)

typedef enum {
  COUNTER0WGM_NORMAL = 0,
  COUNTER0WGM_CTC_OCR = 4,
} counter0wgm_t;

typedef enum {
  COUNTER_OUTPUT_MODE_NORMAL = 0,
} counter_output_mode_t;

typedef enum {
  COUNTER0CS_NONE = 0,
  COUNTER0CS_1,
  COUNTER0CS_8,
  COUNTER0CS_64,
  COUNTER0CS_256,
  COUNTER0CS_1024,
} counter0clock_select_t;

typedef struct {
  uint16_t val;
  counter_output_mode_t mode;
  counter_cb_t on_match;
} countern — 1bit_output_t;

typedef void (*counter_cb_t)(void);

extern unsigned counter0prescalers[];
extern counter0prescalers_size;

void counter0set_attrs(counter0wgm_t wave_gen_mode,
                         counter0clock_select_t prescaler,
                         counter_cb_t on_overflow,
                         countern — 1bit_output_t* output_a,
                         countern — 1bit_output_t* output_b);

#endif  // __COUNTER_H__

counter.c

typedef struct {
  counter_cb_t output_a_on_match;
  counter_cb_t output_b_on_match;
  counter_cb_t on_overflow;
} counter_t;

unsigned counter0prescalers[] = {0, 1, 8, 64, 256, 1024};
size_t counter0prescalers_size = 
  counter0prescalers / counter0prescalers[0];

static counter_t counter_1 = {0};

void counter0set_attrs(counter0wgm_t wave_gen_mode,
                                counter0clock_select_t clock_select,
                                counter_cb_t on_overflow,
                                countern — 1bit_output_t* output_a,
                                countern — 1bit_output_t* output_b) {
  TCCR0B = 0;    // stop the timer
  TIMSK0 = 0;    // disable interrupts
  TIFR0 = 0xFF;  // clear pending interrupts
  TCNT0 = 0;     // reset counter

  // set wave generation mode
  TCCR1A = (((wave_gen_mode >> 0) & 1u) << WGM10) |
           (((wave_gen_mode >> 1) & 1u) << WGM11);
  TCCR1B = (((wave_gen_mode >> 2) & 1u) << WGM12) |
           (((wave_gen_mode >> 3) & 1u) << WGM13);
  
  // selectively enable interrupts, set callbacks, set output compare value
  if (output_a) {
    counter_1.output_a_on_match = output_a->on_match;   // Set on-match callback
    TCCR1A |= output_a->mode << COM1A0;          // Set output mode
    OCR1A = output_a->val;                       // Set compare value
    TIMSK1 |= 1 << OCIE0A;                       // Enable match interrupt
  } else {
    counter_1.output_a_on_match = NULL;
  }
  if (output_b) {
    counter_1.output_b_on_match = output_b->on_match;   // Set on-match callback
    TCCR1A |= output_b->mode << COM1B0;          // Set output mode
    OCR1B = output_b->val;                       // Set compare value
    TIMSK1 |= 1 << OCIE1B;                       // Enable match interrupt
  } else {
    counter_1.output_b_on_match = NULL;
  }
  if (overflow) {
    counter_1.on_overflow = on_overflow;         // Set on-overflow callback
    TIMSK1 |= 1 << TOIE1;                        // Enable overflow interrupt
  } else {
    counter_1.on_overflow = NULL;
  }

  // start the timer
  TCCR1B |= clock_select << CS10;
}

ISR(TIMER1_COMPA_vect) {
  TIFR1 = 1 << OCF1A;
  counter_1.output_a_on_match();
}

ISR(TIMER1_COMPB_vect) {
  TIFR1 = 1 << OCF1B;
  counter_1.output_b_on_match();
}

ISR(TIMER1_OVF_vect) {
  TIFR1 = 1 << TOIE1;
  counter_1.on_overflow();
}

Один и тот же код необходимо распространить на счетчики 0 и 2. Это может привести к повторяющемуся коду, что усложнит его обслуживание. С другой стороны, добавление уровня абстракции для устранения дублирования кода усложнило бы работу, особенно с учетом того, что счетчики имеют разное разрешение (8-битное против 16-битного). Мы оставим это для этой статьи, понимая, что это потенциальная область улучшения.

Наконец, наши clock.c и main.c можно переписать с помощью нового API.

libc/time.h

#ifndef __TIME_H__
#define __TIME_H__

#include <time.h>
#include "counter.h"

#define CLOCK_SELECT (COUNTER_0_CS_8)

void clock_start(void);
clock_t clock(void);

#endif  // __TIME_H__

libc/time.c

#include "libc/time.h"

char* _CLOCKS_PER_SEC_ = (char*)0;
static clock_t clock_overflow_count = 0;

static void clock_on_overflow(void) { clock_overflow_count++; }

void clock_start(void) {
  counter_0_set_attrs(COUNTER_0_WGM_NORMAL, CLOCK_SELECT,
                      &clock_on_overflow, NULL, NULL);
}

clock_t clock(void) { return clock_overflow_count; }

main.c

#include <avr/interrupt.h>
#include <stdio.h>
#include <stdint.h>

#include "libc/time.h"
#include "serial_io.h"
#include "counter.h"


void callback(void) {
  printf("%lu\n", clock() / CLOCKS_PER_SEC);
}

int main(void) {
  serial_io_init();
  sei();
  printf("Starting TinyRTOS...\n");
  clock_start();

  counter0clock_select_t clock_select = COUNTER0CS_256;
  unsigned duration = 1;
  uint16_t ocr_val = duration * F_CPU / COUNTER0PRESCALERS[clock_select];
  counter0set_attrs(COUNTER0WGM_CTC_OCR, clock_select, NULL,
                      &(counter_output_t){.cb = &callback,
                                          .mode = COUNTER_OUTPUT_MODE_NORMAL,
                                          .val = ocr_val},

  while (1) continue;  // Wait forever

  return 0;
}

Заключение

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

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

Вооружившись знаниями, полученными из этой статьи, мы можем уверенно внедрять таймеры, счетчики и прерывания в наши проекты микроконтроллеров AVR.

К этому моменту мы заложили основу для реализации POSIX-совместимой операционной системы. Хотя мы признаем, что еще многое предстоит сделать, мы продвинулись вперед, разрабатывая драйверы в HAL. Двигаясь вперед, мы продолжим опираться на эту основу, изучая реализацию программных таймеров POSIX, многопоточности и драйверов для других платформ. Цель состоит в том, чтобы реализовать облегченную операционную систему, которая придерживается стандарта POSIX, по крайней мере, в значительной части. Следите за обновлениями!