狀態機程序控制_使用Arduino 生態系硬體
Micro State machine framework using Arduino    




Since 2022 0127


優點 

  • 簡單,僅需要引入一個額外的Library
  • 易懂,沒有指標、call back function 等等用法
  • 靈活,適合拿來控制小規模的嵌入式運用(1000~1500 行程式)
  • 可以重複使用的程式框架
  • 不用硬體就可以開始建構系統框架 
  • 可以簡單的切分工作,跟別人同步進行協作


功能

固定執行速率的迴圈
  • 可以簡單的調整執行的頻率
  • 可以簡單避開按鈕彈跳問題
CLI 互動式介面
  • 可以動態改變特定變數值
  • 可以動態改變執行狀態(配合狀態機)
  • 可以隨機執行特定函數
以狀態機為執行的主軸
  • 可以靈活描述目標系統
  • 開發初期可以用模擬器建構系統框架
  • 可以完整進行測試


(到目錄)

寫作目標


在多年的小型嵌入式系統開發後,因為種種的因素現在常常使用Arduino 來解決手邊各式各樣的工程運用需求,也整理出一套簡單可以重複使用的運用程式框架來解決日常工程問題的挑戰。

與其說Arduino 是一個Maker 的工具,對我來說,Arduino 更是一個Make Money 的工具。取之於Arduino 生態系的東西太多,藉這個機會來回報這個偉大的想法與相關的人員。

以下整理的使用Arduino 架構來實現狀態機是小弟我小小的心得,也算是野人獻曝。有錯漏的部分還請大家指導。


關於作者

林永仁   
  • 成大航太系碩士 / RMRL 實驗室( 微衛星與無人飛機實驗室 ,指導教授 蕭飛賓博士
  • (國防役) 資策會嵌入式系統實驗室/ 網路多媒體研究所
  • 智飛科技有限公司 共同創辦人
  • 240 + 次   政府委託飛行任務執行
  • 1000 + 次 任務監督執行
  • 現職 : 超級會作工股份有限公司, 擔任專案清理


本文內容

**以下內容都是作者從事嵌入式系統開發的一點經驗與收穫,算是野人獻曝,多有錯漏請各位先進多多指正。也期待Arduino生態圈可以經由這樣的交流可以更活絡,做出更好的系統,大家互相期勉!。


授權使用



本授權條款允許使用者重製、散布、傳輸著作,但不得為商業目的之使用,亦不得修改該著作。使用時必須按照著作人指定的方式表彰其姓名。


目錄




C. 補充說明







A. 狀態機在Arduino上的實作方式

(回目錄)

A.1 思路


先說了,使用內文這套程式技巧(micro framework)來寫程式並不是要來增加程式的複雜度。發展這整套微型程式框架的主要目標就是可以簡單、好用、好懂、靈活性高、可重複使用,更快就可以打完收工。

用狀態機的技巧來解構一個系統行為的想法跟流程圖也有部分的重疊,但是狀態機給了我們更有彈性的描述空間。

下列是我對這個微型程式框架的期待 :

• 不需要外掛一堆Lib 就可以用
• 淺顯易懂
• 可以快速建立系統的原型,開發初期不需要實際硬體
• 方便單元測試/ 系統測試
• 固定的發展架構(Frame Work)
• 簡單的程式架構 (一樣的Setup() , Loop() …… ) 不用學額外的RTOS 等等
“最好” 是Hard real time


在此舉幾筆者經手的過案子來說明一下,這些都是使用狀態機來進行需求解構與實作的實例。主要的想法是讓讀者可以了解到,使用狀態機來描述是一種設計概念,不一定要使用什麼特定的語言才可以進行實作。而且他的適用範圍很廣,值得大家花點時間去學習。


A.1.1 使用狀態機技巧 使用C#語言  + Raspberry (MONO ) + Arduino MEGA環境來做產線測試治具


 





A.1.3 使用狀態機技巧 配合Ardupilot Lua Script (STM32H7XX,  )來控制無人機行為




A.1.4 使用狀態機技巧 配合Python + STM32 CORE (STM32DUINO )來做實作智慧型機車收費架控制



A.1.5 使用狀態機技巧 搭配CRT+ Arduino UNO來做實作戰鬥機模擬儀表


A.1.6 使用狀態機技巧,配合Arduino 加上ARM Mbed (顯示部分),實作戰鬥機模擬器儀表


A.1.7 使用狀態機技巧,使用Arduino  生態系 (NRF52832)加上手機APP V7RC ,實作BLE 遙控坦克車



A.2 使用狀態機寫程式可以解決的問題


FaceBook  Arduino.Taipei討論區上面最常看到上來問的問題就是 :

  • 用了Delay 之後其他的動作都不能做?
  • 想要一個延遲發生的動作要怎麼寫?
  • 怎麼同時做兩件以上不同的事情?
  • 為什麼程式一直不如預期?


還有一些我認為很重要的事情

  • 怎麼確定程式的”行為”是對的?
  • 怎麼更有效率的測試系統?
  • 怎麼樣可以建立一個可以調整執行速度的結構?
  • 怎們樣確保系統可以持續執行?
  • 怎麼知道我這次的工作跑了多少時間?
  • 怎麼簡單的跟別人協作?

 A.2.1  狀態機是啥?
  
  先來舉個例子,以最近筆者處理的一個需求來作解釋。系統目前是這樣的 :
    
整個系統裡面,有一台門禁伺服機,多台箱體門禁控制器。門禁伺服機的功能在於

1.  通過電話線網路遠端開啟箱體上的門鎖
2. 接收來自門禁控制器的回報訊號


箱體門禁控制的功能在於

1. 接收來自門禁伺服機的開門訊號
2. 回報正常的使用狀態
3. 回報異常的使用狀態


一般來說,寫程式之前會先想一下程式要解決那些事情,然後再根據不同層的需求開始撰寫程式。以這個文件中的描述的小小型嵌入系統系統來說,通常都是一個人重頭弄倒尾(一條龍..),所以通常我都是分成邏輯的部分,跟底層的部分來進行。

邏輯的部分一般我們會用流程圖去描述系統的行為跟邏輯,底層硬體的部分則根據需要驅動的單元去寫驅動程序,諸如 :

  • 電話撥號
  • 接電話
  • 解DTMF 聲音
  • 箱門的磁簧開關偵測
  • ….. 


  
先建立整體的動作邏輯 (執行框架),然後在填入血肉(code + 驅動) )   是筆者處理這種小型嵌入式系統流程的日常。後來因為因緣巧合的情況下看了這本書,發現使用狀態機來設計類似這種簡單的系統更有效率,於是就習慣用狀態圖的方式來描述系統的行為,同一個系統用狀態圖來描述看起來會是這樣的:
   
  
  
  
 如果說用這兩種圖面設計出來的程式有什麼不同的化,我會說習慣了用狀態去拆解描述一個系統後,在後續的開發上會變得比較輕鬆。(我用了狀態機寫程式之後,頭腦就靈光了很多,每次考試都考一百分)
  
  
    
 A.2.2 用狀態機來寫描述系統有什麼優點 ?
  
  
  如果使用狀態機對系統進行描述然後實作程式的好處如果沒有大過用流程圖來實作,那其實也不用太折騰了。筆者認為使用狀態機來進行的優點在於:
  
  
  • 每個狀態之間可以很靈活的串接
  • 要加入或是減少一個或是多個狀態的改動成本很低
  • 每個狀態內的行為可以很簡單的被測試到 
  • 搭配簡單的執行機制就可以在很確定執行所需要的時間
  • 同一個Arduino 上可以同時執行好幾個不同任務的狀態機
  • 不同任務的狀態機可以透過簡單的旗標進行狀態同步

  
 A.2.3 怎麼用狀態機的思路來拆解一個設計需求
   
   
   要從流程圖的思維轉成狀態的思維需要一點心思,但是並不很困難,只是要多練習。用簡單的思考來說明,原本流程圖裡面要作的事情,不外乎是
   
  •    判斷
  •    執行
   
   
   是以資料的變動來貫穿整個流程圖。而狀態圖重視的是系統狀態上的變化,還有為何會從A狀態變成B狀態的觸發原因
   
  • 因為某個事件(event) 切換到新的狀態
  • 進入新的狀態後要做一次的事情
  • 進入到該狀態後要一直作的事情
  • 等待進入下個狀態的事件

