こちらは Arduino Advent Calender 2020 の 12 日目の記事です。
私は普段から Arduino を活用してデバイス開発をしています。そんな中で自作してきた、地味だけど無いと困るライブラリ達を紹介していきたいと思います。少しでも皆さんの参考になれば幸いです。そして皆さんの設計ノウハウも、ぜひ聞かせていただきたいです。
はじめに
今回は Arduino 開発特にハマりがちな (面倒な) 下記の 4 つのカテゴリについて、ちょっとだけ開発を楽にしてくれる地味ライブラリたちを紹介していきます (Arduino 特有の問題でもないかもしれませんが)。
- 通信 (とくにシリアル通信)
- タスク・タイミング管理
- アニメーション (時間軸での操作)
- C++ 標準ライブラリ (っぽいもの) の利用
今回紹介する地味ライブラリを使うと、複雑なプロジェクトでも多少は見通しよく設計ができるんじゃないかな、と思います。こうしたらもっと良くなるよ、こういうライブラリもあるよ、などご意見いただけたら嬉しいです。
また、これらのライブラリたちは、openFrameworks とその アドオン や Processing のようなオープンソースなクリエイティブコーディングフレームワークから様々な知見をいただいて開発しています。偉大なる先人たちに感謝…。
通信 (シリアル通信)
シリアル通信は特に Arduino における鬼門で (ぼくだけでしょうか…)、ここをクリアできればいろいろなプロジェクトを楽に進めることができます。シリアル通信は決まった通信プロトコルがなく、自分でプロトコル決めて実装していかなければならないため、はじめは本当に大変でした。では、どうやってシリアル通信するのが良いのか?を考えていった記事を 2017 年のアドベントカレンダー に書いていますので、こちらも合わせて見てみてください。
個人的な開発方針として、通信バイト数をなるべく小さくしたいので、通信モジュールなどに必要な場合を除いて、基本的に ASCII でのデータ送受信は行いません。以下はバイナリ形式のみを前提としています。
Packetizer
バイナリデータを COBS もしくは SLIP という形式にエンコード・デコードし、それらに index
と crc8
を付与することで、軽量+安全に通信を行うためのライブラリです。
- 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 の光り方を良い感じにするなど、いろんな場面で使えると思います。
#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++ でも使えるように、FastCRC と CRCpp を自動で切り替えます。
- ESP32DMASPI : ESP32 で DMA による大容量・高速な SPI 通信を実現します (例では 8192byte)。
- TsyDMASPI : ESP32DMASPI の Teensy 版です。送信側しかないので、PRお待ちしております。
フィルタリング
- Debouncer : ボタン押下時のリップルを無視し、安定した入力値を取得します。エッジコールバックつき。
- Filters : シンプルなフィルタ集です。種類が少ないですが、LPF・HPF・疑似微分・疑似積分が使えます。
物理演算
- ArduinoEigen : C++ の線形代数ライブラリ Eigen が使えます (AVR 非対応 : 代替品 => EigenArduino)。
- VectorXf : openFrameworks の ofVec2f/3f/4f の Arduino 移植版、簡易ベクトル演算に便利です。
まとめ
だいぶ長くなってしまいましたが、個人的に開発・公開している Arduino 用のライブラリを、ちょっとしたノウハウと一緒に紹介してきました。これらを活用して、機能をたくさん詰め込んでも見通しが良い設計に、多少は貢献できるといいなあと思っております。