88
73

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ArduinoAdvent Calendar 2020

Day 12

Arduino 開発を支える地味なライブラリたち

Last updated at Posted at 2020-12-11

こちらは Arduino Advent Calender 2020 の 12 日目の記事です。

私は普段から Arduino を活用してデバイス開発をしています。そんな中で自作してきた、地味だけど無いと困るライブラリ達を紹介していきたいと思います。少しでも皆さんの参考になれば幸いです。そして皆さんの設計ノウハウも、ぜひ聞かせていただきたいです。

はじめに

今回は Arduino 開発特にハマりがちな (面倒な) 下記の 4 つのカテゴリについて、ちょっとだけ開発を楽にしてくれる地味ライブラリたちを紹介していきます (Arduino 特有の問題でもないかもしれませんが)。

  • 通信 (とくにシリアル通信)
  • タスク・タイミング管理
  • アニメーション (時間軸での操作)
  • C++ 標準ライブラリ (っぽいもの) の利用

今回紹介する地味ライブラリを使うと、複雑なプロジェクトでも多少は見通しよく設計ができるんじゃないかな、と思います。こうしたらもっと良くなるよ、こういうライブラリもあるよ、などご意見いただけたら嬉しいです。

また、これらのライブラリたちは、openFrameworks とその アドオンProcessing のようなオープンソースなクリエイティブコーディングフレームワークから様々な知見をいただいて開発しています。偉大なる先人たちに感謝…。

通信 (シリアル通信)

シリアル通信は特に Arduino における鬼門で (ぼくだけでしょうか…)、ここをクリアできればいろいろなプロジェクトを楽に進めることができます。シリアル通信は決まった通信プロトコルがなく、自分でプロトコル決めて実装していかなければならないため、はじめは本当に大変でした。では、どうやってシリアル通信するのが良いのか?を考えていった記事を 2017 年のアドベントカレンダー に書いていますので、こちらも合わせて見てみてください。

個人的な開発方針として、通信バイト数をなるべく小さくしたいので、通信モジュールなどに必要な場合を除いて、基本的に ASCII でのデータ送受信は行いません。以下はバイナリ形式のみを前提としています。

Packetizer

バイナリデータを COBS もしくは SLIP という形式にエンコード・デコードし、それらに indexcrc8 を付与することで、軽量+安全に通信を行うためのライブラリです。

  • COBS / SLIP に変換することでパケット形式を明確にし、なるべく軽量にデータの送受信可能
  • index バイトを追加することで、パケット種別の判別が可能
  • crc8 バイトを追加することで、パケットの誤りを検出が可能
  • これらの特徴を持つパケットを、一行で送信することが可能
  • index バイトを活用して、ラムダ式などで受信コールバックを簡単に記述可能
// define these macros before include, to enable indexing and verifying
#define PACKETIZER_USE_INDEX_AS_DEFAULT
#define PACKETIZER_USE_CRC_AS_DEFAULT

#include <Packetizer.h>
uint8_t recv_index = 0x12;
uint8_t send_index = 0x34;

void setup() {
    // you can add callback depending on index value
    Packetizer::subscribe(Serial, recv_index,
        [&](const uint8_t* data, const size_t size) {
            Packetizer::send(Serial, send_index, data, size); // send back packet
        }
    );

    // you can also add callback called every time packet comes
    Packetizer::subscribe(Serial,
        [&](const uint8_t index, const uint8_t* data, const size_t size) {
            // send back to same index
            Packetizer::send(Serial, index, data, size);
        }
    );
}

void loop() {
    Packetizer::parse(); // automatically incoming packets are verified by crc
}

MsgPack

実際にプログラム上で使うクラスや変数などを、MessagePack 形式のバイナリデータにシリアライズするためのライブラリです。

  • オリジナルの MessagePack で扱える型はほぼ網羅 (独自のクラスや std::vector<T> なども)
  • 型推論によって、様々な型の変数を一発エンコード・デコードできる

世の中には他にも色々なシリアライザがありますが、個人的には MessagePack が好きなので、これをいつも使っています。みなさんのおすすめのシリアライザも教えてほしいです。

#include <MsgPack.h>

// input to msgpack
int i = 123;
float f = 1.23;
MsgPack::str_t s = "str"; // std::string or String
MsgPack::arr_t<int> v {1, 2, 3}; // std::vector or arx::vector
MsgPack::map_t<String, float> m {{"one", 1.1}, {"two", 2.2}, {"three", 3.3}}; // std::map or arx::map

