透過參考 PLC 技術掌握 Arduino 非阻隔延時

我喜歡 Arduino Opta 可編程邏輯控制器 (PLC) 的原因之一是它提供了探索不同程式語言之間關係的機會。例如,在本文中,我們將探討梯形圖邏輯和涉及類別物件實例化的 C++ 程式設計之間的關係。具體來說,我們將比較基於梯形圖邏輯的定時器的簡潔性,以及在 Arduino 環境中實現非阻隔延時的方法的複雜性。

我們將從經典的 Arduino 阻隔延時開始,然後介紹類似 PLC 定時器的 C++ 類別實現,最後展示相比之下更易於使用的梯形圖邏輯實現。您會發現,梯形圖邏輯實作隱藏了大量的複雜性,從而實現了易於實現且能夠響應即時訊號的邏輯。

雖然梯形圖邏輯實作是 Opta 和其他符合 IEC61131-3 標準的 PLC 所特有的,但非阻隔程式碼可能對所有程式設計師和微控制器系列都有參考價值,而不僅僅是 Arduino。

阻隔程式碼回顧

你還記得你的第一個 Arduino 程式嗎?它很可能是一個控制 LED 閃爍的程序,程式碼結構大致如下:

#define LED_PIN 13

void setup() {
  pinMode(LED_PIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_PIN, HIGH);
  delay(1000);
  digitalWrite(LED_PIN, LOW);
  delay(2000);
}

對於成千上萬的 Arduino 程式設計師來說,這是一個很好的起點。然而,這段程式碼存在一個重大缺陷。它根本不適用於實際的控制介面。問題在於程式碼阻隔

每次呼叫 delay() 函數時,微控制器都會在延遲期間處於「盲區」。例如,如果我們想要響應開關或感測器,就必須等待 delay() 函數執行完畢。在這個例子中,我們最多需要等待三秒鐘。對於實際控制設備的機器來說,這是完全不可接受的。

非阻隔編碼的重要性

為了解決這個問題並加深您對 Arduino 的了解,您可能已經使用過 millis() 函數。回想一下,millis() 就像一個時鐘,它能合理地估算出 Arduino 啟動以來經過的時間。對於非阻隔定時器,我們需要一些變數來保存特定的時間點。有了這些變量,我們就可以監控時間的流逝。

這類似於鬧鐘,我們設定某個事件必須在 06:45:30 發生。當我們循環執行 loop() 時,我們只需判斷當前時間是否大於 07:45:30。如果大於,則執行相應的操作;否則,快速退出循環並再次循環執行。整個過程非常迅速,使得微控制器能夠快速迭代 loop(),從而提供一個能夠回應即時事件的系統。典型的程式每秒會循環執行數千次,從而確保對現實世界的快速回應。

技術提示:這種循環往復的方法能夠提供合理的實際回應。但這種輪詢方式無法提供精確的計時。為了獲得更高的精度和更低的延遲,請考慮使用基於中斷的延遲。

以下程式碼執行相同的 LED 閃爍操作,且不會阻隔。請注意,程式碼中使用了一個狀態變數來追蹤 LED 的亮滅狀態。此外,程式碼保留了兩個定時器操作:一個用於 LED 亮 1s,另一個用於 LED 滅 2s。


#define LED_PIN 13
#define LED_ON HIGH
#define LED_OFF LOW
#define ON_INTERVAL 1000
#define OFF_INTERVAL 2000

bool ledState = LED_OFF;
unsigned long elapsedTime = 0;
unsigned long startTime = 0;          // Global variable: retains value across loop( ) iterations

void setup() {
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, ledState);
  startTime = millis();
}

void loop() {
  unsigned long loopStartTime = millis();
  elapsedTime = loopStartTime - startTime;

  // OFF Logic
  if ((ledState == LED_OFF) && (elapsedTime >= OFF_INTERVAL)) {
    ledState = LED_ON;
    digitalWrite(LED_PIN, ledState);
    startTime = loopStartTime;
  }
  
  // ON Logic
  if ((ledState == LED_ON) && (elapsedTime >= ON_INTERVAL)) {
    ledState = LED_OFF;
    digitalWrite(LED_PIN, ledState);
    startTime = loopStartTime;
  }
  
  /***********************************
   *
   * // Your real-time responsive code goes here.
   *
   **********************************/
}

