組み込み開発で C++ を使う意味
最初に断っておきますが、C 言語プログラマをディスるのでは無い事を確認しておきます。
組み込みの世界では、まだまだまだ C 言語が主流で、サンプル、ライブラリ、フレームワーク、等々が C 言語ベースです。
ですが、十分に熟成した高性能マイコンの開発言語としてベストな選択なのでしょうか?
- 小規模な 8/16 ビットマイコン(R8C,RL78)でさえ C++ は実用的に使えて有用と考えています
- C++ でプログラムする事で、実装ミスを減らし、再利用可能なプログラム資源を充実させる事が出来ます
最近のマイコンは、メモリも十分に大きく、開発環境も整っているので、あえて C 言語をベースで開発する理由は殆ど無いと思われます。
C++ を選択しない理由として最も大きいのは、開発者が C++ を習得して理解する余力が無い事だろうと思います。
世の中に多く出回っている組み込み系 C++ コードが少なく、参考になる実装が少ない事も要因の一つです。
参考にしたコードが C++ と言うよりは、C+- 的な洗練されていない実装で、それを参考にした為、怪しげな方向に進んでしまい、正しい道に戻れなくなっている場合も少なくありません。
昔に、不十分な状態で C++ を使い、痛い目にあった為、避けている場合もあるかと思います(自分もその経験があります)
C 言語で実装された雛形やサンプルコードを利用する場合、使い方が複雑で難解だったり、間違った使い方をしてもエラー無くコンパイルが完了してしまう、定義が微妙で、他のコードと干渉してコンパイル出来ないなど、様々な問題点を多く含んでいる場合が多いと感じます。(C で実装された、非常に優れたプロジェクトも沢山あります)
そもそも、多くの定義を自分の環境用に修正する事が必須だったりします。
それら多くの問題は C++ によって改善する事は多くあり、組み込み開発者が C++ の有用性を再認識して欲しいと思います。
前回のコラム、そこそこ評判が良かったので、もう少し踏み込んだ、第二弾をお送りします。
今回のコラムは、C++17 と言うよりは C++ 全般な内容となります。
今すぐ使いたい C++ の機能
解説は、今まで多くの人がしてきたものを焼き直したもので、正確性に欠けたり、説明不足の側面があると思いますが「雰囲気」と「興味」を持ってもらえればと思います。
「そんな事知ってるよ!」と思う人も多いかと思いますが、あくまでも、組み込み開発で、中々 C++ を使えない人向けの解説ですのでご了承下さい。
二進数リテラル (C++14)
二進数リテラル cpprefjp - C++ 日本語リファレンス
uint8_t x = 0b1010;
- これが今まで無かったのが不思議なくらいですが、「表現」として二進数を扱えるようになりました
- 組み込みでは、ハードウェアー依存の設定値など、二進表現が多くあり、以前は、16進や10進に直して記述していました
- この機能だけでも C++ を選択する理由になると思います
数値リテラルの桁区切り文字 (C++14)
数値リテラルの桁区切り文字 cpprefjp - C++ 日本語リファレンス
uint16_t r = 0b1101'1000'0100'0110;
uint32_t a = 12'800'000; // 12.8MHz
uint32_t b = 0x7f83'663a;
- 長い桁を表現したりする場合、格段に見やすくなり、ミスを減らせます
- 32 ビットの 16 進数を扱うと 8 桁にもなるので、これが無いと辛いと思えます
- 「アンダースコアが良いのに」と思うかもしれませんが、C++ では、「'」が使われます
参照 (C++)
class abc {
public:
struct data_t {
int x;
int y;
int z;
};
data_t data_;
const data_t& get() const { return data_; }
};
int main()
{
abc a;
// t は abc クラス内の data_ を参照しています。
// const が付いているので書き込みは出来ない。
const abc::data_t& t = a.get();
int sum = t.x + t.y + t.z;
}
- C 言語プログラマを悩まさせるけど、最高に便利で精妙な「参照」
- C 言語でお馴染みの変数アドレスとは違うので注意して下さい
- C 言語では、何かアクセスする基点として、ポインタを使ってきました
- ポインタに設定されているアドレスは、正しい事が保証されていませんし、容易に間違った値に変更出来てしまいます
- ポインタが適正かを検査する方法は、「nullptr」チェックくらいしかありません
- C++ では実態をそのまま渡す仕組みが用意されています
- ポインタを受け取った場合、破棄する責任があるのかどうかが明確ではありません
- ポインタアクセスでは得られない深い最適化に貢献します
- 使い始めると、とにかく便利で安全です
auto (C++11)
class abc {
public:
struct data_t {
int x;
int y;
int z;
};
data_t data_;
auto& get() const { return data_; }
};
int main()
{
abc a;
auto t = a.get();
}
- 型の宣言や、一時的に使う変数などをコンパイラが推論してくれます
- 正確な型名を知らなくても、名前空間の奥で定義されていても、コンパイラが適切に処理してくれます
- ぱっと見、型が判らないので不便だとする意見もありますが、VSC に代表される現代のエディターでは、インテリセンスなどにより型やメンバーを教えてくれるので、そうでもないと感じます
enum class 列挙型 (C++11)
enum class holizontal {
left,
center,
right
};
enum class vertical {
top,
center,
bottom
};
void set(holizontal h) { }
void set(vertical v) { }
int main()
{
set(holizontal::left);
set(vertical::center);
}
- C 言語の enum とはスコープを持つ点で異なります
- 生の数字を使わない事で、意味が明確になります
- 「型」に従順で、正確に一致しないとエラーになるので、間違った指示を避ける事が出来ます
- holizontal::center と vertical::center は型が異なるので、異なった意味として扱えます
ファンクタ(C++)
class null_func { // 何も実行しない定義
public:
void operator() () { }
}
template <class T>
class abc {
T func_;
public:
void service() // 外部関数を実行
{
func_();
}
};
class my_func {
public:
void operator() ()
{
// my_func で実行したい処理
}
};
typedef abc<my_func> ABC; // my_api を実行する場合
// typedef abc<null_func> ABC; // 何も実行しない場合
ABC abc_;
- C++ では、しばしば、ファンクタと呼ばれる形式で、外部の関数を取り込みます
- こうする事で、コールバックのような機構を設ける事が出来ます
- コールバックと異なり、関数アドレスを設定する必要が無く、安全です
- 上記の例では、何も指定しない場合、最適化により、関数コールが除外されます
ラムダ式(C++11)
#include <iostream>
#include <functional>
int run(std::function<int (int n)> func) // コールバック関数を受け付ける
{
return func(10); // コールバック関数を実行
}
int aaa(int n)
{
return n * 2 + 1;
}
int main()
{
auto a = run([](int n) { // コールバック関数の中身をラムダ式で直接記述
n *= 100;
n += 99;
return n;
} );
std::cout << a << std::endl;
auto b = run(aaa); // 関数「aaa」を実行する場合
std::cout << b << std::endl;
}
- 組み込みでは、何かの動作に関連して、一連のコードを実行させる機構を提供する必要がしばしばあります
- 通常は、コールバック関数を登録する仕組みを設け、コールバック関数を実装して、設定します
- ラムダ式を使えば、コールバック関数内の手続きを直接実装する事が出来る為、手間が省けます
- 多少書式に慣れが必要ですが、使い始めると、極めて便利で、無い環境に戻れなくなります
- 上記例では「std::function」を使っているので、柔軟な方法でコールバックを実行できます
#define を廃止しよう~
Arduino や、C 言語のサンプルを再利用しようとして酸っぱいのが「#define」です。
#define POS_X 100
#define POS_Y 125
static constexpr int POS_X = 100;
static constexpr int POS_Y = 125;
- 通常、最適化されると、POS_X、POS_Y の値が直接使われる為、余分なメモリを消費しません
- 「const」で定義すると、RAM 上に展開され、開始時に ROM 領域からコピーされる違いがあります
#define CMD_READ 0x10
#define CMD_WRITE 0x20
static constexpr uint8_t CMD_READ = 0b0001'0000;
static constexpr uint8_t CMD_WRITE = 0x0010'0000;
enum class CMD : uint8_t {
READ = 0b0001'0000,
WRITE = 0b0010'0000
};
// CMD 型しか受け付けない
void put(CMD cmd) { } // この関数は CMD::xx 以外の定数を与えるとエラーとなります。
put(CMD::READ); // コメントが無くても、コマンドの READ を投げているのが明確です。
- 「#define」を使うと、グローバル空間に浸透してしまうので、異なる名前空間にある同名の名称が汚染され使えなくなります
- 型が無視され、コンパイル時の厳密な型チェックなどがスルーされます
- 小さな関数を #define で定義してる人を見かけますが、普通に関数で書けば済む話です
- そうすれば、型や引数の整合性がコンパイル時に正しく評価されます
- 「#define」で関数を定義しても、殆どの場合最適化に貢献しません
- それでも「#define」の黒魔術に魅了された患者が、有用性を主張する場合がありますが C++17 ではほぼ不要と思います
名前空間
namespace RELEASE {
void init()
{
}
void service()
{
}
}
namespace DEBUG {
void init()
{
}
void service()
{
}
}
int main()
{
// using namespace RELEASE;
using namespace DEBUG;
init();
service();
}
- C++ では、名前空間が使え、階層的に、定義できます
- 名前空間が異なれば、同じ定義を全く異なる意味で使う事が出来ます
まとめ
- C++ は難しい側面もありますが、習得して損は無いプログラム言語だと思います
- 多くの組み込み開発者が、C++ を最大限活用して頂ければと切に思います
- 二進数リテラルを使うだけが目的で C++ を使うのも十分な理由になります
- ハードルを上げる必要は無く、気楽に、知っている事から使い始めるのが良いと思います