// output from msgpack
int ri;
float rf;
MsgPack::str_t rs;
MsgPack::arr_t<int> rv;
MsgPack::map_t<String, float> rm;

void setup() {
    // serialize to msgpack
    MsgPack::Packer packer;
    packer.serialize(i, f, s, v, m);

    // deserialize from msgpack
    MsgPack::Unpacker unpacker;
    unpacker.feed(packer.data(), packer.size());
    unpacker.deserialize(ri, rf, rs, rv, rm);

    if (i != ri) Serial.println("failed: int");
    if (f != rf) Serial.println("failed: float");
    if (s != rs) Serial.println("failed: string");
    if (v != rv) Serial.println("failed: vector<int>");
    if (m != rm) Serial.println("failed: map<string, int>");
}

MsgPacketizer

個人的なシリアル通信の定番ライブラリを模索して、現状一番よく使っているのがこちらになります。上述の 2 つのライブラリを組み合わせることで、どんな変数でも簡単で安全にシリアル通信ができます。

  • パケットサイズをなるべく小さくするため、MessagePack を使用
  • MessagePack にシリアライズされたデータを、Packetizer を使ってさらに加工
    • index バイトでパケット種別を判別し、crc8 バイトで誤り判定
    • COBS に変換し、小さいデータ量で安全に送受信
  • 型推論により、様々な型の変数を自動的にエンコード・デコード
    • 様々な型の変数を一行でエンコード+送信
    • 様座な型の変数を一行で受信+デコード
    • ラムダ式などでコールバックを書くこともももちろん可能
#include <MsgPacketizer.h>

int i;
float f;
MsgPack::str_t s; // std::string or String
MsgPack::arr_t<int> v; // std::vector or arx::vector
MsgPack::map_t<String, float> m; // std::map or arx::map

uint8_t recv_index = 0x12;
uint8_t send_index = 0x34;

void setup() {
    // update received data directly
    MsgPacketizer::subscribe(Serial, recv_index, i, f, s, v, m);

    // send varibales periodically (default 30[times/sec])
    MsgPacketizer::publish(Serial, send_index, i, f, s, v, m);
}

void loop() {
    // must be called to trigger callback and publish data
    MsgPacketizer::update();
}

タスク・タイミング管理

プロジェクトが大きく・複雑なものになっていくと、さまざまな周期的なタスクや単発のタスクの管理が大変になってきます。それらをなるべくシンプルに記述し、見通しを良くするために活用している地味ライブラリがこちらになります。

ここでも個人的な開発方針があるのですが、基本的には割り込み処理は最小限しか使いません。理由はプログラムの見通しが悪くなるからです。基本的にはポーリングのみで設計を行い、どうしても必要なところにだけ割り込み処理を使います (音系の処理やマイクロ秒単位の処理など)。個人的にはこの方が、見通しの立ちやすいタスク制御ができると思っています (割り込みでバグったときのデバッグが面倒なだけ)。

TaskManager

あまり複雑ではない、周期的なタスクを管理することができます。JavaScript の setTimeout 的なことも once(after_ms, function) を使うと実現できます。

#include <TaskManager.h>

void setup() {
    // task is executed only once after 5000[ms]
    Tasks.once(5000, []{
        Serial.print("once task: now = ");
        Serial.println(millis());
    });

    // task framerate is 1 and repeat forever
    Tasks.framerate(1, []{
        Serial.print("framerate forever task: now = ");
        Serial.println(millis());
    });

    // task framerate is 2 and 10 times only
    Tasks.framerate(2, 10, []{
        Serial.print("framerate limited task: now = ");
        Serial.println(millis());
    });

    // task interval is 1000[ms] and repeat forever
    Tasks.interval(1000, []{
        Serial.print("interval forever task: now = ");
        Serial.println(millis());
    });

    // task interval is 500[ms] and 10 times only
    Tasks.interval(500, 10, []{
        Serial.print("interval limited task: now = ");
        Serial.println(millis());
    });
}

void loop() {
    Tasks.update(); // automatically execute tasks
}

SceneManager

より複雑な一連のタスクを「シーン」という名前でひとつのクラスにまとめ、そのシーンの生成から破棄までの一連の流れにおける様々なタスクを柔軟に管理することができます。TaskManager の拡張版と言えます。例えば、下記のように複数のタスクを個別のシーンクラスに分けて記述することで、loop() に全ての処理を書くよりも、見通しを良くすることができます。

  • センサ A の値を 1 秒に 1 回取得し、それを LCD に反映する
  • センサ B の値を 1 秒に 30 回取得し、それに応じてサーボを駆動する
  • 1 秒に 60 回ポーリングし、通信を受信したときはすぐに LED を光らせる