技術提示:您是否使用過人工智慧 (AI) 來幫助您改進程式碼?這無疑是一個頗具爭議的話題,尤其是在學術界,人們擔心作弊問題。然而,許多程式設計師都從中受益匪淺。至少,您可以嘗試將程式碼展示給 AI,並請求它幫助重構程式碼以提高清晰度。您或許會感到驚喜。

開發非阻隔類別

為了保持本文簡潔,我們跳過非阻隔定時器發展歷程中的幾個步驟,直接進入基於類別的實作。請參考本文末尾的程式碼清單,其中包括 timers.h(標頭檔)和 timers.cpp(實作文件),以及範例應用程式 Opta.ino。這段程式碼抽象化了非阻隔延時,從而提供了一個易於使用的介面。

為了解釋這種 Arduino 類別結構,我們將使用梯形圖邏輯。圖形化實現無疑使程式碼更容易理解。

如圖 1 所示,梯形圖的第一級包含一個開關接點、一個定時開關 (TON),其輸出端分別連接到 Opta 的一個輸出繼電器和一個狀態指示燈。TON 模組包含兩個輸入端:IN 用作邏輯電平控制訊號,程式時間 (PT) 指定程式延遲時間。

這就像一個數位門控器。當輸入訊號保持 PT 指定時間後,輸出 (Q) 變為高電位。它會一直保持高電平,直到輸入訊號 IN 變成低電平。經過時間 (ET) 讓我們監控模組的運作時間。與 Q 類似,當達到 PT 時,ET 值將停止增加。當 IN 變成低電位時,ET 值也會重設。

在本例中,TON 模組由 gxI1 控制。gxI1 是 Opta 的一個數位輸入。PT 時間條件設定為 2000。這遵循 Arduino 以毫秒為單位表示時間的傳統。其工作原理很容易描述。這裡,Opta 的繼電器輸出 (gxO1) 和狀態指示燈 (gxLED1) 將在開關 (gxI1) 閉合 2s 後啟動。當開關斷開時,這兩個輸出將立即斷開。

圖 1:定時器的梯形圖邏輯表示。

本文末尾附有 Opta.ino 程式碼清單,其中展示了該類別的傳統 Arduino 實作。

...
// Timer Instantiation
   TON TON_1, TON_2;
...
PB_1 = digitalRead(I1_PIN);  // Get all Inputs at start of loop
...
// Activate an output two seconds after an input transitions from low to high
  TON_1.update(PB_1, 2000);
  int test_1 = TON_1.Q;
...

第一步是建立定時器實例。這裡我們看到 TON 類別中的 TON_1 和 TON_2 實例。這與圖 1 中的圖形表示等效。在程式碼區塊內部,我們可以看到 TON 程式碼區塊,其上方直接放置了 TON_1 和 TON_2 實例。

digitalRead() 函數讀取 Opta 輸入的狀態,並將值傳遞給按鈕變數 (PB_1)。TON_1.update() 方法接受 PB_1 參數和 2000ms的 PT 值。最後,透過存取 TON_1 的公共 Q 變數 (TON_1.Q) 來取得 Q 值。

繼續到圖 1 中的第 2 級,我們遇到了另一個 TON 實例以及一個 TOF 實例。超時定時器 (TOF) 與 TON 類似,因為它們都用於非阻隔延時。但它們的運作方式有所不同。顧名思義,TON 提供開啟延時,而 TOF 提供關閉延時。其屬性總結如下表:

TON 和 TOF 的比較

定時器開啟時間 定時器停止時間
目的: 在連續輸入啟動指定時間段後啟動輸出。 在 IN 停用後,停用輸出一段時間。
用法: 用於延遲啟動;偵測持續輸入。 常用於延時停止;規定了最短運轉時間。
範例 開關啟動2s 後馬達啟動。 系統至少冷卻 3min.

