プログラミング
C言語における未定義動作の削減
Reducing undefined behavior in the C language (lwn.net)
要約
C言語における未定義動作(UB)は、コンパイラによる積極的な最適化を可能にする一方で、プログラムの予期せぬ動作やセキュリティ脆弱性の原因となっています。この記事では、UBの歴史的背景、開発者間の認識の不一致、そしてC言語標準化委員会が進めるUBの削減やメモリ安全性の向上に向けた取り組みについて解説しています。
全文翻訳
バイオメディカル工学の教授であるマーティン・ウエッカー氏は、おそらくカーネルレシピの典型的な発表者には当てはまらないでしょう。しかし、彼は長年のLinuxユーザーであり、磁気共鳴画像法(MRI)スキャナーを制御するためのフリーソフトウェアに取り組んでいます。彼は、Cプログラミング言語、Cにおける未定義動作という特定の問題、そしてそれが最終的にメモリ安全な言語になり得るかどうかについて話すためにカンファレンスに参加しました。
なぜ2026年になってもC言語を気にするのか?それは、彼によれば、依然として素晴らしい言語だからです。Cは移植性が高く、長期的に安定しており、コンパイルが速く、生成されるバイナリコードも高速です。「見たままが得られる」ため、Cコードを見ればコンピュータが実際に行うことのアイデアを持つのは容易です。言語を扱うためのツールがたくさんあり、必要に応じてCは邪魔になりません。
Cには長い歴史があり、それが今日の言語に影響を与えていると彼は述べました。C89標準は、符号付絶対値または1の補数整数表現、セグメント化メモリ、エキゾチックなポインタ表現、そして型の驚くべきサイズを持つマシンを含む、さまざまなハードウェアに対応する必要がありました。例えば、一部のHoneywellマシンには9ビットバイトがありました。これは、移植可能なコードの記述を可能にする標準を作成するタスクを大いに複雑化させました。
取られたアプローチは、言語のセマンティクスを抽象マシンに基づいて定義することでした。すべての操作は、実際のハードウェアと完全に一致しないかもしれないその抽象マシン上で実行されたかのように実行されるべきです。プログラムの観測可能な動作は、抽象マシンが行ったことと同じでなければなりません。「観測可能」という部分が重要です。volatile変数のアクセスは、観測可能と定義されており、抽象マシンに従って正確に発生しなければなりません。それ以外のすべては、最終的に同じ結果を生み出すだけで十分です。標準はコンパイラ実装者に多くの自由を与えています。観測可能な動作だけが保持されなければなりません。
その動作の多くの側面は、未定義または実装定義です。これらは観測可能な動作ではなく、したがってコンパイラ実装者ができることを制約しません。もちろん、C標準が制約しない場合でも、コンパイラ開発者を制約できる他の仕様があります。これらには、ABI要件、POSIXのような標準、または後方互換性の必要性が含まれます。
未定義動作は、プログラムが移植可能でないか、標準で全く定義されていないことを行う場合に発生します。そのような場合、C89標準は「実装に要件を課さない」と述べています。未定義動作が存在する理由はいくつかあります。それは、実装が拡張をサポートし、ハードウェアベースの安全メカニズムとの相互作用を管理し、積極的な最適化を実行することを可能にしながら、検出が困難なエラーを無視することを可能にします。それは、コンパイラに検出が困難なエラーのクラス全体を無視する権利を明示的に与えます。
鼻の悪魔
問題は、ウエッカー氏が述べたように、標準がコンパイラに未定義動作に対応して、鼻の悪魔を呼び出すことまで、何でもできるようにすることです。コンパイラ開発者によれば、プログラムに未定義動作が少しでも含まれている場合、そのプログラムには期待されるセマンティクスがありません。C++23標準はさらに進んで、これらのプログラムに対して標準は要件を課さないことを明示的に述べています。
これは、開発者の間で言語から何を期待できるかについて、広範な意見の不一致につながっています。例えば、構造体全体をゼロクリアし(memset()の呼び出しで)、その後特定のフィールドに書き込んだ場合、その構造体のパディングバイトを読み取るとどうなるでしょうか?セキュリティに関連するデータが含まれている可能性はありますか?2015年の調査では、その場合の挙動について合意がないことが示されました。
あるいは、この単純なコードを考えてみてください。
extern int x;
int f(int a, int b) {
x = b ? 42 : 43;
return a/b;
}
もしbがゼロなら、return文はゼロ除算であり、これは未定義動作です。この場合、コンパイラはテストを完全に省略し、単にx = 42を実行する権利があるでしょうか?結局のところ、b = 0 のケースには期待されるセマンティクスがなく、したがって無視できるからです。そのようなことを行うコンパイラがあります。未定義動作の場合、xへのストアは観測可能な動作ではありません。
しかし、このケースを考えてみてください。
extern void g(int x);
int f(int a, int b) {
g(b ? 42 : 43);
return a/b;
}
これは同じ状況のように見えるかもしれません。コンパイラはテストを削除して、単に42をg()に渡す権利があると考えられ、一部のコンパイラはそのように扱っていましたが、それはバグでした。もしg()の定義がbがゼロならexit()を呼び出すものだと想像してみてください。その場合、除算は決して起こらず、プログラムの動作は未定義ではありません。したがって、テストを省略して単に42をg()に渡すのは間違っています。
もう一つ興味深いケースです。
volatile int x;
int foo(int a, int b, bool store_to_x) {
if (! store_to_x) return a/b;
x = b;
return a/b;
}
ここでの問題は、コンパイラは最後の除算操作をxへの代入よりも上にホイストできるかということです。もしb = 0 のケースにセマンティクスが関連付けられていないなら、観測可能な動作の変更はありません。これもコンパイラが行ってきたことですが、C23標準はそれを禁止するために「時間旅行禁止」の規定を追加しました。代わりにC++では、std::observable_checkpoint()を呼び出すことによって明示的にホイストを防ぐ必要があります。
時間旅行バグはやがてなくなるはずですが、標準が明確であっても、コンパイラ開発者がしばしば意見の不一致を起こす他の状況はたくさんあります。これらには、初期化されていない変数の読み取り(これはほぼ常に定義されています)、およびポインタの等価性比較(これは常に定義されていますが、ClangとGCCの両方で誤ってコンパイルされます)が含まれます。
未定義動作との戦い
これらの問題のすべてに対処するために、C委員会はメモリオブジェクトモデル、メモリ安全性、および未定義動作に特化した3つのスタディグループを運営しています。C標準には現在約100件の未定義動作がありますが、進行中のC2yドラフトではそのうち45件が削除されています。状況は確かに改善しています。
問題を発見するためのツールはますます豊富になっています。コンパイラ警告、静的アナライザ、サニタイザ、LLMベースのツール、形式検証などです。コンパイラが未定義動作の可能性を検出した場合に警告を発する状況は増えています。最近の例としては、整数オーバーフローや使用済み解放(use-after-free)の可能性に対する警告の改善があります。
静的アナライザはスタンドアロンツールとして利用可能ですが、コンパイラ自体に組み込まれることも増えています。例えば、GCCは現在、多くの潜在的なバッファオーバーフロー状況について警告できます。サニタイザは、実行時チェックを挿入することによって機能します。これらは多くの未定義動作を捕捉でき、トラッピングモードでは、ハードニングにも使用できます。
メモリ安全性はCの強みではありませんでしたが、ウエッカー氏は改善できると強調したいと考えていました。この問題は、型安全性、空間メモリ安全性、および時間的メモリ安全性の3つのサブ問題に分解されます。Cには強力な型システムがあり、残りの問題は修正可能だと彼は述べました。例えば、タグなし共用体は型混乱を引き起こす可能性がありますが、コンパイラは追加のアノテーションで型を強制できます。voidからの安全でないキャストは、新しい診断で検出できます。翻訳単位をまたぐ型チェックは、ヘッダーファイルが一貫した型を保証するために使用されるため、伝統的にCでは大きな問題ではありませんでしたが、リンク時チェッカーで状況を改善できます。
空間メモリ安全性(境界チェック)は部分的に解決された問題です。コンパイラは現在、多くの状況で配列境界チェックを実行できます。場合によっては、このチェックを完全に活用するためにコードの変更が必要になります。例えば、counted_by属性の使用は、柔軟な配列メンバーのチェックを有効にできます。
時間的メモリ安全性(使用済み解放バグなどの回避)はより困難だとウエッカー氏は述べ、Rustには明らかに利点があります。それでも、より良い時間的メモリ安全性の強制は...