リーダブル低レイヤコード

ここでは、低レイヤのコードを書くに当たって、経験上大事にしていることを書いています。 内容はあくまで個人的なものですので、読み物として参考程度にしてください。

内容は適宜追加していきます。

構造体のstatic assertは確認すべき箇所を書こう

struct Hoge {
    uintptr_t a;
    size_t b;
}

static_assert(sizeof(struct Hoge) == 16, "The size of hoge was changed!");

上のコードでは、構造体のサイズが変わった際にコンパイルエラーになる。 この手のAssertionは低レイヤではよく見るが、この情報だけでは構造体のサイズが変わると何が不味いのかが分からない。

一つ考えられる事として、uintptr_tsize_tのサイズが処理系やCPUアーキテクチャが変わった際に対処が必要な場合がある。 本来ならばuint32_tuint64_tなどサイズの決まった型を使うべきだが、デバイスやインターフェイスの仕様書などに合わせるためにこのように書いている場合がある。 そのため他の環境に移植した際は注意深く挙動を見るべきということでstatic_assertがあるのかもしれない。

もう一つの考えられる事として、構造体が別の場所にも定義されていて、データの受け渡しに使われているのかもしれない。 この場合、一方を書き換えたらもう一方も書き換える必要もある。 さらに構造体の定義自体だけの問題ではなく、構造体の確保自体もサイズに依存している場合がある。 例えば、以下のように確保したメモリをキャストしていたり、リンカスクリプトにサイズが直書きされている場合である。

struct Hoge *message_buffer = NULL;

void init_struct(void) {
    auto *page =  alloc_page(GFP_KERNEL);
    if (!page) {
        /* ... */
    }
    message_buffer = page_address(page);
    for(unsigned int i = 0; i < 256; i++) {
        message_buffer[i] = /* ... */;
    }
}

複数箇所修正しないといけない場合、コードをよく知らない人は一箇所だけ修正して満足してしまうことが多い。

そのため、構造体のサイズやメンバのオフセットに対して、static_assertを書く場合は確認すべき箇所をコメントなどでリストとしておくと良い。 また、サイズやオフセット依存のコードを書いた場合は、それらに追記するべきである。

// If you modified the struct, you should check `payload/boot.h`.
// And `init_struct` assumes that `sizeof(Hoge) * 256 <= PAGE_SIZE`
static_assert(sizeof(struct Hoge) == 16, "The size of hoge was changed!");

do whileとcontinueの使い方には気をつけよう

別に低レイヤに限らないが、C言語でdo { ... } while(0);を使う場合がある。 マクロで変数のスコープを区切るためにカーネルなどで使用されるが、その中でcontinueを使用すると意図しない挙動になる場合がある。 例えば以下のようなマクロがあるとする。

#define HOGE(a) do { int b = foo(a); if (b) { continue; } } while(0);

マクロの良し悪しはともかく、HOGEではfooがゼロを返すまで呼ぶことを期待しているように見える。 もしくは書いた人はそれを期待して書いたのかもしれない。

しかしこれは期待どおりに動かない。 なぜならcontinue文は「ブロックの最初に戻る」のではなく、「ブロックの一番最後に飛ぶ」文であるからである。 すなわち、ここでのcontinuewhile(0)の手前にジャンプする事を意味し、実質breakと同等の働きをすることになる。

continueの挙動を理解していても、案外目が滑りがちである。

この場合は、以下のように書き直すか、ラベルの使用や別の手段(インライン関数など)を検討すべきである。

#define HOGE(a) while(1) { int b = foo(a); if (b) { continue; } break;};

メモリアドレスは大切に使おう

なけなしの小遣いを握りしめて自転車で行ったPCショップで買ったDDR2 2GBの二枚セットを手元のPCに刺して起動したら、リソースモニターの「ハードウェア予約済み:700MB」の表記に怒り狂い、徹底的に調べて32bit版Windowsと64bit版Windowsの違いとメモリアドレス空間はDRAMだけではないことを知ったのも昔の話である。

とはいえ、32ビットプロセッサの需要がなくなったわけではなく、MMUの実装の簡略化などで組み込みなど限定された用途ではまだまだ現役である。 また、命令セットを64ビット命令セットを使用しながらポインタを32ビットにする試み(link)など新しい取り組みもある。

そのため、「いつどこで再利用されるかわからない」低レイヤのコードではアドレスの使用についてはよく考えるべきである。 もちろん、巨大なメモリ空間を使用して大規模なデータを処理することもあるだろうから64ビットアドレス空間が使えないことを意識せよという事ではない。

具体的にはsizeof(uintptr_t) == 64を前提としたコードを書くのではなく、ポインタの格納にはuintptr_tを使用し、uint64_tからのキャストについては注意深く検討する、ヒープやスタックが広大に存在することを前提としたコードを書かないなどである。

ローカル変数で巨大な配列を作るのはやめよう

組み込み向けのコードを初めて書く人にありがち(観測範囲)なのは、「mallocが使えないからローカル変数で巨大な配列を用意して使用する」事である。 例えば、以下のようなコードを書く人がいる。

#define BUF_SIZE    8096

int process_something(const void *hoge, size_t len) {
    char buf[BUF_SIZE] = { 0 };
    int p, r;

    memcpy(buf, hoge, len);
    for (p = 0; p < len; p++) {
        r = foo(buf[p]);
        /* ... */
    }

    return 0;
}

RTOSのような組み込みではメモリアクセス制御が無くスタックとヒープが隣接している場合が多い。 この場合、スタックポインタが8KiB進み(8KiB分デクリメントされて)ヒープにはみ出してゼロクリアされることになる。 結果、ヒープのデータが関数を呼び出しただけでゼロクリアされてしまうという中々デバッグに手こずる状態になる。

この場合、スタックガードページの導入やガードページのギャップサイズを大きくするなどで対策するが、アドレス空間にそんな余裕がない、もしくはそもそも実現できない場合が多い。

ガードページを導入していても、メモリ破壊を防げないときがある。 ガードページはスタックポインタがスタック領域をはみ出して「アクセスを行った際に」発火するものである。 未初期化のページサイズの何倍もの巨大な配列を定義した場合、スタックポインタは更新されるが、実際にアクセスされるのが配列の前半部分だけであればガードページは発火しない。 再帰関数などと組み合わさると、ややこしくなる。

さらに、スタックガードページ上にスタックポインタがある状態で、デバイス割り込みなどが発火すると、割り込みハンドラでスタックにレジスタの値を退避する際に、アクセス違反が発生し、ダブルフォルトになったりCPUがハンドルできずに電源が落ちたりする。

このような問題は低レイヤに明るくない人にデータ処理部分のコードを書いてもらう場合に発生し、上記の例以外にも少し大きい配列が各所で確保されているのが塵も積もって発火する場合がある。 テストでは処理するデータのサイズが小さい、もしくは手元のPC環境でテストしたなどで発火せず、実際に処理を行うと発火するかもしれない。

スタック変数について予め関係者で認識をすり合わせて、巨大なローカル変数の使用は避けるようにしよう。