組み込みシステム上でのC++

去年の10月、私が所属している会社の部署で、組み込みファームウェアの開発をC言語からC++に切り替えました。C++のクラス、リソースの自動クリーンアップ、パラメータ多相、そして強化された型安全性などは、汎用OSをデスクトップ機で稼働している時と同様、リアルタイムOS(RTOS)やベアメタル上でも便利です。C++を使えば、安全で表現豊かなファームウェアを書くことができます。

しかしC++のこの自動的な魔法は諸刃の剣とも言えます。いくつかの機能は、組み込み環境1には搭載したくないシステムのファシリティに依存するからです。ツールチェーン周りをどうするかも厄介です。memcpyやアトミック操作、ハードウェア固有の浮動小数点関数などの重要なファシリティが提供されるので、libgcclibstdc++を完全に破棄するのではなく、特定の部分を避けて使わなくてはなりません。

このガイドは、C++にファームウェアを移植するにあたって私たちが学んだことを文書化してみたものです。信頼できる入門書となれば幸いです。

設定

ツールチェーンを構築する

ラッキーなことに、私たちが組み込み開発2に使うARMシステムを含むあらゆるターゲットに対して、GCCがクロスコンパイラとしてうまく機能します。いくつかのバージョンがLinuxディストリビューションのパッケージマネージャからインストールできますが、チーム内で独自のクロスコンパイラを開発し、使用することを大いにお勧めします。下記のような利点があるからです。

  • チーム全体で同じバージョンの同じツールチェーンを使うことによって、チーム全員が同じビルドを取得できるはずです。そうするとデバッグとテストの時に非常に助かります。

  • 最近のC++のアップデートにより、コンパイラ開発にかかる時間が大いに短縮されました。また最新バージョンはいくつかの局面での3コード生成を大変進化させました。以前のプロジェクト開発の時には、古いバージョン(4.8x)で、システムクラッシュを引き起こすコンパイラのバグにも出くわしました。

クロスコンパイラのツールチェーン全体を構築するのは大変な作業ですが、crosstool-NGを使ってうまくいきました。インターフェースはLinuxのsmake nconfigに似ていて、ツールチェーンが構成しやすく、依存関係を管理したりダウンロードしたりして、ビルドしてくれます。最近のバージョン、例えば最新のリリースでは、任意のGCCソースも提供しています。成果物のバイナリは静的にリンクすることができるので、ツールチェーンをターボール化してアクセス可能な場所に配置するだけで簡単にデプロイできます。このツールチェーンを使っているプロジェクトは短いスクリプトを使って引き出したり、抽出したり、実行したりできます。

C言語のコードをリンクする

私たちの組み込みのプロジェクトには、製造側が提供したドライバやRTOSなど、C言語に依存した要素が多々あります。gccを使ってビルドし、extern "C" { }でC++に#includeしたそれらのヘッダをラップします。同様に、非C++環境から呼び出したいC++の関数、例えばRTOSの関数やスタートアップのアセンブリ、などはextern "C"でタグ付けしなくてはなりません。そうすればシンボル名が名前修飾されません。通常はされてしまいますが。

コンパイラフラグ

例外処理と実行時型情報(RTTI)は動的なメモリ割り当て(後ほど説明します)なしでは難しいので、-fno-exceptions-fno-non-call-exceptionsと、-fno-rttiを使って無効にするといいでしょう。リブートするにせよ、ファームウェアは決して、ユーザ空間のプログラムと同じように終了するわけではありません。TearDownのコード(グローバルなデストラクタも含む)は-fno-use-cxa-atexitを使って省略できます。他に、組み込み開発に便利なフラグは以下の通りです。

  • -ffreestandingは、プログラムが標準ライブラリのファシリティが存在しない環境上にある可能性、またはプログラムがmain()で始まっていない可能性を指し示します。

  • -fstack-protector-strongについては、後ほど触れます。

  • -fno-commonは各グローバル変数が1つのオブジェクト内で一度だけしか宣言されていないことを保証します。パフォーマンスが改善するターゲットもあるかもしれません。

  • -ffunction-sections-fdata-sectionsは関数とデータを独自のELFへと分割します。これによって、リンカが--gc-sectionsを参照する時に、使用されていないコードを削除することができます。

上記はC++に限った話ではありませんが、ここで述べておく価値のあるものです。

言語機能を有効にする

上述のように、いくつかの便利なC++の機能は下層システムのサポートが必要になります。ベアメタルやRTOS環境では、自分たちで用意しなくてはなりません。

注意: これらの多くは、もちろん、実装に依存します。下記に記述することは全て、ARM Cortex-M4の基盤とGCC 6を使った私たちの経験に基づくものです。詳細は違ったとしても、これが有用な出発点となれば幸いです。

グローバルオブジェクトの初期化

