2016年9月11日 星期日

Arduino 的按鈕開關測試 (一) : 輪詢法 (Polling)

過去兩周把 Blynk 搞定之後, 突然覺得有點茫然, 因為一時不知道接下來要做甚麼. 其實 Arduino 還有很多課題還沒玩透, 但可能就是太多了才不知道該先玩哪一個. 今天隨便翻書看到按鈕開關測試, 嘿! 擇期不如撞期, 不如回來測試 Arduino 基本功能好了. 雖說這是 Arduino 比較基礎的實驗, 但實際測試後才發現學問還蠻多的, 特別是處理彈跳的部分, 不是我原先想的那麼簡單, 真的.

此實驗我主要是參考了下面四本書的範例進行修改 :
  1. Arduino 輕鬆入門, 葉難, 博碩出版 (第 3-3, 3-4 節)
  2. Arduino 完全實戰手冊, 王冠勛譯, 博碩出版 (第 2-2-4 節)
  3. Prototyping Lab, 小林茂, 馥林文化 (第 25 章)
  4. Arduino Cookbook 錦囊妙計, 歐萊禮 (第 5-2, 5-3 節)
按鈕開關 (Push button) 是一個常開按鈕, 平常為開路不連通, 按下去時才形成通路. 常見的按鈕開關是有四隻針腳的四方形元件, 中間有一圓鈕, 可以跨過麵包板溝槽插在上面, 如下圖所示 :


同一側兩隻腳在按鈕尚未按下時是開路的, 按下時為通路; 而對面的兩隻腳則永遠是連通的, 我們通常是使用同一側的兩個接腳, 電路結構如下圖 :


第一個實驗是要測試按鈕按下時, Arduino 內建的 D13 LED 會交替明滅, 即原來是暗的變亮, 原來是亮的變暗, 接線方式為將 Arduino D2 設定為使用內建上拉電阻之輸入腳, 並連線至按鈕開關一腳, 而按鈕開關另一腳則接地, 使按鈕按下時 Arduino 之 D2 接地, 放開時則被內建上拉電阻拉到 HIGH. 程式如下 :

測試 1 :

const byte swPin=2;  //switch pin
const byte ledPin=13;  //built-in LED
boolean ledState=LOW;  //initial state of LED

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(swPin, INPUT_PULLUP); //enable pull-up resistor
  digitalWrite(ledPin, ledState); //set LED OFF
  }

void loop() {
  boolean swState=digitalRead(swPin);
  if (swState==LOW) { //switch pressed
    if (ledState==HIGH) {ledState=LOW;} //ON:turn LED OFF
    else {ledState=HIGH;}  //OFF:turn LED ON
    digitalWrite(ledPin, ledState); //toggle LED
    }
  }

因為要記住 D13 LED 目前狀態, 因此必須用一個全域變數 ledState 來記錄, 預設狀態為 LOW, 所以程式開始執行後, 跑完 setup() 時 LED 是暗的. 這裡我們使用 INPUT_PULLUP 將 D2 腳設為內建上拉電阻的輸入腳, 這樣當 D2 浮接 (floating) 時, 其位準會被上拉至 HIGH.

注意, 開啟上拉電阻是必要的, 因為當輸入腳是浮接時, digitalRead() 讀到的是隨機的雜訊, 如果 D2 只設為 INPUT 而且浮接 (不接 GND 也不接 +5V), 則送電後 D13 LED 會不斷閃爍, 因為在 loop() 主迴圈中, 每一次迴圈讀到的 D2 值有時是 HIGH, 有時是 LOW, 於是看起來就是隨機地在閃爍, 只要將上面測試 1 中的 INPUT_PULLUP 改為 INPUT 即知, 如下面影片所示 :


若 D2 為 INPUT 模式, 沒有上拉電阻的話, 上電後 LED 就隨機閃爍, 直到按下按鈕被拉到 LOW 才停止, 但一放開變浮接又開始閃了. 關於上拉電阻更詳細說明參考 :

Arduino Internal Pull-Up Resistor Tutorial (超棒)

