АППАРАТНЫЙ ТАЙМЕР AVR В MICROCHIP STUDIO

 

Так... Здрасте... Ну что же, девочки и мальчики. Здесь мы поднимем тему, без которой, пожалуй, в мире микроконтроллеров просто нехуй делать. Кто этого не понимает - продолжают сидеть на своих пальмах, есть бананы и курить бамбук. У них, в отличае от нас, все хорошо и просто.

Для начала рассмотрим немного скучной теории. Если у тебя случайно во рту бамбук - немедленно выплюнь! И немножко сосредоточься.

Основные характеристики таймеров в Atmega:

  1. Количество таймеров:

    • В зависимости от модели микроконтроллера Atmega, количество доступных таймеров может варьироваться. Например, в Atmega328P (используется в Arduino Uno) есть три таймера: Timer0, Timer1 и Timer2.
  2. Разрядность:

    • 8-битные таймеры: Например, Timer0 и Timer2 в Atmega328P. Они могут отсчитывать значения от 0 до 255.
    • 16-битные таймеры: Например, Timer1 в Atmega328P. Такие таймеры могут отсчитывать значения от 0 до 65535.
  3. Режимы работы:

    • Normal Mode (Нормальный режим): Таймер просто считает от 0 до максимального значения, после чего перезагружается.
    • CTC Mode (Clear Timer on Compare Match): Таймер обнуляется при совпадении с заранее установленным значением, что позволяет генерировать периодические события.
    • Fast PWM и Phase Correct PWM: Используются для генерации широтно-импульсной модуляции (ШИМ).
  4. Прескалеры:

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

    • Таймеры могут генерировать прерывания при достижении определённого состояния (например, совпадение с заданным значением, переполнение таймера и т. д.), что позволяет микроконтроллеру выполнять определённые действия в нужное время.
  6. Выходы управления (OCx):

    • Таймеры могут напрямую управлять внешними пинами микроконтроллера для генерации сигналов, таких как ШИМ. Например, пины OC0A, OC0B для Timer0.

Мы здесь будем рассматривать 16-битный таймер в режиме CTC с дискретностью в одну миллисекунду.

Вот, у нас табличка "прескейлеров":

CS12CS11CS10Описание
0 0 0 Stop условие - таймер/счетчик1 остановлен
0 0 1 CK
0 1 0 CK / 8
0 1 1 CK / 64
1 0 0 CK / 256
1 0 1 CK / 1024
1 1 0 Внешний тактирующий сигнал на выводе T1, нарастающий фронт
1 1 1 Внешний тактирующий сигнал на выводе T1, падающий фронт

 

В нашем коде используются следующие настройки:

  • CS12 = 1 (установлен).
  • CS11 = 0 (сброшен).
  • CS10 = 1 (установлен).

Это соответствует делителю на 1024. Таким образом, наш таймер увеличивает своё значение каждый 1024-й такт микроконтроллера. При частоте тактирования 16 МГц, таймер будет увеличиваться с частотой 16 МГц / 1024 ≈ 15,625 кГц.

Для расчета периода таймера используем следующую формулу:

