0x6A Logbook

0x6A Logbook
Shi6a的筆記本
  1. 首頁
  2. 程式開發
  3. 正文

GPIO 中斷(外部中斷)完整教學:從原理到 ESP32/STM32 實作

2026 年 6 月 4 日 754點熱度 0人點贊 0條評論

GPIO 中斷(外部中斷)完整教學封面:電路板上的一顆微動開關

什麼是中斷?

剛開始寫韌體的時候,我以為中斷只是「一個通知」。後來才發現,它其實是整顆 MCU 的即時性骨幹 —— 沒有它,你的系統只能一直問「變了嗎?變了嗎?」。

正式一點說:GPIO 中斷(GPIO Interrupt)是讓微控制器即時響應外部事件的核心機制。你不用讓 CPU 一直輪詢(Polling)腳位的電位變化,事件發生時由硬體主動通知 CPU,CPU 再暫停手邊的工作去處理。

原文那個比喻我一直覺得很好用:你在看書(主程式在跑),有人敲門(GPIO 觸發),你放下書去開門(執行 ISR),開完門回來繼續看書(恢復主程式)。

跟輪詢(Polling)比起來,中斷的好處非常明顯:

  • 省電:CPU 不需要一直去檢查 GPIO 狀態,可以待在睡眠模式
  • 即時性:事件發生到回應的延遲(Latency)可控且極短
  • 效率:不用浪費 CPU 週期在無意義的查詢上

流程圖:輪詢與中斷在同一件事上的兩種做法

還有一件事值得先講:中斷跟 DMA 傳輸是天生一對。DMA 負責搬資料,中斷負責通知 CPU「資料搬好了」—— 分工乾淨,CPU 也不會被綁在搬運上。

GPIO 外部中斷的工作原理

我習慣把硬體路徑拆成六步來看,這樣比較不會把「誰負責什麼」搞混:

  1. GPIO 腳位:外部訊號(按鈕、感測器輸出、其他 MCU 的訊號)接到 GPIO
  2. 邊緣/電平檢測電路:每個 GPIO 內部都有硬體電路,可以檢測 Rising Edge、Falling Edge 或 Low Level
  3. NVIC 或 CPU 中斷控制器:中斷條件滿足時,向 CPU 發送中斷請求
  4. 向量表中查找 ISR:CPU 依中斷編號,在 Vector Table 找到對應的中斷服務函式入口
  5. 執行 ISR:CPU 保存當前 Context,然後執行 ISR
  6. 返回:ISR 執行完畢,CPU 恢復 Context 回到原本的程式

方塊圖:從 GPIO 事件到 ISR 的硬體路徑

這條路徑上有一個關鍵字值得記住:延遲(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)

邏輯分析圖:Rising、Falling 與 Both Edge 三種觸發的中斷位置

原文那張時序圖想講的其實是三件事: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 交出來,我整理成下面這張圖:

表格:ISR 交付事件的旗標、計數與 FreeRTOS 佇列三種做法

同樣的問題,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 看起來像當機。先把三個關鍵字記住:

表格:ISR 的三個關鍵字與漏掉的後果

接著是原文那張「可以做的事/不該做的事」,這張我建議直接背下來:

✅ 可以做的事 ❌ 不該做的事
讀取/寫入 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 會在短時間內快速變換,一次按下可能造成多次中斷。

流程圖:軟體消抖以 50 ms 門檻過濾彈跳

// 軟體消抖範例(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 就是最後一道防線。

標籤: 教學
最後更新:2026 年 9 月 30 日

shi6a

這個人很懶,什麼都沒留下

點贊
< 上一篇
下一篇 >

文章評論

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回覆

COPYRIGHT © 2026 0x6A Logbook. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang