AVR微控制器教程——定時器中斷
本文隸屬於AVR微控制器教程系列。
中斷,是微控制器的精華。
中斷基礎
當一個事件發生時,CPU會停止當前執行的程式碼,轉而處理這個事件,這就是一箇中斷。觸發中斷的事件成為中斷源,處理事件的函式稱為中斷服務程式(ISR)。
中斷在微控制器開發中有著舉足輕重的地位——沒有中斷,很多功能就無法實現。比如,在程式幹別的事時接受UART總線上的輸入,而uart_scan_char
等函式只會接收呼叫該函式後的輸入,先前的則會被忽略。利用中斷,我們可以在每次接受到一個位元組輸入時把資料存放到緩衝區中,程式可以從緩衝區中讀取已經接收的資料。
AVR微控制器支援多種中斷,包括外部引腳中斷、定時器中斷、匯流排中斷等。每一箇中斷被觸發時,通過中斷向量表跳轉到對應ISR。如果一箇中斷對應的ISR不存在,連結器會把復位地址放在那裡,如果這個中斷被響應程式就會復位(但微控制器不會復位)。
那麼,我們以前從未寫過ISR,但經常改變引腳電平,為什麼沒有復位呢?因為中斷預設是不開啟的。要啟用一箇中斷,需要讓兩個位於不同暫存器中的位為1
,一個是中斷對應的中斷使能位,每個中斷都有各自的位,另一個是全域性中斷使能位,位於暫存器SREG
中,不能直接存取,需要通過定義在<avr/interrupt.h>
標頭檔案中的sei()
函式開全域性中斷,相對地,cli()
用於關全域性中斷。
先來寫第一個帶中斷的程式吧。從原理圖中可以看到,PB2
旁邊標明瞭INT2
,表示PB2
引腳可用於外部中斷2。把一個按鍵連線到PB2
引腳上,即開發板最下方的7P排母的最右邊。利用中斷,我們實現每按一次按鍵就翻轉LED狀態的功能。
#include <avr/io.h>
#include <avr/interrupt.h>
int main()
{
PORTB |= 1 << PORTB2;
EICRA |= 0b10 << ISC20;
EIMSK |= 1 << INT2;
DDRC |= 1 << DDC4;
sei();
while (1)
;
}
ISR(INT2_vect)
{
PORTC ^= 1 << PORTC4;
}
ISC21:0
兩位指定外部中斷的型別,這裡設定為下降沿,即按鍵按下時觸發;INT2
sei()
啟用全域性中斷,然後微控制器就會相應按鍵按下的事件了。
ISR(INT2_vect)
指示這個函式是外部中斷2的ISR。每個中斷ISR都有自己的名字,由資料手冊12章Source
一欄的內容加上_vect
組成,這個名字可以當成函式名字來使用。
如果多箇中斷同時觸發,微控制器會先響應優先順序高的。一些微控制器支援自定義的優先順序,但在AVR微控制器中,只有簡單的地址低的優先順序高的規則。
中斷可以被中斷嗎?在AVR微控制器中,執行一箇中斷處理函式會自動地關閉全域性中斷,此時程式不會被中斷,但可以手動地sei()
使中斷可以被處理。程式是否相應中斷僅取決於該中斷是否被啟用,與其優先順序無關。
當然,中斷不是完美的。其一,你也許已經發現上面的程式不能很好的工作,有時候明明按下了按鍵,燈卻一閃就滅。這是因為,按鍵存在抖動,比微控制器時鐘週期長,能觸發多箇中斷。以前把button_down()
放在main
函式的while
迴圈裡時就沒有這個問題,正是迴圈中的delay
濾除了這種抖動。
其二,進入和退出中斷,除了需要CPU幾個週期來改變PC(程式計數器,當前執行指令的地址)外,還需要保護和恢復現場,包括SREG
暫存器與ISR中用到的通用暫存器。下面這段彙編程式碼可以在Solution Explorer
中Output Files\xxx.lss
中找到。
00000094 <__vector_3>:
#include <avr/io.h>
#include <avr/interrupt.h>
ISR(INT2_vect)
{
94: 1f 92 push r1
96: 0f 92 push r0
98: 0f b6 in r0, 0x3f ; 63
9a: 0f 92 push r0
9c: 11 24 eor r1, r1
9e: 8f 93 push r24
a0: 9f 93 push r25
PORTC ^= 1 << PORTC4;
a2: 98 b1 in r25, 0x08 ; 8
a4: 80 e1 ldi r24, 0x10 ; 16
a6: 89 27 eor r24, r25
a8: 88 b9 out 0x08, r24 ; 8
}
aa: 9f 91 pop r25
ac: 8f 91 pop r24
ae: 0f 90 pop r0
b0: 0f be out 0x3f, r0 ; 63
b2: 0f 90 pop r0
b4: 1f 90 pop r1
b6: 18 95 reti
這段程式碼不必理解,更不用會寫。94
到a0
行是保護現場,依次將暫存器r1
、r0
、SREG
(即0x3f
)、r24
和r25
push進棧,把r1
清零,一共用了12個週期,還要加上響應中斷的4個週期;a2
到a8
是恢復現場,把這些暫存器原來的值逆序地從棧上pop出來,用了15個週期;而只有中間aa
到b6
的語句是用於執行使用者程式碼的,在總共35個週期中只佔4個週期。
當然,這個比例很小是因為這個ISR過於簡單。但是,ISR更復雜也意味著有更多暫存器需要push和pop,中斷的響應時間更長。
這個例子並沒有中斷效率低下的意思,而是表明不能過於頻繁地依賴中斷。比如接下來要講的定時器中斷,我通常設定為1ms間隔,只有一次到0.1ms,再快恐怕就起不到定時的作用了。
定時器中斷
定時器,顧名思義,定時用的。之前我們在main
函式的while (1)
迴圈中,每個週期執行一些程式碼,然後延時一個固定的時長。我也曾見過根據該次週期的工作量來計算延時時長的操作,但畢竟寫BASIC的人學得也basic吧,這種做法的定時仍不精確。利用定時器中斷(其實不必中斷),我們可以實現精確的定時,使每一週期的時間嚴格相同。
如果對作業系統有一點了解,就會知道作業系統需要進行任務排程。然而,任務在執行時,並不知道自己該何時被排程走。實際上,是作業系統在定時器中斷中打斷了任務的正常執行,然後進行排程。定時器中斷是作業系統的基礎。
在AVR微控制器定時器的各種模式中,普通模式和CTC模式常用於產生定時器中斷。我們仍然以定時/計數器0為例。
在普通模式中,使用TIMER0_OVF
中斷,頻率為\(\frac {f_{CPU}} {256 \cdot N}\),\(N\)為分頻係數。這樣產生的定時器中斷精確但不確切,因為N
的取值是很離散的。如果只需要在中斷中進行外設輪詢的話,普通模式就足夠了。
如果在ISR的第一行就給TCNT0
賦值,或是使用TIMER0_COMPA
中斷並在起始處寫TCNT0 = 0
,那麼可以改變中斷頻率,但由於有編譯器插入的保護現場的程式碼的存在,這種定時不夠精確,而CTC模式解決了這個問題。
在CTC模式中,使用TIMER0_COMPA
中斷,頻率精確地為\(\frac {f_{CPU}} {N \cdot (OCR0A + 1)}\)(注意沒有蜂鳴器頻率公式中的\(2\))。
還需要提醒一句,如果想要中斷被響應,必須保證main
函式不退出,因為編譯器會在退出處加上一句cli()
。最簡單的方法是在main
函式的最後加上一句while (1);
。
後臺動態掃描
數碼管的動態掃描需要每隔一段時間就換一位點亮是一件很煩人的事,尤其是在操控其他外設的程式已經比較複雜的時候。我本來想把中斷完美地拖到第二期再講,沒想到自己也受不了動態掃描的折磨,在某個版本的庫中就放出了segment_auto
函式來接管這項工作。它正是使用了定時器中斷。
實現思路很簡單,把要顯示的資料放在客戶和庫可以共同取用的變數中,在中斷裡逐位顯示,只要中斷夠快,就可以實現動態掃描,使每一位看起來都在亮。
#include <avr/io.h>
#include <avr/interrupt.h>
#include <ee1/segment.h>
void segment_int_init()
{
// other initializations, ex. pins
TCCR0A = 0b10 << WGM00; // CTC mode
TCCR0B = 0b0 << WGM02 | 0b100 << CS00; // divide by 256
OCR0A = 97; // ~1ms
TIMSK0 = 1 << OCIE0A; // compare match A interrupt
sei();
}
static uint8_t segment_int_data[SEGMENT_DIGIT_COUNT];
void segment_int_display(/* ... */)
{
// store the display pattern in segment_int_data
}
ISR(TIMER0_COMPA_vect)
{
static uint8_t cur = 0;
// display the cur-th digit according to segment_int_data
if (++cur == SEGMENT_DIGIT_COUNT)
cur = 0;
}
如果你把以上程式碼放在可執行程式的專案中,那完全沒有問題,但如果是放在一個靜態庫專案中,然後在可執行程式專案中引用它,那麼定時器中斷的ISR是不會連結程序序的。這是因為,從連結器的角度來講,這個ISR從來沒有被呼叫過,因此就被當成無用的函式扔掉了。為了讓連結器把ISR連結程序序,我們需要在main
會執行的程式碼中呼叫它,最簡單地:
if (0)
TIMER0_COMPA_vect();
放在初始化中,既達到了目的,又沒有執行時的負擔。
作業
試著寫一個庫,管理開發板引出的16個引腳的外部中斷。
研究定時器中斷與PWM的關係。
改進ADC一講中最後一個例程,把
main
函式還給客戶。