本文隸屬於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位使能外部中斷2;全部初始化完成后,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和r25push進棧,把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函數還給客戶。
