Многоканальный программный ШИМ в AVR

Что такое ШИМ и как он работает особо подробно расписывать не буду, информацию без труда найдёте на просторах интернета. Коснусь лишь общих понятий. ШИМ — это Широтно-Импульсная Модуляция, (по-английски PWM — Pulse Width Modulation) уже из самого названия ясно, что здесь что-то связанное с импульсами и их шириной. Если изменять ширину (длительность) импульсов постоянной частоты, то можно управлять, например, яркостью источника света, скоростью вращения вала электродвигателя или температурой какого-либо нагревательного элемента. Обычно, именно с помощью ШИМ микроконтроллер управляет подобной нагрузкой. Микроконтроллеры имеют аппаратную реализацию ШИМ, но, к сожалению, количество аппаратных ШИМ-каналов ограничено, например, в AТmega88 их аж шесть штук, в ATtiny2313 — четыре, в ATmega8 — три, а в ATtiny13 только два. В AVR ШИМ-каналы используют таймеры и их регистры сравнения OCRxx. Изменяя их содержимое и задавая параметры таймеров, в зависимости от задач, можно управлять состоянием, связанного с регистром, выхода — подавать на него 1 либо 0. То же самое можно организовать программно, управляя любым выводом контроллера, а главное, реализовать большее количество ШИМ-каналов, чем имеется на борту аппаратных. Практически, количество каналов ограничено лишь количеством ножек-выводов микроконтроллера (по крайней мере, если говорить о семействах Mega или Tiny). Как оказалось, алгоритм довольно прост, но у меня ушло некоторое время на его понимание и полное осознание.

Данный алгоритм подробно изложен в оригинальном Appnote AVR136: Low-Jitter Multi-Channel Software PWM. Принцип работы программной реализации заключается в имитации работы таймера в режиме ШИМ. Требуемая длительность импульсов задаётся переменными, соответственно, по одной на каждый канал (в моём коде lev_ch1, lev_ch2, lev_ch3), а так же задаются «близнецы» этих переменных, которые хранят значение для конкретного периода работы таймера (в моём коде buf_lev_ch1, buf_lev_ch2, buf_lev_ch3). Восьмибитный таймер запускается на основной частоте МК и генерирует прерывание по переполнению, то есть, каждые 256 тактов. Это накладывает ограничение на длительность процедуры обработки прерывания — необходимо уложиться в 256 тактов, чтобы не пропустить следующее прерывание. В результате, один полный период ШИМ равняется 256*256=65536-и тактам. Восьмибитная переменная-счетчик (в моём примере counter) увеличивается на единицу каждое прерывание и действует, как указатель позиции внутри цикла ШИМ. Всё это обеспечивает разрешение (минимальный шаг) ШИМ в 1/256, а частоту импульсов в ƒ/(256*256), где ƒ-частота задающего генератора микроконтроллера. Следует заметить, что тактовая частота микроконтроллера должна быть довольно высокой. В моём примере ATtiny13 работает на максимально возможной частоте, без применения внешнего генератора — 9,6МГц. Это даёт период ШИМ в 9600000/65536≈146,5Гц чего вполне достаточно в большинстве случаев.
Код на C, пример реализации идеи для МК ATtiny13 (три канала ШИМ на выводах PB0, PB1, PB2):

#define F_CPU 9600000 //fuse LOW=0x7a
#include <avr/interrupt.h>
#include <util/delay.h>

uint8_t counter=0;
uint8_t lev_ch1, lev_ch2, lev_ch3;
uint8_t buf_lev_ch1, buf_lev_ch2, buf_lev_ch3;

void delay_ms(uint8_t ms) //функция задержки
{
  while (ms)
  {
    _delay_ms(1);
    ms--;
  }
}

int main(void)
{
  DDRB=0b00000111; // установка PortB пины 0,1,2 выходы
  TIMSK0 = 0b00000010; // включить прерывание по переполнению таймера
  TCCR0B = 0b00000001; // настройка таймера, делитель выкл
  sei();               // разрешить прерывания

  lev_ch1=0; //начальные значения
  lev_ch2=64; //длительности ШИМ
  lev_ch3=128; //трёх каналов

  while (1)       //бесконечная шарманка
  {
    for (uint8_t i=0;i<255;i++)
    {
      lev_ch1++; //увеличиваем значения
      lev_ch2++; //длительности ШИМ
      lev_ch3++; //каждого канала
      delay_ms(50); //пауза 50мс
     }
   }
}