Период таймера = (Fтактовая​ /( Делитель / OCR1A) + 1​ = 16000000 / 1024 / 16000​ = 1 мс

Если интересно узнать об аппаратных таймерах побольше, то datasheet вам в руки - там все расписано достаточно подробно.

Итак, поехали:

#define F_CPU 16000000UL // Тактовая частота 16 МГц
#include <avr/io.h>
#include <avr/interrupt.h>
// Инициализация таймера
void setup() {
    // Устанавливаем PB0 как выход
    DDRB |= (1 << DDB0);
    // Настройка таймера 1
    TCCR1B |= (1 << WGM12);  // Включаем режим CTC
    OCR1A = 15999;           // Устанавливаем значение для сравнения (1 мс при 16 МГц и делителе 1024)
    TCCR1B |= (1 << CS12) | (1 << CS10); // Устанавливаем делитель 1024
    TIMSK1 |= (1 << OCIE1A);    // Разрешаем прерывание по совпадению с OCR1A
    sei();    // Разрешаем глобальные прерывания
}
// Основной цикл программы
void loop() {
    while (1) {

    }    
// Здесь можно разместить основной код программы // Эта функция будет вызываться постоянно } // Прерывание по совпадению таймера 1 с OCR1A ISR(TIMER1_COMPA_vect) { // Инвертируем состояние пина PB0 PORTB ^= (1 << PORTB0); } int main(void) { setup(); // Инициализация loop(); // Основной цикл return 0; // Никогда не достигается }

Грубо говоря, как-то так, но тут очент много нюансов. Максимум точности, которой я добивался от разных микроконтроллеров семейств Atmega и Attiny (а они по сути одинаковые) - это дискретность, приблизительно равная 3-5 микросекундам. К сожалению, дискретность в одну микросекунду посредствам языка Си вам получить никак не удастся. То есть, такие настройки

// Устанавливаем таймер 1 для создания интервала 1 мкс
TCCR1B |= (1 << WGM12);  // Включаем режим CTC
OCR1A = 1;               // Считаем два такта для 1 мкс интервала
TCCR1B |= (1 << CS11);   // Устанавливаем делитель на 8 (CS11=1, CS12=0, CS10=0)
TIMSK1 |= (1 << OCIE1A); // Разрешаем прерывание по совпадению
sei();  // Разрешаем глобальные прерывания

/*
Для достижения дискретности 1 мкс при 16 МГц тактирующего сигнала на T1, можно установить прескалер на 8 и настроить таймер на режим CTC с параметром OCRx = 1. Это значит, что таймер будет генерировать событие через каждые 2 такта, что эквивалентно 1 мкс
*/

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

asm("nop");

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

void my_delay(){
asm("nop");
}

Второй вариант будет "перевариваться" микроконтроллером гораздо дольше. Такие вещи надо обязательно учитывать в реальной боевой ситуации.

Кстати, в реальной боевой ситуации я чаще использую следующий подход:

#define F_CPU 16000000UL // Тактовая частота 16 МГц
#include <avr/io.h>
#include <avr/interrupt.h>
uint16_t milliseconds = 0;
// Инициализация таймера
void setup() {
    // Устанавливаем PB0 как выход
    DDRB |= (1 << DDB0);
    // Настройка таймера 1
    TCCR1B |= (1 << WGM12);  // Включаем режим CTC
    OCR1A = 15999;           // Устанавливаем значение для сравнения (1 мс при 16 МГц и делителе 1024)
    TCCR1B |= (1 << CS12) | (1 << CS10); // Устанавливаем делитель 1024
    TIMSK1 |= (1 << OCIE1A);    // Разрешаем прерывание по совпадению с OCR1A
    sei();    // Разрешаем глобальные прерывания
}
// Основной цикл программы
void loop() {
    while (1) {
    if(milliseconds >= 100){
       PORTB ^= (1 << PB0);    // Инвертируем состояние пина PB0
milliseconds = 0;
} } // Здесь можно разместить основной код программы // Эта функция будет вызываться постоянно } // Прерывание по совпадению таймера 1 с OCR1A ISR(TIMER1_COMPA_vect) { milliseconds++; } int main(void) { setup(); // Инициализация loop(); return 0; // Никогда не достигается }

 И еще один примерчик специально для маленьких и тупых, т. е., для ардуинщиков:

#define F_CPU 16000000UL // Тактовая частота 16 МГц
#include <avr/io.h>
#include <avr/interrupt.h>
volatile uint16_t milliseconds = 0;
// Функция для получения времени в миллисекундах
uint16_t millis() {
    uint16_t ms;
    cli();  // Отключаем прерывания, чтобы избежать ошибок чтения
    ms = milliseconds;
    sei();  // Включаем прерывания
    return ms;
}
// Инициализация таймера
void setup() {
    // Устанавливаем PB0 как выход
    DDRB |= (1 << DDB0);
    // Настройка таймера 1
    TCCR1B |= (1 << WGM12); // Включаем режим CTC
    OCR1A = 15;             // Устанавливаем значение для сравнения (1 мс при 16 МГц и делителе 1024)
    TCCR1B |= (1 << CS12) | (1 << CS10); // Устанавливаем делитель 1024
    TIMSK1 |= (1 << OCIE1A); // Разрешаем прерывание по совпадению с OCR1A
    sei(); // Разрешаем глобальные прерывания
}
// Основной цикл программы
void loop() {
    uint16_t previousMillis = 0;
    while (1) {
        uint16_t currentMillis = millis(); // Получаем текущее время
        // Проверяем, прошло ли 100 миллисекунд
        if (currentMillis - previousMillis >= 100) {
            previousMillis = currentMillis; // Обновляем время
            PORTB ^= (1 << PB0); // Инвертируем состояние пина PB0
        }
 
        // Здесь можно разместить основной код программы
    }
}
// Прерывание по совпадению таймера 1 с OCR1A
ISR(TIMER1_COMPA_vect) {
    milliseconds++;
}
int main(void) {
    setup(); // Инициализация
    loop();  // Основной цикл
    return 0; // Никогда не достигается
}

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

Вот это, исходя из уже обозначенных причин, по идее, тоже говнокод:

int main(void) {
    setup();  // Инициализация
    loop();
    return 0;  // Никогда не достигается
}

Но не такой критичный как millis(); и к тому же несет в себе некую смысловую нагрузку, связанную с тем, что умные люди должны предвидель глупость миньонов, которые потом будут им служить.

P.S.

В дальнейшем я собираюсь подправить эту статью даже более широко чем на примерах AVR. Мой опыт охватывает еще и ESP32, а скоро, может быть, еще и STM32. Вот, будет здорово провести какой-то сравнительный анализ, особенно в силу того факта, что в случае с AVR микроконтроллерами у нас гораздо более низкий уровень абстракции в методах программирования. Ну что же, как говорят пиндосы, "we'll see", то есть, "будем посмотреть".