當我們繼續討論時,請記住程式碼通常以讀取、修改、寫入 (RDV) 的順序執行。還要記住,所有程式碼都在循環中按順序執行。在 PLC 中,我們稱之為程式掃描。在 C++ 微控制器實作中,我們稱之為超級循環 (Superloop)。

圖 1 的第 2 行包含一些微妙的條件,有助於理解 TON 和 TOF 的工作原理。我們首先關注 TON。請注意,它是自我驅動的;TON_2 的 Q 輸出透過一個常閉觸點作為輸入。初始啟動時,Q 輸出為低電平,允許邏輯 TRUE 傳遞到 TON_2 的 IN 控制輸入。定時器開始計數。當達到 PT 時,Q 輸出被啟動。記住讀取、修改、寫入 (RDV) 的約束,Q 輸出將在一個程式掃描週期內保持高電平。因此,在下一次循環迭代中,TON_2 被重置,重新開始整個過程。

換句話說,TON_2 每 5s 產生一個脈衝。此脈衝的持續時間為一個程式掃描週期。等效的基於類別的脈衝產生器包含在以下這行簡單的程式碼中:

TON_2.update(!TON_2.Q, 5000); 

如圖 2 所示,該脈衝被傳送到 TOF_1。回想一下,當 IN 為真時,TOF 定時器的 Q 輸出會立即變成高電位。然後,TOF 定時器會保持 Q 輸出,直到 PT 時間結束。在本例中,TOF_1 每 5s 接收一次脈衝。每次接收到脈衝時,它都會將 Q 輸出保持高電平 300ms。結果會在 Opta 的狀態 LED 上顯示為每 5s 出現一次的短脈衝(300ms)。基於類別的表示如下所示。請注意,TOF 實例接收 TOF_2.Q 脈衝和 5000ms 的 PT 時間。最後一行將 TOF_1.Q 的值傳遞給一個變量,供 Arduino 的 digitalWrite() 函數使用,以設定所需的輸出。

  // Produce a pulse that is one program scan long that occurs once every 5 seconds
  // Use that pulse to blink an LED with a 300 ms on period

  TON_2.update(!TON_2.Q, 5000);

  TOF_1.update(TON_2.Q, 300);
  int test_2 = TOF_1.Q;

結論

了解本文中介紹的梯形邏輯和基於類別的實作是非阻塞的非常重要。也就是說,刺激和反應之間存在著無窮小的時間交互作用。從人類對現實世界事件的角度來看,TON_1、TON_2 和 TON_3獨立運作;梯級 1 和梯級 2 之間沒有交互作用。可以添加許多這樣的行,同時保留對實際應用中的電腦介面和控制至關重要的非阻塞約束。

本文的其餘部分包含基於 Arduino 類別的非阻隔程式碼。雖然它是為在 Opta PLC 中使用而編寫的,但可以對其進行修改以適用於 Arduino 系列中的大多數成員。透過取代 Arduino millis( ) 函數,它可以用於大量現代微控制器。這是建構非阻隔計時器的多種方法之一。可能還有更好的方法;記憶體密集程度較低、速度更快或更易於閱讀的方法。不過,我相信它會有用。

請務必查看此連結,以獲取相關的 Arduino 教育內容




timers.h

/************************************************************************************** 
 * Disclaimer:
 *     The content provided herein was generated with assistance from an artificial 
 *     intelligence model (ChatGPT V 4.0). 
 *     
 *     This code is subject to Term and Conditions associated with the DigiKey TechForum.
 *     Refer to https://www.digikey.com/en/terms-and-conditions?_ga=2.92992980.817147340.1696698685-139567995.1695066003
* 
 *     Should you find an error, please leave a comment in the forum space below. 
 *     If you are able, please provide a recommendation for improvement.
 *
 * Description:
 *     The code demonstrates the parallels between Arduino programming and PLC 
 *     constructs such as TON, TOF, and TONOFF.
 *
 *     The program is best demonstrated on the Arduino Opta PLC. However, it may be useful
 *     for any Arduino application where simple to implement non-blocking delays are desired. 
 *  
 * Acknowledgments:
 *     Thanks to OpenAI's ChatGPT for guidance and code assistance.
 * 
 ***************************************************************************************/