上拉電阻有一個舊式用法, 先用 pinMode() 設定 DIO 為 INPUT 腳, 然後再用 digitalWrite() 輸出 HIGH 即可啟用 Arduino 數位接腳的內建上拉電阻 :

pinMode(swPin, INPUT);
digitalWrite(swPin, HIGH);

不過這是還未支援 INPUT_PULLUP 的舊版 (1.0.1 以前) Arduino IDE 之用法, 現在應該使用 INPUT_PULLUP 為宜. Arduino 的 DIO 若設為 INPUT, 其輸入阻抗約 100M 歐姆, 而內建的上拉電阻, 根據 "Arduino Cookbook 錦囊妙計" 5-2 節末尾描述, 其值約在 20K~50K 歐姆之間. 關於上拉電阻說明, 參考 :

pinMode()
Input Pullup Serial
Arduino內部的上拉電阻
使用Arduino 的pull-up 電路
從按鈕開關看上拉pull-up電阻下拉電阻是蝦密碗糕
https://www.arduino.cc/en/Tutorial/InputPullupSerial

上面測試 1 的程式上傳後, 測試發現實際上 LED 的明滅並非如我們所期望的那樣, 按一下亮, 再按一下滅, 而可能是本來亮著, 按一下還是亮, 再按一下滅, 再按一下閃一下又滅 ... 或者按著的時候亮, 放開即滅, 與程式的邏輯不同. 這種不正常現象來自按鈕開關的機械彈跳 (Bounce) 特性, 當按下按鈕時, D2 並不是一次從上拉的 HIGH 直接跳到 LOW, 而是在 HIGH 與 LOW 之間來回切換好幾次才成為穩定的 LOW 狀態, 這種彈跳的暫態現象通常可能持續 10 毫秒到 50 毫秒左右.

因為 loop() 執行速度很快, 所以在按鈕按下的瞬間, digitalRead() 已經讀取到數十次到數百次 HIGH 與 LOW 切換的值了, 因此如果在到達穩定狀態前讀取到偶數次的 HIGH-LOW 變換, 那麼 LED 就明滅偶數次, 看起來 LED 就好像沒變換狀態; 如果是奇數次, 就跟程式邏輯相符, LED 會變換狀態. 如果在上面測試 1 程式中加入計數器, 就可以知道在按下按鈕時迴圈已跑多少圈了, 參考 "Arduino 輕鬆入門" 第 3-3 節 :


測試 2 :

const byte swPin=2;  //switch pin
const byte ledPin=13;  //built-in LED
boolean ledState=LOW;  //initial state of LED
int count=0; //bounce counter

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(swPin, INPUT_PULLUP); //enable pull-up resistor
  digitalWrite(ledPin, ledState); //set LED OFF
  Serial.begin(9600);
  }

void loop() {
  boolean swState=digitalRead(swPin);
  if (swState==LOW) { //switch pressed
    if (ledState==HIGH) {ledState=LOW;} //ON:turn LED OFF
    else {ledState=HIGH;}  //OFF:turn LED ON
    digitalWrite(ledPin, ledState); //toggle LED
    Serial.print("Low count:");
    Serial.println(count); //output counter value
    count++; //increment bounce counter
    }
  }

此程式加入一個全域變數 count 來計算在 D2 為 LOW 期間 loop() 迴圈已跑幾次 (會累計), 通常按即放開至少都十幾次以上, 當然按著越久就越多次. 序列埠監控視窗擷取訊息如下 :

Low count:0
Low count:1
Low count:2
Low count:3
Low count:4
Low count:5
Low count:6
Low count:7
Low count:8
Low count:9
Low count:10
Low count:11
Low count:12
Low count:13
Low count:14

解決這個問題的辦法最直接的是時間延遲法, 意即當偵測到按鈕被按下, D2 位準變 LOW 後, 延遲一段時間若狀態還是 LOW, 表示彈跳情況已消失, 按鈕已穩定在被按下狀態, 這時再去反轉 LED 明滅狀態, 我參考 "Arduino 輕鬆入門" 第 3-4 節範例, 將其修改為下面測試 3 :

測試 3 :

