本篇為譯文,略有刪減,原文連結見文末。推薦理由:平時關於Arduino函數功能和各種模塊使用的教程很多,卻鮮有關於Arduino編程方法和思路的教程。而本篇文章從最基本的示例切入,深入淺出地介紹了Arduino的多任務、狀態機、面向對象以及定時器、中斷等概念,非常適合提升和開闊初學者Arduino編程的思維方式。
在本篇文章中,我們將會繼續深化上篇《Arduino如何多任務(上)》中介紹的知識點,同時了解Arduino的幾種不同中斷類型。看一看如何使用這些不同的工具和技巧,讓Arduino處理更多任務的同時仍然保持代碼的簡潔和較高的響應度。
什麼是中斷(Interrupt)
中斷機制可以說是現代計算機系統中的基本機制之一,不光像Arduino這樣的嵌入設備上的晶片有,複雜如PC上的CPU也有中斷機制。
那到底什麼是中斷機制呢?打個比方:
你在看一本書,突然你的手機來電話了,這時候你會先放下書,然後接電話,接完電話後你繼續拿起書接著看。
這個過程中,手機電話鈴聲就是中斷信號(Interrupt Signal)。這個信號告訴你(處理器),有急事了,你要放下手中正在做的事,先處理這個電話緊急事件,接電話這個過程就是中斷處理函數(Interrupt Handler)。
在Arduino環境裡,中斷處理函數就像普通的void函數一樣,你可以自己定義,然後綁定在特定的中斷上。當這個中斷信號被觸發時,這個中斷處理函數就會被調用。中斷處理函數執行完畢後,處理器會繼續處理中斷前的事務。
中斷信號如何產生?
有以下幾種方式可以產生中斷:
Arduino晶片內部的定時器Timer
外部中斷針腳狀態變化引起的外部中斷(External Interrupts),UNO外部中斷針腳為D2、D3
針腳組狀態變化引起針腳變化中斷(Pin-change Interrupts),UNO針腳組有3組:A0-A5,D0-D7和D8-D13。
使用中斷的好處
使用中斷一個最大的好處是,你不用在每次loop()裡去條件判斷一個事件是否已經開始了。而且你再也不必擔心延遲響應,或者由於某個子片段長時間佔時導致沒有監測到按鈕按下等情況。
只要中斷條件被觸發,處理器會第一時間掛起正在處理的事務,優先處理中斷處理函數。你所要做的就是規定好中斷處理函數的具體內容。
定時中斷/Timer Interrupts
不用你來叫我們,到時間了我們提醒你!
在上篇中,我們介紹了如何用millis()代替delay(),可以不霸佔處理器實現控時。但這種方法也有它的問題:我們在每次loop循環裡調用一次millis()來獲取當前的時間戳,然後跟上一次的時間比較,看一看是否滿足時間要求了。其實這樣操作有點浪費資源,因為在1毫秒(millisecond)的時間裡,我們可能不止一次地調用millis(),而大部分時間裡判斷條件是不滿足的。那麼。我們可不可以不要這麼頻繁地調用millis(),每隔1毫秒檢查一次是不是更好呢?
定時器和定時中斷就是為此量身設計的。我們可以創建一個每毫秒產生中斷的定時器。這個定時器會觸發中斷處理函數來檢查時間。
Arduino定時器
Arduino UNO內部有三個定時器:Timer0,Timer1和Timer2。實際上,Arduino已經將Timer0的溢出中斷運用到了delay()、delayMicroseconds、millis()、micros()這些時間函數裡,比如每隔1毫秒,Timer0會更新millis()的計數值。而這個頻率正好是我們需要的,所以我們一會兒就直接使用Timer0來產生我們所需的中斷。
頻率和計數
定時器簡而言之就是以一定頻率更新的計數器。而個頻率的來源就是單片機的內部時鐘或者外部晶振產生的時鐘。Arduino UNO默認系統時鐘是16MHz。你可以設置不同的分頻來更改頻率和不同的計數模式。你也可以設置在特定的數值時產生中斷。
Timer0是8位計數器,從0加到255然後可以產生一個溢出中斷。因為默認預分頻器值是64,那麼:
中斷頻率(Hz) = Arduino系統時鐘 / 預分頻器*(計數器周期+1)= 16MHz/64*(255+1)=976.5625Hz
剛好接近我們需要的1KHz,所以我們也不需要額外修改Timer0的頻率,況且我們也不能亂改Timer0,不然會導致millis()、micros()這些函數錯亂。
比較匹配寄存器
Timer0除了計數溢出產生中斷(TIMER0_OVF),還可以設置比較匹配寄存器來產生中斷(TIMER0_COMPA和TIMER0_COMPB)。
比如我們可以設置名為OCR0A的寄存器,比較值設為175(十六進位為0xAF),Timer0每次計數時都會跟OCR0A的值比較,如果相等那麼就會產生一個中斷。代碼如下:
OCR0A = 0xAF; TIMSK0 |= _BV(OCIE0A);然後我們需要定義中斷處理函數
SIGNAL(TIMER0_COMPA_vect){ unsigned long currentMillis = millis(); sweeper1.Update(currentMillis); sweeper2.Update(currentMillis); led1.Update(currentMillis); led2.Update(currentMillis); led3.Update(currentMillis); }這樣上篇中控制三個LED閃爍和2個舵機掃臂的整體代碼可以改成這樣:
#include <Servo.h>
class Flasher{ int ledPin; long OnTime; long OffTime;
int ledState; unsigned long previousMillis;
public: Flasher(int pin, long on, long off) { ledPin = pin; pinMode(ledPin, OUTPUT); OnTime = on; OffTime = off; ledState = LOW; previousMillis = 0; }
void Update(unsigned long currentMillis){ if((ledState == HIGH) && (currentMillis - previousMillis >= OnTime)) { ledState = LOW; previousMillis = currentMillis; digitalWrite(ledPin, ledState); } else if ((ledState == LOW) && (currentMillis - previousMillis >= OffTime)) { ledState = HIGH; previousMillis = currentMillis; digitalWrite(ledPin, ledState); } }};
class Sweeper{ Servo servo; int pos; int increment; int updateInterval; unsigned long lastUpdate;
public: Sweeper(int interval) { updateInterval = interval; increment = 1; } void Attach(int pin){ servo.attach(pin); } void Detach(){ servo.detach(); } void Update(unsigned long currentMillis){ if((currentMillis - lastUpdate) > updateInterval) { lastUpdate = millis(); pos += increment; servo.write(pos); if ((pos >= 180) || (pos <= 0)) { increment = -increment; } } }}; Flasher led1(11, 123, 400);Flasher led2(12, 350, 350);Flasher led3(13, 200, 222);
Sweeper sweeper1(25);Sweeper sweeper2(35); void setup() { sweeper1.Attach(9); sweeper2.Attach(10); OCR0A = 0xAF; TIMSK0 |= _BV(OCIE0A);} SIGNAL(TIMER0_COMPA_vect) { unsigned long currentMillis = millis(); sweeper1.Update(currentMillis); if(digitalRead(2) == HIGH) { sweeper2.Update(currentMillis); led1.Update(currentMillis); } led2.Update(currentMillis); led3.Update(currentMillis);}
void loop(){}這樣,在loop()裡我們連一行代碼也沒寫,甚至你現在可以在loop()裡隨便用delay()也沒關係,因為現在LED閃爍和舵機控制完全用中斷實現,跟主循環已經完全解耦。
外部中斷/External Interrupts
與定時中斷不同,外部中斷由外部的事件觸發,比如當一個按鈕被按下、或者處理器收到編碼器產生的一個脈衝信號。但和定時中斷一樣,外部中斷同樣不需要你在主循環loop()函數裡不斷地輪詢某個針腳的狀態變化。
Arduino UNO有兩個外部中斷針腳,分別為D2、D3。
如圖連接好電路,我們將使用D2上的按鈕來重置我們的舵機擺臂。
首先,我們在Sweeper類裡添加一個名為reset()成員函數,這個函數的主要作用就是無論舵機角度在哪,立馬讓舵機轉到零度位置。
void reset() { pos = 0; servo.write(pos); increment = abs(increment); }然後我們創建一個大寫名為Reset的中斷處理函數,函數內調用兩個舵機的reset()函數。
void Reset(){ sweeper1.reset(); sweeper2.reset();}最後,我們只要通過AttachInterrupt()函數,將Reset函數綁定指定的外部中斷即可。UNO Interrupt 0對應就是D2針腳,當D2上的按鈕被按下時,電平信號會FALLING(從HIGH高電平到LOW低電平),這樣就會觸發中斷執行Reset。
pinMode(2, INPUT_PULLUP); attachInterrupt(0, Reset, FALLING);
整體代碼如下:
#include <Servo.h>
class Flasher{ int ledPin; long OnTime; long OffTime;
volatile int ledState; volatile unsigned long previousMillis;
public: Flasher(int pin, long on, long off) { ledPin = pin; pinMode(ledPin, OUTPUT); OnTime = on; OffTime = off; ledState = LOW; previousMillis = 0; }
void Update(unsigned long currentMillis){ if((ledState == HIGH) && (currentMillis - previousMillis >= OnTime)) { ledState = LOW; previousMillis = currentMillis; digitalWrite(ledPin, ledState); } else if ((ledState == LOW) && (currentMillis - previousMillis >= OffTime)) { ledState = HIGH; previousMillis = currentMillis; digitalWrite(ledPin, ledState); } }};
class Sweeper{ Servo servo; int updateInterval; volatile int pos; volatile unsigned long lastUpdate; volatile int increment;
public: Sweeper(int interval) { updateInterval = interval; increment = 1; } void Attach(int pin){ servo.attach(pin); } void Detach(){ servo.detach(); } void reset(){ pos = 0; servo.write(pos); increment = abs(increment); } void Update(unsigned long currentMillis){ if((currentMillis - lastUpdate) > updateInterval) { lastUpdate = currentMillis; pos += increment; servo.write(pos); if ((pos >= 180) || (pos <= 0)) { increment = -increment; } } }}; Flasher led1(11, 123, 400);Flasher led2(12, 350, 350);Flasher led3(13, 200, 222);
Sweeper sweeper1(25);Sweeper sweeper2(35); void setup() { sweeper1.Attach(9); sweeper2.Attach(10); OCR0A = 0xAF; TIMSK0 |= _BV(OCIE0A); pinMode(2, INPUT_PULLUP); attachInterrupt(0, Reset, FALLING);} void Reset(){ sweeper1.reset(); sweeper2.reset();}
SIGNAL(TIMER0_COMPA_vect) { unsigned long currentMillis = millis(); sweeper1.Update(currentMillis); sweeper2.Update(currentMillis); led1.Update(currentMillis); led2.Update(currentMillis); led3.Update(currentMillis);}
void loop(){}上傳代碼到Arduino UNO,當我們按一下按鈕時,兩個舵機就是立馬就歸零。
針腳變化中斷/Pin-change Interrupts
Arduino UNO只有兩個外部中斷的針腳,那如果2個不夠怎麼辦?幸運地是,UNO的所有針腳都支持針腳變化中斷(Pin-change Interrupts),
但和外部中斷不同的是,針腳變化中斷是以組存在的。比如UNO有三組:A0-A5,D0-D7和D8-D13。組內任何一個針腳的狀態變化都會觸發這個組的針腳變化中斷。所以與外部中斷的單一陣腳不同,使用針腳變化中斷會稍微複雜些,因為你必須要知道一組內到底是哪個針腳的狀態變化了。
這裡我們就不展開了。
定時器和中斷的使用忠告
定時器和中斷雖然好用,但也不能濫用!
1. 如果一切事務都是緊急事務,那麼它們裡沒有一件是緊急的。
中斷應該被用來處理最緊急且用時比較短的事務。要記住,當處理器處於一個中斷裡時,其他的事務都是被掛起凍結的。如果該中斷處理函數佔用太多時間,其他事務和中斷就可能沒有時間執行。
2. 一次一個中斷
當處理器處於中斷處理流程(ISR, Interrupt Service Routine)時,其他中斷是處於停滯狀態的。所以要注意:
3. 將耗時運算放到loop()裡
如果,你事務實在比較長,那麼儘量讓中斷處理最重要的部分,把耗時的運算放到主循環裡,通過共享變量等方法精簡中斷處理。
4. 不要隨意修改定時器的配置
處理器裡的定時器屬於有限資源,比如Arduino UNO裡只有3個定時器,但它們被很多函數共享。如果你隨意修改,可能會牽一髮而動全身,導致其他函數錯亂。以UNO為例:
Timer0 - 用於millis(), micros(), delay()函數,還有D5、D6針腳的PWM
Timer1 - 用於Servos, WaveHC等庫,還有D9、D10針腳的PWM
Timer2 - 用於Tone庫,還有D11、D13針腳的PWM
5. 安全地共享變量
因為處理器在進入中斷後,主循環所有狀態都會被「凍」起來,直到中斷結束,所以對於中斷函數和loop()的變量交換我們要十分小心。
使用volatile變量
有時候代碼編譯的時候,編輯器為了優化程序速度會加一些優化。程序運行時,這些優化會把代碼中常用的變量在內存裡做一份拷貝用來快速訪問。所以在共享變量的時候,可能會產生變量拷貝和原值不同步的情況。所以在這種變量申明的時候,儘量加上volatile修飾符,用來告訴編輯器不要給這個變量優化,每次都要去讀取原值。
保護超長變量
當你使用比整數類型(integer)還要大的變量時,比如strings, arrays, structures,在變量前加volatile估計還不夠。超長變量一般需要一定的周期更新。可能在更新過程中,中斷就發生了,那麼這個變量可能就會被破壞。所以如果你在主循環裡要更新一個超長變量時,最好在更新前關閉相關的中斷,更新好以後再開啟。
總結
在本篇教程中,我們了解並學習了如何使用定時器中斷和外部中斷來「升級」我們的代碼,也談了一些定時器和中斷使用的注意事項。
定時器、中斷的知識並不是只有學習Arduino時才有用,可以說理解並掌握定時器和中斷的使用對於任何一種嵌入式平臺的進階都是必要的。結合上篇教程中介紹的狀態機、面向對象編程等概念,可以說我們已經有足夠的工具來解決絕大部分等編程問題了。
原文連結:https://learn.adafruit.com/multi-tasking-the-arduino-part-2