以下列簡單的例子來看,要描述一隻灰熊 肚子餓,要吃東西,吃飽了就等肚子餓的系統,流程圖上面來說是這樣的。


  • 待續.......................
   
   

        後續的程式我們都會使用Wokwi 模器來進行模擬,讀者可以嘗試著改變程式來玩玩看,這樣會更有感覺。
        
        
        
        

A.3 可以掌控執行時間的迴圈


      
        下面這個程式(BasicCMDLoop2022_00.ino)是我們這個framework 的起手式,主要是建立一個可以掌握時間的迴圈。它的概念是這樣的,利用 millis() 函數跟 fast_loopTimer  變數來創造一個可以每秒固定跑幾次的架構。如果我希望這個某個程式區段可以1 秒跑 10 次我們排定的工作,也就是10 Hz ,那麼可以在 :

LINE#23       #define HZ_SETTING   10 

定義寫個 10 ,這樣下面這個除錯訊息就會以接近每秒10 次的速度被印出,改成20然後再燒錄進去就會以每秒大概20次的速度被從除錯視窗印出。       

LINE#72       Serial.println(mainLoop_count);      



程式區段 LINE#74 LINE#80  這個程式方塊實作了一個每1 秒執行 1 次的結構,所以我們在這裡讓他每1 秒閃滅1 次LED燈。 注意喔,使用這樣的技巧,就不需要Delay(1000) 把整個MCU的執行時間都吃掉了。



程式區段 LINE#83 LINE#84  這個程式區塊有兩個有趣的知識點可以學習

1.  DEBUGP / DEBUGN 這兩個MACRO 的用法
2. 每次執行執行n Hz 的特定工作之後,還剩多少時間可以用? 這個功能後續可以用來讓我們知道,我們給定的時間是否足夠系統在時間內把任務給做完

 

一般來說,程式的迴圈1秒可以執行幾次我們是不太會去在意的,反正能跑多快就跑多快。

但是有些時候我們會希望更精確的控制時間,比方說某些控制系統需要定時的去更新特定的制動器或是去讀取感測器。那就需要特別的去處理。

要固定週期的去執行程式有很多方法,這裡我們用一個簡單的方法來解決。

固定執行速度的機制是我們這個framework 的起手式,主要是建立一個可以掌握時間的迴圈。它的概念是這樣的,利用 millis() 函數跟 fast_loopTimer  變數來創造一個可以每秒固定跑幾次的架構。如果我希望這個某個程式區段可以1 秒跑 10 次我們排定的工作,也就是10 Hz。

我的使用經驗一般的簡單程式跑個10Hz 就可以了,如果需要反應更快,調整到100~200Hz 都還跑的動。

有了固定執行速度的迴圈之後,馬上有感的就是可以掌握時間的間隔,所以就可以比較精緻的去安排什麼時候要做什麼事情。基本上用就不用delay函數來延遲時間了。

最後來跑個模擬器看看喔。這是一個10Hz執行速度的迴圈,然後後面加入一個計算在跑完每個迴圈之後剩下的時間,這樣我們就可以評估我們的工作是否可以在這個速度下跑完。而這是評估系統是否有達到hardware real-time 的一個關鍵指標,後續我們再來用範例來展示這個概念。



BasicCMDLoop2022_00.ino (先跑個模擬器來試試看)
/*
 * A.3 可以掌控執行時間的迴圈
 * 使用Arduino AVR MCU.. (UNO,MEGA.. )
 */

#include <avr/sleep.h>.
#include <avr/wdt.h>

#define VERSION "ARDUINO STATE MACHINE 2022 00"


#define DEBUG 1
#if(DEBUG)
#define DEBUGP(x) Serial.print(x)        
#define DEBUGN(x) Serial.println(x)
#else
#define DEBUGP(x) 
#define DEBUGN(x) 
#endif


//HZ Control 
#define HZ_SETTING   10
int mainLoop_count;
unsigned long fast_loopTimer;       // Time in miliseconds of main control loop
const int hzCount = (1000 / HZ_SETTING)-1;
const int timeRemind = 1000 / HZ_SETTING;