#include <SceneManager.h>

// make your scene class
class Blink : public Scene::Base {
    bool b;
public:
    virtual ~Blink() {}

    Blink(const String& name, double fps)
    : Base(name, fps)
    , b(false)
    {
        pinMode(13, OUTPUT);
        digitalWrite(13, LOW);
    }

    virtual void update() override {
        digitalWrite(13, b);
        b = !b;
    }
};

void setup() {
    Scenes.add<Blink>("blink", 1); // add scene to run in 1 [fps]
    Scenes.start();                // start scenes
}

void loop() {
    Scenes.update(); // automatically Blink::update() is called in 1 [fps]
}

PollingTimer

上記のような、時間に応じたタスク管理を行うライブラリには PollingTimer が使用されています。割り込みは使わず、名前の通りポーリングのみでタスクの実行タイミングを制御します。下記のような種類があります。

  • PollingTimer : シンプルなタイマ
  • IntervalCounter : インターバル指定でコールバックを呼び出す
  • FrameRateCounter : フレームレート指定でコールバックを呼び出す
  • OneShotTimer : 指定時間後に一度だけコールバックを呼び出す
#include <FrameRateCounter.h>
FrameRateCounter fps(30); // set framrate to 30[Hz]

void setup() {
    fps.addEvent([&]() {
        Serial.print("frame no. = ");
        Serial.print(fps.frame());
        Serial.print(", time = ");
        Serial.println(fps.msec());
    });
    fps.start();
}

void loop() {
    fps.update(); // event occurs if frame has changed
}

DebugLog

プロジェクトが大きく複雑になってくるにつれて、デバッグ作業がどんどんしんどくなってきます。そんなときに、柔軟にシリアルデバッグ出力とそのロギング、Arduino 標準では使えない Assertion のようなものが使えると捗るな、、、と思って作ったライブラリです (ICE を使いましょう)

  • 可変長引数で楽にシリアル出力ができる
  • LOG_ERROR LOG_WARNING LOG_VERBOSE PRINTLN などでログを出力
  • LOG_SET_LEVEL で表示するログレベルを選択
  • #define NDEBUG を定義することですべて表示のログを非表示 (リリースモード)
  • ログ出力データを SD カードへ自動で保存
// uncommend NDEBUG disables ASSERT and all debug serial (Release Mode)
//#define NDEBUG

#include <DebugLog.h>

void setup() {
    Serial.begin(115200);

    // you can change target stream (default: Serial, only for Arduino)
    // LOG_ATTACH_SERIAL(Serial2);

    // set log level (default: DebugLogLevel::VERBOSE)
    LOG_SET_LEVEL(DebugLogLevel::ERRORS); // only ERROR log is printed
    LOG_SET_LEVEL(DebugLogLevel::WARNINGS); // ERROR and WARNING is printed
    LOG_SET_LEVEL(DebugLogLevel::VERBOSE); // all log is printed

    // set log output format options (show file, line, and func)
    // default: true, true, true
    LOG_SET_OPTION(false, false, true);

    LOG_ERROR("this is error log");
    LOG_WARNING("this is warning log");
    LOG_VERBOSE("this is verbose log");

    // you can change delimiter from default " " to anything
    LOG_SET_DELIMITER(" and ");
    LOG_VERBOSE(1, 2, 3, 4, 5);

    int x = 1;
    ASSERT(x != 1); // if assertion failed, Serial endlessly prints message
}

TimeProfiler

これまで紹介してきたポーリングに基づくタスク管理では、そのタスク実行周期を超えた重い処理があると、すべてのタスクのタイミングが狂ってしまいます。そのような場合に備えて、タイムプロファイリングを行えばボトルネックを可視化することが可能です。

  • TIMEPROFILE_BEGIN(hoge) TIME_PROFILE_END(hoge) で任意の部分の時間を計測
  • SCOPED_TIMEPROFILE(hoge) で任意のスコープの時間を計測
#include <TimeProfiler.h>

