AVR單片機教程——ADC


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

 

ADC

計算機的世界是0和1的。單片機可以通過讀取0和1來確定按鍵狀態,也可以輸出0和1來控制LED。即使是看起來不太0和1的PWM,好像可以輸出0到5V之間的電壓一樣,達到0和1之間的效果,但本質上還是高低電平。

但是,世界上終究還是有0和1無法表示的。如果引腳上被施加0到5V之間的電壓,寄存器PINx無法告訴我們具體情況,只能指示這個電壓是1.5V以下還是3V以上(參考數據手冊“Electrical characteristics”)。這種可以連續變化的信號稱為模擬信號,與離散的、只能取0或1(0或5V)的數字信號對立。

這並不代表數字世界無法處理模擬信號,相反,一種相當常用的處理模擬信號的方法,就是把模擬信號轉換成數字信號,用處理器來運算,然后再轉換成模擬信號。這個過程中涉及到模擬-數字轉換和數字-模擬轉換,分別需要ADC和DAC來實現。大多數單片機,作為現實世界中的工具,需要接觸模擬信號,尤其是模擬信號的輸入,會集成ADC。

ADC的一個參數是分辨率,指它的位數,反映了可以產生的不同輸出的數量(8位ADC可以產生0~255的值)與量化最小物理量(通常是電壓)的能力(比如當參考電壓為2.56V時,理想情況下,8位ADC可以分辨兩個相差0.01V的電壓的不同)。AVR單片機帶有的ADC是10位的。

另一個參數是轉換速率,每秒進行A/D轉換的次數。AVR單片機的ADC為了達到10位分辨率的精度,最大轉換速率為15kSPS(千次采樣每秒)。如果可以接受較低的精度,也可以以200kSPS采樣,獲得8位數據。

分辨率與精度是不同的概念。在這篇入門級教程中,我們只需要知道,A/D轉換是會有誤差的(數據手冊23.7.4一節介紹了可能的誤差來源)。即使是相同的電壓,兩次測量的結果也可能是不同的。

要進行A/D轉換,需要提供參考電壓和待測電壓,轉換的結果為\(\frac {待測電壓} {參考電壓} \times 2^{分辨率}\)。寄存器ADMUX中的ADLAR位控制轉換結果的對齊方式。當右對齊時,公式中分辨率取10,轉換結果在16位寄存器ADC中(實際上是兩個8位寄存器ADCHADCL,但程序可以直接使用ADC,編譯器會處理好一些注意事項);當左對齊時,分辨率取8,轉換結果在ADCH中。可以直接把ADC當做16位寄存器,編譯器會處理好一些注意事項。

ADC有4種參考電壓可供選擇,分別是AREFAVCC(5V)、1.1V2.56V,由REFS1:0選擇。8個單端端口(開發板上引出了4個,端口03),以及一些差分端口(1x10x200x增益)和兩個參考電壓,共32個通道,可以通過多路復用器連接到ADC上進行轉換,由MUX4:0選擇。注意,ADC只有一個,在同一時刻只能轉換一個通道的電壓。

ADCSRAADCSRB用於控制A/D轉換。ADCSRAADEN啟用ADC組件,ADSC位啟動一次轉換,到ADIF位為1時轉換結束,需要寫1才能清零。ADPS2:0選擇ADC時鍾分頻系數,這關系到轉換速率:首次采樣(啟用ADC后第一次或同時)需要25個ADC時鍾周期,隨后每次采樣需要13個。ADCSRB可以選擇A/D轉換觸發源。

開發板提供了3.3V電源,可用於給只支持3.3V的設備供電。我們用ADC來測量這個電壓,然后在串口上輸出。

#include <avr/io.h>
#include <ee1/uart.h>

int main()
{
    uart_init(UART_TX);
    ADMUX  =    0b01 << REFS0  // AVCC as reference
           |     0b0 << ADLAR  // right adjust
           | 0b00000 << MUX0;  // ADC0 single ended
    ADCSRA =       1 << ADEN   // enable ADC
           |       1 << ADSC   // start conversion
           |       1 << ADIF   // clear flag
           |   0b111 << ADPS0; // divide by 128
           
    while (!(ADCSRA & 1 << ADIF)) // wait until flag is set
        ;
    
    uint16_t voltage = (uint32_t)ADC * 500 >> 10; // ADC / 1024 * 500 (* 10mV)
    uint8_t integer = 0;                          // integer part of voltage
    while (integer * 100 <= voltage)              // calculate integer part
        ++integer;
    --integer;
    uint8_t decimal = voltage - integer * 100;    // calculate decimal part
    
    uart_print_int(integer);                      // print the voltage
    uart_print_char('.');
    uart_set_align(UART_ALIGN_RIGHT, 2, '0');
    uart_print_int(decimal);
    uart_print_string("V\n");
    
    while (1)
        ;
}