void(* resetFunc) (void) = 0; //declare reset function @ address 0 call resetFunc(); if you want to reset
#define  LED_PIN   13 


 void hw_init(){

    // 啟動Serial 
    Serial.begin(115200); 
    // 初始化 IO
    pinMode(LED_PIN,OUTPUT);
     
 }

 


void setup() {


     
//Setup I/O

     hw_init();
 
//setup WDT
   
    wdt_enable(WDTO_4S);

}

 
 
int  secCount =0;

void loop() {
 

  if (millis()-fast_loopTimer >  hzCount) {
      fast_loopTimer = millis();
      mainLoop_count++;
      wdt_reset(); // Reset WDT ... 
                      
 
/// do things ********************************************************************
      Serial.println(mainLoop_count);         
               
      if(mainLoop_count%HZ_SETTING==0){
          secCount++;
          mainLoop_count = 0;
          DEBUGP("1 S :");     
          DEBUGN(secCount);    
          digitalWrite(LED_PIN,!digitalRead(LED_PIN));
       }
                 
      // Time Remind for this Loop ------------------------------------------
       DEBUGP("Time Remind  ms :");
       DEBUGN(timeRemind - (millis()-fast_loopTimer));
   }

}

Q: fast_loopTimer  尚未設定初始值, 就執行判斷式 (millis()-fast_loopTimer >  hzCount),  是否有風險?

使用Wokwi 來跑一下模擬喔… 

按下右上角綠圈白色三角型可以開始模擬,修改HZ_SETTING 的定義可以變更執行的速度












A.4 可以互動/ 測試的程式結構


在這小節中,我們在程式裡面加入一個叫做CLI 命令列介面(英語:Command-Line Interface) 的特殊功能。

不知道大家有沒有使用DOS 或是 Linux 的指令介面的經驗,是不是打入了特殊的指令之後,系統就會有特殊的回應? 在Arduino 中,我們也可以使用這樣的技巧來讓我們的系統發展更簡單。

一般我們在Arduino 的硬體上面開發程式,每次都要編譯之後下載,每次都要等好幾分鐘。如果是小的程式那當然沒有問題,但是如果程式稍微複雜一點,需要調整不同的參數,那光是編譯下載的時間就足以讓人覺得乏力。

另一方面當程式需要完整測試的時候,要實際的驗證到各個程式段落,如果每次都要從頭開始開始測起,那真的是會讓人覺得了無生趣。這時候如果有一個簡單的互動機制可以讓我們可以跳到我們要測試的段落進行測試,那不是很美妙嗎? 

因此,總結一下在我們程式裡面加入CLI 的好處

  • 可以動態的改變特定參數,不用重新編譯燒錄
  • 可以動態的執行特定的函數
  • 可以動態的改變執行的路徑( 要配合後面的狀態機技巧)
  • 因為可以改變執行的路徑(狀態) ,所以要進行特定程式區段的測試與驗證就變得很簡單



A.4.1 CLI 怎麼導入? 

下面的程式裡面我們加入一個很好用的Arduino Library 叫做 SerialCommand, 它可以幫助我們簡單註冊我們要回呼(call back)的程式,讓我們在需要的時候去呼叫函數,甚至傳入額外的參數。

稍微解釋一下我們的多加進去的程式碼。


LINE#5 ~ LINE#6  
導入lib

LINE#23  
宣告一個SerialCommand 物件

LINE#117      
在這裡加入我們要註冊進去的回呼函數   指令名稱 叫做  P , 實作的函數叫做process_command

LINE#118      
加入一個內定的回呼函數,如果遇到使用呼叫沒有註冊過的函數,這個unrecognized就會被呼叫

LINE#135
 SCmd.readSerial();  這個是程式去攔截使用者輸入的地方,在收到使用者從特定的serial port 輸入之後,開始解析是否為正確的命令,然後開始執行

A.4.2 CLI 怎麼用? 

有了CLI之後,我們的程式就可以在不重新編譯的情況下去改變特定的參數,甚至可以改變程式的流程,這讓我們有下面的能力


  • 經由改變特定的變數,讓變數改變
  • 經由改變特定的變數,讓執行流程改變
  • 經由呼叫特定的函數,讓系統跟使用者進行互動


對應上述的的能力,我們可以在不重新編譯的程式的前提下進行下列操作

  • 改變特定的參數來測試我們的程式
  • 經由改變程式的流程來跳進去我們要測試的區段
  • 經由呼叫特定的函數,來動態的進行與系統之間的互動

根據慣例,我們來跑一下模擬器,啟動模擬器後,在右下方的輸入區打下面的指令然後按下(ENTER )試試看會有什麼改變
P,10
P,5
P,1
如果一切都正常的話,板子上的LED 燈就會有不同的閃爍速度(10Hz, 5Hz, 1 Hz ) , 我們就是利用這樣的方式,來格式化的輸入我們要改變的變數或是 流程,這樣就不用每次都需要在改變變數數值之後還要重新燒錄,或是弄了半天才跳到我們要測試的那個段落。

BasicCMDLoop2022_01.ino (先跑個模擬器來看看摟)
/*
 * ARDUINO STATE MACHINE 2022 01
 * 
 */
#include <SoftwareSerial.h>   
#include <SerialCommand.h>

#include <avr/sleep.h>.
#include <avr/wdt.h>

#define VERSION "ARDUINO STATE MACHINE 2022 01"


#define DEBUG 1
#if(DEBUG)
#define DEBUGP(x) Serial.print(x)        
#define DEBUGN(x) Serial.println(x)
#else
#define DEBUGP(x) 
#define DEBUGN(x) 
#endif

SerialCommand SCmd;   // The demo SerialCommand object


//HZ Control 
#define HZ_SETTING   20
int mainLoop_count;
unsigned long fast_loopTimer;                          // Time in miliseconds of main control loop
const int hzCount = (1000 / HZ_SETTING)-1;
const int timeRemind = 1000 / HZ_SETTING;

void(* resetFunc) (void) = 0; //declare reset function @ address 0 call resetFunc(); if you want to reset
#define  LED_PIN   13 


