AVR單片機教程——PWM調光


本文隸屬於AVR單片機教程系列。

 

PWM

兩位數碼管的驅動方式是動態掃描,每一位都只有50%的時間是亮的,我們稱這個數值為其占空比。讓引腳輸出高電平點亮LED,占空比就是100%。

在驅動數碼管時,我們迫不得已使占空比為50%,因為不能讓兩位真正同時地顯示不同的數字。但是,我們也可以有意地讓LED的占空比不到100%,以降低其亮度。

占空比是可以用程序來調節的。下面的程序允許用戶用按鍵調整藍色LED的占空比,並通過數碼管來顯示。

#include <ee1/ee.h>

#define DUTY_MAX 9

int main()
{
    led_init();
    button_init(PIN_NULL, PIN_NULL);
    segment_init(PIN_NULL, PIN_8);
    uint8_t duty = 0;
    while (1)
    {
        if (button_pressed(BUTTON_0) && duty > 0)
            --duty;
        if (button_pressed(BUTTON_1) && duty < DUTY_MAX)
            ++duty;
        segment_dec(duty);
        segment_display(SEGMENT_DIGIT_R);
        for (uint8_t i = 0; i != DUTY_MAX; ++i)
        {
            if (i < duty)
                led_set(LED_BLUE, true);
            else
                led_set(LED_BLUE, false);
            delay(1);
        }
    }
}

duty是一個整數,取值范圍為09,分別表示LED的占空比為0/99/9。比如,當占空比為4/9時,在9毫秒的周期中,前4毫秒LED亮,后5毫秒LED不亮。

可以看見,占空比越大,LED亮度也越高。原來,在亮與暗之間,LED還有中間的狀態。我們不是通過讓引腳輸出一個0V和5V之間的電壓,而是讓引腳電平迅速地在高低之間變化來實現的。

這種通過電平的快速跳變來實現模擬量效果的技術,稱為脈沖寬度調制,簡稱PWM。

定時器

大多數單片機的定時器都可以輸出PWM波,外設豐富的AVR單片機自然不例外。上一講提到定時器0有四種工作模式,后兩種就是快速PWM模式與相位修正PWM模式。

在快速PWM模式中,TCNT0寄存器的動作與普通模式相同,但還可以把OCR0A作為上限。對於非反轉輸出,TCNT0達到上限並清零后,引腳會輸出高電平;而當TCNT0OCR0AOCR0B匹配時,OC0AOC0B會分別輸出低電平;對於反轉輸出,前者為低,后者為高。一般使用非反轉,輸出PWM波的頻率為\(f_{CPU} / 256N\)(對於上限為255的情況;\(N\)為分頻系數),占空比為\((OCR0x + 1) / 256\)。由於占空比分母256為2的8次方,這個PWM輸出是8位分辨率的。

相位修正PWM主要用於電機控制等對PWM波的形狀要求比較嚴格的場合,這里不細講。定時器1有更多工作模式,定時器2的時鍾系統更為豐富,你可以在數據手冊中一探究竟。

在占空比公式\((OCR0x + 1) / 256\)中,OCR0x可以取0255的值,因此占空比可以達到1,PWM模式下LED可達最大亮度;占空比不能達到0,因此用PWM控制的LED不能全暗。這有點麻煩,必須關掉PWM才能使LED暗,而不僅僅是往OCR0x中寫入一個值了。為了日后使用方便,我們用函數把寄存器操作包裝起來(整個庫都在做這件事)。

在Atmel Studio中,靜態庫與可執行程序都屬於project,可以並列存在於solution中。在上面軟件PWM的程序所屬的solution中,點擊菜單欄File->New->Project(或Ctrl+Shift+N),選擇“GCC C Static Library Project”,命名為“pwm”,在“Solution:”中選擇“Add to solution”,“OK”后選擇MCU型號,靜態庫項目就創建好了,默認帶有一個library.c文件。

在“Solution Explorer”中,將library.c重命名為oc0a.c。選中“pwm”項目,右鍵->Add->New Item或菜單欄->Project->Add New Item或Ctrl+Shift+A,選擇“Include File”,命名為oc0a.h(通常取相同的名字,但不是必須的)。

