關閉

第15章 Arduino Interrupt(中斷)與即時事件處理:attachInterrupt、ISR、volatile 與脈衝計數完整教學

完整介紹 Arduino Interrupt(中斷)與即時事件處理,說明 Polling 輪詢與中斷的差異、Arduino Uno 外部中斷腳位 D2 與 D3、attachInterrupt()、detachInterrupt()、RISING、FALLING、CHANGE、LOW 觸發模式,以及 ISR 中斷服務常式的設計原則。文章並透過按鍵計數、軟體去彈跳、脈衝頻率、馬達 RPM 與旋轉編碼器實作,說明 volatile、noInterrupts()、interrupts() 與共享變數安全存取方式。

本章承接第 14 章的非阻塞程式設計,進一步說明 Arduino 如何使用 Interrupt(中斷)即時回應按鍵、脈衝與快速事件。

內容以 Arduino Uno R3 為主要示範環境,涵蓋外部中斷腳位、attachInterrupt()、ISR、volatile、按鍵去彈跳、脈衝計數、轉速計算與共享變數的安全存取。

中斷適合處理短暫且不能漏掉的事件,但 ISR 必須保持簡短,不可在其中執行 delay()、大量 Serial 輸出或耗時工作。

本章學習目標

什麼是 Interrupt 中斷

Interrupt 中文稱為中斷。當 Arduino 正在執行主程式時,如果外部發生重要事件,中斷機制會暫停目前工作,立即執行指定的中斷服務常式,處理完成後再回到原本位置繼續執行。

常見中斷事件包括按鍵按下、感測器脈衝、旋轉編碼器轉動、流量計輸出、馬達轉速感測器脈衝與計時器事件。

為什麼需要中斷

一般 Arduino 程式常在 loop() 中不斷讀取輸入腳位,這種方式稱為 Polling(輪詢)。程式簡單時,輪詢容易理解且容易除錯。

當主程式同時更新 LCD、讀取多個感測器、處理 Serial、控制馬達、執行長時間運算或使用 delay() 時,短暫脈衝可能在兩次檢查之間消失,中斷便能提高事件反應速度。

void loop()
{
  if (digitalRead(2) == LOW)
  {
    Serial.println("Button pressed");
  }
}

輪詢與中斷的差異

適合使用中斷的情況

不一定需要中斷的情況

中斷不是越多越好。只有在需要快速反應,或輪詢可能漏掉事件時,才應優先考慮使用。

Arduino Uno 的外部中斷腳位

不同 Arduino 開發板的外部中斷腳位可能不同,因此不建議直接在程式中寫死中斷編號。

建議使用 digitalPinToInterrupt(pin) 將 Arduino 腳位轉換成中斷編號,讓程式更容易移植。

attachInterrupt(
  digitalPinToInterrupt(2),
  handleInterrupt,
  FALLING
);

attachInterrupt() 基本語法

attachInterrupt(
  digitalPinToInterrupt(pin),
  ISR,
  mode
);

中斷觸發模式

LOW 低電位觸發

當腳位維持 LOW 時觸發。只要訊號持續為低電位,就可能反覆進入 ISR,因此初學階段較少使用。

attachInterrupt(
  digitalPinToInterrupt(2),
  handleInterrupt,
  LOW
);

CHANGE 狀態改變觸發

當訊號由 LOW 變 HIGH 或由 HIGH 變 LOW 時觸發,適合旋轉編碼器、脈衝寬度量測及同時偵測上升緣與下降緣。

attachInterrupt(
  digitalPinToInterrupt(2),
  handleInterrupt,
  CHANGE
);

RISING 上升緣觸發

當訊號由 LOW 變成 HIGH 時觸發,適合偵測正向脈衝的上升緣。

attachInterrupt(
  digitalPinToInterrupt(2),
  handleInterrupt,
  RISING
);

FALLING 下降緣觸發