int LED_HZ = 2;
int HZ_COUNT = HZ_SETTING;

 void hw_init(){
    Serial.begin(115200); 
    pinMode(LED_PIN,OUTPUT);
 }
 


void process_command()    
{
  int aNumber,bNumber;  
  char *arg; 

  Serial.println("We're in process_command"); 
  arg = SCmd.next(); 
  if (arg != NULL
  {
    aNumber=atoi(arg);    // Converts a char string to an integer
    Serial.print("First argument was: "); 
    Serial.println(aNumber); 
    
  
  } 
  else {
    Serial.println("No arguments"); 
    aNumber =999;
  }

  arg = SCmd.next(); 
  if (arg != NULL
  {
    bNumber=atol(arg); 
    Serial.print("Second argument was: "); 
    Serial.println(bNumber); 
    
  } 
  else {
    Serial.println("No second argument"); 
    bNumber =999;
  }



  switch(aNumber){
    case 0 :
      Serial.println("0");
    break;
    case 1 :
      Serial.println("1 HZ");
      LED_HZ =1 *2;      
    break;
    case 2 :
      Serial.println("2 HZ");
      LED_HZ =2 *2;
    break;
    case 5 :
      Serial.println("5 Hz");
      LED_HZ =5 *2
    break;
    case 10 :
      Serial.println("10 Hz");
      LED_HZ =10 *2
    break;
    case 104 :
      Serial.println(VERSION);
    break;
    default:
      Serial.println("default");
    break;
  }
}


// This gets set as the default handler, and gets called when no other command matches. 
void unrecognized()
{
  Serial.println("What?"); 
}



void setup() {


     
//Setup I/O

     hw_init();

  

// register CMD     
     SCmd.addCommand("P",process_command);   
     SCmd.setDefaultHandler(unrecognized);   
      
     
//setup WDT
  
            wdt_enable(WDTO_4S);

}

 
 
int  secCount =0;
///Test Pattern is ON for 60 SEC, OFF for 180 Sec 


void loop() {
  // put your main code here, to run repeatedly:
     SCmd.readSerial();     // We don't do much, just 

   // system Loop  HZ define in  #define HZ_SETTING   5

   if (millis()-fast_loopTimer >  hzCount) {
                     fast_loopTimer                = millis();
                     mainLoop_count++;
                     wdt_reset(); // Reset WDT ... 
                      
      /// do things
               
               
  if(mainLoop_count% (HZ_COUNT/LED_HZ) ==0){

     
      digitalWrite(LED_BUILTIN,!digitalRead(LED_BUILTIN));
      

               
               
   }
    // Time Remind for this Loop -----------------------------------------                 // DEBUGP("Time Remind  ms :");
    // DEBUGN(timeRemind - (millis()-fast_loopTimer));
     }

}

這個好用的Lib 是這個哥哥寫的喔,請這裡下載


來跑個模擬器試試看吧~  : D  請記得上傳SerialCommand.cpp 跟SerialCommand.h


模擬的畫面會像這樣,右下角那邊鍵入 P,104 會看到除錯輸出視窗印出我們預期的程式版本號碼



A.4.4    串口屏HMI互動整合                                                                                                                                      
                                                                                                                                                                          
使用CLI 這種架構有一個附帶的好處,在專案需要一個使用者互動介面的時候,很容易的就可以加進來。有一種叫做UASRT HMI (或稱串口屏)的小東西,使用它可以快速的建立一些使用Arduino 很難去處理的   圖形化使用者介面。                                                                                                                                         

串口屏顧名思義就是使用串口(serial port ) 來進行通訊的一個小裝置,使用者介面的部分可以在串口屏的開發介面藉由拖拉的方式來完成,串口屏跟Arduino 之間則是使用serial port 來進行通訊。

也因為這樣的關係,再搭配上述的CLI介面後,我們就很容易在程式中去設計一些對應使用者操作的函數執行,這會讓系統更容易的跟使用者產生互動。

簡單的來說,就是在我們的程式端設計幾個特定的CLI指令讓HMI 去呼叫。比方說以上述的例子,我們就可以建立這樣的介面,然後指定該按鈕在按下去的時候,傳送出P,1 \r\n 這樣的字串給Arduio 就可以完成本來需要自己鍵入的指令,這樣一來一個簡單的圖形化人機介面就完成一半了。

另一個是由Arduino 要傳遞特定的變數到HMI 端口的方式,也是藉由從Serial 傳輸特定的字串就可以讓這個變數呈現在HMI上,實現HMI 雙向控制的目的。



   

A.5 系統的狀態解構


從流程圖的思維轉換成利用狀態機來描述系統的過程需要一點時間去習慣,但是也不是很困難。筆者比較習慣的做法是把等待\判斷\執行的行為變成狀態,以我們常常會執行的上廁所這個活動來說,可以簡化成下面的兩種描述方式。

畫上廁所的流程圖是我常常舉的例子,因為這個例子我們常常會執行,也比較好理解。如果很努力地去描述這件事情,我們可以發現,要好好的上一個廁所,所需要執行的步驟跟確定的事情並不少。這種個活動,只有少了一個物件(如衛生紙) ,或是隨便調動一個步驟,都會產生悲劇。也因此筆者一再強調,如果沒有先規劃就開始寫程式,就跟沒有脫褲子就執行後續的程序依樣,可能會有洗不完的褲子(或是常常要買內褲補充)。

下列流程圖的部分筆者沒有把所有的判斷寫出來,因為這樣要花很大的一個篇幅才能把完整的上廁所流程描述出來。主要的原因是: 上廁所的流程有很多的細節,每個人的細節也都不一樣。如果要用流程圖把完整的流程給描述出來,整個程式的會有很多多層的if else  排到天荒地老。另一個針對這種比較細緻的活動的流程圖還有一種問題,就是如果我要加入一個新的判斷路徑,整個程式碼的if 巢狀結構又會變得更龐大,更難一維護。

另一方面,下列的狀態圖是從另一個角度來描述系統,相對於流程圖需要針對不同的步驟進行的判斷還有等待,我們利用不同的狀態來描述該事件。這樣的做法會讓程式碼多出很多,但是得到的是讓我們的

  1. 程式比較好管理
  1. 相對容易加入新的狀態 ( 比方先看報紙培養感覺)
  1. 容易改變執行流程 ( 比方說先沖水再拉上褲子)
  1. 容易加上例外處理 ( 總是有很多例外狀況要處理)

所以針對一個系統的拆解,一般筆者會傾向使用狀態圖來描述系統,然後再個別的狀態裡面用普通的流程圖來完成該狀態流程的判斷與描述。所以即使我們是以狀態機的形式來描述系統,其實在特定狀態的實作上,還是使用流程來實作的。

下圖是筆者實作某個系統時候的狀態圖,提供各位參考。

流程圖




狀態圖


A.6 狀態機的實作


這面這個程式BasicCMDLoop2022_02.ino 基本上就是下面這個圖的實作。這個程式做的事情就是 : 

  1. 開機後,進入stateInit 
  1. 經過 2 秒後,進入state1 狀態內,然後以每秒10次的速度執行這個狀態裡面的工作
  1. 經過10秒後,進入state2狀態內,然後以每秒10次的速度執行這個狀態裡面的工作
  1. 經過10秒後,進入state3狀態內,然後以每秒10次的速度執行這個狀態裡面的工作
  1. 經過10秒後,進入state1 狀態內,然後周而復始上述 2~ 5 的工作



利用這個framework 來實作狀態的時候,有幾個簡單的約定要注意一下,讓我們來看一下實作狀態的程式碼。


LINE#4~ LINE#15 

這個綠色區塊裡面是進入這個狀態後只會做一次的事情,為什麼只會做一次呢? 因為我們用兩個變數來記錄系統的狀態變化

stateCurrent 記錄著目前的狀態
stateLast 記錄著上一次的狀態

所以如果這次進到這個狀態的數值跟上一次的不同,就可以知道我們是第一次進入這個狀態
進入後的呼叫 showStateName() 這個函數主要是把目前狀態的名稱印出來,主要是要讓我們知道目前在那個狀態。

stateLast =stateCurrent  這個會更新stateLast 到最新的狀態,配合LINE#5 這個判斷式 下次就不會再進來這個綠色的區塊了

這兩個變數設定成0 的原因是,每個狀態都會有自己要維持的變數,因為通常這種小系統不會太複雜,所以我們宣搞兩個參數來讓我們知道 
     taskCounter =0;   /// 這個變數用來記憶某件事情的變動
     taskTimer =0;     //// 這個變數用來記憶這個狀態被呼叫了幾次  



LINE#17 
 taskTimer++;  //每次進來這個狀態一次,就會被呼叫一次,所以這個變數可以用來記錄狀態被呼叫的次數
因為整個程式是以特定的時間隔在執行的,所以可以這樣說,我們可以用這個變數來算計算時間


呼應上面LINE#17 的變數變化跟我們程式的設定,每進入這個狀態10 次,就(大約)是1 秒鐘,所以只要算
10 *HZ_SETTING 次,就是經過10 秒了

  if(taskTimer> 10*HZ_SETTING ){
      
      stateCurrent = TASK_STATE_2;
  }


因為我們可以精確的掌握時間,所以就不用利用delay(10000) 這樣的函數去等10 秒,這就是這個架構有趣的地方。

最後stateCurrent = TASK_STATE_2; 這個設定,就會讓我們讓我們的程式再跑下一輪的時候,就跳到狀態2 去了喔….



void state1(){


  /// 下面這區只有進來的時候會作一次 -------------------------------
  if(stateCurrent!=stateLast){
     
    /// 顯示一下目前的狀態 
    showStateName(stateCurrent);
    /// 把狀態裡面的變數清空
     stateLast =stateCurrent;
     taskCounter =0;
     taskTimer =0;
    /// 故意清0 讓大家看一下
    secCount=0;
  }

   taskTimer++; 

  // 10 秒後離開這個狀態
  if(taskTimer> 10*HZ_SETTING ){
      
      stateCurrent = TASK_STATE_2;
  }


}







/*
 * ARDUINO STATE MACHINE 2022 02
 * 
 */
//#include <SoftwareSerial.h>
//#include <SerialCommand.h>
#include "SerialCommand.h"
#include <avr/sleep.h>.
#include <avr/wdt.h>
#define VERSION "ARDUINO STATE MACHINE 2022 01"
#define DEBUG 1
#if (DEBUG)
#define DEBUGP(x) Serial.print(x)
#define DEBUGN(x) Serial.println(x)
#else
#define DEBUGP(x)
#define DEBUGN(x)
#endif
SerialCommand SCmd; // The demo SerialCommand object
//HZ Control
#define HZ_SETTING 10
int mainLoop_count;
unsigned long fast_loopTimer; // Time in miliseconds of main control loop
const int hzCount = (1000 / HZ_SETTING) - 1;
const int timeRemind = 1000 / HZ_SETTING;
void (*resetFunc)(void) = 0; //declare reset function @ address 0 call resetFunc(); if you want to reset
#define LED_PIN 13



//----------------- 在這裡定義State 的編號
#define TASK_STATE_INIT       0
#define TASK_STATE_1          1
#define TASK_STATE_2          2
#define TASK_STATE_3          3
#define TASK_STATE_ERROR      100
#define TASK_STATE_UNKNOW     255



byte stateCurrent = TASK_STATE_INIT;  /// 初始的狀態
byte stateLast = TASK_STATE_UNKNOW;

unsigned long taskCounter =0;  // State 裡面專用的計數器
unsigned long taskTimer   =0;  // State 裡面專用的計時器
int secCount = 0;

void showStateName(byte st){
  switch(st){
    case TASK_STATE_INIT:
      Serial.println("TASK_STATE_INIT");
    break;
    case TASK_STATE_1:
      Serial.println("TASK_STATE_1");
    break;
    case TASK_STATE_2:
      Serial.println("TASK_STATE_2");
    break;
    case TASK_STATE_3:
      Serial.println("TASK_STATE_3");
    break;
 
    case TASK_STATE_ERROR:
      Serial.println("TASK_STATE_ERROR");
    break;

    default:
      Serial.println("TASK_STATE_UNKNOW");
    break;


  }



}
void stateInit(){


  /// 下面這區只有進來的時候會作一次 -------------------------------
  if(stateCurrent!=stateLast){
     
    /// 顯示一下目前的狀態 
    showStateName(stateCurrent);
    /// 把狀態裡面的變數清空
     stateLast =stateCurrent;
     taskCounter =0;
     taskTimer =0;
  }

   taskTimer++; 
  // 2 秒後離開這個狀態
  if(taskTimer> 2*HZ_SETTING ){
      
      stateCurrent = TASK_STATE_1;
  }


}



void state1(){


  /// 下面這區只有進來的時候會作一次 -------------------------------
  if(stateCurrent!=stateLast){
     
    /// 顯示一下目前的狀態 
    showStateName(stateCurrent);
    /// 把狀態裡面的變數清空
     stateLast =stateCurrent;
     taskCounter =0;
     taskTimer =0;
    /// 故意清0 讓大家看一下
    secCount=0;
  }

   taskTimer++; 

  // 10 秒後離開這個狀態
  if(taskTimer> 10*HZ_SETTING ){
      
      stateCurrent = TASK_STATE_2;
  }


}

void state2(){


  /// 下面這區只有進來的時候會作一次 -------------------------------
  if(stateCurrent!=stateLast){
     
    /// 顯示一下目前的狀態 
    showStateName(stateCurrent);
    /// 把狀態裡面的變數清空
     stateLast =stateCurrent;
     taskCounter =0;
     taskTimer =0;
  }

   taskTimer++; 


  // 10 秒後離開這個狀態
  if(taskTimer> 10*HZ_SETTING ){
      
      stateCurrent = TASK_STATE_3;
  }

}


void state3(){


  /// 下面這區只有進來的時候會作一次 -------------------------------
  if(stateCurrent!=stateLast){
     
    /// 顯示一下目前的狀態 
    showStateName(stateCurrent);
    /// 把狀態裡面的變數清空
     stateLast =stateCurrent;
     taskCounter =0;
     taskTimer =0;
  }

   taskTimer++; 
  // 10 秒後離開這個狀態
  if(taskTimer> 10*HZ_SETTING ){
      
      stateCurrent = TASK_STATE_1;
  }

}

void stateError(){


  /// 下面這區只有進來的時候會作一次 -------------------------------
  if(stateCurrent!=stateLast){
     
    /// 顯示一下目前的狀態 
    showStateName(stateCurrent);
    /// 把狀態裡面的變數清空
     stateLast =stateCurrent;
     taskCounter =0;
     taskTimer =0;
  }

   taskTimer++; 
  // 10 秒 秀一下目前的狀態
  if(( taskTimer % 10*HZ_SETTING )==0  ){
      
    /// 顯示一下目前的狀態 
    showStateName(stateCurrent);
  }
}

void stateUnknow(){


  /// 下面這區只有進來的時候會作一次 -------------------------------
  if(stateCurrent!=stateLast){
     
    /// 顯示一下目前的狀態 
    showStateName(stateCurrent);
    /// 把狀態裡面的變數清空
     stateLast =stateCurrent;
     taskCounter =0;
     taskTimer =0;
  }

   taskTimer++; 
  // 10 秒 秀一下目前的狀態
  if(( taskTimer % 10*HZ_SETTING )==0  ){
      
    /// 顯示一下目前的狀態 
    showStateName(stateCurrent);
  }

}


void doSM(){

  switch(stateCurrent){
  case TASK_STATE_INIT:
 
    stateInit();
  break;

  case  TASK_STATE_1:
   state1();
  break;
  case  TASK_STATE_2:
    state2();
  break;
  case  TASK_STATE_3:
    state3();
  break;

  case  TASK_STATE_ERROR:
    stateError();

  break;

  default:
    stateUnknow();
  break;



  }


}

void hw_init()
{
  pinMode(LED_PIN, OUTPUT);
}
void process_command()
{
  int aNumber, bNumber;
  char *arg;
  Serial.println("We're in process_command");
  arg = SCmd.next();
  if (arg != NULL)
  {
    aNumber = atoi(arg); // Converts a char string to an integer
    Serial.print("First argument was: ");
    Serial.println(aNumber);
  }
  else
  {
    Serial.println("No arguments");
    aNumber = 999;
  }
  arg = SCmd.next();
  if (arg != NULL)
  {
    bNumber = atol(arg);
    Serial.print("Second argument was: ");
    Serial.println(bNumber);
  }
  else
  {
    Serial.println("No second argument");
    bNumber = 999;
  }
  switch (aNumber)
  {
  case 0:
    Serial.print("stateCueent =");
    Serial.println(stateCurrent);
    break;
  case 1:
    //// 如果使用者沒有輸入參數,直接跳走
    if(bNumber==999){
      return
    }
    
    stateCurrent = bNumber;
    Serial.print("stateCueent =");    
    Serial.println(stateCurrent);

    break;
  case 2:
    Serial.println("2");
    break;
  case 104:
    Serial.println(VERSION);
    break;
  default:
    break;
  }
}
// This gets set as the default handler, and gets called when no other command matches.
void unrecognized()
{
  Serial.println("What?");
}
void setup()
{
  // put your setup code here, to run once:
  Serial.begin(115200);
  //Setup I/O
  hw_init();
  // register CMD
  SCmd.addCommand("P", process_command); // Converts two arguments to integers and echos them back
  SCmd.setDefaultHandler(unrecognized);  // Handler for command that isn't matched  (says "What?")
  //setup WDT
  wdt_enable(WDTO_4S);
}


void loop()
{
  // put your main code here, to run repeatedly:
  SCmd.readSerial(); // We don't do much, just
  // system Loop  HZ define in  #define HZ_SETTING   5
  if (millis() - fast_loopTimer > hzCount)
  {
    fast_loopTimer = millis();
    mainLoop_count++;
    wdt_reset(); // Reset WDT ...
    /// do things
    doSM();
    if (mainLoop_count % HZ_SETTING == 0)
    {
      secCount++;
      digitalWrite(LED_PIN,!digitalRead(LED_PIN));
      DEBUGP("1 S :");
      DEBUGN(secCount);
      
    }
    // Time Remind for this Loop ---------------------------------------------------------------
    // DEBUGP("Time Remind  ms :");
    // DEBUGN(timeRemind - (millis()-fast_loopTimer));
  }
}








A.7 全部放在一起完工




A.7.1  同時跑很多個不同的工作

剛剛接觸Arduino 的朋友,一定會問,如果我要一個1秒閃10次的燈,加上一個1秒要閃5次的燈,加上一個要1分鐘閃一次的燈那要怎麼寫。其實有很多的程式技巧、硬體呼叫或是使用現成的RTOS都可以辦到,下面用狀態機的寫法在我們的micro framewok 中實作看看。

首先,我們先來畫圖,圖畫得出來,程式的架構應該都寫得出來。初學Arduino 的朋友會問,我們如果寫一個一秒可以閃1次的燈,不是應該要這樣寫嗎?


loop(){
    digitalWrite(LED_PIN,HIGH)
    delay(500)
    digitalWrite(LED_PIN,LOW)
    delay(500)
}


不過這樣寫完? 其他的燈都不用動了耶? 沒錯啊,因為程式上面就是叫Arduino 去休息啊,它當然沒有辦法做其他的事情。在我們這個micro framewark 裡面,要實作下面的這個程式的話,是可以這樣做的。





A.7.2  前景跑前景的,然後叫背景去跑一個時間比較長的工作


作程序控制的時候有會遇到一種使用情境,例如馬達打完之後,風扇還要繼續在背景跑個15 秒之類的。如果用我們的這個micro framework 來實作這個情境的話,可以把冷卻風扇的行為也用一個狀態機來描述。當需要叫冷卻風扇開始打的時候,就讓它從待機的狀態開始啟動。風扇打完特定秒數之後,就會回到待機的狀態等待其他有需要的人來調用。

Wokwi模擬器中,我們使用紅色的LED 燈來模擬TASK1 的狀態,TASK1 的狀態A 會用每秒5Hz 的速度去點滅紅色 LED 燈,經過10 秒後跳到狀態B。TASK1 的狀態B 會用每秒一次的速度去點滅紅色 LED燈。在TASK1 進入狀態B 的時候,會送一個訊號去給冷卻風扇的狀態機,這時候冷卻風扇的狀態會從待機變成啟動( 會持續15秒,用綠色的LED 燈來模擬) ,然後跑個10 秒鐘之後切換回去狀態A

 




A.7.3  偵測按鈕輸入不用debounce 

如果系統需要外接一些外接的開關作為感測的來源,諸如按鈕、微動開關等等類似的輸入,都會遇到一些按鈕彈跳的問題,跟這個這個文件這個的講法,我們都需要做一些特別的判斷去消彌彈跳的效應。

利用上述的固定周期迴圈,可以把等待按鈕(或是偵測物件到達感測點) 設計成一個等待輸入狀態,直到我們要的條件達到(比方說確定按下,確定到達) 再觸發到另一個狀態。

先說一下使用的前提,下面這樣的技巧適合要非常確定按鈕或是微動開關從一個狀態變到另一個狀態的時候使用,如果一秒內要偵測很多次的輸入的情境( 如高橋名人的16 連打.. ) 可以考慮一下用中斷來處理比較好。

再來說一下使用的概念。假設我們要去偵測一個按鈕/ 開關,它按下去的時候會有1/20 秒的彈跳,那麼我們只要確認系統有連續的1/20 *2   或是 1/20 * 3 的狀態變化就可以確認它已經完成按鈕狀態改變的動作。如下圖,再我們偵測到有1 到0 的DI 狀態變換之後,等待連續3個都是0 狀態就可以知道開關確實被按下,這樣再來進行我們下一步的動作即可。





一樣的,我們來跑一下模擬器,每變換一次按鈕狀態之後(須維持3/20 模擬秒),狀態會切換一次。




A.7.4  動態的改變目前的運行狀態


在系統的開發初期與測試階段,我們可能需要跑到特定的狀態裡面去驗證特定的邏輯,以下圖為例,如果每個狀態的切換時間是30 秒,如果要測試到狀態C (StateC) 需要等一分鐘,這樣實在是很不方便,如果要重複的測試就需要一直等下去。

結合CLI 與狀態機的寫作技巧,可以讓使用者可以輕鬆的跳到特定的狀態去驗證我們要測試的邏輯與程式。另外如果加上一個Unknow 狀態來捕捉可能的邏輯誤區,也可以很方便的讓我們回到原點再次進行系統的測試與驗證。

 
 
 
 



A.7.5 簡單的Timeout 機制

在某些使用情境下,如果系統被觸發了,但是有一段時間沒有使用者輸入,需要跳回原來的等待狀態,這時候就可以使用timeout 的機制來實作。使用狀態機很容易就可以實作這個需求,大家可以參考一下模擬器跟狀態B(    task1StateB )的範例程式碼的實作應該就會有點感覺。

Timeout 在很多的使用情境下都會被用到,用狀態機配合固定執行的迴圈來實作,應該算是簡單的解決方案。

老規矩,模擬器跑起來玩看看,自己也動手改一下,會更有感。





A.7.6 論real-time 


A.8 後記


撰寫這個文件的想法已經醞釀很久,本想說這種雕蟲小技大概也不用拿出來獻寶。但是我發現很多的問題一再被重複的詢問。另一方面因為我很多日常的工作都是用Arduino 來解決的,取之於Arduino 社群,用之於Arduino 社群, 所以才大膽的把這些小技巧給整理整理。

因為工作的關係常常遇到一些需要很奇思妙想的需求,在較短的時間內要完成原型的展示,利用上述的架構我不能講每次都可以完美解決問題,但是大部分的時間都可以很快的打完收工。

其實狀態機解題的想法也不僅只可以用在Arduino 這個環境,只要掌握精神,在不同的平台環境裡面只是用不同的語法或是API去建構可以去執行使用者描述的狀態機行為的執行環境。期待這樣的分享可以帶給大家一些新的想法與幫助。


2022 0205

自己亂寫文章的好處就是自己想寫啥就寫啥,話說筆者也是亂入嵌入式系統這個領域的,所以也不是什麼”正統”科班訓練出來的。

最早接觸MCU(單晶片、單片機 what ever…) 是大三下學習的時候。那個時候筆者的恩師蕭飛賓老師要我弄個跟無人機相關的專題,然後提撥了一筆經費給筆者自己去想可以用實驗室的資源做些什麼事情。剛好那時候我的直屬學長梁博士(那時是博士候選人) 說我們在另一個校區有一個做低速風洞可以使用,然後另一個官學長說希望可以做一個量測無人飛機攻角(anagle of attack)的小裝置,就這樣開始了我跟微控器(8X51 )的因緣,也因為這樣認識了旗威林伸茂老師,想想這寫是快要25 年前的事情了。


因為筆者從小就對遙控飛機充滿了熱情(這是另一個被推坑的故事) 所以到了研究所還是一直往要讓遙控飛機自己會飛的這個方向前進。可是對於一個流體力學專業,又沒有正式學過CS(computer science)課程的人來說,這條路無異是很漫長的,因為要讓飛機可以自己在天上飛行,除了航空力學之外,還需要有一台控制電腦來主控整個系統,在1998年那個年代我們系上的環境,可以用到最小的控制器大概就是PC104 的”小”電腦配合一些專用的控制卡片(如GPS 卡、IO控制卡)來完成這個系統控制器的建構。可想而知的,這個系統搭配電池本身就是一個龐然大物。不論在體積、重量對我們自己可以掌握的小飛機來說,都是一個很大的負擔。為了在X86 上面控制一堆不曾看過的東西,碩班的兩年中,硬是學了C語言跟QNX,然後把電腦給搞上天,這樣就畢業了… 後續的學弟在這個不成熟的基礎上,又努力了幾年,終於… 飛機可以自己飛了。在這個階段,也遇到了一個足以改變的整個視野的老師,戎凱老師 他的一門三學分的太空系統工程,可能比大學修的所有學分對筆者來說更為深刻。

國防役的時候,本來要追隨梁學長去工研院的航太中心去繼續”航太”相關的工作,不過在性向測驗的時候被刷了下來,因為性向測驗說筆者的個性不是很合群,我只能說這測驗還真準,哈哈。也因為這樣,因緣巧合的來到資策會的嵌入式系統實驗室,開始了4年CS學習旅途。嵌入式系統實驗室主要的業務是維護一套叫做@VIS 的RTOS (即時作業系統) ,在這裡因為之前學校學的都是偏底層的驅動部分,所以主要的處理的部分都是底層的驅動,諸如MMC卡、USB .. 之類的驅動與協議。由於可以跟CS專業的一起工作,所以也了解了我們大學學的東西真的是天差地遠。也因為這樣的關係,好好地去瞭解了什麼是RTOS,什麼是軟體工程,什麼是source control ,30個人怎麼維護一個系統等等。

國防役畢業後,就跟網路上認識的吳老師一起開了一家無人機公司(2005),不過這也不是重點,重點是我們用嵌入式系統跟之前學的所有功夫來建置了可以執行任務的無人機系統系統跟團隊,這對筆者來說也是一段很難忘的回憶。

後來小孩出生了,覺得好像也不能在夢幻下去,加上種種的創業問題,就北上討生活。也就是在這段青黃不接的時光,接觸了Arduino ,也用它做了很多好玩的東西,可以說從天上飛的到地上爬的都有涉獵,所以對於Arduino 這個生態系,真的是充滿感謝。

會寫這篇文章的本意基本上就是,建議先學一套通用的運用技巧,雖然不是很漂亮,也不是最少行程式碼,也不是最帥氣的,但是它可以很快速地幫使用者渡過前期的系統描述(用模擬器), 使用一些現成的程式技巧拆分系統(timeout , HMI, 多工…) ,渡過開發後期的測試驗證(使用CLI 配合狀態機),也可以跟別人協同作業(跟別人分開撰寫)。

筆者也很認同鄉下老師張老師觀點 (上再多課都不如拿到一個可用的程式範例!   ) ,看過了那麼多本Arduino書後,好像也沒有一本講怎麼去思考拆解一個系統,加上筆者這幾年,一路上都是靠Arduino 的生態系過生活的,所以認為這樣的一套架構,或許會對非科班出身又需要用到Arduio 的朋友有幫助吧?


以上

林永仁 
2022 0205 


C. 補充說明

(回目錄)

C.1 我會好好記得你 使用EEPROM  (2022 0303)  



EEPROM 的使用


今天早上(2022 0303)的無預警停電,各位沒有存檔的資料不見了嗎?

在某些使用情境中,我們會希望可以動態的改變某些參數,比方說感測器的校正值等。以前的老三步是把數值記錄下來,然後重新編譯,再下載。

但是這樣做實在是很麻煩又很困擾,因為不能使用同一份程式碼去下載到各個硬體上。於是乎,如果可動態的調整參數,然後永遠紀錄在系統裡面的這種需求就應運而生。

為了去紀錄可能的改變,並永久的存放在系統內部,我們可以使用EEPROM 這個功能來幫我們紀錄會變動的參數。

大部分的Arduino 生態系的硬體都會實作EEPROM 的Lib,即便是沒有真的EEPROM 的硬體,諸如STM32 也會用Flash 去模擬的EEPROM 功能。

下面解釋一下筆者常用的手法

1. 開機後去檢查特定的位置是否有已經寫入特定的數值
1.1 如果沒有,那就把內定的值寫入EEPROM 
1.2 如果有,那就把EEPROM 的數值搬到變數表裏面去

這樣我們就可以永久的保存變更的參數,然後再每次的開機都調用的到最新的參數。

------------- 後紀---------------

下一集會把CLI 補進去,這樣就可以動態改變參數,然後放進EEPROM 就算關機(掉電) 新的參數也不會不見喔



 
 
 







B. 網路上的資源  

(回目錄)

網路上在Arduino 環境下使用State Machine 撰寫程式的資料收集


B.1 之前寫的,但是永遠寫不完的一個Arduino 使用分享 GITHUB





B.2 之前寫的,但是永遠沒有時間再多寫的BLOG



B.3 其他網路上看到的Arduino + State Machine 相關的資料與影片










(回目錄)