void setup()
{
    TIMEPROFILE_BEGIN(one); // about 1000 [ms]
    TIMEPROFILE_BEGIN(two); // about 3000 [ms]
    TIMEPROFILE_BEGIN(three); // about 6000 [ms]

    // create scope for test
    {
        SCOPED_TIMEPROFILE(all);
        {
            SCOPED_TIMEPROFILE(a);
            delay(1000);
            TIMEPROFILE_END(one);
        }
        {
            SCOPED_TIMEPROFILE(b);
            delay(2000);
            TIMEPROFILE_END(two);
        }
        {
            SCOPED_TIMEPROFILE(c);
            delay(3000);
            TIMEPROFILE_END(three);
        }
        // "all" ends here
    }

    Serial.print("all   : ");
    Serial.println(TimeProfiler.getProfile("all"));
    Serial.print("one   : ");
    Serial.println(TimeProfiler.getProfile("one"));
    Serial.print("two   : ");
    Serial.println(TimeProfiler.getProfile("two"));
    Serial.print("three : ");
    Serial.println(TimeProfiler.getProfile("three"));
    Serial.print("a     : ");
    Serial.println(TimeProfiler.getProfile("a"));
    Serial.print("b     : ");
    Serial.println(TimeProfiler.getProfile("b"));
    Serial.print("c     : ");
    Serial.println(TimeProfiler.getProfile("c"));
}

アニメーション

こちらはグラフィックではなく、値をアニメーションさせるライブラリです。Arduino に限った話ではありませんが、時間軸によって変化していく値を作るのは if 文の嵐になったりして、非常にめんどくさいです。これをなるべく楽に書けるようにしたいな、と思って下記のライブラリを作りました。

Easing

Andy Brown's Easing Function Library という基本的なイージングのライブラリを Arduino 用に移植したものです。イージングがどいうものなのか、についてはこちらを見るとイメージしやすいと思います。

#include <Easing.h>
EasingFunc<Ease::Sine> e;
float start;

void setup() {
    e.duration(duration); // default duration is 1.0
    e.scale(scale); // default scale is 1.0
    start = millis() / 1000.;
}

void loop() {
    // value moves from 0.0 to scale in duration [sec]
    float now = millis() / 1000.;
    float value = e.get(now - start);
    Serial.println(value);
}

Tween

上記の Easing を内部で使用し、Tween アニメーションをサクッと作れるライブラリです。値の連続的なアニメーションをそれなりに簡単に作ることができます。モータの動作や LED の光り方を良い感じにするなど、いろんな場面で使えると思います。

tween.gif

#include <Tween.h>
Tween::Timeline timeline;
float target = 0.f;

void setup() {
    timeline.add(target) // target tweens
        .then(10, 5000)  // to 10 in 5000[ms]
        .then(5, 5000)   // then to 5 in 5000[ms]
        .wait(1000)      // then stops 1000[ms]
        .then(0, 5000);  // then to 0 in 5000[ms]

    timeline.start();
}

void loop() {
    timeline.update(); // must be called to tween target
    Serial.println(target); // target value tweens automatically
}

C++ 標準ライブラリ (っぽいもの) の利用

ここまで、普通に std::vector<T> のような C++ の標準ライブラリにあるコンテナクラスを使ってきました。ESP 系や ARM 系のチップであれば使うことは可能なのですが、残念なことに Arduino UNO や Mega などの AVR 系チップのものは、標準ライブラリを使えません…。マイコンはメモリの容量が限られているため致し方ないことではあるのですが、ライブラリを書いていると、やはり Uno などへの対応をお願いされることが多々あります

そんな経験を経て、一念発起して C++ 標準ライブラリのような挙動をするライブラリを、C++ の勉強だと思って作りました。Arduino 用のライブラリを作っているような方々は、これらを使うとちょっと幸せになれるかもしれません。

これらのライブラリは、標準ライブラリが使用可能な場合には標準ライブラリを優先して使うようになっており (ArduinoSTL などの uClibc++ ベースのライブラリとも共存可能)、プラットフォーム間の差異を意識せずにとりあえず include しておけば良いものになっています。

ArxContainer

std::vector std::deque std::map の主要なメソッドを使用可能な arx::vector arx::deque arx::map を使えるようになります。内部では固定長のリングバッファを使って上記の機能を模倣しているため、可変長っぽく見えてもコンパイル時にサイズが固定され、必要な分のメモリしか確保せずに済みます。

#include <ArxContainer.h>

// vector
arx::vector<int> vs {1, 2, 3};
for (size_t i = 4; i <= 5; ++i)
    vs.push_back(i);