當訊號由 HIGH 變成 LOW 時觸發。按鍵搭配 INPUT_PULLUP 時,放開為 HIGH、按下為 LOW,因此按下瞬間通常使用 FALLING。

attachInterrupt(
  digitalPinToInterrupt(2),
  handleInterrupt,
  FALLING
);

第一個中斷實驗:按鍵計數器

使用 Arduino 內部上拉電阻,D2 經按鍵接到 GND,不需要額外外接上拉電阻。

const int BUTTON_PIN = 2;
volatile unsigned long pressCount = 0;

void setup()
{
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  Serial.begin(9600);

  attachInterrupt(
    digitalPinToInterrupt(BUTTON_PIN),
    handleButtonPress,
    FALLING
  );
}

void loop()
{
  Serial.print("Press count: ");
  Serial.println(pressCount);
  delay(500);
}

void handleButtonPress()
{
  pressCount++;
}

每當按鍵訊號由 HIGH 變成 LOW,ISR 就會把 pressCount 加一。此範例主要用來理解中斷流程,後續會改善共享變數讀取與按鍵彈跳問題。

什麼是 ISR

ISR 是 Interrupt Service Routine 的縮寫,中文稱為中斷服務常式。

當中斷發生時,Arduino 暫停 loop(),執行 ISR,完成後再回到主程式。ISR 的核心原則是越短越好。

ISR 適合執行的工作

ISR 不適合執行的工作

ISR 執行太久會暫停主程式並延遲其他中斷,可能造成程式反應遲鈍、資料遺失、計時不準、通訊異常或系統卡住。

ISR 中應避免的函式

不要在 ISR 中使用 delay()

void handleButtonPress()
{
  delay(100);
  pressCount++;
}

delay() 依賴計時中斷運作,而 ISR 執行期間其他中斷通常受到限制,因此 delay() 可能無法正常工作。ISR 內不應等待。

不要在 ISR 中大量使用 Serial

void handleButtonPress()
{
  Serial.println("Button pressed");
}

Serial.print() 可能依賴緩衝區與中斷傳送資料。在 ISR 中大量輸出可能造成緩衝區塞滿、程式停住、輸出不完整或其他中斷延遲。

volatile bool buttonEvent = false;

void handleButtonPress()
{
  buttonEvent = true;
}

void loop()
{
  if (buttonEvent)
  {
    buttonEvent = false;
    Serial.println("Button pressed");
  }
}

volatile 關鍵字

若變數會同時由 ISR 修改,並由主程式讀取或修改,就應宣告為 volatile。

volatile 告訴編譯器,該變數可能隨時被另一個執行流程修改,每次存取都必須以記憶體中的實際值為準。

volatile bool buttonEvent = false;
volatile unsigned long pulseCount = 0;

使用旗標處理中斷事件

較安全的架構是讓 ISR 只設定旗標,再由 loop() 執行 LED 控制、Serial 輸出與其他完整工作。

const int BUTTON_PIN = 2;
const int LED_PIN = 9;

volatile bool buttonEvent = false;
bool ledState = false;

void setup()
{
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(9600);

  attachInterrupt(
    digitalPinToInterrupt(BUTTON_PIN),
    handleButtonPress,
    FALLING
  );
}

void loop()
{
  if (buttonEvent)
  {
    buttonEvent = false;
    ledState = !ledState;
    digitalWrite(LED_PIN, ledState);

    Serial.print("LED: ");
    Serial.println(ledState ? "ON" : "OFF");
  }
}

void handleButtonPress()
{
  buttonEvent = true;
}

按鍵彈跳與誤觸發

機械按鍵在接點接觸瞬間,訊號可能在 HIGH 與 LOW 之間快速反覆變化。使用者只按一次,Arduino 卻可能收到多次中斷,這種現象稱為 Contact Bounce(接點彈跳)。

按鍵彈跳可能使 pressCount 一次增加多次,因此必須加入軟體或硬體去彈跳。

在 ISR 中進行基本時間判斷

const int BUTTON_PIN = 2;

