JOVANA
Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

及時反應:中斷與計時器

想像一位接待員:電話一響她就立刻接起,卻從不曾停下手邊正在打的報告去「看看電話是不是要響了」。這個魔術正是[[ee-interrupt|中斷]]為[[ee-microcontroller|微控制器]]所做的事,而[[ee-timer-counter|計時器]]則是讓它在不盯著時鐘的情況下、仍能保持精準節奏的節拍器。讀完本篇,你將不再浪費週期去輪詢,而是開始「及時反應」。

輪詢迴圈的暴政

在第 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 ...........
Cortex-M 上的中斷生命週期:硬體會在你的 ISR 前後自動存檔與還原上下文。

兩個數字決定一個中斷是否好用:延遲(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 ----------->|
預除器與自動重載把 48 MHz 晶體變成精準的 1 毫秒心跳。

同一套計數器硬體身兼三職。讓它自由奔跑、讀取它的值,就能「量測」某件事花了多久(碼錶)。餵給它外部脈衝串而非時脈,它就成了事件的「計數器」。而每個脈衝都拿它的值去和一個門檻比較,藉以把腳位拉高再拉低——這正是你在第 2 階做的PWM,全在硬體中產生,同時 CPU 還在睡覺。一個周邊,三種超能力。

實作範例:用硬體閃爍的 LED

來把理論換成實作。目標:讓 LED 穩定地以 1 Hz 閃爍(亮 500 毫秒、暗 500 毫秒),「同時 CPU 還能自由地做正事」——比方說跑一個慢速運算或和感測器對話。我們會設定計時器每 1 毫秒中斷一次,在 ISR 裡數這些 tick,每 500 個 tick 就切換一次 LED。注意 ISR 有多短:它只做絕對最少的事,然後就走人。

  1. 開啟時脈。打開到計時器周邊、以及驅動 LED 的GPIO埠的時脈——周邊在被供給時脈之前,只是一塊沒生命的矽。
  2. 設定計時器。設 PSC = 47、ARR = 999,從 48 MHz 時脈得到 1 毫秒週期,接著啟用「更新中斷」(即溢位事件)。
  3. 接上 NVIC。在中斷控制器裡啟用計時器的中斷線並指定優先序,這樣 CPU 才知道要去聽。
  4. 啟動計時器,讓 `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.
    }
}
一個 1 毫秒的計時器 ISR 讓 LED 以 1 Hz 閃爍,同時 main() 做不相干的工作——心跳活在硬體裡。

優先序、巢狀,與「短 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 快速抓資料;主迴圈負責粗重處理。

每個人都會被咬一次的陷阱

中斷之所以強大,正是因為它打破了「你的程式由上而下執行」這個舒適的錯覺。這份力量帶著鋒利的邊緣,而每個嵌入式工程師身上都有疤。好消息是:這些失敗都很典型,所以你能一眼認出它們。

  1. 沒清的旗標。ISR 觸發一次後就永無止境,把系統吊死。請「永遠」清除來源旗標,最好就放在第一行。
  2. 忘掉的 `volatile`。主程式讀到共享變數一個過時的快取值,永遠看不到 ISR 的更新。把每個與 ISR 共享的變數標上 `volatile`。
  3. 競爭條件。主程式正讀一個多位元組的共享值,而 ISR 才寫到一半。在讀取前後短暫關閉中斷,或使用原子存取。
  4. 會阻塞的 ISR。ISR 裡的 `delay()`、`printf()` 或慢迴圈會餓死其他所有中斷,並讓抖動爆炸。保持短小,把工作往後延。
  5. 巢狀導致的堆疊溢位。層層巢狀的高優先序 ISR 各自把上下文推上堆疊;請以最壞情況、而非平均情況來設定堆疊大小。

退一步,整幅畫面便清晰對焦。時脈是晶片的節奏;計時器把這節奏分割成精準、零漂移的間隔;中斷讓真實世界的事件與計時器溢位,在重要的那一瞬抓住 CPU 的注意力;而短 ISR 加上主迴圈分工合作,讓系統在每條戰線上同時保持反應。有了這些,你的微控制器便不再「等待」,而開始「反應」——這正是它擁有一顆心跳的全部理由。