
什麼是中斷?
剛開始寫韌體的時候,我以為中斷只是「一個通知」。後來才發現,它其實是整顆 MCU 的即時性骨幹 —— 沒有它,你的系統只能一直問「變了嗎?變了嗎?」。
正式一點說:GPIO 中斷(GPIO Interrupt)是讓微控制器即時響應外部事件的核心機制。你不用讓 CPU 一直輪詢(Polling)腳位的電位變化,事件發生時由硬體主動通知 CPU,CPU 再暫停手邊的工作去處理。
原文那個比喻我一直覺得很好用:你在看書(主程式在跑),有人敲門(GPIO 觸發),你放下書去開門(執行 ISR),開完門回來繼續看書(恢復主程式)。
跟輪詢(Polling)比起來,中斷的好處非常明顯:
- 省電:CPU 不需要一直去檢查 GPIO 狀態,可以待在睡眠模式
- 即時性:事件發生到回應的延遲(Latency)可控且極短
- 效率:不用浪費 CPU 週期在無意義的查詢上

還有一件事值得先講:中斷跟 DMA 傳輸是天生一對。DMA 負責搬資料,中斷負責通知 CPU「資料搬好了」—— 分工乾淨,CPU 也不會被綁在搬運上。
GPIO 外部中斷的工作原理
我習慣把硬體路徑拆成六步來看,這樣比較不會把「誰負責什麼」搞混:
- GPIO 腳位:外部訊號(按鈕、感測器輸出、其他 MCU 的訊號)接到 GPIO
- 邊緣/電平檢測電路:每個 GPIO 內部都有硬體電路,可以檢測 Rising Edge、Falling Edge 或 Low Level
- NVIC 或 CPU 中斷控制器:中斷條件滿足時,向 CPU 發送中斷請求
- 向量表中查找 ISR:CPU 依中斷編號,在 Vector Table 找到對應的中斷服務函式入口
- 執行 ISR:CPU 保存當前 Context,然後執行 ISR
- 返回:ISR 執行完畢,CPU 恢復 Context 回到原本的程式

這條路徑上有一個關鍵字值得記住:延遲(Latency)。從事件發生到 ISR 的第一行被執行,中間的每一段都是成本。後面 ESP32 範例裡的 IRAM_ATTR 就是為了這個 —— 讓 ISR 的程式碼待在 IRAM,避免 Flash 快取未命中造成延遲。
中斷觸發方式
GPIO 外部中斷支援四種觸發方式:
- Rising Edge Trigger(上升緣觸發):GPIO 從 Low 變 High 時觸發
- Falling Edge Trigger(下降緣觸發):GPIO 從 High 變 Low 時觸發
- Both Edge Trigger(雙緣觸發):電平無論上升或下降都觸發
- Level Trigger(電平觸發):GPIO 保持在特定電平時持續觸發(較少用於 GPIO)

原文那張時序圖想講的其實是三件事:Line A 每當 GPIO 從 0→1 時 Rising Edge 觸發;Line B 每當 GPIO 從 1→0 時 Falling Edge 觸發;Line C、D 的 Both Edge 模式則在每個 Transitions 都觸發。
選哪一種取決於你的應用:
- 按鈕按下 → 通常用 Falling Edge(內部 Pull-Up,按下=Low)
- 編碼器(Encoder)→ 用 Both Edge 才能捕捉所有脈衝
- 感測器中斷輸出 → 看 datasheet 指定 Rising 還是 Falling
ESP32 GPIO 中斷實作
ESP32 使用 Tensilica Xtensa LX6 核心(S3 是 LX7),中斷控制器支援所有 GPIO 的中斷。下面兩個框架我都寫過,各有各的順手之處。
Arduino 框架:attachInterrupt()
// ESP32 Arduino - GPIO 外部中斷範例(按鈕計數)
const int BUTTON_PIN = 0; // BOOT button on most dev boards
const int LED_PIN = 2;
volatile int press_count = 0;
volatile bool triggered = false;
void IRAM_ATTR button_isr() {
press_count++;
triggered = true;
}
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
// 設定 Falling Edge 觸發(按鈕按下時 GPIO 從 High→Low)
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), button_isr, FALLING);
}
void loop() {
if (triggered) {
triggered = false;
Serial.printf("按鈕已被按下 %d 次\n", press_count);
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
}
}
關鍵重點:
IRAM_ATTR—— 強制 ISR 程式碼放在 IRAM 中,避免 Flash 快取未命中造成延遲volatile—— 編譯器不會優化這個變數,確保 ISR 和主迴圈之間共用資料正確- ISR 中只做最小必要操作,複雜邏輯放主迴圈
中斷也常與 FreeRTOS 多工管理搭配,在 ISR 中用 xQueueSendFromISR() 將事件傳遞給任務。
ESP-IDF 框架
// ESP-IDF - GPIO 中斷範例
#include "driver/gpio.h"
#define GPIO_BUTTON GPIO_NUM_0
#define GPIO_LED GPIO_NUM_2
static QueueHandle_t gpio_evt_queue = NULL;
static void IRAM_ATTR gpio_isr_handler(void* arg) {
uint32_t gpio_num = (uint32_t) arg;
xQueueSendFromISR(gpio_evt_queue, &gpio_num, NULL);
}
void gpio_task_example(void* arg) {
uint32_t io_num;
for(;;) {
if(xQueueReceive(gpio_evt_queue, &io_num, portMAX_DELAY)) {
printf("GPIO %d 觸發中斷!\n", io_num);
gpio_set_level(GPIO_LED, !gpio_get_level(GPIO_LED));
}
}
}
void app_main() {
gpio_config_t io_conf = {
.intr_type = GPIO_INTR_NEGEDGE,
.mode = GPIO_MODE_INPUT,
.pull_up_en = 1,
.pin_bit_mask = (1ULL << GPIO_BUTTON),
};
gpio_config(&io_conf);
gpio_set_direction(GPIO_LED, GPIO_MODE_OUTPUT);
gpio_evt_queue = xQueueCreate(10, sizeof(uint32_t));
xTaskCreate(gpio_task_example, "gpio_task_example", 2048, NULL, 10, NULL);
gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT);
gpio_isr_handler_add(GPIO_BUTTON, gpio_isr_handler, (void*) GPIO_BUTTON);
}
這段就是前面那句話的完整實作:ISR 只負責 xQueueSendFromISR(),真正要做的 gpio_set_level() 交給 gpio_task_example 這個任務。事件怎麼從 ISR 交出來,我整理成下面這張圖:

