輪詢迴圈的暴政
在第 3 階你用「輪詢」讀按鈕:CPU 跑一個迴圈,每秒數百萬次地問GPIO腳位「按下了嗎?按下了嗎?」這做法可行,但想想它的代價。當你的程式黏在那一根腳位上時,它就沒辦法同時看感測器、驅動馬達或更新顯示器。而且只要你的迴圈跑去做別的事 50 毫秒,一個只持續 30 毫秒的按鍵就會神不知鬼不覺地溜過去。輪詢就像一個只能盯著一扇門的警衛——而且總在最糟的時刻眨眼。
更深層的問題是「浪費」。現代 MCU 可能以 48–480 MHz 運作,但人的手指或 10 Hz 的感測器相形之下慢如冰川。輪詢迴圈每秒燒掉數億條指令,只為等待一件每秒才發生幾次的事。在電池供電下,這就是把韌體的努力直接轉成沒電的電池。我們需要一種方式,讓硬體在事情發生時「主動來告訴我們」——這樣 CPU 在那之前可以睡覺,或去做別的工作。
中斷究竟做了什麼
中斷是硬體為你的程式安排的暫停鍵。當設定好的事件觸發時——腳位電位改變、計時器溢位、UART收到一個位元組——一個專責區塊(在 Arm Cortex-M 上稱為 NVIC,巢狀向量中斷控制器)會在指令流中途凍結 CPU。它存下程式的位置,查出你寫的那個小函式——中斷服務常式(ISR)——的位址,跳過去執行它,然後復原一切,並回到主程式離開時的「確切位置」繼續跑。主程式甚至不知道自己被中斷過——就像被瞬移去處理緊急狀況、再瞬移回你正在打的同一個句子裡。
main program running ......................
| add r0,r1 ldr r2,[r3] str ... <-- normal flow
| ^
===|=====================|=========================
| EVENT! (e.g. timer overflow) fires here
v |
1. NVIC saves PC, status, r0-r3 onto the stack (auto)
2. NVIC fetches ISR address from the vector table
3. CPU jumps to TIM2_IRQHandler() <-- your ISR
clear the interrupt flag
toggle the LED
return
4. NVIC pops the saved registers back
5. CPU resumes the very next instruction: str ...
=================================================
main program continues, none the wiser ...........兩個數字決定一個中斷是否好用:延遲(latency)與抖動(jitter)。延遲是從事件發生到你 ISR 第一行之間的耽擱——在 Cortex-M 上以「確定性」著稱,約 12 個時脈週期,所以在 48 MHz 下是四分之一微秒。抖動則是這段延遲在一次次事件之間「晃動」多少,取決於 CPU 當下還在忙什麼。一個必須在精準瞬間取樣電流的馬達控制迴圈,全靠又低又可預測的延遲才能存活。
計時器/計數器:矽晶裡的節拍器
如果說中斷是 MCU 的耳朵,那計時器/計數器就是它的心跳。撇開行銷話術,計時器簡單到令人屏息:一個暫存器,每來一個時脈脈衝就加一。就這樣。它所有的威力都來自你在這個計數器周圍接上的東西——一個在時脈到達計數器前先做除頻的預除器(prescaler),以及一個說「數到這個值就繞回零並升起旗標」的自動重載(auto-reload,即週期)暫存器。
這兩個旋鈕能把一個快速時脈變成你想要的任何時序。算式就是整場遊戲的核心:計時器每 (PSC + 1) × (ARR + 1) 個時脈週期升起一次中斷。假設你的 MCU 計時器由 48 MHz 時脈驅動,而你想每 1 毫秒(1000 Hz)來一次中斷。每毫秒需要數 48,000 個週期。把它拆成預除器 48(那個 +1 讓它除以 48,得到 1 MHz 的計數時脈——每微秒數一次)與週期 1000。乾淨、精確、零漂移,因為它錨定在石英晶體上,而不是錨定在你的程式碰巧跑多快上。
Goal: a 1 ms (1000 Hz) timer tick from a 48 MHz clock
--------------------------------------------------------
f_tick = f_clk / ( (PSC+1) * (ARR+1) )
1000 = 48e6 / ( (PSC+1) * (ARR+1) )
=> (PSC+1)*(ARR+1) = 48000
Pick PSC+1 = 48 -> counting clock = 48e6/48 = 1 MHz (1 us/count)
ARR+1 = 1000 -> overflow every 1000 us = 1.000 ms exact
So: PSC = 47 , ARR = 999
Counter: 0,1,2, ... ,998,999, [overflow! IRQ + reload], 0,1,2,...
|<----------- 1000 counts = 1.000 ms ----------->|同一套計數器硬體身兼三職。讓它自由奔跑、讀取它的值,就能「量測」某件事花了多久(碼錶)。餵給它外部脈衝串而非時脈,它就成了事件的「計數器」。而每個脈衝都拿它的值去和一個門檻比較,藉以把腳位拉高再拉低——這正是你在第 2 階做的PWM,全在硬體中產生,同時 CPU 還在睡覺。一個周邊,三種超能力。
實作範例:用硬體閃爍的 LED
來把理論換成實作。目標:讓 LED 穩定地以 1 Hz 閃爍(亮 500 毫秒、暗 500 毫秒),「同時 CPU 還能自由地做正事」——比方說跑一個慢速運算或和感測器對話。我們會設定計時器每 1 毫秒中斷一次,在 ISR 裡數這些 tick,每 500 個 tick 就切換一次 LED。注意 ISR 有多短:它只做絕對最少的事,然後就走人。
- 開啟時脈。打開到計時器周邊、以及驅動 LED 的GPIO埠的時脈——周邊在被供給時脈之前,只是一塊沒生命的矽。
- 設定計時器。設 PSC = 47、ARR = 999,從 48 MHz 時脈得到 1 毫秒週期,接著啟用「更新中斷」(即溢位事件)。
- 接上 NVIC。在中斷控制器裡啟用計時器的中斷線並指定優先序,這樣 CPU 才知道要去聽。
- 啟動計時器,讓 `main()` 落入它自己的工作迴圈。從此 LED 自己運轉。
volatile uint32_t ms_ticks = 0; // shared with main: must be volatile
void TIM2_IRQHandler(void) { // the ISR: runs every 1 ms
TIM2->SR &= ~TIM_SR_UIF; // 1. CLEAR the flag (or it re-fires!)
ms_ticks++; // 2. one millisecond has passed
if (ms_ticks % 500 == 0) // 3. every 500 ms ...
GPIOA->ODR ^= (1 << 5); // ... toggle the LED pin. Done.
}
int main(void) {
clocks_enable(); // step 1
TIM2->PSC = 47; // step 2: 48 MHz / 48 = 1 MHz
TIM2->ARR = 999; // 1 MHz / 1000 = 1 kHz = 1 ms
TIM2->DIER |= TIM_DIER_UIE; // enable update interrupt
NVIC_EnableIRQ(TIM2_IRQn); // step 3: let the NVIC route it
TIM2->CR1 |= TIM_CR1_CEN; // step 4: start counting
while (1) {
do_real_work(); // CPU is 100% free here.
// The LED keeps perfect time even if this is slow,
// because the TIMER, not this loop, defines the rhythm.
}
}優先序、巢狀,與「短 ISR」的藝術
真實系統有許多中斷源,而它們不會客氣地輪流來。一個位元組在UART上抵達的同一微秒,計時器溢位了,馬達的故障腳位也跳脫了。誰先處理?這就是優先序要決定的。你給每個來源一個優先序號碼,中斷控制器先服務最緊急的。在 Cortex-M 上,較高優先序的中斷甚至能「搶占(preempt)」一個正在執行的中斷——ISR 中斷 ISR,稱為巢狀(nesting)——這樣硬即時的安全事件,絕不會卡在一個懶散的雜務常式後面排隊。
這就是為什麼嵌入式的黃金守則是:讓 ISR 保持短小。你在 ISR 裡多待一微秒,就是讓每個較低優先序的中斷多被擋一微秒,也讓其他所有人的抖動往上爬。一個 ISR 該做三件事、不該多做:清除旗標、抓取時間關鍵的資料(讀感測器取樣、把位元組塞進緩衝區),然後設一個旗標讓主迴圈稍後去做粗重活。絕不要在 ISR 裡呼叫慢函式、絕不等待、絕不列印、絕不空轉延遲迴圈——那就像接起緊急電話後,對著來電者朗讀一整本小說,而其他每條線都還在響。
GOOD ISR (top half) BAD ISR (does everything inline)
------------------------ -------------------------------
void ADC_IRQ(void){ void ADC_IRQ(void){
clear_flag(); clear_flag();
buf[i++] = ADC->DR; float v = filter(ADC->DR); // slow!
if(i==N) ready = 1; printf("v=%f\n", v); // VERY slow!
} // ~1 us, returns fast delay_ms(2); // disaster
} // blocks everything for ms
main loop later:
if(ready){ heavy_process(buf); ready=0; } // do the work here每個人都會被咬一次的陷阱
中斷之所以強大,正是因為它打破了「你的程式由上而下執行」這個舒適的錯覺。這份力量帶著鋒利的邊緣,而每個嵌入式工程師身上都有疤。好消息是:這些失敗都很典型,所以你能一眼認出它們。
- 沒清的旗標。ISR 觸發一次後就永無止境,把系統吊死。請「永遠」清除來源旗標,最好就放在第一行。
- 忘掉的 `volatile`。主程式讀到共享變數一個過時的快取值,永遠看不到 ISR 的更新。把每個與 ISR 共享的變數標上 `volatile`。
- 競爭條件。主程式正讀一個多位元組的共享值,而 ISR 才寫到一半。在讀取前後短暫關閉中斷,或使用原子存取。
- 會阻塞的 ISR。ISR 裡的 `delay()`、`printf()` 或慢迴圈會餓死其他所有中斷,並讓抖動爆炸。保持短小,把工作往後延。
- 巢狀導致的堆疊溢位。層層巢狀的高優先序 ISR 各自把上下文推上堆疊;請以最壞情況、而非平均情況來設定堆疊大小。
退一步,整幅畫面便清晰對焦。時脈是晶片的節奏;計時器把這節奏分割成精準、零漂移的間隔;中斷讓真實世界的事件與計時器溢位,在重要的那一瞬抓住 CPU 的注意力;而短 ISR 加上主迴圈分工合作,讓系統在每條戰線上同時保持反應。有了這些,你的微控制器便不再「等待」,而開始「反應」——這正是它擁有一顆心跳的全部理由。