トホホな疑問(32) .ino ファイル内のリソース参照、どうするのがよろしいの?

Joseph Halfmoon

Arduino環境で使用される .ino 拡張子のスケッチとよばれるファイル。細事にとらわれずお手軽に書けるのでお気に入りです。しかし、普通のC++のようでいてちょっと違うところもこれあり。今回は、.inoファイル内部の関数とか変数とかに普通の.cppのクラスからどうアクセスするのがよいか、チトやってみました。

ま、最初に告白しておきますと、私、人生を複雑にしたくない派であります。つまり組み込み用のプログラミングなら「Cでええじゃないか」派です。仕方なく C++ 使うこともありますが、嫌々なので何時まで経っても「C++の心」が分かりません。

けれどもArduino環境の .ino ファイル、最終的には C++ のソースになってコンパイルされるといいつつも、適度にゆるゆるとしており、私のような者でも心地よく書けるのでお気に入りです。同じマナーで、AVR、ESP32、Armなど複数マイコンのコードを書けてしまう(微妙な違いはあるにせよ)ところも素晴らしいです。さて、この.inoファイルの特徴を上げるとしたら以下のような感じでしょうか。

  • 通常、プロジェクトフォルダ名と同名の.inoファイルが存在する
  • 通常、上記ファイル中に setup() と loop()なる関数が存在する
  • setup()関数には起動後1回だけ行われる初期化処理など書く
  • loop()関数内に繰り返し処理されるコードを書く
  • loop()関数は、.inoファイルの外にある真の無限loopから毎度呼ばれる
  • .inoファイルは複数あっても良い
  • 複数の.inoファイルは上記の.inoファイルを先頭に一本の.cppとなる
  • 一本化されたファイル内の関数のプロトタイプは自動生成される
  • 特定のヘッダ arduino.h(ターゲットによっては異なる)も挿入される

クラスを .ino 側で「参照」するときは各クラスのヘッダファイルを取り込んでいるので特に問題がないでしょう。しかし、.ino ファイル内の関数や変数をクラス側で使いたい場合は、引数として呼び出し時に与えるか、それとも?、というのが今回の疑問です。どれでもいいじゃん、という気もするし、少しでもカッコよく、効率のよい方法が良いようにも思えるしで、トホホです。

今回はほぼ共通な機能を持つ3種類のクラスを定義してみました。動作検証に使うマイコンボードは、Arduino業界標準 Uno です。いつも「互換機だけれども」みたいな注釈書いていますが、今回は珍しく「純正品」利用であります。

さてクラスの共通の構造/機能は

  • クラスインスタンスには変数(abc)がある
  • そのabc変数の値をシリアルポートに出力する関数prn1がある
  • .ino側のloopカウンタをシリアル出力する関数prn2がある

です。クラスの「考え方」の違いは以下のところです。

  1. .cppのクラス側でも arduino.h インクルードすれば共通っしょ
  2. .cppのクラス側にオブジェクトへポインタ渡しておけば良いでしょ
  3. .cppのクラス側に.inoのヘッダ相当の情報を書き入れればリンクされるでしょ

3つのクラスを動作させた時にシリアルポート出力の様子がこちら。全クラス機能的には同じ動作をしていることが分かります。

TEST ---
TestClass1: prn1: 123
MAIN .ino: printCounter: 1
TestClass2: prn1: 123
TestClass2: prn2: 1
TestClass3: prn1: 123
MAIN .ino: printCounter: 1
TEST ---
TestClass1: prn1: 123
MAIN .ino: printCounter: 2
TestClass2: prn1: 123
TestClass2: prn2: 2
TestClass3: prn1: 123
MAIN .ino: printCounter: 2

最初の TestClass1 の実装は、Arduino環境依存です。arduino環境でシリアル出力を制御しているSerialクラスのオブジェクトは .ino ファイルの記述の外側で予め生成されています。そしてこのオブジェクトを参照するために必要な情報は .ino ファイルにおいては自動的に挿入される arduino.h ヘッダ(M5Stackなど他のマシンでは M5Stack.h など別なファイルとなる場合があります。ただし、M5Stack.h の内部でもさらに arduino.h はインクルードされています)をインクルードすれば得られます。よって arduino.h ヘッダをクラス側のファイルでインクルードすれば TestClass1 のインスタンス変数 abc を印字する prn1 というメンバ関数の中で Serialオブジェクトは「普通に」使えてしまいます。これは Serial がそういう特殊なオブジェクトであるからです。一方、 arduino.h をインクルードでは .ino ファイル内の変数 counter にも、counterの値を印字する関数 printCounter() のどちらにもアクセスできないので、prn2では引数としてそのどちらかを与える必要がありました。

.inoと「同じSerialが見える」Classのヘッダ
#ifndef TESTCLASS1_H
#define TESTCLASS1_H

class TestClass1 {
  private:
    int abc;
  public:
    TestClass1(int abc);
    void prn1(void);
    void prn2(void (*func)(void));  
};
#endif

上のクラス定義に対応する関数の実装です。

.inoと「同じSerialが見える」Classの.cpp
#include <arduino.h>
#include "TestClass1.h"

TestClass1::TestClass1(int arg) {
  abc = arg;
}

void TestClass1::prn1(void) {
  Serial.print("TestClass1: prn1: ");
  Serial.println(abc);
}