數據手冊28.8節指明,當ADC時鍾為200kHz時,ADC絕對精度可以達到1.9LSB(1LSB就是1024中的1)。經計算得,為了使ADC時鍾不超過這個速率,分頻系數應該取128。

所測電壓為\(voltage = \frac {ADC} {1024} \times 5V\),但直接這樣計算會涉及到浮點運算,而AVR硬件不支持浮點,所有浮點運算都是軟件實現的,速度相當慢,兩個float相乘需要1000多個指令周期,除法需要更多,都是應該竭力避免的。盡管最后的電壓是一個小數,但可以通過移動小數點把它變成整數。5V參考電壓下,精度1.9LSB約為9.28mV,因此右移兩位,以10mV為單位計算。先算乘法以避免浮點除法,算式變為\(voltage = \frac {ADC \times 500} {1024}\)

ADC的值直接與500相乘會溢出,因此需要先提升為uint32_t。當然,你可以把算式約分一下,但不改變會溢出的事實。盡管32位整數不太好處理,但相比浮點數還是容易得多。然后是一個除法。16位整數除法需要173個CPU指令周期(參考:Multiply and Divide Routines),是比較耗時的。盡管這個程序中只計算一次,但還是應該盡量想辦法避免耗時的操作。注意到除數1024是一個特殊的數,是2的10次方,可以通過移位運算來做除法,而移位運算相比除法快得多(也許編譯器會把/ 1024優化成>> 10)。

然后我們需要把這個數的百位部分拿出來作電壓的整數部分,十位和個位作小數部分,可以通過除以100和模100來實現。由於這里的100是一個編譯期常數,編譯期很可能把這個除法和取模優化掉,不調用100多周期的過程。這里我們感受一下手動優化。由於變量voltage一定小於500,可以用乘法和比較的循環來試出這個商,其中乘法的執行次數不超過6次——AVR單片機有雙周期乘法指令。然后,用乘法與減法求出余數。

ADC是單片機編程中相對容易用到浮點與乘除法的場合,設計算法時應盡量注意避免耗時的運算,或手動編寫優化的算法來代替。

電位器

電位器,開發板右側兩個旋鈕中左邊一個,可以連續轉動300°。電氣屬性相當於物理實驗中的滑動變阻器,如果把兩個定片接在VCCGND上,動片電壓就可以指示旋鈕旋轉的角度,並且通常與角度是成正比的。

之前提到過,A/D轉換是有誤差的,即使輸入電壓保持不變,轉換結果也可能上下浮動。如果再加上一些電磁干擾,比如附近有電機,這種噪音會更加明顯。如果一個程序需要檢測電位器旋轉的位置在中點的哪邊,並僅僅是簡單地比較轉換結果與128的大小關系,這種噪聲會導致嚴重后果,如紅色波形所示:

在閾值128附近,噪聲使轉換結果上下浮動,導致判斷出的狀態迅速跳變。用戶只是慢慢地把旋鈕轉過中間的位置,這顯然不是我們想要的結果。

這時候就需要滯回比較器出場了。滯回比較器的核心特性是,使輸出在0和1之間改變的輸入閾值在兩個方向上是不同的:當信號從低到高越過高閾值時,輸出變為1;當信號從高到低越過低閾值時,輸出變為0;如綠色波形所示(圖中是反相的)。於是,當輸入達到高閾值時,輸出變為1,此時只要噪音沒有大到使輸入回到低閾值,輸出將一直保持為1,濾除了噪聲。

我們寫一個程序,用LED來指示電位器旋鈕位置在中點的哪一側,並在串口上輸出每一次狀態改變,方便我們觀察。

#include <ee1/pot.h>
#include <ee1/led.h>
#include <ee1/uart.h>
#include <ee1/delay.h>

void init();
void normal();
void hysteresis();

int main()
{
    init();
    while (1)
    {
        normal();
//         hysteresis();
        delay(1);
    }
}

static bool status;

void change(bool _value)
{
    status = _value;
    uart_print_string(_value ? "on\n" : "off\n");
    led_set(LED_BLUE, _value);
}