for (size_t i = 0; i < vs.size(); ++i)
    Serial.println(vs[i]);
for (const auto& v : vs)
    Serial.println(v);

// deque
arx::deque<int> dq {1, 2, 3};
for (int i = 4; i <= 5; ++i)
    dq.push_back(i);
for (int i = 0; i < dq.size(); ++i)
    Serial.print(dq[i]);

// map
arx::map<String, int> mp {{"one", 1}, {"two", 2}};
mp.insert("three", 3);
mp["four"] = 4;
for (const auto& m : mp) {
    Serial.print("{");
    Serial.print(m.first); Serial.print(",");
    Serial.print(m.second);
    Serial.println("}");
}
Serial.print("one   = "); Serial.println(mp["one"]);
Serial.print("two   = "); Serial.println(mp["two"]);
Serial.print("three = "); Serial.println(mp["three"]);
Serial.print("four  = "); Serial.println(mp["four"]);

ArxSmartPtr

カウンタ付きスマートポインタの std::shared_ptr が使えるようになります。Boost.SmartPtr を簡略化して移植したものです。

#include <ArxSmartPtr.h>

{
    Serial.println("start");
    std::shared_ptr<Base> t1(new Base(4));
    std::shared_ptr<Base> t2;
    {
        std::shared_ptr<Base> t3(new Base(5));
        std::shared_ptr<Base> t4(new Base(6));
        t2 = t3;
    }
    Serial.println("end");
}
// start
// Base::Constructor 4
// Base::Constructor 5
// Base::Constructor 6
// Base::Destructor 6
// end
// Base::Destructor 5
// Base::Destructor 4

ArxTypeTraits

テンプレートメタプログラミングを使いやすくする type_traits の一部が使えるようになります。それに付随して、std::function std::tuple も使えるようになります。ある程度のテンプレートメタプログラミングが可能になるため、そのような技法を使ったライブラリを AVR などのプラットフォーム間の差異を意識せずに作れるようになります。

標準ライブラリが使えるときはそれを優先して使うように設計されており、ArduinoSTL などの uClibc++ ベースのライブラリとも補完し合って使うことが可能です。これらと組み合わせて使うことで、C++ 標準ライブラリの多くを使用することが可能となります。ただ、その分プログラム領域やメモリを食うことになるので、非力な AVR マイコンを使用する場合はご注意下さい。

Arduino では基本的に C++14 でビルドできるようになっていますが、ArxTypeTraits ではごく一部の C++17/20 のものもカバーしています。Pull Request もお待ちしております。

#include <ArxTypeTraits.h>

template <class T>
auto f(T)
-> typename std::enable_if<std::is_integral<T>::value>::type {
    Serial.println("T is integral");
}

template <class T>
auto f(T)
-> typename std::enable_if<std::is_floating_point<T>::value>::type {
    Serial.println("T is floating point");
}

template <class T>
auto f(T)
-> typename std::enable_if<!std::is_arithmetic<T>::value>::type {
    Serial.println("T is not arithmetic");
}

void setup() {
    f(1);
    f(1.1);
    f("1.11");
}
// T is integral
// T is floating point
// T is not arithmetic

その他のライブラリ

せっかくなので、その他の使用頻度が高いライブラリも羅列しておきます。

通信系

  • ArduinoOSC : Arduino で OSC 通信を簡潔に実現します。詳細は 以前の記事 をご覧ください。
  • ArtNet : Arduino で Art-Net の送受信を可能にし、受信データを直接 FastLED に流し込むこともできます。
  • CRCx : CRC を計算します。普通の C++ でも使えるように、FastCRCCRCpp を自動で切り替えます。
  • ESP32DMASPI : ESP32 で DMA による大容量・高速な SPI 通信を実現します (例では 8192byte)。
  • TsyDMASPI : ESP32DMASPI の Teensy 版です。送信側しかないので、PRお待ちしております。

フィルタリング

  • Debouncer : ボタン押下時のリップルを無視し、安定した入力値を取得します。エッジコールバックつき。
  • Filters : シンプルなフィルタ集です。種類が少ないですが、LPF・HPF・疑似微分・疑似積分が使えます。

物理演算

まとめ

だいぶ長くなってしまいましたが、個人的に開発・公開している Arduino 用のライブラリを、ちょっとしたノウハウと一緒に紹介してきました。これらを活用して、機能をたくさん詰め込んでも見通しが良い設計に、多少は貢献できるといいなあと思っております。

参考

88
73
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
88
73

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?