attribute((nonnull))でNULL契約を強制する設計、レビューで消耗しなくなった話
「このポインタ、NULLチェックいらないんですか」
レビューでこの一言が出るたびに、自分は同じ説明を繰り返していた。「この関数は呼び出し側でNULLじゃないこと保証してるので」。口頭で説明して、コメントに書いて、それでも半年後に別の人が同じ関数を呼ぶときにまた同じ質問が出る。C言語歴23年やって、この不毛な往復に一番消耗してきたかもしれない。
この記事は、関数の引数契約(NULLを許すか許さないか)を口頭やコメントではなくコンパイラに強制させる方法を書く。__attribute__((nonnull))を使えば、契約違反を静的解析やビルド時の警告として検出できる。レビューコストが減るし、CWE-476(NULLポインタデリファレンス)由来の本番障害も減る実感がある。
想定読者は、組み込み・制御系でC言語を書いていて、「NULLチェックどこまで書くか問題」に決着をつけられずにいるエンジニア。新人が入るたびに同じ説明をする羽目になっている人にも刺さると思う。
結論から書く
__attribute__((nonnull(1,2)))のように引数番号を指定すると、その引数にNULLを渡すコードをコンパイラが検出してくれる。GCCとClangで有効。契約をコードに埋め込むことで、コメントに書くだけでは伝わらなかった「これはNULL禁止」という情報が、ビルドの警告として可視化される。
ただしこれだけでは不十分で、-Wnonnullが有効になっているか、最適化レベルによって検出漏れがないか、呼び出し側の値がコンパイル時に静的に追えるケースしか拾えないという限界も含めて理解しておく必要がある。動的にNULLが混入するケース(メモリ確保失敗、外部入力経由)は別の防御線が必要になる。
なぜこの問題が起きるか
C言語には「このポインタはNULLを渡してはいけない」という契約を型システムで表現する手段がない。RustのOption<T>のような仕組みがないので、契約は自然言語(コメント、設計書、口頭伝承)でしか表現できなかった。
この結果、契約は書いた本人の頭の中にしか残らないことが多い。3年前に自分が書いた関数を、別の担当者がNULLチェックなしで別の場所から呼び出し、そのポインタが実はmallocの戻り値チェックをすり抜けたNULLだった、というケースを実際に見たことがある。原因調査に丸2日かかった。
この手の不具合はコンパイラの警告ゼロを通していても発生する。警告ゼロは「文法的に正しい」ことの証明であって、「契約を守っている」ことの証明ではない。この温度差に気づいていない現場は今も多いと感じる。
自分はこの問題を最初、レビューでの目視チェックだけで防ごうとしていた。だが目視チェックは属人化する。レビュアーが変わればチェック基準も変わる。仕組みで強制しない限り、いずれ同じ穴に落ちる。この気づきから、契約をコード側に埋め込む方向に舵を切った。
こういう「警告ゼロなのに落ちる」系のバグを機械的に洗い出したくて、自分はCSAFというツールを作った。CWE-476(NULLデリファレンス)とCWE-457(未初期化変数)を区別して検出する部分に、この経験がそのまま反映されている。
解決手順
ステップ1:契約が曖昧な関数を洗い出す
まず自分のコードベースで、「NULLを渡してはいけないのに、それが明示されていない関数」を棚卸しする。目安として、ポインタ引数を持つ関数のうち、関数コメントに「NULL不可」と書いてあるものだけを数えると、実際にNULL不可な関数の3〜4割程度しかカバーできていないケースが多い。
自分が過去にレビューした組み込み案件(センサー制御系、関数数にして約400)では、ポインタ引数を持つ関数が180ほどあり、そのうちNULL不可条件が明文化されていたのは60程度だった。残り120は「暗黙の了解」で運用されていた。
ステップ2:__attribute__((nonnull))を段階的に付与する
いきなり全関数に付けるとビルドが警告だらけになるので、まずは外部公開ヘッダ(他モジュールから呼ばれる関数)から着手する。1週間で10〜15関数ずつ、優先度の高いものから片付けていくペースが現実的だと感じる。
// センサー値を読み取り、バッファに書き込む
// buf, sensor はNULL不可
__attribute__((nonnull(1, 2)))
int sensor_read(uint8_t *buf, sensor_t *sensor, size_t len);
引数番号は1始まりで、複数指定する場合はカンマ区切り。すべての引数がNULL不可なら番号を省略できる。
// 全引数NULL不可
__attribute__((nonnull))
int sensor_init(sensor_t *sensor, config_t *cfg);
ステップ3:-Wnonnullをビルドフラグに追加する
GCCではデフォルトで有効になっていることが多いが、明示的に-Wall -Wextra -WnonnullをMakefileやCMakeLists.txtに追加しておくと、CI環境でも確実に効かせられる。ここを見落として「ローカルでは警告出てたのにCIで出てない」という事故を自分は一度経験した。原因はCI側のビルドフラグ設定漏れで、半日потра無駄にした。
ステップ4:静的にNULLを渡している箇所を修正する
nonnullを付けた瞬間に、既存コードの中でNULLを渡している箇所がコンパイル警告として出てくる。ここで初めて「実は昔からNULLを渡していたが、たまたま関数内でNULLチェックしていたので落ちていなかった」箇所が見つかることがある。自分の経験では、120関数に付けた際に7箇所、実際にNULLを渡しているコードが見つかった。うち2つは本番稼働中のコードだった。
ステップ5:動的なNULL混入への防御線を別に用意する
nonnullはコンパイル時に静的に追えるNULLしか検出しない。mallocの戻り値や外部入力経由のポインタは実行時にNULLになりうるので、これは別途アサーションや戻り値チェックで守る必要がある。ここを混同して「nonnull付けたからNULLチェック要らない」と誤解している人を見たことがあるが、それは違う。nonnullは「呼び出し側の契約違反」を検出するもので、「実行時にNULLになりうる値」への防御はまた別の話になる。
コード例:契約違反を検出する最小構成
#include <stdio.h>
__attribute__((nonnull(1)))
void print_value(const int *value) {
printf("value: %d\n", *value);
}
int main(void) {
int x = 10;
print_value(&x); // OK
int *p = NULL;
print_value(p); // ここでコンパイラが警告を出す
return 0;
}
GCCで-Wallを付けてビルドすると、print_value(p)の行に対してwarning: null argument where non-null requiredという警告が出る。これがレビュー時の口頭説明を代替してくれる。
pが変数経由ではなく関数の戻り値(mallocなど)であれば、コンパイラは静的に追えないので警告は出ない。ここはnonnullの検出範囲の限界として認識しておく必要がある。
落とし穴と実体験
自分が最初にこれを導入したとき、既存の大規模コードベース(関数数600超)にいきなりnonnullを付けて回ったら、警告が300件以上出て収拾がつかなくなったことがある。原因は、契約上NULL不可のはずの引数に、防御的プログラミングとして「念のため」NULLチェックを書いていたコードが大量にあり、その分岐先でNULLを渡すテストコードが多数存在していたためだった。
このときの教訓は、外部公開APIからではなく、まず内部の末端関数(他から呼ばれる頻度が低い関数)から始めて、影響範囲を確認しながら広げるべきだったということ。自分はこれで導入計画を1週間分やり直す羽目になった。
もう一つの落とし穴は、nonnullを付けた関数のポインタをコールバックとして関数ポインタ経由で呼ぶ場合、コンパイラが静的にたどれず警告が出ないケース。これは組み込み系のイベントドリブン設計だと頻出するパターンで、nonnullだけに頼らず、関数の先頭でassert(ptr != NULL)を残す運用にしている。二重の防御になるが、コールバック経由のNULL混入は実際に本番で1回踏んだことがあるので、コストをかけてでも残す価値があると判断した。
関連ツール
契約違反や未初期化バグをコードベース全体で機械的に洗い出したい場合、自分はCSAFというツールを作って使っている。CWE-457(未初期化変数)とCWE-665(初期化の欠落)、CWE-476(NULLデリファレンス)を区別して検出する部分と、nonnull運用の抜け漏れをチェックする部分を含んでいる。完全ローカル動作なので、社外にコードを出せない環境でも導入できる。Boothで買い切り¥4,980。
まとめ
__attribute__((nonnull))は、NULL契約を自然言語からコンパイラの警告に変換する仕組みで、レビューの属人化を減らす効果がある。ただし静的に追えるNULLしか検出できないので、動的に混入するNULLへの防御は別途必要になる。導入は外部公開APIより先に、影響範囲の小さい内部関数から始めるのが手戻りを減らすコツだと感じている。
著者:ぽん(@pon_freelance)
C言語実務23年、組み込み/制御系。
副業で技術記事販売と自作ツール販売をやっている。
書いているもの:
- CSAF - C言語の業務・監査レベル安全性監査フレームワーク(Booth ¥4,980)
(その他:(なし))