void init()
{
    pot_init(ADC_0);
    led_init();
    uart_init(UART_TX);
    status = pot_read() >= 128;
}

void normal()
{
    bool now = pot_read() >= 128;
    if (status != now)
        change(now);
}

void hysteresis()
{
    uint8_t pot = pot_read();
    if (status && pot < 124)
        change(0);
    else if (!status && pot >= 132)
        change(1);
}

normalhysteresis函數二選一,其中后者使用了滯回比較的算法。

normal模式下,把電位器調整到中點附近的一個位置,你會發現黃色的TX指示燈發了瘋一樣地閃,串口軟件顯示一長串的“on”和“off”(仔細調,一定會有)——你根本不需要制造任何干擾,僅憑ADC的誤差就可以讓程序運行地非常糟糕。如果用滿10位的分辨率,這樣的現象會更加明顯。

而在hysteresis模式下,這樣的狀況不會出現。

光敏電阻

光敏電阻是一種特殊的電阻器,在光強的時候電阻小,在光弱的時候電阻大。將一個光敏電阻與一個普通電阻串聯,接在VCCGND之間,測量中間點的電壓,就能知道光的強弱。

當然,已知開發板上與光敏電阻串聯的電阻是10kΩ,根據某一時刻的ADC轉換結果,也可以計算出此時光敏電阻的阻值。不過不要誤會,是通過電壓而不是阻值來獲得光強。

與電位器一樣,如果要檢測光的強與弱兩種狀態,也要用到滯回比較。取兩個閾值為100150,兩者相差較大,這是因為我們要在光較弱時開燈,這又會增強亮度(有點負反饋的意味),如果相差不夠大,就會陷入循環當中。

這兩個閾值是隨便取的,實際應用應根據具體環境取值。於是容易想到要把這個功能從應用程序中抽離出來成為一個庫。但是,不同於之前常用的、返回外設狀態讓客戶來決定操作的函數(盡管還是可以這么寫),這個庫是事件驅動的:客戶注冊事件發生時要執行的動作,把程序流程交給框架來控制。

程序分為三個文件:event.hevent.cmain.c,前兩個可以獨立成庫,供以后使用,為了方便,和可執行程序放在一起了。

event.h

#ifndef EVENT_H
#define EVENT_H

#include <stdint.h>
#include <stdbool.h>

void ldr_event_init(uint8_t _thl, uint8_t _thh, void (*_func)(bool));
void ldr_event_cycle();

#endif

event.c

#include "event.h"
#include <ee1/ldr.h>

static void (*handler)(bool);
static uint8_t low, high;
static bool status;

void ldr_event_init(uint8_t _thl, uint8_t _thh, void (*_func)(bool))
{
    ldr_init(ADC_1);
    low = _thl;
    high = _thh;
    handler = _func;
    uint8_t ldr = ldr_read();
    if (ldr <= low)
        handler(status = 0);
    else
        handler(status = 1);
}

void ldr_event_cycle()
{
    uint8_t ldr = ldr_read();
    if (status && ldr <= low)
        handler(status = 0);
    else if (!status && ldr >= high)
        handler(status = 1);
}

main.c

#include <ee1/led.h>
#include <ee1/delay.h>
#include "event.h"

void handler(bool e)
{
    if (e)
        led_off();
    else
        led_on();
}

int main()
{
    led_init();
    ldr_event_init(100, 150, handler);
    while (1)
    {
        ldr_event_cycle();
        delay(1000);
    }
}

客戶先編寫事件處理函數handler,參數為一個bool,返回void,這是ldr_event_init所規定的。handler根據參數執行相應動作:當etrue時,光由弱變強,關燈;反之開燈。在調用ldr_event_init時,把這個函數的指針作為參數傳入。隨后,每隔1秒調用一次ldr_event_cycle

請先花一點時間,把庫的每一行理解清楚。然后,我們站在客戶的角度來看,使用這個庫是相對方便的——只需考慮事件,即光的變化,而無需考慮過程,即如何檢測這一變化——事實上客戶根本沒有去檢測,更別說如何了。不過,main函數必須每隔一段時間調用一次ldr_event_cycle。在學了定時器中斷以后,main函數就可以完全還給客戶了。

作業

  1. 查閱相關資料,了解ADC有哪些類型。

  2. 改進上一講中的RGBW燈程序,使LED亮度適應環境光強。

  3. 結合代碼消化吸收事件驅動的概念。推薦閱讀:Event-driven Programming - TechnologyUK


免責聲明!

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



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