1. 程式人生 > >ATmega328P定時器詳解

ATmega328P定時器詳解

寫這篇文章,純粹是想為部落格拉點點選量。在部落格園,遊客訪問好像是不計入閱讀量的,而作為一個十八線博主,註冊使用者的訪問應該以搜尋引擎為主,部落格園首頁為次,個位數的粉絲就別談了。

所以,希望各位從搜尋引擎點進來的朋友,多多評論,有問題咱們一起討論。

我寫過AVR微控制器教程,設計過自己的Arduino板,希望你相信我能給你帶來收穫。

我不想聽你放那麼多屁,我只想知道週期為1ms的定時器中斷怎麼寫!

什麼是定時器

在ATmega328P微控制器中,定時/計數器(Timer/Counter)是這樣的元件:它需要一個時鐘源,驅動一個8或16位的計數器遞增或遞減,當計數器等於一個值時,會觸發一些操作,如產生中斷、翻轉引腳電平等。由於定時器的時鐘源是系統時鐘或外接晶振(一種產生頻率精準的波的器件)分頻得到的,一旦設定好定時器的工作引數,直到下次調整引數,定時器都會按照預期工作,與CPU執行的程式碼無關。

為什麼要用定時器

之前有過這樣的經歷:

跟一個優秀作品設計者聊了幾句,他說同時控制舵機和揚聲器很難控制好延時,揚聲器輸出的音樂節奏會亂。我第一反應當然是他沒有用定時器中斷,一問果然如此,並且他不知道中斷也不知道定時器。

還有一位同學,寫TI計算器的程式。在他的一個作品中,每次迴圈的計算量不定,迴圈間隔也不定,導致遊戲效果不好。他的解決方法是根據計算量計算出迴圈最後需要的延時,使得迴圈間隔基本保持不變。

這種思路是相當優秀的。但是如果有定時器可用的話,程式設計難度會降低,迴圈間隔的一致性也會更好,是更加優秀的解決方案。

其實你一直在用定時器

Arduino Uno Rev3的3569

1011號埠可以使用analogWritetone函式,它們的功能都是利用定時器實現的。用函式確實方便,但是隻知使用而不知其原理就只能停留在技術的表面——Arduino的強大封裝對開發者的學習有兩面性。

定時器其實不知道什麼3號埠,它只知道OC2B。兩種表示之間的對應關係如下表:

埠編號 硬體符號
3 PD3(PCINT19/OC2B/INT1)
5 PD5(PCINT21/OC0B/T1)
6 PD6(PCINT22/OC0A/AIN0)
9 PB1(PCINT1/OC1A)
10 PB2(PCINT2/SS/OC1B)
11 PB3(PCINT3/OC2A/MOSI)

暫存器

暫存器是開發者與硬體打交道的方式。從程式設計的語法上,可以把暫存器當作是變數,可以對它賦值,也可以讀取它的數值。

暫存器中的位有幾種不同的組織結構,它們的存取方式也不盡相同:

TCCR1B暫存器中有4組引數:ICNC1ICES1WGM1[3:2]CS1[2:0]。現在你完全無需理解這些字母的含義,但是得對這些數字有個概念:WGM1[3:2]表示從WGM13WGM12TCCR1B中的1表示該暫存器屬於定時器1,ICNC1WGM13等名字中的1也是;CS12中的2表示該位為CS1[2:0]位域(bitfield)中的第2位(最低位為第0位)。

ICNC1ICES1都是1位的位域,它們的值可以是01WGM1[3:2]是2位的位域,它的值可以是00011011CS1[2:0]同理。

你也許一眼就能看出二進位制的11在十進位制中是3,但是你很可能看不出23對應10111。在Arduino程式設計中(語言為C++),二進位制數可以直接寫,無需與十進位制或十六進位制轉換。Arduino提供的方法是B10111,GCC提供的是0b101110b字首字面量是C++14標準才規定的)。後者是我一直以來的習慣。

假如我要把這4個引數分別寫為100b000b101,就要寫:

TCCR1B =     1 << ICNC1
       |     0 << ICES1
       |  0b00 << WGM12
       | 0b101 << CS10
       ;

全是0的可以不寫,寫是為了可讀性。ICNC1是暫存器的第7位,所以程式碼中它的值就是7,其他位同理。

如果要判斷ICNC1位是否為1

if (TCCR1B & 1 << ICNC1)
    // ...

如果要讀取WGM1[3:2]位:

uint8_t wgm = (TCCR1B & 0b11 << WGM12) >> WGM12;

有的位因為不存在而不能寫,如TCCR1B的第5位;有的位即使存在但是隻讀所以也不能寫;有的位域分佈於多個暫存器中,如WGM1[3:0],低兩位在TCCR1A,高兩位在TCCR1B

除了一個或多個位的位域以外,有些暫存器是整體使用的:

可以直接當變數讀寫:

OCR0A = 233;
uint8_t ocr0a = OCR0A;

還有16位暫存器,雖然讀寫不能用一句彙編搞定,但是高階語言層面上可以:

TCNT1 = 10086;
uint16_t tcnt1 = TCNT1;

不超過255的話可以只寫低位元組TCNT1L

定時器相關暫存器總覽:

定時器的工作模式

讀資料手冊無疑是深入瞭解微控制器的最好方法,可惜很多人沒這個耐心,幾十頁的英語也不是每個人都吃得消的。有些中文書打著介紹AVR微控制器的幌子翻譯資料手冊,不僅沒有營養還漏洞百出,我不也推薦。寫這篇文章,也有避免後人重蹈覆轍的目的。

當然,除了有程式碼示例以外,本文再“詳解”也詳細不過資料手冊,不過至少可以讓你對定時器有個大致的印象,不致於讓你讀的時候一頭霧水。

ATmega328P有3個定時器:定時器0、定時器1和定時器2(簡單粗暴)。0和2都是8位的,2支援非同步工作;1是16位的,精度更高,支援更多工作模式。我接觸過其他型號的微控制器,AVR的定時器是相對簡單的。

定時器有3種工作模式:普通模式、CTC模式、PWM模式,其中PWM還分快速PWM、相位矯正(波形居中)PWM、相位與頻率矯正PWM(頻率可以任取,僅限定時器1)。

先講各種模式中共通的部分。定時器需要一個時鐘源,它可以是:

時鐘源 適用範圍
所有
\(clk_{I/O} / N, N = 1, 8, 64, 256, 1024\)
(\(clk_{I/O}\)為系統時鐘,16MHz)
定時器0/1
T04)引腳上升/下降沿 定時器0
T15)引腳上升/下降沿 定時器1
\(clk_{T2S} / N, N = 1, 8, 32, 64, 128, 256, 1024\)
(\(clk_{T2S}\)為系統時鐘或外接32kHz晶振)
定時器2

工作模式之間的區別在於計數器的變化方向與範圍,介紹之前需要先下3個定義:

名稱 描述
BOTTOM 0,計數器的最小值
MAX 對8位定時器為0xFF,對16位定時器為0xFFFF,計數器的最大可能值
TOP 計數器達到這個值時,可能會被清零,或變化方向改變
對定時器0和2,可以為MAXOCRnA
對定時器1,可以為0x00FF0x01FF0x03FFOCR1AICR1
  • 普通模式中,計數器從0開始增長到MAX,然後溢位回到0,周而復始。頻率為(\(clk\)為定時器時鐘頻率)

    \[\frac {clk} {MAX + 1} \]

  • CTC模式和快速PWM模式中,計數器從0開始增長到TOP,然後不再繼續增長而是直接回到0,重新開始增長。頻率為

    \[\frac {clk} {TOP + 1} \]

  • 兩種相位矯正PWM模式中,計數器從0TOP,再從TOP回到0,如此迴圈。頻率為

    \[\frac {clk} {2 TOP} \]

計數器比較

當計數器的值與OCRnAOCRnB相等時,可以對OCnx的電平進行一些操作。

  • 所有模式下,OCnx都可以不連線定時器。

  • 非PWM模式下,可以把OCnx置為低電平、高電平或翻轉電平,tone就是這樣實現的;

  • PWM模式下,有正相和反相兩種模式,正相為OCRnx越大佔空比越高,analogWrite就是這樣實現的;反相反之;有些配置下OCnA可以被翻轉,請參考資料手冊。

由於引腳電平可以有巨集觀表現,我們終於可以開始寫程式碼了。

先試試tone。在9號埠上連線一個蜂鳴器,使用定時器1的CTC模式,產生440Hz方波:

void setup() {
  pinMode(9, OUTPUT);
  TCCR1A = 0b01 << COM1A0 | 0b00 << WGM10;
  TCCR1B = 0b01 << WGM12 | 0b001 << CS10;
  OCR1A = 18181;
}

void loop() {
  
}

OCR1A = 18181是怎麼來的呢?每次計數器與OCR1A相等電平翻轉,兩次為一週期,頻率為\(\frac {clk} {2(OCR1A + 1)}\)。先取\(clk\)為不分頻試試,算出OCR1A為18181,沒有超過最大值65535,因此就取這個。如果超過了,就要把定時器頻率下調,直到OCRnx合理為止。

如果要讓程式以頻率為引數計算出合適的分頻係數與OCRnx值,可以參考tone的實現。

再試試analogWrite。在3號埠上連線一個LED,使用定時器2的快速PWM模式,實現呼吸燈的效果:

void setup() {
  pinMode(3, OUTPUT);
  TCCR2A = 0b10 << COM2B0 | 0b11 << WGM20;
  TCCR2B = 0 << WGM22 | 0b100 << CS20;
}

int brightness = 0;
int fadeAmount = 5;

void loop() {
  OCR2B = brightness;
  brightness = brightness + fadeAmount;
  if (brightness <= 0 || brightness >= 255)
    fadeAmount = -fadeAmount;
  delay(30);
}

在快速PWM模式中,正相輸出佔空比不能為0,反相輸出佔空比不能為1,如果要達到這兩個值,需要斷開引腳與定時器的連線,用digitalWrite等方法輸出。

定時器中斷

懶得寫了,我抄我自己:

中斷,是微控制器的精華。

當一個事件發生時,CPU會停止當前執行的程式碼,轉而處理這個事件,這就是一箇中斷。觸發中斷的事件成為中斷源,處理事件的函式稱為中斷服務程式(ISR)。

中斷在微控制器開發中有著舉足輕重的地位——沒有中斷,很多功能就無法實現。比如,在程式幹別的事時接受UART總線上的輸入,而uart_scan_char等函式只會接收呼叫該函式後的輸入,先前的則會被忽略。利用中斷,我們可以在每次接受到一個位元組輸入時把資料存放到緩衝區中,程式可以從緩衝區中讀取已經接收的資料。

AVR微控制器支援多種中斷,包括外部引腳中斷、定時器中斷、匯流排中斷等。每一箇中斷被觸發時,通過中斷向量表跳轉到對應ISR。如果一箇中斷對應的ISR不存在,連結器會把復位地址放在那裡,如果這個中斷被響應程式就會復位(但微控制器不會復位)。

那麼,我們以前從未寫過ISR,但經常改變引腳電平,為什麼沒有復位呢?因為中斷預設是不開啟的。要啟用一箇中斷,需要讓兩個位於不同暫存器中的位為1,一個是中斷對應的中斷使能位,每個中斷都有各自的位,另一個是全域性中斷使能位,位於暫存器SREG中,不能直接存取,需要通過定義在<avr/interrupt.h>標頭檔案中的sei()函式開全域性中斷,相對地,cli()用於關全域性中斷。

定時器中斷同樣有著舉足輕重的地位——作業系統的任務排程就是在定時器中斷中進行的。如果沒有中斷,CPU就在那自顧自地執行程式碼,它哪知道什麼時候要排程呢?正因為定時器是獨立於CPU執行的,時間控制非常精準且不受影響,因而能解決前面優秀作品和計算器遊戲中的問題。

什麼時候需要定時器中斷呢?當你發現沒有中斷的程式結構不能勝任你的需求時,或者……把所有程式碼都放進ISR。比如,每1ms產生一次中斷,先檢測按鍵是否被按下,根據其情況執行相應操作。

每個定時器都有3箇中斷源:OVFCOMPACOMPB(定時器1還有CAPT),分別在計數器溢位、與OCRnAOCRnB相等時觸發。

產生精準的定時器中斷,一般使用CTC模式和COMPA中斷,分頻係數與TOP值的計算方法與上面相同。

void setup() {
  pinMode(13, OUTPUT);
  TCCR1A = 0b00 << WGM10;
  TCCR1B = 0b01 << WGM12 | 0b100 << CS10;
  OCR1A = 31249;
  TIMSK1 = 1 << OCIE1A;
  sei();
}

ISR(TIMER1_COMPA_vect)
{
  static bool light = true;
  digitalWrite(13, light = !light);
}

void loop() {
  
}