/************************************************************************************** 
 * CAUTION with the use of a global variable loopStartTime
 *
 * This allows a consistent time for all timer instances for a given loop iteration.
 *
 * The loop time is set once in the main loop as: loopStartTime = millis();
 *
 ***************************************************************************************/


#ifndef TIMERS_H
#define TIMERS_H

extern unsigned long loopStartTime;

class TON {
public:
  TON();                                   // Constructor declaration
  void update(bool IN, unsigned long PT);  // Method declaration

  bool Q;                                  // Output
  unsigned long ET;                        // Elapsed Time

private:
  bool prevIN;                             // Previous input state
  unsigned long startTime;                 // Time at which the timer was started
  unsigned long prevPT;                    // Previous PT value
};




// TOF - Timer Off Delay
class TOF {
public:
  TOF();
  void update(bool IN, unsigned long PT);
  bool Q;                                  // Output
  unsigned long ET;                        // Elapsed Time

private:
  unsigned long startTime;                 // Time when the input became FALSE
  bool prevIN;                             // Previous state of input
  unsigned long prevPT;                    // Previous delay time to check if it has changed
};




class TONOFF {
public:
  TONOFF();
  void update(bool IN, unsigned long OnDelay, unsigned long OffDelay);
  bool Q;                                  // Output
  unsigned long ET;                        // Elapsed Time

private:
  unsigned long startTime;                 // Time when the input changes state
  bool prevIN;                             // Previous state of input
  unsigned long prevOnDelay;               // Previous on-delay time to check if it has changed
  unsigned long prevOffDelay;              // Previous off-delay time to check if it has changed
  int mode;                                // 0: Idle, 1: OnDelay mode, 2: OffDelay mode
};




#endif  // TIMERS_H


Timers.cpp


#include "timers.h"

unsigned long loopStartTime;        // CAUTION: this is a global variable

TON::TON()
  : Q(false), ET(0), prevIN(false), prevPT(0) {} // Default values

void TON::update(bool IN, unsigned long PT) {

  // Check if PT has changed during the count
  if (IN && (PT != prevPT)) {
    startTime = loopStartTime;
    ET = 0;
    prevPT = PT;
  }

  // Rising edge detection
  if (IN && !prevIN) {
    startTime = loopStartTime;
    ET = 0;
  }

  // Timer logic
  if (IN) {
    ET = loopStartTime - startTime;
    Q = ET >= PT;
  } else {
    Q = false;
    ET = 0;
  }

  prevIN = IN;
}




TOF::TOF()
  : Q(true), ET(0), prevIN(true), prevPT(0) {}  // Default values

void TOF::update(bool IN, unsigned long PT) {

  // Check if PT has changed during the count
  if (!IN && (PT != prevPT)) {
    startTime = loopStartTime;
    ET = 0;
    prevPT = PT;
  }

  // Falling edge detection
  if (!IN && prevIN) {
    startTime = loopStartTime;
    ET = 0;
  }

  // Timer logic for TOF
  if (!IN) {
    ET = loopStartTime - startTime;
    Q = ET < PT;  // Q remains true until ET exceeds PT
  } else {
    Q = true;
    ET = 0;
  }

  prevIN = IN;
}




TONOFF::TONOFF()
  : Q(false), ET(0), prevIN(false), prevOnDelay(0), prevOffDelay(0), mode(0) {}