const byte swPin=2;  //switch pin
const byte ledPin=13;  //built-in LED
boolean ledState=LOW;  //initial state of LED
int debounceDelay=200; //debounce delay (ms)
unsigned long lastMillis; //record last millis

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(swPin, INPUT_PULLUP); //enable pull-up resistor
  digitalWrite(ledPin, ledState); //set LED OFF initially
  }

void loop() {
  boolean swState=digitalRead(swPin);
  if (swState==LOW) { //switch pressed
    if (debounced()) { //debounced: reverse LED state
      ledState = !ledState; //reverse LED state
      digitalWrite(ledPin, ledState); //toggle LED    
      }
    }
  }

boolean debounced() { //check if debounced
  unsigned long currentMillis=millis(); //get current elapsed time
  if ((currentMillis-lastMillis) > debounceDelay) {
    lastMillis=currentMillis; //update lastMillis with currentMillis
    return true; //debounced
    }
  else {return false;} //not debounced
  }

在此程式中我添加了一個自訂的 debounced() 函數來檢查現在的毫秒數 (程式開始執行以來) 跟上一次紀錄之毫秒數 (即上一次 D2 為 LOW 之時間) 是否已超過預期的彈跳時間 bounceDelay, 如果是, 表示按鈕已穩定地被按下, 就可以將 LED 狀態反轉了; 否則表示彈跳還沒結束, 不進行反轉.

在上面程式中我定義了一個全域變數 lastMillis 來記錄上一次 D2 為 LOW的時間, 其實也可以將此變數設為 static 變數放在 debounced() 內, 效果是一樣的, 因為跳出函數後, 宣告為 static 的區域變數不會消失, 會被保留下來, 下次呼叫時可再次存取. 參考 "Arduino 完全實戰手冊" 第 2-2-4 節 :

測試 3-1 :

const byte swPin=2;  //switch pin
const byte ledPin=13;  //built-in LED
boolean ledState=LOW;  //initial state of LED
int debounceDelay=50; //debounce delay (ms)

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(swPin, INPUT_PULLUP); //enable pull-up resistor
  digitalWrite(ledPin, ledState); //set LED OFF
  }

void loop() {
  boolean swState=digitalRead(swPin);
  if (swState==LOW) { //switch pressed
    if (debounced()) { //debounced: reverse LED state
      ledState = !ledState; //reverse LED state
      digitalWrite(ledPin, ledState); //toggle LED    
      }
    }
  }

boolean debounced() { //check if debounced
  static unsigned long lastMillis=0; //record last millis
  unsigned long currentMillis=millis(); //get current elapsed time
  if ((currentMillis-lastMillis) > debounceDelay) {
    lastMillis=currentMillis; //update lastMillis with currentMillis
    return true; //debounced
    }
  else {return false;} //not debounced
  }

同樣是使用時間延遲, 我在 "Arduino Cookbook 錦囊妙計" 第 5-2 節找到不一樣的做法 (主要是 debounced 函數), 程式改編如下 :

測試 4 :

const byte swPin=2;  //switch pin
const byte ledPin=13;  //built-in LED
boolean ledState=LOW;  //initial state of LED
int debounceDelay=100; //debounce delay (ms)

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(swPin, INPUT_PULLUP); //enable pull-up resistor
  digitalWrite(ledPin, ledState); //set LED OFF
  }

void loop() {
  if (debounced(swPin)) { //switch pressed and statble for 50 ms
    ledState =! ledState; //reverse LED state
    digitalWrite(ledPin, ledState); //toggle LED
    }
  }

boolean debounced(int pin) {
  boolean currentState; //current pin state
  boolean previousState; //previous pin state
  previousState=digitalRead(pin); //record current state as previous
  for (int i=0; i<debounceDelay; i++) { //detect if stable
    delay(1); //delay 1 ms
    currentState=digitalRead(pin); //get current state
    if (currentState != previousState) { //still unstable
      i=0; //reset counter
      previousState=currentState; //updtae previous state
      }
    }
  if (currentState==LOW) {return true;} //switch pressed (pull-up)
  else {return false;} //switch released
  }