volatile unsigned long pressCount = 0;
volatile unsigned long lastInterruptTime = 0;

const unsigned long DEBOUNCE_TIME = 50;

void setup()
{
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  Serial.begin(9600);

  attachInterrupt(
    digitalPinToInterrupt(BUTTON_PIN),
    handleButtonPress,
    FALLING
  );
}

void loop()
{
  noInterrupts();
  unsigned long countCopy = pressCount;
  interrupts();

  Serial.print("Press count: ");
  Serial.println(countCopy);
  delay(500);
}

void handleButtonPress()
{
  unsigned long currentTime = millis();

  if (currentTime - lastInterruptTime >= DEBOUNCE_TIME)
  {
    pressCount++;
    lastInterruptTime = currentTime;
  }
}

millis() 可在 ISR 中讀取目前值,但 ISR 執行期間通常不會持續正常更新。可用於短時間事件判斷,不可在 ISR 內等待 millis() 改變。

較安全的旗標式去彈跳

const int BUTTON_PIN = 2;
const int LED_PIN = 9;

volatile bool interruptTriggered = false;

unsigned long lastAcceptedTime = 0;
const unsigned long DEBOUNCE_TIME = 50;
bool ledState = false;

void setup()
{
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(9600);

  attachInterrupt(
    digitalPinToInterrupt(BUTTON_PIN),
    handleButtonInterrupt,
    FALLING
  );
}

void loop()
{
  if (interruptTriggered)
  {
    noInterrupts();
    interruptTriggered = false;
    interrupts();

    unsigned long currentTime = millis();

    if (currentTime - lastAcceptedTime >= DEBOUNCE_TIME)
    {
      lastAcceptedTime = currentTime;
      ledState = !ledState;
      digitalWrite(LED_PIN, ledState);

      Serial.print("LED: ");
      Serial.println(ledState ? "ON" : "OFF");
    }
  }
}

void handleButtonInterrupt()
{
  interruptTriggered = true;
}

這種設計讓 ISR 只設定旗標,時間判斷、LED 控制與 Serial 輸出都由 loop() 完成。

noInterrupts() 與 interrupts()

Arduino Uno 使用 8 位元微控制器。unsigned long 需要 4 Bytes,主程式讀取到一半時若 ISR 修改變數,可能得到混合且不完整的數值。

讀取多位元共享變數時,可短暫停用中斷,複製到一般變數後立即重新啟用。停用區段必須越短越好。

noInterrupts();
unsigned long countCopy = pulseCount;
interrupts();

不要在 noInterrupts() 與 interrupts() 之間執行 delay()、Serial 輸出、顯示更新或長時間運算,否則可能漏掉脈衝、影響通訊與時間計算。

detachInterrupt() 停用外部中斷

不再需要某個外部中斷時,可使用 detachInterrupt() 停用該腳位的中斷處理。

void stopButtonInterrupt()
{
  detachInterrupt(
    digitalPinToInterrupt(BUTTON_PIN)
  );
}

第二個實驗:脈衝計數器

流量感測器、霍爾感測器、馬達轉速感測器、光電編碼器、風速計與電能計量裝置常以脈衝代表事件。

const int PULSE_PIN = 2;
volatile unsigned long pulseCount = 0;

void setup()
{
  pinMode(PULSE_PIN, INPUT_PULLUP);
  Serial.begin(9600);

  attachInterrupt(
    digitalPinToInterrupt(PULSE_PIN),
    countPulse,
    FALLING
  );
}

void loop()
{
  noInterrupts();
  unsigned long countCopy = pulseCount;
  interrupts();

  Serial.print("Pulse count: ");
  Serial.println(countCopy);
  delay(1000);
}

void countPulse()
{
  pulseCount++;
}

每收到一個下降緣脈衝,pulseCount 就增加一次。主程式以短暫原子區段複製計數值,避免讀取不完整資料。

第三個實驗:測量每秒脈衝數

const int PULSE_PIN = 2;

volatile unsigned long pulseCount = 0;