同樣的問題,Arduino 版用 volatile 旗標、ESP-IDF 版用佇列,差別在事件會不會連續來:只在乎「有沒有發生」就一個旗標就夠,要排隊、要帶資料,就交給佇列。
STM32 GPIO 中斷實作
STM32 是 ARM Cortex-M 核心搭配 NVIC(Nested Vectored Interrupt Controller),中斷機制比 ESP32 更嚴謹,而且支援巢狀搶佔。它的原理跟 硬體定時器(Timer)的中斷很像,差別只在定時器是由內部計數器觸發,不是 GPIO。
STM32 HAL 框架
// STM32 HAL - GPIO 外部中斷範例 (STM32F4)
void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
GPIO_InitStruct.Pull = GPIO_PULL_UP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
__HAL_RCC_SYSCFG_CLK_ENABLE();
HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}
void EXTI0_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_0) {
HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_7);
}
}
STM32 中斷優先級管理
ARM Cortex-M 的 NVIC 支援巢狀中斷:高優先級的中斷可以打斷正在執行的低優先級 ISR。
// 設定中斷優先級(STM32F4)
HAL_NVIC_SetPriority(EXTI1_IRQn, 1, 0); // 感測器中斷(高優先級)
HAL_NVIC_SetPriority(EXTI0_IRQn, 5, 0); // 按鈕中斷(低優先級)
看註解就知道:EXTI1_IRQn 給 1(感測器中斷,高優先級)、EXTI0_IRQn 給 5(按鈕中斷,低優先級)—— 數字越小,講話越大聲。
中斷服務函式(ISR)最佳實踐
寫 ISR 跟寫一般函式很不一樣。同樣的邏輯放進一般函式沒事,放進 ISR 就可能讓整顆 MCU 看起來像當機。先把三個關鍵字記住:

接著是原文那張「可以做的事/不該做的事」,這張我建議直接背下來:
| ✅ 可以做的事 | ❌ 不該做的事 |
|---|---|
| 讀取/寫入 GPIO | delay() / HAL_Delay() |
| 設定 volatile 標記 | Serial.print() / printf() |
| xQueueSendFromISR() | malloc() / new |
| 清除中斷標記 | 長時間迴圈 / 阻塞操作 |
| 讀取感測器暫存器 | 操作 I²C/SPI 通訊(除非 DMA) |
其中最容易順手寫錯的是 delay()/HAL_Delay() 與 Serial.print()/printf():它們都不該出現在 ISR 裡,一放進去就會換來一堆莫名其妙的逾時與漏中斷。
按鈕消抖(Debouncing)
機械式按鈕按下時會有彈跳(Bouncing)現象,GPIO 會在短時間內快速變換,一次按下可能造成多次中斷。