這裡 debounced() 要傳入欲偵測的腳位編號, 函數內宣告了兩個布林變數來儲存目前與上一次的開關狀態, 然後以 debounceDelay 當迴圈終止值每 1 毫秒去偵測現在開關狀態是否與上一次變化時所儲存的狀態相同, 如果不同, 表示還在彈跳, 就會重新計數, 並持續記錄狀態. 如果迴圈結束時, 開關狀態是 LOW, 就回傳 true 表示按鈕已穩定被按下, 否則傳回 false.

也可以使用 Bounce2 函式庫來處理彈跳問題, 在 "Arduino 輕鬆入門" 3-4 有介紹, 但那是比較舊的 Bounce 函式庫, 現在已更新為 Bounce2 了. 此函式庫可從下列網址下載 :

# http://playground.arduino.cc/Code/Bounce

將下載之 zip 檔解壓縮到 Arduino IDE 安裝目錄的 libraries 下, 重開 IDE 即可看到 "檔案/範例" 下有 Bounce2 的範例. 根據此範例改寫為如下測試 5 :

測試 5 : 

#include <Bounce2.h>

const byte swPin=2;  //switch pin
const byte ledPin=13;  //built-in LED
boolean ledState=LOW;  //initial state of LED
int debounceDelay=50; //debounce delay
Bounce debouncer=Bounce(); //create bounce object

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(swPin, INPUT_PULLUP); //enable pull-up resistor
  digitalWrite(ledPin, ledState); //set LED OFF
  debouncer.attach(swPin); //monitor button switch
  debouncer.interval(debounceDelay); //set debounce interval
  }

void loop() {
  debouncer.update();
  int swState=debouncer.read();
  if (swState==LOW) {
    ledState = !ledState; //reverse LED state
    digitalWrite(ledPin, ledState); //toggle LED
    }
  }

與舊版 Bounce 不同的地方是, 在產生全域的 Bounce 物件時不要再傳入參數, 設定要偵測的腳位與彈跳時間要在 setup() 中分別用 Bounce 物件的 attach() 與 interval() 函數來設定. 此函式庫其實就像是一個濾波器, 會把彈跳期間的雜訊濾掉, 輸出穩定的 HIGH-LOW 或 LOW-HIGH 狀態變化. 使用此程式必須在 loop() 中不斷呼叫 Bounce 物件的 update() 來更新濾波結果, 然後再呼叫 read() 將偵測到的最新狀態讀取進來. 我照 "Arduino 輕鬆入門" 3-4 節範例去試結果不會動作, 那是舊版寫法, 新版的 Bounce2 要用新的寫法才行.

其實我有找到另外一個 Bounce 函式庫如下, 不過這更舊, 因為它的 Bounce.cpp 裡還在用 WProgram.h, 須改為 Arduino.h, 這麼麻煩我就不用了 :

https://www.pjrc.com/teensy/td_libs_Bounce.html
WProgram.h 这个文件是做什么用的?哪能下载到?

最後, 我發現 Arduino IDE 的 "檔案/範例" 裡就有 Debounce 的程式, 它的作法稍有不同, 我把它稍作整理便於閱讀如下 :

測試 6 : 

const int buttonPin=2; //the number of the pushbutton pin
const int ledPin=13; //the number of the LED pin
int ledState=HIGH; //the current state of the output pin

int buttonState; //previous stable state of the input pin
int lastButtonState=LOW; //the previous reading from the input pin
long lastDebounceTime=0; //the last time the output pin was toggled
long debounceDelay=50; //the debounce time; increase if the output flickers

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP); //enable pullup resistor
  digitalWrite(ledPin, ledState);
  }

void loop() {
  int reading=digitalRead(buttonPin);
  if (reading != lastButtonState) { //button state changed
    lastDebounceTime=millis(); //update last debounce time
    }
  if ((millis() - lastDebounceTime) > debounceDelay) { //overtime
    if (reading != buttonState) { //button state has changed
      buttonState=reading; //update previous stable button state
      if (buttonState == LOW) { //button pressed
        ledState = !ledState; //reverse LED state
        }
      }
    }
  digitalWrite(ledPin, ledState);
  lastButtonState=reading; //update last button state
  }