コンストラクタを備えているかもしれないので、グローバルオブジェクトは組み込みシステムのハードウェアリソースに対してインターフェースを定義するのにとても便利です。C++のランタイムは通常、main()に入る前に全てのグローバル(またはファイルローカル)オブジェクトの構築を保証しますが、組み込み環境では、手動でコンストラクタを呼び出さなくてはなりません。

GCCは.init_arrayというシンボル名で、オブジェクトを関数ポインタの配列へとグループ分けします。リンカスクリプトにエントリを追加すると以下のようになります。

. = ALIGN(4);
.init_array :
{
    __init_array_start = .;
    KEEP (*(.init_array*))
    __init_array_end = .;
} > FLASH
we can call the functions like so:
static void callConstructors()
{
    // Start and end points of the constructor list,
    // defined by the linker script.
    extern void (*__init_array_start)();
    extern void (*__init_array_end)();

    // Call each function in the list.
    // We have to take the address of the symbols, as __init_array_start *is*
    // the first function pointer, not the address of it.
    for (void (**p)() = &__init_array_start; p < &__init_array_end; ++p) {
        (*p)();
    }
}

このステップをいつ実行するかというのも問題です。ハードウェア初期化の後なのか? RTOSのセットアップの後でかつ、タスクが実行される前? 最初のRTOSのタスクで実行するのか?

タイミングによっては、上述のコンストラクタがOSコールやハードウェアの状態(もちろんRAM以外です)を変更しないことを確認した方がいいかもしれません。

継承

継承やランタイムの多相性を賢く使えば、組み込みシステムではとても便利です。

しかし、仮想デストラクタを規定クラスに与える時は常に、そのクラス4のオブジェクトを決してヒープ割り当てしないとしても、標準的な方法としてoperator deleteが必要となります。libgcc版ではUNIXなどのユーザ空間を想定するので、独自のものを定義しなくてはなりません。動的メモリ割り当てを避けようとするならdeleteを呼び出してしまうのは、パニックになってしまうような重大なバグです。

void operator delete(void* p)
{
    DIE("delete called on pointer %p (was an object heap-allocated?)", p);
}

// Same as above, just a C++14 specialization.
// (See http://en.cppreference.com/w/cpp/memory/new/operator_delete)
void operator delete(void* p, size_t t)
{
    DIE("delete called on pointer %p, size %zu", p, t);
}

仮想デストラクタのオブジェクトがスタックに割り当てられている場合、operator deleteのないバージョンのデストラクタが使われます。

推奨しない言語機能

Scoped、静的なオブジェクト

static変数を持つ関数について考えてみましょう。

void foo()
{
    static Bar someObject;
    // Do some work with someObject here.
}

.data.bssに配置することでオブジェクトが明白に初期化されるなら、問題はありません。問題が起こるのは、someObjectを初期化するため、ランタイム中にコンストラクタが呼び出されなくてはならない場合です。C++11はローカルの静的オブジェクトの構築が競合しないことを保証します。つまり、もし複数のスレッドが一度にfoo()を呼び出した場合、コンパイラは、そのオブジェクトの初期値が1つのスレッドによって決定されることが保証されるように、何らかの排他制御5を提供しなくてはなりません。

OSが起動して有効な排他制御を提供する前に関数を呼び出す可能性もあります。また、組み込みシステムでは、可能な限り後続のコードが決定されるように起動時に全ての初期化をしたいので、静的オブジェクトをファイルレベルに配置することをお勧めします。他には、静的オブジェクトを備えた関数が同時に呼び出されないと確信がある場合は、-fno-threadsafe-staticsを付けてコンパイルする方法もあります。

例外

最新の例外処理メカニズムは複雑で6glibcを含むほとんどの実装で、動的メモリ割り当てや他の私たちが持っていないファシリティを必要とします。

libunwindのようなサードパーティのソリューションも、UNIXなどのユーザ空間の上に配置されることを想定しています。これらの複雑さから、私たちは組み込みプロジェクトに例外処理を使う試みはしませんでした。

もしそういった障害を乗り越えるつもりがあるなら、CppCon 2016でのRian Quinnの発表した研究が、いい出発点のように思います。LinuxカーネルでC++コードを実行するため、彼は独自のスタックアンワインドのライブラリをビルドしました。https://github.com/Bareflank/hypervisor/tree/master/bfunwindにあります。

いろいろなツールと注意事項

スタックオーバーラン検出

スタックに記憶域を割り当てる下記の関数を考えてみましょう。

void bar()
{
    char arrayOnStack[10];
    // ...read and write to the local array...
}

もし、配列の最後まで行ってしまったら、現在のスタックフレームに続くメモリを破損し、「未定義の動作」の領域に入って、プログラムの状態が全く分からなくなる可能性があります。笑っている場合ではありません。幸い、関数ごとに各1回読み書きするだけで、コンパイラがちょっとしたセイフティネットを作ってくれます。これをフラグの1つに追加すると、GCCが上記の関数を以下のように変換します。