void TestClass1::prn2(void (*func)(void)) {
  func();
}

第2の TestClass2 の実装では、arduino.h をインクルードしていません。クラスをコンストラクトする最初の時に関数ポインタを引数にとり、クラスのメンバ変数に保存しています。ここに .ino 内で定義されている印字用の関数を渡してもらい、以降、その関数を呼び出すことでシリアル出力を行います。ここでは Serialオブジェクトはアカラサマに出現しません。ここで2種類の関数ポインタを引き渡し、prn1, prn2 で使い分ければ、prn2で counter 値を引数として渡す必要はありませんでしたが、そうしなかったのは面倒だったから。

.ino内の「関数ポインタ」を渡したClassのヘッダ
#ifndef TESTCLASS2_H
#define TESTCLASS2_H
class TestClass2 {
  private:
    int abc;
    void (*func)(String, int);
  public:
    TestClass2(int arg, void (*func));
    void prn1(void);
    void prn2(int arg);
};
#endif

さて下のソースでちょっと気になるのが、arduino.h はインクルードしなかったものの、WString.h をインクルードしていることです。これは Serialクラスのprint系関数が文字列引数として String()型を取るため、データ型を使うために必要なヘッダということでそうしています。ただ、Arduinoの文字列データ型については私も混乱があったので以下に参照URLを掲げました。Arduino API Reference内のURLです。string型とString()型の違いです。

string

上記の説明を読むと、string型は NULL末尾の char型配列であり、Cの文字列そのものです。C++のstd::stringとは違うものに見えます。それに対して以下の型が 一般のC++ の std::string相当の型だと思います。

String()

ただし、普通のC++なら以下のヘッダのインクルードで使える筈ですが、そうではなかったです。

#include <string>

私の環境で上記のように書くとエラーになりました。エラーにならずに String()型を使う為には以下のようにする必要がありました。

#include <WString.h>

実際には、arduino.hをインクルードすればString()を使えるようになるので、わざわざ WString.h を単体インクルードすることは無いように思えますが念のため。以下は、関数ポインタをクラス内で保持する場合のクラスの実装です。

.ino内の「関数ポインタ」を渡したClassの.cpp
#include <WString.h>
#include "TestClass2.h"

TestClass2::TestClass2(int arg, void (*farg)) {
  abc = arg;
  func = farg;
}

void TestClass2::prn1() {
  func("TestClass2: prn1: ", abc);
}

void TestClass2::prn2(int arg) {
  func("TestClass2: prn2: ", arg);
}

第三の実装は、.ino ファイルの「ヘッダ」相当の記述を自力でクラス側に書き入れるというものです。.ino ファイル内の関数プロトタイプは自動生成ですが、他のファイルに手動で書きこむことはできます。また、変数についても extern で宣言すれば参照できます。この場合のヘッダファイルは以下のようになります。

.ino内の「関数プロトタイプ」を取り込んだClassのヘッダ
#ifndef TESTCLASS3_H
#define TESTCLASS3_H

void printCounter(void);
void printX(String argS, int argV);

class TestClass3 {
  private:
    int abc;
  public:
    TestClass3(int);
    void prn1(void);
    void prn2(void);
};
#endif

実装のソースファイルは以下のようです。ここでも String() 型を使うために WString.h をインクルードしています。この実装例では、.ino ファイル内の関数をそのまま呼び出せます。

.ino内の「関数プロトタイプ」を取り込んだClassの.cpp
#include <WString.h>
#include "TestClass3.h"

TestClass3::TestClass3(int arg) {
  abc = arg;
}

void TestClass3::prn1(void) {
  printX("TestClass3: prn1: ", abc);
}

void TestClass3::prn2(void) {
  printCounter();
}

第3の実装は、ストレートで分かり易いですが、手作業で本来自動生成される .ino ファイルのヘッダ相当の情報を書き出すという点に多少心が引っかかります。第2の方法は関数ポインタでなくとも、クラスオブジェクトへのポインタ、あるいはC++の本来の意味の「参照」使っても機能すると思います。文法的にはC++として首尾一貫な感じがします。しかし第3の方法に比べるとメンドイです。第1の方法は、これまたストレートで分かり易いですが、Arduino環境依存の C++のクラス定義になってしまいます。もっとカッコよくて手のかからない方法あるかもしれないな。何分C++は不案内だし。。。トホホ。

トホホな疑問(31) MtStack、IMUの種類と取り扱いに戸惑う へ戻る

トホホな疑問(33) BBC micro:bitのピン番号にハマる。Arduino環境にて へ進む

.ino 型式のメインプログラム全文
#include "TestClass1.h"
#include "TestClass2.h"
#include "TestClass3.h"

TestClass1 tc1(123);
TestClass2 tc2(123,printX);
TestClass3 tc3(123);

int counter;

void printCounter(void) {
  Serial.print("MAIN .ino: printCounter: ");
  Serial.println(counter);
}

void printX(String argS, int argV) {
  Serial.print(argS);
  Serial.println(argV);
}

void setup() {
  Serial.begin(9600);
  while (!Serial) {};
  counter = 0;
}

void loop() {
  Serial.println("TEST ---");
  tc1.prn1();
  tc1.prn2(printCounter);
  tc2.prn1();
  tc2.prn2(counter);
  tc3.prn1();
  tc3.prn2();
  counter++;  
  delay(5000);
}