POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

Matt Kline

本記事は、原著者の許諾のもとに翻訳・掲載しております。

去年の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 を付けてコンパイルする方法もあります。

例外

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

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. フルスペックは、 https://mentorembedded.github.io/cxx-abi/abi-eh.html で見てください。