コンピュータプログラミングにおいて、
「未定義のコードを実行した結果コンパイラは何をしてもいい。鼻から悪魔が飛び出しても仕様に反しない」というcomp.std.cでの投稿から、C言語コミュニティではユーモアを込めて未定義動作のことを nasal demons と呼ぶことがある。
概要
一部のプログラミング言語では、プログラムの実行中に未定義動作が決して発生しないならば、ユーザーから見える副作用が同じである限り、プログラムがソースコードと異なる動作をすることや、異なる制御フローを持つことさえ許容されている。この意味において未定義動作とは、仕様においてプログラムが満たしてはならない条件のリストを指すといえる。
C言語の初期のバージョンにおいて未定義動作を設けることは、さまざまなマシンに対応したパフォーマンスの高いコンパイラを作成するために有利だった。特定の構成をマシン固有の機能にマッピングでき、コンパイラは言語によって課せられたセマンティクスに副作用が一致するよう、ランタイム用に追加のコードを生成する必要がなかった。これにより、ユーザーは特定のコンパイラとそれがサポートするプラットフォームさえ知っていればプログラムのソースコードを書くことが可能となった。
しかしながら、プラットフォームの標準化が進むにつれ、特に新しいバージョンのCでは、これは大きな利点ではなくなっていった。現在のプログラムにおける未定義動作は、配列の範囲外アクセスなど、コード内の明確なバグである可能性が高い。定義上、ランタイムシステムは未定義動作が発生しないと想定するため、このような無効な条件をチェックする必要がない。コンパイラからすると、これにより様々なプログラム変換をすることができるようになったり、プログラムの正当性の証明を単純化できるということでもある。これにより、さまざまな種類の最適化が可能になるが、逆に言えばプログラムの実行状態がそのような未定義の条件を満たしてしまった場合、誤動作につながってしまうこともある。また、コンパイラはプログラマーに通知することなくソースコードに含まれる明示的なチェックを削除することができるため、例えば、未定義の動作が発生したかどうかをテストして検出する、などということは、定義上保証されない。これにより、可搬性のあるフェイルセーフオプションをプログラムすることは事実上困難、あるいは不可能となる(一部の構成では可搬性のない解決はできる)。
現在のコンパイラ開発では、通常、コンパイラのパフォーマンスを評価する際、マイクロ最適化を中心に実装されたベンチマーク結果によって比較する。これは、汎用デスクトップおよびラップトップ市場で主に使用されるプラットフォーム(AMD64など)でも同様である。したがって、未定義動作を定めることにより、特定のソースコードの記述を実行時に任意のものにマッピングできるため、コンパイラのパフォーマンスを向上させるための十分な余地を与えることができる。
CやC の場合、コンパイラはコンパイル時に未定義動作のチェックを行うことができるが、これは必須ではない。論理式におけるドントケア項(英: Don't-care term)と同じように、コンパイラの実装では未定義動作が含まれる場合には何をしても正しいと見なされる。未定義動作を引き起こさないコードを作成するのはプログラマーの責任だが、コンパイラの実装側で未定義動作が発生したかどうかの診断を実行することも可能であり、特に最近のコンパイラには、そのような診断を有効にするフラグがある。たとえば、-fsanitizeオプションを使用すると、 GCC 4.9およびClangで「未定義動作サニタイザ」(UBSan)を有効にすることができる。ただし、このフラグはデフォルトではなく、有効にするかどうかはコードをビルドする人に委ねられている。
状況によっては、未定義動作の実装に特定の制限がある場合がある。たとえば、CPUの命令セットの仕様では、一部の命令形式の動作が未定義とされる場合があるが、CPUがメモリ保護をサポートしている場合、仕様には、ユーザーがアクセスできる命令がオペレーティングシステムのセキュリティに穴を開けてはいけないと規定する上位のルールが含まれている可能性がある。したがって、実際のCPUはそのような未定義の命令に応答してユーザーレジスタを破損することは許されるが、たとえば、スーパーバイザーモードに切り替えることは許可されない。
ツールチェーンまたはランタイムによって、ソースコード中の特定の未定義動作のコードが実行時に使用可能な特定のメカニズムに対応付けされると明示的に文書化することにより、ランタイムプラットフォームは未定義動作に対してある種の制約または保証を行うこともできる。たとえば、言語仕様では定義されていない操作の特定の動作について、その言語のインタプリタは文書化することしないことも可能である。未定義動作のコードに対して、コンパイラはABIの実行可能コードを生成し、その動作に対する制限を行うことができる。すなわち、コンパイラのバージョンに依存する方法で言語仕様のセマンティクスのギャップを埋めることが可能である。これらの実装の詳細に依存することでソフトウェアの移植性は失われてしまうが、そのソフトウェアが特定のランタイム以外で使用することが想定されない場合など、このような移植性が問題ではない場合もある。
未定義動作は、プログラムのクラッシュなどのほか、データのサイレントロスや誤った結果の生成など、検出することが難しく一見正常に動作しているように見える障害を引き起こす可能性がある。
利点
ある操作を未定義動作として文書化することにより、コンパイラは、そのような操作が仕様に準拠したプログラムでは絶対に発生しないと想定することができる。これにより、コンパイラはコードに関するより多くの情報を得ることができ、この情報によってより踏み込んだ最適化を行うことができる可能性がある。
C言語での例:
xは符号なし整数であるため負になることはない。よって、符号付き整数型のオーバーフローがCでの未定義の動作であることを踏まえると、コンパイラはvalue < 2147483600の条件が常に偽であると仮定できる。したがって、if文の条件節は副作用を持たず、かつその条件が必ず満たされないため、コンパイラはif文とそれに含まれるbar関数の呼び出しを無視することができる。つまり、このコードは意味的には次のものと同等である。
もしも符号付き整数型のオーバーフローにラップアラウンド動作(オーバーフローした値が一周してもとに戻る)があると規定されている場合、上記の変換は正当ではなくなる。
コードがさらに複雑だったり、インライン化など他の最適化が行われたりすると、このような最適化は見つけるのが難しくなる。たとえば、別の関数が上記の関数を次のように呼び出した場合、
foo()関数を検査すると、ptrxが指す初期値が47を超えることはないことが保証されることがわかる(intが32ビットで、intの最大値INT_MAXが2147483647である環境だとすると、仮に47を超える値であった場合はfoo()関数で未定義動作を引き起こすため)。つまり仕様に準拠したプログラムでは*ptrx > 60の条件チェックは常に偽になるため、コンパイラはwhileループを直ちに除去することができる。さらに、戻り値のzは使用されずfoo()関数は副作用を持たないため、コンパイラはrun_tasks()を最適化して、即時に終了する空の関数にすることができる。foo()が既にコンパイルされた別のオブジェクトファイルで定義されている場合、このようにwhileループが消えることは予測することが難しいかもしれない。
符号付き整数オーバーフローを未定義動作とすることのもう1つの利点は、ソースコード内の変数のサイズよりも大きいレジスタに変数の値を格納・操作できることである。たとえば、ソースコードで指定されている変数の型がレジスタのサイズよりも小さい場合(64ビットマシンで32ビット整数型を利用する場合など)、コンパイラは(定義された)動作を変更することなく、生成するマシンコード内の変数としてレジスタを安全に使用できる。もしプログラムが32ビット整数型のオーバーフローの動作に依存している場合、ほとんどのマシン命令のオーバーフロー動作はレジスタサイズに依存するため、コンパイラは64ビットマシン用にコンパイルするときに追加のロジックを挿入する必要がある。
リスク
CおよびC の標準には、全体を通していくつかの未定義の動作が定められており、これによってコンパイラの実装とコンパイル時検証の自由度が増す一方、これらの未定義動作がプログラムに含まれていた場合、実行時に未定義なふるまいをすることになる。特に、C言語のISO規格には、未定義動作の一般的な要因を列挙した付録が存在する。さらに、コンパイラが未定義動作に依存したコードを検出する必要はないため、未定義動作に依存したコードをプログラマが知らずに書いてしまう危険性がある。未定義動作に依存したコードは、異なるコンパイラや異なるコンパイル設定が使用されたときにはじめて明らかになる、潜在的なバグを生じ得る。予防的な対策として、Clangサニタイザなどの、動的な未定義動作の検査を有効にしてテストまたはファジングを行うことにより、コンパイラまたは静的解析によって検出されていない未定義動作を検出するのに役立つ可能性がある。
また未定義動作は、ソフトウェアセキュリティの脆弱性につながる可能性もある。たとえば、主要なWebブラウザのバッファオーバーフローやその他の脆弱性は、未定義動作が原因である。2038年問題も符号付き整数のオーバーフローに起因するバグの一つである。GCCの開発者が2008年にコンパイラの動作を修正して、未定義動作に依存する特定のオーバーフローチェックを省略した際、CERTは新しいバージョンのコンパイラを使うことに対して警告を行った。Linuxウィークリーニュースは、PathScale Cや、Microsoft Visual C 2005など複数のコンパイラで同じ動作が観察されたことを指摘したところ、CERTは警告の内容を修正し、対象のコンパイラをこれらのコンパイラに拡大した。
CおよびC における例
パスカル・クオックとジョン・レガーによれば、C言語における未定義動作は、大きく次のような種類に分類できる。
- spatial memory safety violations (空間的メモリ安全性違反)
- temporal memory safety violations (時間的メモリ安全性違反)
- integer overflow (整数オーバーフロー)
- strict aliasing violations (厳密なエイリアシング違反)
- alignment violations (アライメント違反)
- unsequenced modifications (非逐次的変更)
- data races (データ競合)
- loops that neither perform I/O nor terminate (入出力も終了も行わないループ)
C言語では、初期化される前に自動変数を使用すると、ゼロ除算、符号付き整数のオーバーフロー、配列の境界違反(バッファオーバーフローを参照)、またはヌルポインタのデリファレンスと同様の未定義動作が発生する。一般に未定義動作は、抽象化された実行マシンを不明な状態にするため、プログラム全体の動作を未定義にしてしまう。
文字列リテラルを変更しようとすると、未定義動作が発生する。
整数をゼロで除算すると、未定義動作が発生する。
特定の種類のポインタ操作は、未定義動作を引き起こす可能性がある。
CおよびC では、オブジェクトへのポインタの比較(大小比較)は、ポインタが同じオブジェクトのメンバーである、もしくは同じ配列の要素を指している場合にのみ厳密に定義される。
return文に到達することなく値を返す(main()以外の)関数の終わりに到達すると、関数呼び出しの値が呼び出し元によって使用される場合、未定義動作が発生する。
2つのシーケンスポイント(英語: sequence point)の間でオブジェクトを複数回変更すると、未定義動作が発生する。C 11の時点で、シーケンスポイントに関連して未定義動作を引き起こす要因にはかなりの変更が行われたが、次の例では、CとC の両方で未定義動作が発生する。
2つのシーケンスポイントの間でオブジェクトを変更する場合、格納する値を決定する以外の目的でオブジェクトの値を読み取ることも、未定義動作となる。
CとC のビットシフト演算では、ビット演算子の右オペランド(被演算子)の値が負数あるいは格上げされた左オペランドのビット幅以上である場合、未定義動作が発生する。
コンパイラに関係なく最も安全な回避方法は、ビット演算子 << および >> の右オペランド(シフトするビット数)を、常に左オペランドのデータ型のビット長より小さな非負の整数にすること、すなわち [0, sizeof(value) * CHAR_BIT - 1] の範囲内におさめることである。ここで、valueはビット演算子の左オペランド、sizeofはバイト単位(char単位)で型のサイズをコンパイル時に求める演算子、CHAR_BITはchar型のビット数である。
脚注
注釈
出典
関連項目
- コンパイラ
- ホルト・アンド・キャッチ・ファイア(英語: Halt and Catch Fire (computing))
- 未規定動作
参考文献
- Peter van der Linden, Expert C Programming. ISBN 0-13-177429-8ISBN 0-13-177429-8
- UB Canaries(2015年4月): John Regehr (University of Utah, USA)
- Undefined Behavior in 2017(2017年7月): Pascal Cuoq (TrustInSoft, France) and John Regehr (University of Utah, USA)
外部リンク
- Programming languages — C #pragmaについてはセクション6.10.6を参照