這個庫需要提供兩個函數:oc0a_init用於將OC0A引腳配置為PWM輸出,oc0a_pwm設置輸出PWM占空比,參數為一個無符號8位整數。

// oc0a.h

#ifndef OC0A_H
#define OC0A_H

#include <stdint.h>

/*
 * 函數:oc0a_init
 * 參數:無
 * 返回:void
 * 功能:將OC0A引腳配置為PWM輸出,占空比為0。
 */
void oc0a_init();

/*
 * 函數:oc0a_pwm
 * 參數:uint8_t _duty - 占空比的整數表示
 * 返回:void
 * 功能:將OC0A引腳輸出PWM波的占空比設置為(_duty / 256)。
 */
void oc0a_pwm(uint8_t _duty);

#endif

在頭文件oc0a.h中,我們定義了這兩個函數,並以注釋形式提供了說明,包括參數、返回值與功能。

然后,在oc0a.c中提供這些函數的實現。

// oc0a.c

#include "oc0a.h"
#include <avr/io.h>

void oc0a_init()
{
    PORTB &=   ~(1 << PORTB3); // PB3 low level
    DDRB  |=     1 << DDB3;    // PB3 output mode
    TCCR0A =  0b00 << COM0A0   // normal port operation
           |  0b11 << WGM00;   // fast PWM mode
    TCCR0B =   0b0 << WGM02    // fast PWM mode
           | 0b010 << CS00;    // divide by 8
}

void oc0a_pwm(uint8_t _duty)
{
#define COMA_MASK (~(0b11 << COM0A0)) // mask for COMnA bits
    if (_duty)                        // fast PWM mode
        TCCR0A = (TCCR0A & COMA_MASK) // protect other bits
               | 0b10 << COM0A0,      // non-inverting mode
        OCR0A  = _duty - 1;           // duty = (OCRnx + 1) / 256
    else                              // turn PWM off
        PORTB &= ~(1 << PORTB3);      // PB3 low level
        TCCR0A = (TCCR0A & COMA_MASK) // protect other bits
               | 0b00 << COM0A0;      // normal port operation
}

實現文件應該首先包含對應的頭文件,以確保函數接口一致。

作為底層操作的封裝,這些函數中涉及到很多寄存器。對寄存器的操作沒有寫成直接用一個數字來賦值,而是由多種位運算組合起來,這是單片機編程特有的。比如,PORTB3宏定義在<avr/io.h>中,值為3,意義為PORTB的第3位(最低位為第0位)控制PB3引腳;1 << PORTB3生成一個這一位為1,其余位為0的數;對它取~,得到只有這一位為0,其余位為1的數;讓PORTB與這個數進行&=運算,可以保持其他位不變而這一位變成0,這是因為0與一位“與”的結果是0,而1與一位“與”的結果就是那位的值。再比如,COM0A060b00 << COM0A0COM0A1:0兩位填00,同理0b11 << WGM00WGM1:011,兩數|運算,就把TCCR0A中的這兩段同時填好了(參考數據手冊查看位定義)。

並且,這樣寫是有多種原因的:對於PORTB等寄存器,函數只負責其中的一位,而賦值語句會影響其他位;對於OCR0A等寄存器,代碼中明確寫出每一位的名稱與值,可以增強可讀性。

如果是開源庫,注釋是寫給想深入了解的用戶看的;如果是閉源庫,以頭文件與庫文件的形式發布,注釋是寫給以后的自己看的;總之,需要有注釋。注釋的目的是消除讀者(包括自己)的疑惑。讀者不知道0b010 << CS00的意義,就注明“8分頻”,這是數據手冊寫的;讀者不明白為什么OCR0A的賦值語句中需要-1,就把占空比的公式放上去,其中有+1

還需要提醒的是,以上代碼的可移植性有些欠缺,因為0b前綴的二進制數是GCC的擴展,不屬於C語言標准。最貼近二進制的標准表示方法是十六進制,但是需要手動地轉換(在0b00000b11110x00xF之間建立映射,就像塗答題卡時的F-AB到K-BD一樣),這也是把寄存器賦值展開寫的理由。

呼吸燈