unsigned long previousMillis = 0;
const unsigned long REPORT_INTERVAL = 1000;

void setup()
{
  pinMode(PULSE_PIN, INPUT_PULLUP);
  Serial.begin(9600);

  attachInterrupt(
    digitalPinToInterrupt(PULSE_PIN),
    countPulse,
    FALLING
  );
}

void loop()
{
  unsigned long currentMillis = millis();
  unsigned long elapsedTime =
    currentMillis - previousMillis;

  if (elapsedTime >= REPORT_INTERVAL)
  {
    previousMillis = currentMillis;

    noInterrupts();
    unsigned long pulseCopy = pulseCount;
    pulseCount = 0;
    interrupts();

    float pulsesPerSecond =
      pulseCopy * 1000.0 / elapsedTime;

    Serial.print("Pulses per second: ");
    Serial.println(pulsesPerSecond, 2);
  }
}

void countPulse()
{
  pulseCount++;
}

從脈衝計算轉速

感測器每轉一圈輸出一個脈衝時,RPM 等於每秒脈衝數乘以 60。pulsesPerSecond 應使用實際經過時間換算,而不是假設每次量測都剛好等於一秒。

float rpm = pulsesPerSecond * 60.0;

若每轉一圈產生多個脈衝,則必須再除以每圈脈衝數。

const int PULSES_PER_REVOLUTION = 20;

float rpm =
  pulsesPerSecond * 60.0 /
  PULSES_PER_REVOLUTION;

第四個實驗:中斷控制 LED 狀態

const int BUTTON_PIN = 2;
const int LED_PIN = 9;

volatile bool buttonEvent = false;
bool ledState = false;

void setup()
{
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(9600);

  attachInterrupt(
    digitalPinToInterrupt(BUTTON_PIN),
    handleButtonInterrupt,
    FALLING
  );
}

void loop()
{
  if (buttonEvent)
  {
    noInterrupts();
    buttonEvent = false;
    interrupts();

    ledState = !ledState;
    digitalWrite(LED_PIN, ledState);

    Serial.print("LED state: ");
    Serial.println(ledState ? "ON" : "OFF");
  }
}

void handleButtonInterrupt()
{
  buttonEvent = true;
}

中斷與旋轉編碼器

旋轉編碼器常輸出 A、B 兩組方波,透過兩組訊號的先後順序,可判斷順時針、逆時針與旋轉步數。簡化接線為 CLK 接 D2、DT 接 D4、GND 接 GND。

const int ENCODER_CLK = 2;
const int ENCODER_DT = 4;

volatile long encoderPosition = 0;

void setup()
{
  pinMode(ENCODER_CLK, INPUT_PULLUP);
  pinMode(ENCODER_DT, INPUT_PULLUP);
  Serial.begin(9600);

  attachInterrupt(
    digitalPinToInterrupt(ENCODER_CLK),
    handleEncoder,
    FALLING
  );
}

void loop()
{
  noInterrupts();
  long positionCopy = encoderPosition;
  interrupts();

  Serial.print("Position: ");
  Serial.println(positionCopy);
  delay(100);
}

void handleEncoder()
{
  if (digitalRead(ENCODER_DT) == HIGH)
  {
    encoderPosition++;
  }
  else
  {
    encoderPosition--;
  }
}

此為入門版範例。實際旋轉編碼器可能有接點彈跳、多次邊緣、方向誤判與快速旋轉漏計數,進階專案可採完整狀態解碼或專用函式庫。

外部中斷與 Pin Change Interrupt

Arduino Uno 除了 D2、D3 的標準外部中斷外,ATmega328P 還支援 Pin Change Interrupt(腳位變化中斷),可讓更多數位腳位偵測狀態改變。

Pin Change Interrupt 設定較複雜,通常需要操作暫存器或使用函式庫,而且多個腳位可能共用同一個 ISR,程式必須自行判斷是哪個腳位發生變化。初學階段建議先熟悉 D2 與 D3。

中斷優先順序與嵌套