ISR (TIM0_OVF_vect)  //обработка прерывания по переполнению таймера
{
  if (++counter==0) //счетчик перехода таймера через ноль
  {
    buf_lev_ch1=lev_ch1; //значения длительности ШИМ
    buf_lev_ch2=lev_ch2;
    buf_lev_ch3=lev_ch3;
    PORTB |=(1<<PB0)|(1<<PB1)|(1<<PB2); //подаем 1 на все каналы
  }
  if (counter==buf_lev_ch1) PORTB&=~(1<<PB1); //подаем 0 на канал
  if (counter==buf_lev_ch2) PORTB&=~(1<<PB0); //по достижении
  if (counter==buf_lev_ch3) PORTB&=~(1<<PB2); //заданной длительности.
}

Думаю, всё достаточно наглядно и пояснения излишни. Для значений длительности и их буферов, при большем числе каналов, возможно, будет лучше использовать массивы, но в данном примере, я этого делать не стал, ради большей наглядности.
Проверено на avr-gcc-4.7.1 и avr-libc-1.8.0. Компиляция и получение файла прошивки:
avr-gcc -mmcu=attiny13 -Wall -Wstrict-prototypes -Os -mcall-prologues -std=c99 -o softPWM.obj softPWM.c
avr-objcopy -O ihex softPWM.obj softPWM.hex
Для правильной работы нужно выставить младшие fuse-биты в 0x7a (частота 9,6МГц). в avrdude это, например, делается так:
avrdude -p t13 -c usbasp -U lfuse:w:0x7a:m

Мой вариант реализации на ассемблере. Программа делает абсолютно то же самое, что и предыдущий код на C.
;чтобы не тянуть include-файл
.list
.equ    DDRB= 0x17
.equ    PORTB= 0x18
.equ    RAMEND= 0x009f
.equ    SPL= 0x3d
.equ    TCCR0B= 0x33
.equ    TIMSK0= 0x39
.equ    SREG= 0x3f

;это лишь демонстрация, потому регистров и не жалеем
.def    temp=R16
.def    lev_ch1=R17
.def    lev_ch2=R18
.def    lev_ch3=R19
.def    buf_lev_ch1=R13
.def    buf_lev_ch2=R14
.def    buf_lev_ch3=R15
.def    counter=R20
.def    delay0=R21
.def    delay1=R22
.def    delay2=R23

.cseg
.org 0
;таблица прерываний из даташита:
rjmp RESET                  ; Reset Handler
rjmp EXT_INT0               ; IRQ0 Handler
rjmp PIN_CHG_IRQ            ; PCINT0 Handler
rjmp TIM0_OVF               ; Timer0 Overflow Handler
rjmp EE_RDY                 ; EEPROM Ready Handler
rjmp ANA_COMP               ; Analog Comparator Handler
rjmp TIM0_COMPA             ; Timer0 CompareA Handler
rjmp TIM0_COMPB             ; Timer0 CompareB Handler
rjmp WATCHDOG               ; Watchdog Interrupt Handler
rjmp ADC_IRQ                ; ADC Conversion Handler

;RESET:
EXT_INT0:
PIN_CHG_IRQ:
;TIM0_OVF:
EE_RDY:
ANA_COMP:
TIM0_COMPA:
TIM0_COMPB:
WATCHDOG:
ADC_IRQ:

reti

RESET:
  ldi temp,0b00000111 ; назначаем PortB пины PB0, PB1
  out DDRB,temp       ; и PB2 выходами

  ldi temp,0          ; выставляем все выводы
  out PORTB,temp      ; PortB в 0 
  ldi temp,low(RAMEND) ; инициализация
  out SPL,temp         ; стека

  ldi temp,0b00000001 ; вкл. таймер
  out TCCR0B,temp     ; без делителя

  ldi temp,0b00000010 ; вкл. прерывание
  out TIMSK0,temp     ; таймера по переполнению

  sei                 ; разрешить прерывания

start_pwm:          ; бесконечная шарманка
  inc lev_ch1       ; увеличиваем значения
  inc lev_ch2       ; длительности ШИМ
  inc lev_ch3       ; по всем каналам
  rcall delay       ; небольшая пауза для плавности
  rjmp start_pwm
delay:           ; процедура задержки
  ldi delay2,$01  ; выставляем число
  ldi delay1,$77  ; до скольки считать
  ldi delay0,$00  ; $017700 - даст задержку в 50мс
  loop:
    subi delay0,1 ; считаем
    sbci delay1,0 ; считаем
    sbci delay2,0 ; считаем
    brcc loop
 ret

TIM0_OVF:       ; обработка прерывания таймера 
  push temp     ; на всякий пожарный сохраняем
  in temp,SREG  ; temp и SREG в стеке
  push temp

  inc counter   ; счетчик перехода таймера через 0

  cpi counter,0 ; если не 0, то проверяем
  brne ch1_off  ; не надо ли чего погасить

  mov buf_lev_ch1,lev_ch1 ; если счетчик 0
  mov buf_lev_ch2,lev_ch2 ; то задаем новые
  mov buf_lev_ch3,lev_ch3 ; значения длительности ШИМ каналов

  ldi temp,0b00000111 ; включить все
  out PORTB,temp      ; три выхода

ch1_off:                 ; а не погасить ли нам
  cp counter,buf_lev_ch1 ; первый канал?
  brne ch2_off           ; нет, рано - проверяем второй
  cbi PORTB,0            ; да погасить

ch2_off:                 ; а не погасить ли нам
  cp counter,buf_lev_ch2 ; второй канал?
  brne ch3_off           ; нет, рано - проверяем третий
  cbi PORTB,1            ; да погасить

ch3_off:                 ; а не погасить ли нам
  cp counter,buf_lev_ch3 ; третий канал?
  brne irq_end           ; нет, рано - двигаемся к выходу из прерывания
  cbi PORTB,2            ; да, погасить

irq_end:         ; достаем из стека
  pop temp       ; SREG и temp
  out SREG,temp
  pop temp

reti             ;выходим из прерывания

Компилируется с помощью avra или tavrasm. Не забыть про fuse-биты (см. выше).
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 26

    +1
    Омг, какой говнокод. И лучше делать так.

    mask = OUT1|OUT2|OUT3;
    if(...) mask &= ~OUT1;
    if(...) mask &= ~OUT2;
    if(...) mask &= ~OUT3;

    PORTx &= ~(OUT1|OUT2|OUT3);
    PORTx |= mask;
      0
      Спасибо за конструктивную критику.
        0
        Хотелось бы не повторять своих ошибок в дальнейшем.
        Как исправить, чтобы не был «говнокод»? Может, есть ссылки в загашнике, что почитать?
        Говнокод везде — и в asm и в C?
        +2
        Допустим, для одного из каналов выставлена длительность 192. На нулевом (по счету) переполнении таймера порт выставляется в единицу, на 192-м — в ноль.
        А теперь представим, что на 128-ом интервале мы меняем значение длительности ШИМ со 192 на 64. Что произойдет? Условие установки порта в ноль не выполнится на данном периоде, и будет импульс высокого уровня длительностью 256+64.
        Если «удачно» угадать с моментами изменения длительности, можно растянуть этот импульс на несколько периодов насколько угодно.

        Как с этим бороться:
        1) Самое простое — вместо «counter==buf_lev_ch1» писать «counter>=buf_lev_ch1»
        2) Самое правильное — буферизовать значение длительности ШИМ. То есть на каждый канал использовать две переменные: первую юзер меняет, когда ему вздумается, вторая используется в обработчике прерывания, и ей значение присваивается один раз в начале каждого периода.
          +1
          2) Самое правильное — буферизовать значение длительности ШИМ. То есть на каждый канал использовать две переменные: первую юзер меняет, когда ему вздумается, вторая используется в обработчике прерывания, и ей значение присваивается один раз в начале каждого периода.

          но ведь именно так и сделано. при if (++counter==0) присваюваются значения буферным переменным.
            0
            Тогда примите мои извинения, не заметил.
          +2
          так слишком много прерываний — лучше не так делать — отмерять время интервалами 128 64 32 16 8 4 2 1 тик таймера.при переходе через 0 включаем те каналы, у которых есть старший бит в значении яркости, таймер тикает до 128- оставляем только те, у которых установлен 7 бит, устанавливаем прерывание по сравнению на 192 и так далее — получается не шим, но для светодиодов более чем достаточно и даже лучше, чем шим — всего 8 прерываний таймера, никаких почти вычислительных затрат, несущая частота в таком случае будет выше, чем у шима — светодиоды будут меньше мерцать. у bsvi подробней статья на этот счет была
            0
            Спасибо за информацию. Интересная идея.
          0
          Делал RGB «лампу настроения» (mood-lamp) с подобным алгоритмом, только на PIC16.
            0
            Если не требуется, чтобы все ШИМ были синхронны, можно сделать проще.
            Создаём для каждого ШИМ типовую структуру (период, вывод схемы и текущий счётчик).
            Например, если нужно 9 ШИМ — берём массив из 9 структур.
            Запускаем тактовый таймер со счётчиком и прерываниями.
            По прерыванию таймера — берём значение счётчика по модулю количества ШИМов. Используя это значение как индекс массива, обрабатываем структуру (ну, как обычно — меняем счётчик, сравниваем с периодом, если нужно дёргаем вывод).

            В отличие от вашего кода — тут чтобы добавить ШИМ нужно всего лишь его описать (назначить период и вывод; увеличить общее число ШИМов). Т.е. никакой хардкодной зависимости. Плюс — скорость обработки не зависит от количества ШИМ (покуда в каждое прерывание обрабатываем ровно один). Плюс — ВСЯ обработка находится в прерывании (т.е. в основной программе «вечный цикл» уже не нужен. Можно уйти в спячку).

            Чуть сложнее будет с частотой (очевидно — добавили к трём ШИМ четвёртый — и теперь каждому достаётся не каждое третье прерывание, а каждое четвёртое). Но это тоже обходится, при необходимости.
              0
              Ну не знаю, как на счет проще. Мне кажется сомнительной такая выгода.
              Похоже, вы не совсем поняли что происходит в программе.
              Обработка и так вся в прерывании. Можно смело идти в спячку. Бесконечный цикл лишь для демонстрации работы, в нём постепенно увеличивается коэффициет заполнения ШИМ по всем каналам. Если подключить к выходам светодиоды, то их яркость будет плавно увеличиваться, и гаснуть достигнув максимума. Начальные значения яркости даны со сдвигом, по-этому светодиоды будут разгораться и гаснуть по очереди.
                0
                В вашем случае: есть три ШИМ. Нужно добавить четвёртый.
                Что будете делать? Добавлять по несколько строчек обработки в каждое зависимое место программы, перекомпилировать и перезаливать прошивку?

                Ещё потенциальная проблема (может быть не актуальна на AVR, но в общем достаточно часта) — код с кучей условий (ветвлений) практически невозможно предсказать и правильно закэшировать. Линейная обработка может оказаться гораздо быстрее.

                  0
                  В моем конкретном коде — дa, добавить пару строк, ввести дополнительно две переменные, изменить строку с заданием маски выходов. Про применение массивов я упомянул, при большем числе каналов — есть смысл, и будет меньше манипуляций при добавлении каналов.
                  Перекомпилировать и перезалить прошивку. (а без этого никак, в любом случае).

                  Согласен.
                  Но если мы говорим о AVR, а тем более о Tiny, то тут, по большому счёту, даже использование высокоуровнего C — перебор. Для примера, если скомпилировать приведенный код, то C-прошивка займет 266 байт, а ассемблерная только 118. А на борту всего лишь 1 килобайт флеша.
                    +1
                    > Перекомпилировать и перезалить прошивку. (а без этого никак, в любом случае)

                    не-не, если описание ШИМов в массиве — ничего перекомпилировать не придётся!
                    Дописываем в RAM новую структуру (в простейшем случае однобайтных счётчиков — 3 байта). И меняем байт, где хранится общее число структур. И всё! Никаких прошивок!
                      0
                      Идея понятна. Как-нибудь, на досуге, надо будет попробовать реализовать. Интересно, как оно будет по ресурсам.
              0
              а вообще — на самом деле проще купить микросхему — драйвер светодиодный и не париться — загружаешь в него по i2c или spi значения, а он уже свои шимы включает — можно по разному выходы настроить — тяни толкай, открытый коллектор и т.п., драйвер простенький протаскивает спокойно 500мА тока через одну ногу, разрядность 12бит, количество ног -16, а если это всё по i2c, то на одну шину вешается скока угодно таких микросхем и от контроллера хавается только 2 ноги и i2c, таймеры при этом свободны и процессор не занимается обработкой прерываний.Практического смысла особо нет реализовывать такое на контроллере напрямую, только ради обучения если
                0
                Может подскажешь несколько конкретных ходовых моделей?
                  0
                  PCA9685 — в халявных семлпах NXP например, в Российских магазинах вроде нет. Можно загуглить — шим драйвер светодиодов называется, ну и количество каналов еще указываешь, если мощные светодиоды — вешаешь еще сборки дарлингтона транзисторные дополнительно
                    0
                    Да, перед вопросом погуглил, единственное, что нашел, доступнoe — TLC5940 и PCA9635, и то только на ebay.
                0
                про 500мА я тут погорячился — просто давно делал свою платку с драйверами для 10 rgb светодиодов — 500мА это со сборками дарлингтона получилось — без них до 100мА протаскивают
                0
                А можно сделать чтобы ШИМ был не 8-ми битным а к примеру 7 битным? Тогда полагаю можно выиграть в частоте ШИМ.
                Собственно это мне сейчас и нужно.
                  0
                  Можно. Почему бы и нет?
                    0
                    А как?
                      +1
                      Нужно поменять настройки таймера чтобы прерывание генерилось при значении 128, использовать прерывание по совпадению, и, соответственно, подкорректировать значения в обработчике прерывания. Корректировать значения длительности два раза за оборот таймера if (++counter==0 || ++counter==128)
                      как-то так.

                Only users with full accounts can post comments. Log in, please.