為了測試這個庫,我們再新建一個項目,這次選擇“GCC C Executable Project”,之后的過程想必你已經做過很多遍了。不同的是引用頭文件的寫法有點變化,之前寫的oc0a.h位於../pwm/目錄下,../意為上級目錄;以及,需要手動添加這個庫,在“Solution Explorer”中該項目的“Libraries”上右鍵,點擊“Add Library”,在“Project Libraries”一頁中勾選“pwm”項目;這樣就可以使用剛才寫的兩個函數了。

我們來實現呼吸燈的效果,即LED從暗慢慢變亮,再變暗,像呼吸一樣。

#include <ee1/delay.h>
#include "../pwm/oc0a.h"

int main()
{
    oc0a_init();
    int brightness = 0, fadeAmount = 5;
    while (1)
    {
        oc0a_pwm(brightness);
        brightness = brightness + fadeAmount;
        if (brightness <= 0 || brightness >= 255)
            fadeAmount = -fadeAmount;
        delay(30);
    }
}

OC0A引腳連接到開發板左側RGBW中任意一個,你就會看到對應的LED有呼吸燈的效果。

RGBW

RGBW代表紅綠藍白。理論上,紅綠藍即可組合出所有顏色,而白色的加入即提供了純正的白光,也能增強整個LED的亮度。

如果你在室內光下觀察上面程序的效果,你會發現,盡管變量brightness,所謂亮度,是隨時間線性變化的,但是視覺效果上,在整個亮起的過程中,明顯是前半段亮度變化快,后面亮度幾乎不變。而如果你用手電筒去照着它然后觀察,就能感受到后半段的亮度變化。這可能是因為人眼對弱光環境下的強光變化不敏感。

rgbw_set函數解決了這個問題。它不是直接把參數轉發給pwm_set,而是用映射后的參數調用;這個映射作為數學上的函數,在x較小時y增長較慢,較大時增長較快,從而抵消人眼的錯覺。

#include <ee1/delay.h>
#include <ee1/rgbw.h>

void init();
void breathe();
void flash();

int main()
{
    init();
    while (1)
        breathe(), flash();
}

void init()
{
    rgbw_init(PIN_4, PIN_5, PIN_6, PIN_7);
}

void breathe_phase(uint8_t* _status, int8_t* _alter)
{
    for (uint8_t step = 0; step != 200; ++step)
    {
        for (uint8_t which = 0; which != 4; ++which)
            rgbw_set(which, _status[which] += _alter[which]);
        delay(5);
    }
}

void breathe()
{
    uint8_t status[4] = {0, 0, 0, 0};
    int8_t pre[4] = {1, 0, 0, 0};
    int8_t loop[][4] =
    {
        {-1, 1, 0, 0},
        {0, -1, 1, 0},
        {1, 0, -1, 0},
    };
    int8_t post[4] = {-1, 0, 0, 0};
    breathe_phase(status, pre);
    for (uint8_t cnt = 2; cnt--;)
        for (uint8_t pha = 0; pha != sizeof(loop) / sizeof(*loop); ++pha)
            breathe_phase(status, loop[pha]);
    breathe_phase(status, post);
}

void flash_phase(bool* _pattern)
{
    for (uint8_t which = 0; which != 4; ++which)
        rgbw_set(which, _pattern[which] ? 200 : 0);
    delay(500);
}

void flash()
{
    bool extra[4] = {0, 0, 0, 0};
    bool loop[][4] =
    {
        {1, 0, 0, 0},
        {1, 1, 0, 0},
        {0, 1, 0, 0},
        {0, 1, 1, 0},
        {0, 0, 1, 0},
        {1, 0, 1, 0},
    };
    flash_phase(extra);
    for (uint8_t cnt = 2; cnt--;)
        for (uint8_t pha = 0; pha != sizeof(loop) / sizeof(*loop); ++pha)
            flash_phase(loop[pha]);
    flash_phase(extra);
}

這段代碼把燈變化的模式用數字表示,而不是用一定參數的函數調用來硬編碼,使程序易於修改與擴展。

作業

  1. 閱讀數據手冊,實現在OC1A引腳上輸出12位分辨率的、帶相位與頻率修正的PWM波。注意占空比為0和1的情況。

  2. 玩玩燈吧!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM