ATmega328P定時器詳解
寫這篇文章,純粹是想為部落格拉點點選量。在部落格園,遊客訪問好像是不計入閱讀量的,而作為一個十八線博主,註冊使用者的訪問應該以搜尋引擎為主,部落格園首頁為次,個位數的粉絲就別談了。
所以,希望各位從搜尋引擎點進來的朋友,多多評論,有問題咱們一起討論。
我寫過AVR微控制器教程,設計過自己的Arduino板,希望你相信我能給你帶來收穫。
我不想聽你放那麼多屁,我只想知道週期為1ms的定時器中斷怎麼寫!
什麼是定時器
在ATmega328P微控制器中,定時/計數器(Timer/Counter)是這樣的元件:它需要一個時鐘源,驅動一個8或16位的計數器遞增或遞減,當計數器等於一個值時,會觸發一些操作,如產生中斷、翻轉引腳電平等。由於定時器的時鐘源是系統時鐘或外接晶振(一種產生頻率精準的波的器件)分頻得到的,一旦設定好定時器的工作引數,直到下次調整引數,定時器都會按照預期工作,與CPU執行的程式碼無關。
為什麼要用定時器
之前有過這樣的經歷:
跟一個優秀作品設計者聊了幾句,他說同時控制舵機和揚聲器很難控制好延時,揚聲器輸出的音樂節奏會亂。我第一反應當然是他沒有用定時器中斷,一問果然如此,並且他不知道中斷也不知道定時器。
還有一位同學,寫TI計算器的程式。在他的一個作品中,每次迴圈的計算量不定,迴圈間隔也不定,導致遊戲效果不好。他的解決方法是根據計算量計算出迴圈最後需要的延時,使得迴圈間隔基本保持不變。
這種思路是相當優秀的。但是如果有定時器可用的話,程式設計難度會降低,迴圈間隔的一致性也會更好,是更加優秀的解決方案。
其實你一直在用定時器
Arduino Uno Rev3的3
、5
、6
、9
10
、11
號埠可以使用analogWrite
和tone
函式,它們的功能都是利用定時器實現的。用函式確實方便,但是隻知使用而不知其原理就只能停留在技術的表面——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組引數:ICNC1
、ICES1
、WGM1[3:2]
、CS1[2:0]
。現在你完全無需理解這些字母的含義,但是得對這些數字有個概念:WGM1[3:2]
表示從WGM13
到WGM12
;TCCR1B
中的1
表示該暫存器屬於定時器1,ICNC1
和WGM13
等名字中的1
也是;CS12
中的2
表示該位為CS1[2:0]
位域(bitfield)中的第2
位(最低位為第0
位)。
ICNC1
和ICES1
都是1位的位域,它們的值可以是0
或1
;WGM1[3:2]
是2位的位域,它的值可以是00
、01
、10
、11
;CS1[2:0]
同理。
你也許一眼就能看出二進位制的11
在十進位制中是3
,但是你很可能看不出23
對應10111
。在Arduino程式設計中(語言為C++),二進位制數可以直接寫,無需與十進位制或十六進位制轉換。Arduino提供的方法是B10111
,GCC提供的是0b10111
(0b
字首字面量是C++14標準才規定的)。後者是我一直以來的習慣。
假如我要把這4個引數分別寫為1
、0
、0b00
、0b101
,就要寫:
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 |
T0 (4 )引腳上升/下降沿 |
定時器0 |
T1 (5 )引腳上升/下降沿 |
定時器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,可以為MAX 或OCRnA |
|
對定時器1,可以為0x00FF 、0x01FF 、0x03FF 、OCR1A 或ICR1 |
-
普通模式中,計數器從
\[\frac {clk} {MAX + 1} \]0
開始增長到MAX
,然後溢位回到0
,周而復始。頻率為(\(clk\)為定時器時鐘頻率) -
CTC模式和快速PWM模式中,計數器從
\[\frac {clk} {TOP + 1} \]0
開始增長到TOP
,然後不再繼續增長而是直接回到0
,重新開始增長。頻率為 -
兩種相位矯正PWM模式中,計數器從
\[\frac {clk} {2 TOP} \]0
到TOP
,再從TOP
回到0
,如此迴圈。頻率為
計數器比較
當計數器的值與OCRnA
或OCRnB
相等時,可以對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箇中斷源:OVF
、COMPA
和COMPB
(定時器1還有CAPT
),分別在計數器溢位、與OCRnA
、OCRnB
相等時觸發。
產生精準的定時器中斷,一般使用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模式,TOP
為OCR1A
; -
CS1[2:0] = 0b100
,時鐘為\(clk_{I/O} / 256\),分頻係數\(N = 256\); -
OCR1A
為31249
; -
TIMSK1
中OCIE1A
置位,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,5
和6
的analogWrite
和tone
可以照常使用。
如果你要用定時器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() {
// ...
}
需要注意:
-
由於Arduino庫在
setup
之前動過TCCR1A
,不能認為在執行我們的程式碼時TCCR1A
為預設值,因此即使我們想要的是預設值也不能省略。 -
9
和10
埠不僅analogWrite
和tone
不能用,digitalWrite
也不能用!請直接使用暫存器寫引腳電平,參見:AVR微控制器教程——數字IO暫存器。
定時器2同理,3
和11
不能用。
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
寫值並不會立即更新它,而是會在計數器達到BOTTOM
或TOP
時更新,這保證了PWM佔空比的正確性,但是CTC模式中OCRnx
是立即更新的,可能會錯過匹配。
定時器1有輸入捕獲單元,可以對訊號進行計數,計數達到一定值時觸發中斷。外部中斷同樣可以捕獲引腳電平變化,但是中斷是有成本的,訊號頻率不能太高,而定時器的捕獲功能更加強大。
定時器1有額外的ICR1
暫存器,作為TOP
值可以實現許多特殊的功能,並且由於定時器1是16位的,即使是複用時的精度也比定時器0和2高,見思考題1。
定時器2可以用外接晶振驅動,比較適合實現實時時鐘,可以在系統時鐘停止的省電狀態下工作。
思考題
-
對於同一個定時器,中斷與PWM能否同時使用?方波與PWM波能否同時輸出?
-
嘗試用定時器中斷來創造新的PWM通道,頻率和精度能實現呼吸燈即可。
-
在STM8微控制器中,定時器
TIM1
、TIM5
、TIM6
可以相互控制。ATmega328P能否實現類似的功能?