微控制器可能同時具有外部中斷、Timer、UART、ADC 與 SPI 等中斷來源。一般 Arduino 程式不需要手動設定優先順序,但 ISR 仍應保持簡短,避免長時間阻擋其他中斷。

Arduino Uno 一般在執行 ISR 時不會立即進入其他 ISR。初學者不應在 ISR 內呼叫 interrupts() 允許中斷嵌套,否則會大幅增加程式複雜度與風險。

中斷與 millis()、micros()

共享變數與不可分割存取

若 ISR 修改 volatile unsigned long pulseCount,而 loop() 同時讀取,主程式可能先讀取部分 Byte,接著 ISR 修改變數,再讀取剩餘 Byte,最後得到混合結果。

使用 noInterrupts() 與 interrupts() 保護必要的讀寫區段,可形成 Atomic Access(不可分割存取)。

noInterrupts();
unsigned long countCopy = pulseCount;
interrupts();

bool 或 byte 是否需要停用中斷

在 Arduino Uno 上,單一 byte 或 bool 通常可以一次完成讀寫,因此較不容易讀到一半。

若操作包含讀取後立刻清除,例如讀取 eventFlag 後設為 false,仍可能在兩個動作之間再次發生中斷。要求嚴謹時,可把複合操作放在短暫原子區段內。

noInterrupts();
bool eventCopy = eventFlag;
eventFlag = false;
interrupts();

推薦的中斷程式架構

void handleInterrupt()
{
  eventFlag = true;
}

void loop()
{
  noInterrupts();
  bool eventCopy = eventFlag;
  eventFlag = false;
  interrupts();

  if (eventCopy)
  {
    updateLed();
    updateDisplay();
    sendSerialReport();
  }
}

綜合實驗:即時脈衝監測系統

const int PULSE_PIN = 2;
const int LED_PIN = 9;

volatile unsigned long pulseCount = 0;

unsigned long previousMillis = 0;
const unsigned long SAMPLE_INTERVAL = 1000;
const float ALERT_THRESHOLD = 20.0;

void setup()
{
  pinMode(PULSE_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(9600);

  attachInterrupt(
    digitalPinToInterrupt(PULSE_PIN),
    countPulse,
    FALLING
  );
}

void loop()
{
  unsigned long currentMillis = millis();
  unsigned long elapsedTime =
    currentMillis - previousMillis;

  if (elapsedTime >= SAMPLE_INTERVAL)
  {
    previousMillis = currentMillis;

    noInterrupts();
    unsigned long pulseCopy = pulseCount;
    pulseCount = 0;
    interrupts();

    float frequency =
      pulseCopy * 1000.0 / elapsedTime;

    bool alertState =
      frequency > ALERT_THRESHOLD;

    digitalWrite(
      LED_PIN,
      alertState ? HIGH : LOW
    );

    Serial.print("Frequency: ");
    Serial.print(frequency, 2);
    Serial.print(" Hz");
    Serial.print(" Alert: ");
    Serial.println(
      alertState ? "ON" : "OFF"
    );
  }
}

void countPulse()
{
  pulseCount++;
}

預期輸出

Frequency: 8 Hz Alert: OFF
Frequency: 16 Hz Alert: OFF
Frequency: 25 Hz Alert: ON
Frequency: 31 Hz Alert: ON

常見錯誤與故障排除

中斷除錯檢查順序

中斷使用原則

本章實作練習

練習一:按鍵事件旗標

練習二:中斷計數器

練習三:不阻塞脈衝計數器

練習四:轉速計

假設感測器每圈輸出兩個脈衝,使用公式 RPM = 每秒脈衝數 × 60 ÷ 2 計算轉速。

練習五:緊急停止事件

Challenge:即時轉速監測器

參考輸出

Pulses: 800 RPM: 2400 Alert: OFF
Pulses: 1050 RPM: 3150 Alert: ON

本章重點整理

下一章預告:Arduino EEPROM 非揮發性記憶體