ArduinoのC++からOpta PLCのラダーロジックまで、ノンブロッキングディレイとタイマの習得

APDahlen Applications Engineer

Arduino Optaプログラマブルロジックコントローラ(PLC)を評価する理由の1つは、異なるプログラミング言語間の関係を探求できる機会があることです。たとえば、この記事では、ラダーロジックとクラスオブジェクトのインスタンス化を含む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()が完了するまで待つ必要があります。この例では、最大3秒間待つ必要があります。これは、実際に機器を制御するマシンにとっては、まったく受け入れがたいことです。

ノンブロッキングコード

この問題を解決し、Arduinoの知識を深めるために、あなたはmillis()関数を使うでしょう。millis()は時計のようなものです。Arduinoが起動してからの経過時間を合理的に推定してくれます。ノンブロッキングタイマには、特定の時間インスタンスを保持するための変数がいくつか必要です。これらの変数があれば、時間の経過を監視することができます。

これは、6時45分30秒にアラームが鳴る目覚まし時計に似ています。Loop()を回しながら、現在時刻が7時45分30秒より大きいかどうかを確認します。もしそうなら、何かをします。もしそうでなければ、すぐに終了してloop()を繰り返します。プロセス全体が高速で、マイクロコントローラはloop()を素早く繰り返し、リアルタイムのイベントに反応するシステムを提供します。一般的なプログラムでは、スーパーループを1秒間に何千回も回すため、実際の応答が速くなります。

技術ヒント:ループを継続的に回すこの方法は、合理的な実際のレスポンスを提供します。このポーリング方法は決して正確なタイミングを提供するものではありません。より正確で、時間のずれを減らすには、タイマ割り込みを使用することを検討してください。

以下のコードは、同じLED点滅動作をブロックなしで実行します。LEDがオンかオフかを追跡するためにステート変数を使っていることに注意してください。また、このコードでは2つのタイマ動作が維持されていることにも注意してください。1つは1秒間のオン、もう1つは2秒間のオフです。


#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を参照してください。このラダーロジック図の最初の行(ラング)には、スイッチ接点(gxI1)、および出力(TON_1のQ)がOptaの出力リレーの1つ(gxO1)とステータスLEDの1つ(gxLED1)に接続されたタイムオンタイマ(TON_1)が含まれています。TONブロックには2つの入力があり、入力(IN)はロジックレベルの制御信号として機能し、プログラム時間(PT)はプログラムされた遅延時間を指定します。

これはデジタル門番のように機能します。入力がPTで指定された時間が経過すると、出力(Q)はHighになります。INがLowになるまでHighのままです。経過時間(ET)はブロックの時間をモニタすることができます。Qと同様に、ETの時間値はPTに達すると増加しなくなります。また、INがLowになるとリセットされます。

この例のTONブロックは、gxI1によって制御されます。これはOptaのデジタル入力の1つです。PTの時間条件は2000に設定されています。これは、Arduinoの伝統に従って、ミリ秒単位で時間を記載しています。動作は簡単に説明できます。ここでは、スイッチ(gxI1)が閉じられてから2秒後に、Optaのリレー出力(gxO1)とステータスLED(gxLED1)がアクティブになります。スイッチ(gxI1)が開くと、両方の出力は直ちに非アクティブになります。

Ladder Loic example for TON and TOF.

図 1:ラダーロジックによるタイマの表現

このクラスの従来のArduino実装は、この記事の最後にあるOpta.inoリストからのつぎの抜粋で示されています。

...
// 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 パラメータとPT値2000msを受け入れます。最後に、TON_1パブリック Q 変数(TON_1.Q)にアクセスしてQ値を取得します。

図1の2行目(ラング2)に進むと、TONの別のインスタンスとTOFのインスタンスに遭遇します。タイムオフタイマ(TOF)はTONと似ており、どちらもノンブロッキング遅延を提供します。しかし、その動作には違いがあります。名前に暗示されているように、TONはターンオンの遅延を提供し、TOFはターンオフの遅延を提供します。属性はこの表にまとめられています。

TONとTOFの比較

タイムオンタイマ タイムオフタイマ
目的: 指定された期間の連続IN起動後に
出力をアクティブにします。
INが非アクティブ化されてから指定された
期間後に出力を非アクティブ化します。
使用方法: 遅延スタートに使用します。 遅延停止によく使用され、最小動作時間が
規定されています。
例: スイッチが2秒間アクティブになると
モータが始動します。
システムは最低3分間停止します。

この説明を続けるにあたり、コードは通常、読み取り(read)、変更(modify)、書き込み(write)の一連の操作で機能することを覚えておいてください。また、すべてのコードはループ内で順次実行されることも覚えておいてください。PLCでは、これをプログラムスキャンと呼びます。C++マイクロコントローラ実装では、これをスーパーループと呼びます。

図1の2行目(ラング2)には、TONとTOFの動作を理解するのに役立つ若干の条件が含まれています。まずTONに注目しましょう。TON_2のQ出力は、ノーマルクローズの接点を介してTON_2への入力となります。最初の起動時、Q出力はLowであり、TON_2のIN制御入力に論理TRUE(High)が渡されます。タイマはカウントを開始します。PTに達すると、Q出力がアクティブになります。読み取り、変更、書き込みの制約を覚えておくと、Q出力は1回のプログラムスキャンでハイになります。その結果、次のループ反復でTON_2がリセットされ、プロセスが最初からやり直されます。

別の言い方をすれば、TON_2は5秒に1回パルスを生成します。このパルスの幅は1プログラムスキャンです。同等のクラスベースのパルスジェネレータは、以下のシンプルなコードに含まれています。

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

図2のように、このパルスはTOF_1へ送られます。TOF_1タイマのQ出力は、INが真になると直ちにアクティブになることを思い出してください。TOF_1タイマはPT時間が経過するまでQを保持します。この例では、TOF_1は5秒に1回パルスを受信します。パルスを受信するたびにQは300msの間Highに保持されます。この結果、OptaのステータスLED2には、5秒に1回発生する短い(300ms)パルスとして表示されます。クラスベースの表現を以下に示します。TOFインスタンスがTON_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()関数を置き換えれば、多くの最新のマイクロコントローラで使用できます。これは、ノンブロッキングタイマを構築する多くの方法の1つです。よりメモリを消費しない方法、より高速な方法、より可読性の高い方法など、もっと良い方法があるかもしれません。しかし、私はこのコードが役立つことを信じています。

成功を祈ります。

APDahlen




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;
}




オリジナル・ソース(English)