void bar()
{
    // Assuming the stack grows down, out-of-bounds writes to arrayOnStack
    // may clobber _canary.
    uintptr_t _canary = __stack_chk_guard;

    char arrayOnStack[10];
    // ...read and write to the local array...

    if (_canary != __stack_chk_guard) {
        // We have done terrible things to our stack. Panic.
        __stack_chk_fail();
    }
}

いろいろなフラグによって、コンパイラがこれらのチェックを追加する頻度をコントロールできます。manページに導く方法は、以下のとおりです。

  • -fstack-protectorは、”allocaを呼び出す関数と、8バイトを超える[character]バッファを持つ関数”に対してガードを出します。

  • -fstack-protector-strongは、”ローカル配列の定義を含むかローカルフレームアドレスを参照する[functions]”に対してガードを出します。これは、一般的に推奨されている設定です。

  • -fstack-protector-allは、あらゆる関数に対してガードを出します。これは組み込みシステムには恐らくやり過ぎで、コストがかかり過ぎます。

  • -fstack-protector-explicitは、stack_protect属性でマークされた関数に対してガードを出します。これは恐らく効果的に使えるほどのものではありません。

これらのガードを使うには、上記の例の中のシンボルを両方提示しなければなりません。1つはカナリアで、もう1つはスタック破損を検出した際に呼び出される関数です。パニックかリブートが、恐らくここでは唯一の健全な対応方法でしょう。

extern "C" {

// The canary value
extern const uintptr_t __stack_chk_guard = 0xdeadbeef;

// Called if the check fails
[[noreturn]]
void __stack_chk_fail()
{
    DIE("Stack overrun!");
}

} // end extern "C"

コンパイラが残りの作業をしてくれます。不運が重なって、カナリアを修正せずに現在のスタックフレームを超えて読み書きしようとする場合、このシステムでは破損に気付かないことに注意が必要です。うまくいかない時もあるのです。

インライン化と最適化

組み込みシステムにおいては、しばしばコードを最小にしなければというプレッシャーがかかります。インライン化は、目的とは真逆のことに思えるかもしれません。しかし、必ずしもそうではありません。ヘッダにinline関数として小さなコードを置くと、最近のC++コンパイラは信じられないほど小さく効率的なアウトプットを生成してくれます。極端な例としては、CppCon 2016でのJason TurnerのRich Code for Tiny Computersと題した発表を聞いてください。

ファームウェアに-Os(つまり、サイズの最適化)を付けて構築するなら、以下のものの追加を検討してください。

  • -finline-small-functionsは、関数本体に含まれる命令の数がその関数を呼び出すのに必要なコードよりも少ないとコンパイラが判断した場合に、関数をインライン化します。

  • -findirect-inliningは、追加のインライン化パスを実行します。例えば、main()a()を呼び出し、a()b()を呼び出した場合、このコンパイラはa()の本体をmain()に折り畳むことができます。その結果、b()もインライン化の良い対象となるので、同様に折り畳みます。このような二次効果によって、大きな改善が得られます。

現在のプロジェクトは、-02を使って構築されていますので、”サポートされている最適化で、空間と速度のトレードオフを伴わないもののほとんど全て”を適用します。いつものように、テスト、テスト、さらにテストを行ってください。ARMとその他のRISCのアーキテクチャは、特に可読性の高い分解をします。いろいろなオプションでテストして、コンパイラがどのようなものを生成するかを調べてください。簡単な実験としては、Godbolt compiler explorerを試すと良いでしょう。コードのどの行が生成したのかが分かるよう、色分けしてくれます。

成功を祈ります。

くそ真面目な1980年代初頭からのPascalプログラムの方が最近のWebレンダラよりもいまだに良いテキストを流すという理由で、これを素晴らしい組版のPDFで欲しければ、ここか、そのソースであるここからコピーを入手できます。


  1. 動的メモリ割り当ては、最もシンプルで、最も普及している例です。通常、少なくとも起動後は、リアルタイム組み込みシステムにおいては避けたいものです。しかし、例外処理や多くの他のC++の機能では必要となります。 

  2. Clangもクロスコンパイルの信頼性の高いツールを提供しているようです。しかし、今まで、私たちのファームウェアでは試していません。 

  3. 例えば、https://gcc.gnu.org/bugzilla/show_bug.cgi?id=69878を見てください。 

  4. 詳細は、http://stackoverflow.com/q/31686508http://eli.thegreenplace.net/2015/c-deleting-destructors-and-virtual-operator-delete/を参照してください。優しい読者が、多相的な消去が必要ない場合、仮想デストラクタも同時に避けるべきと指摘しています。1つ追加すると、vtableが必要になるためにクラスサイズが大きくなります。 

  5. http://preshing.com/20130930/double-checked-locking-is-fixed-in-cpp11/参照。 

  6. フルスペックは、https://mentorembedded.github.io/cxx-abi/abi-eh.htmlで見てください。