原理大致與上面類似, 不同之處是這裡它使用了兩個全域變數來記錄按鈕狀態 : buttonState 紀錄前一次的穩定狀態位準, 而 lastButtonState 則記錄動態的最近狀態. 如果目前狀態與最近狀態不同, 表示還在彈跳當中, 就更新最近去彈跳時間 lastDebounceTime 為目前已執行時間 millis(), 如果狀態相同, 表示可能已趨於穩定, 最近去彈跳時間 lastDebounceTime 就不會被更新, 以便迴圈持續偵測這個穩定是否能撐過 debounceDelay, 若撐過了還須比較現在狀態與上次穩定狀態是否不同, 是的話才進一步去看目前狀態是否為 LOW (按下了). 此程式乍看之下覺得其演算法有點難懂, 但實際測試發現, 在設定 50 毫秒延遲時, 其執行結果完全正確, 且一直壓住按鈕, LED 也不會像上面測試 3, 4 那樣閃爍.

我把上面測試 6 改成用 debounced() 函數來判斷是否已去除彈跳 :

測試 6-1 :

const int buttonPin=2; //the number of the pushbutton pin
const int ledPin=13; //the number of the LED pin
int ledState=HIGH; //the current state of the output pin

int buttonState; //previous stable state of the input pin
int lastButtonState=LOW; //the previous reading from the input pin
long lastDebounceTime=0; //the last time the output pin was toggled
long debounceDelay=50; //the debounce time; increase if the output flickers

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP); //enable pullup resistor
  digitalWrite(ledPin, ledState);
  }

void loop() {
  if (debounced(buttonPin)) { //debounced
    ledState = !ledState; //reverse LED state
    }
  digitalWrite(ledPin, ledState);
  }

boolean debounced(int pin) { //check if debounced
  boolean debounced=false;  //default
  int reading=digitalRead(pin);  //current button state
  if (reading != lastButtonState) { //button state changed
    lastDebounceTime=millis(); //update last debounce time
    }
  if ((millis() - lastDebounceTime) > debounceDelay) { //overtime
    if (reading != buttonState) { //button state has changed
      buttonState=reading; //update previous stable button state
      if (buttonState == LOW) { //button pressed
        debounced=true;
        }
      }
    }
  lastButtonState=reading; //update last button state
  return debounced;
  }

功能與測試 6 相同, 但這樣感覺比較有結構.

總之, 彈跳現象經過上面如此處理後, 執行起來大都正常了, 但也不是百分之百符合程式所期望的邏輯, 主要的關鍵在於 debouceDelay 要設幾毫秒為宜, 設得太短或太長都沒效果. 經我多次測試結果, 測試 3 與 3-1 要設 200 ms 延遲; 測試 4 要設 100 ms 延遲時, 而測試 6 與 6-1 則要設 50 ms 延遲, 這樣正確率最高, 幾乎無失誤; 而使用 Bounce2 函式庫最糟糕, 延遲從 20, 40, 60, ... 到 200 都試過, 沒有一個設定值是滿意的, 好像跟測試 1 差不多.

參考 :

Arduino練習:以開關切換LED明滅狀態


4 則留言 :

WilliamFOX 提到...

Hi ,

debounced函數以多個變數代入,怎麼沒有辦法動阿,
有拆開每PIN去查,但不能執行

if (debounced_v2(buttonPin) == HIGH ){
Serial.println(btn_state);
Serial.print("buttonPin : "); Serial.println(buttonPin);
}
else if (debounced_v2(buttonPin2) == HIGH ){
Serial.println(btn_state2);
Serial.print("buttonPin2 : "); Serial.println(buttonPin2);
}

小狐狸事務所 提到...

請問您這 debounced_v2() 是上面哪一個函數呢? 我都測試過 ok 哩!

WilliamFOX 提到...

debounced_v2()是另外copy出來的,作為與debounced()來比較
我用的是 1.6.12版的ide

WilliamFOX 提到...

以example 6 來看,問題在於全區變數的存取,後來再用笨的點方法,就是多一組變數,就可以解決這些狀況了,感謝

int buttonState; //previous stable state of the input pin
int lastButtonState = LOW; //the previous reading from the input pin
long lastDebounceTime = 0; //the last time the output pin was toggled