void TONOFF::update(bool IN, unsigned long OnDelay, unsigned long OffDelay) {

  // Check if OnDelay or OffDelay has changed during the count
  if (mode == 1 && (OnDelay != prevOnDelay)) {
    startTime = loopStartTime;
    ET = 0;
    prevOnDelay = OnDelay;
  }

  if (mode == 2 && (OffDelay != prevOffDelay)) {
    startTime = loopStartTime;
    ET = 0;
    prevOffDelay = OffDelay;
  }

  // Rising edge detection for On Delay
  if (IN && !prevIN) {
    startTime = loopStartTime;
    ET = 0;
    mode = 1;  // OnDelay mode
  }

  // Falling edge detection for Off Delay
  if (!IN && prevIN) {
    startTime = loopStartTime;
    ET = 0;
    mode = 2;  // OffDelay mode
  }

  // Timer logic for OnDelay
  if (mode == 1) {
    ET = loopStartTime - startTime;
    Q = ET >= OnDelay;
    if (Q) {
      mode = 0;  // Reset mode when OnDelay time is reached
    }
  }

  // Timer logic for OffDelay
  if (mode == 2) {
    ET = loopStartTime - startTime;
    Q = ET < OffDelay;
    if (!Q) {
      mode = 0;  // Reset mode when OffDelay time is reached
    }
  }

  prevIN = IN;
}

pins.h

#ifndef PINS_H
#define PINS_H

//Names for the Opta I/O

#define I1_PIN A0
#define I2_PIN A1
#define I3_PIN A2
#define I4_PIN A3
#define I5_PIN A4
#define I6_PIN A5
#define I7_PIN A6
#define I8_PIN A7

#define O1_PIN D0
#define O2_PIN D1
#define O3_PIN D2
#define O4_PIN D3

#define LED_RED_PIN LEDR
#define LED_GRN_PIN LED_RESET
#define LED_BLU_PIN LED_USER

#define S1_PIN LED_D0
#define S2_PIN LED_D1
#define S3_PIN LED_D2
#define S4_PIN LED_D3

#define USER_PB_PIN BTN_USER

#endif // PINS.H

Opta.ino


#include "timers.h"
#include "pins.h"

// Global Variable

extern unsigned long loopStartTime;
bool gxFirstScan = true;  // High for one and only one program scan

// Program Constants

  // FIXME: This demo program contains magic numbers

// Timer Instantiation
TON TON_1, TON_2;
TOF TOF_1;
TONOFF TONOFF_1;

void setup() {
}

void loop() {

  bool PB_1;


  /************************************************************************************** 
   * PRELIMINARY LOOP TASKS:
   * 
   * This is where inputs are read and housekeeping tasks are conducted
   * such as the essential update to the global variable loopStartTime 
   *
   */

  PB_1 = digitalRead(I1_PIN);  // Get all Inputs at start of loop

  loopStartTime = millis();

  /************************************************************************************** 
   * MAIN PROGRAM BODY
   * 
   * DO NOT read from or write to I/O in this section of code. Stated another way, 
   * we will NOT use Ix_PIN and Ox_PIN in this section.
   *
   */


  // Activate an output two seconds after an input transitions from low to high
  TON_1.update(PB_1, 2000);
  int test_1 = TON_1.Q;

  // Produce a pulse that is one program scan long that occurs once every 5 seconds
  // Use that pulse to blink an LED with a 300 ms on period
  TON_2.update(!TON_2.Q, 5000);
  TOF_1.update(TON_2.Q, 300);
  int test_2 = TOF_1.Q;

  // Use the Time ON / Off Timer to blink a LED with one second on and 3 seconds off
  TONOFF_1.update(!TONOFF_1.Q, 3000, 1000);
  int test_3 = TONOFF_1.Q;


  /************************************************************************************** 
   * END OF LOOP TASKS
   * 
   * This is where physical outputs are updated. 
   * 
   */

  digitalWrite(O1_PIN, test_1);
  digitalWrite(O2_PIN, test_2);
  digitalWrite(O3_PIN, test_3);

  digitalWrite(S1_PIN, test_1);
  digitalWrite(S2_PIN, test_2);
  digitalWrite(S3_PIN, test_3);

  if (gxFirstScan == true)  // First scan is true for first loop iteration
    gxFirstScan = false;
}

返回工業控制與自動化索引