// 軟體消抖範例(ESP32 Arduino)
const int DEBOUNCE_MS = 50;
volatile unsigned long last_interrupt_time = 0;
void IRAM_ATTR debounced_isr() {
unsigned long now = millis();
if (now - last_interrupt_time > DEBOUNCE_MS) {
last_interrupt_time = now;
button_pressed = true;
}
}
這個寫法的重點是:判斷與過濾都在 ISR 裡用時間戳完成,事件本身只丟一個旗標給主迴圈。ISR 越短,漏掉中斷的機率就越低。
中斷向量表(Vector Table)
中斷向量表是 MCU 啟動時設定的表格,記錄每個中斷編號對應的 ISR 函式位址。在 ARM Cortex-M 上,Vector Table 通常放在 Flash 開頭。它的設計跟 FIFO 緩衝區類似 —— 都是為了解決生產者(硬體事件)與消費者(CPU)之間的速度匹配問題。
// STM32 預設中斷向量表 (節錄)
// 6 EXTI0_IRQHandler // ← GPIO PA0 中斷
// 7 EXTI1_IRQHandler // ← GPIO PA1 中斷
// 23 EXTI9_5_IRQHandler // ← GPIO 5~9 共享
// 40 EXTI15_10_IRQHandler // ← GPIO 10~15 共享
STM32 的 GPIO 中斷是分組的:PA0~Px0 共享 EXTI0、PA1~Px1 共享 EXTI1 等等,所以同一個 EXTI 編號底下的不同 Port,不能同時拿來做外部中斷。
ESP32 vs STM32 中斷對照表
把兩邊的 API 擺在一起看最清楚:
| 功能 | ESP32 (Arduino) | STM32 (HAL) |
|---|---|---|
| 註冊 ISR | attachInterrupt(pin, isr, mode) |
HAL_GPIO_EXTI_IRQHandler(pin) + Callback |
| 觸發方式 | RISING, FALLING, CHANGE, LOW, HIGH | GPIO_MODE_IT_RISING, _FALLING |
| ISR 中關中斷 | portDISABLE_INTERRUPTS() |
__disable_irq() |
| 優先級設定 | ESP_INTR_FLAG_LEVELx |
HAL_NVIC_SetPriority() |
| 中斷巢狀 | 有限支援 | NVIC 完整支援 |
| 可用 GPIO 數量 | 所有 GPIO(最多 34 腳) | 最多 16 個 EXTI(每個 Pin 可共享) |
語法幾乎沒有交集,但要做的事情一模一樣:註冊 ISR、選觸發方式、決定優先級。差別最大的是中斷巢狀 —— ESP32 是有限支援,STM32 的 NVIC 則是完整支援,需要嚴謹的搶佔行為時,STM32 這端會讓你少想很多。
實戰:雙按鈕中斷控制 LED 亮度
把前面學到的接起來:兩個按鈕分別控制 LED 亮度增減。PWM 信號怎麼產生,可以參考 PWM 完整教學。
// ESP32 - 雙按鈕中斷 PWM 控制 LED 亮度
const int BTN_UP = 0;
const int BTN_DOWN = 4;
const int LED_PIN = 2;
volatile bool up_pressed = false;
volatile bool down_pressed = false;
void IRAM_ATTR up_isr() { up_pressed = true; }
void IRAM_ATTR down_isr() { down_pressed = true; }
int brightness = 128;
void setup() {
Serial.begin(115200);
pinMode(BTN_UP, INPUT_PULLUP);
pinMode(BTN_DOWN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BTN_UP), up_isr, FALLING);
attachInterrupt(digitalPinToInterrupt(BTN_DOWN), down_isr, FALLING);
ledcSetup(0, 5000, 8);
ledcAttachPin(LED_PIN, 0);
ledcWrite(0, brightness);
}
void loop() {
if (up_pressed) {
up_pressed = false;
brightness = min(brightness + 32, 255);
ledcWrite(0, brightness);
Serial.printf("亮度: %d\n", brightness);
}
if (down_pressed) {
down_pressed = false;
brightness = max(brightness - 32, 0);
ledcWrite(0, brightness);
}
delay(50);
}
注意這段的結構:ISR 只設旗標(up_pressed/down_pressed),ledcWrite() 這種比較重的動作全部留在 loop() 裡,delay(50) 順便當成最陽春的節奏控制。
常見陷阱與除錯技巧
最後把四個最常見的陷阱整理成一張排查表,出問題時照第一欄對號入座就好:

陷阱 1:ISR 中做太多事情
ISR 應該保持極簡 —— 設定一個標記(Flag),然後馬上返回。我自己的判準是:如果 ISR 裡出現迴圈或延遲,那件事就不該待在 ISR 裡。
陷阱 2:忘記 volatile
ISR 與主迴圈/任務共用的全域變數必須加上 volatile。少了它,編譯器會「很聰明」地幫你把讀取優化掉,然後你就會得到一個永遠不會更新的旗標。
陷阱 3:NVIC 優先級分組不一致
STM32 上,整個專案的 NVIC_PriorityGroup 必須一致。混用的結果是優先級看起來設對了,搶佔行為卻完全不是你想的那樣。
陷阱 4:中斷標記未清除
void EXTI0_IRQHandler(void) {
if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0)) {
// 處理中斷...
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
}
}
標準寫法就是這樣:先 GET_IT 判斷是不是自己的中斷,處理完再 CLEAR_IT 清掉旗標。少了清除這一步,中斷會一直重進來。
總結
GPIO 外部中斷是嵌入式系統中不可或缺的基礎技術。從 ESP32 的 attachInterrupt(),到 STM32 的 HAL 加 NVIC,語法和 API 都不一樣,但核心概念是一致的:事件由硬體通知、ISR 只做最少的事、複雜的處理留給主迴圈或任務。
把中斷摸熟之後,下一步可以看 Watchdog Timer —— 當系統因為中斷處理異常而卡住時,Watchdog 就是最後一道防線。
文章評論