在這個程式中:

  • WGM1[3:0] = 0b0100,定時器1工作於CTC模式,TOPOCR1A

  • CS1[2:0] = 0b100,時鐘為\(clk_{I/O} / 256\),分頻係數\(N = 256\);

  • OCR1A31249

  • TIMSK1OCIE1A置位,sei()開全域性中斷,COMPA中斷啟用;

  • ISR(TIMER1_COMPA_vect)為定時器1COMPA中斷的函式頭,TIMER1_COMPA_vect這個名字可以當成函式來用;

  • 定時器中斷頻率為\(f = \frac {clk} {TOP + 1} = \frac {F\_CPU} {N \cdot (OCR1A + 1)} = \frac {16 \times 10^6} {256 \times (31249 + 1)} = 2Hz\)。

一般而言,定時器中斷的頻率不要超過10kHz,1kHz已經能夠應付旋轉編碼器了。

進入中斷後,全域性中斷會自動禁用,如果中斷程式碼執行期間發生了定時器事件,對應的中斷不會觸發,而是等到當前中斷返回後再處理。可以用sei()開中斷,但是要小心程式碼執行時間接近或超過週期的情況,雖然定時準了,但中斷巢狀導致記憶體耗盡,程式跑飛了,得不償失。可以考慮另一種時間同步的方法,在loop的最後輪詢OCFnA直到它置位:

void setup() {
  pinMode(13, OUTPUT);
  TCCR1A = 0b00 << WGM10;
  TCCR1B = 0b01 << WGM12 | 0b100 << CS10;
  OCR1A = 31249;
}

void loop() {
  static bool light = true;
  digitalWrite(13, light = !light);
  while (!(TIFR1 & 1 << OCF1A))
    ;
  TIFR1 |= 1 << OCF1A;
}

這種程式結構有定時作用,但不能中斷。頻率較高的時候,推薦使用後一種,頂多定時不準,程式還是能執行的。

照顧一下Arduino

Arduino庫非常貪心,在setup之前就把所有定時器都開啟了(也許你不同意,但我想把這種行為稱為“流氓”——想想百度網盤偷了你多少頻寬和流量!)。定時器0是時間相關函式的命根,除非你想把它割掉,否則不要動定時器0。如果你不動定時器0,56analogWritetone可以照常使用。

如果你要用定時器1,如用以下程式碼配置週期為1ms的定時器中斷:

void init_timer1()
{
  TCCR1A = 0b00 << WGM10;
  TCCR1B = 0b01 << WGM12 | 0b011 << CS10;
  OCR1AL = 249;
  TIMSK1 = 1 << OCIE1A;
  sei();
}

void setup() {
  init_timer1();
  // ...
}

ISR(TIMER1_COMPA_vect)
{
  // ...
}

void loop() {
  // ...
}

需要注意:

  1. 由於Arduino庫在setup之前動過TCCR1A,不能認為在執行我們的程式碼時TCCR1A為預設值,因此即使我們想要的是預設值也不能省略。

  2. 910埠不僅analogWritetone不能用,digitalWrite也不能用!請直接使用暫存器寫引腳電平,參見:AVR微控制器教程——數字IO暫存器。

定時器2同理,311不能用。

void init_timer2()
{
  TCCR2A = 0b10 << WGM20;
  TCCR2B = 0 << WGM22 | 0b100 << CS20;
  OCR2A = 249;
  TIMSK2 = 1 << OCIE2A;
  sei();
}

void setup() {
  init_timer2();
  // ...
}

ISR(TIMER2_COMPA_vect)
{
  // ...
}

void loop() {
  // ...
}

其他功能

有些工作模式下,向OCRnx寫值並不會立即更新它,而是會在計數器達到BOTTOMTOP時更新,這保證了PWM佔空比的正確性,但是CTC模式中OCRnx是立即更新的,可能會錯過匹配。

定時器1有輸入捕獲單元,可以對訊號進行計數,計數達到一定值時觸發中斷。外部中斷同樣可以捕獲引腳電平變化,但是中斷是有成本的,訊號頻率不能太高,而定時器的捕獲功能更加強大。

定時器1有額外的ICR1暫存器,作為TOP值可以實現許多特殊的功能,並且由於定時器1是16位的,即使是複用時的精度也比定時器0和2高,見思考題1。

定時器2可以用外接晶振驅動,比較適合實現實時時鐘,可以在系統時鐘停止的省電狀態下工作。

思考題

  1. 對於同一個定時器,中斷與PWM能否同時使用?方波與PWM波能否同時輸出?

  2. 嘗試用定時器中斷來創造新的PWM通道,頻率和精度能實現呼吸燈即可。

  3. 在STM8微控制器中,定時器TIM1TIM5TIM6可以相互控制。ATmega328P能否實現類似的功能?