未定義動作コンピュータプログラミングにおいて、 「未定義のコードを実行した結果コンパイラは何をしてもいい。鼻から悪魔が飛び出しても仕様に反しない」というcomp.std.cでの投稿から、C言語コミュニティではユーモアを込めて未定義動作のことを nasal demons と呼ぶことがある[1][注釈 1]。 概要一部のプログラミング言語では、プログラムの実行中に未定義動作が決して発生しないならば、ユーザーから見える副作用が同じである限り、プログラムがソースコードと異なる動作をすることや、異なる制御フローを持つことさえ許容されている。この意味において未定義動作とは、仕様においてプログラムが満たしてはならない条件のリストを指すといえる。 C言語の初期のバージョンにおいて未定義動作を設けることは、さまざまなマシンに対応したパフォーマンスの高いコンパイラを作成するために有利だった。特定の構成をマシン固有の機能にマッピングでき、コンパイラは言語によって課せられたセマンティクスに副作用が一致するよう、ランタイム用に追加のコードを生成する必要がなかった。これにより、ユーザーは特定のコンパイラとそれがサポートするプラットフォームさえ知っていればプログラムのソースコードを書くことが可能となった。 しかしながら、プラットフォームの標準化が進むにつれ、特に新しいバージョンのCでは、これは大きな利点ではなくなっていった。現在のプログラムにおける未定義動作は、配列の範囲外アクセスなど、コード内の明確なバグである可能性が高い。定義上、ランタイムシステムは未定義動作が発生しないと想定するため、このような無効な条件をチェックする必要がない。コンパイラからすると、これにより様々なプログラム変換をすることができるようになったり、プログラムの正当性の証明を単純化できるということでもある。これにより、さまざまな種類の最適化が可能になるが、逆に言えばプログラムの実行状態がそのような未定義の条件を満たしてしまった場合、誤動作につながってしまうこともある。また、コンパイラはプログラマーに通知することなくソースコードに含まれる明示的なチェックを削除することができるため、例えば、未定義の動作が発生したかどうかをテストして検出する、などということは、定義上保証されない。これにより、可搬性のあるフェイルセーフオプションをプログラムすることは事実上困難、あるいは不可能となる(一部の構成では可搬性のない解決はできる)。 現在[いつ?]のコンパイラ開発では、通常、コンパイラのパフォーマンスを評価する際、マイクロ最適化を中心に実装されたベンチマーク結果によって比較する。これは、汎用デスクトップおよびラップトップ市場で主に使用されるプラットフォーム(AMD64など)でも同様である。したがって、未定義動作を定めることにより、特定のソースコードの記述を実行時に任意のものにマッピングできるため、コンパイラのパフォーマンスを向上させるための十分な余地を与えることができる。 CやC++の場合、コンパイラはコンパイル時に未定義動作のチェックを行うことができるが、これは必須ではない。論理式におけるドントケア項(英: Don't-care term)と同じように、コンパイラの実装では未定義動作が含まれる場合には何をしても正しいと見なされる。未定義動作を引き起こさないコードを作成するのはプログラマーの責任だが、コンパイラの実装側で未定義動作が発生したかどうかの診断を実行することも可能であり、特に最近[いつ?]のコンパイラには、そのような診断を有効にするフラグがある。たとえば、 状況によっては、未定義動作の実装に特定の制限がある場合がある。たとえば、CPUの命令セットの仕様では、一部の命令形式の動作が未定義とされる場合があるが、CPUがメモリ保護をサポートしている場合、仕様には、ユーザーがアクセスできる命令がオペレーティングシステムのセキュリティに穴を開けてはいけないと規定する上位のルールが含まれている可能性がある。したがって、実際のCPUはそのような未定義の命令に応答してユーザーレジスタを破損することは許されるが、たとえば、スーパーバイザーモードに切り替えることは許可されない。 ツールチェーンまたはランタイムによって、ソースコード中の特定の未定義動作のコードが実行時に使用可能な特定のメカニズムに対応付けされると明示的に文書化することにより、ランタイムプラットフォームは未定義動作に対してある種の制約または保証を行うこともできる。たとえば、言語仕様では定義されていない操作の特定の動作について、その言語のインタプリタは文書化することしないことも可能である。未定義動作のコードに対して、コンパイラはABIの実行可能コードを生成し、その動作に対する制限を行うことができる。すなわち、コンパイラのバージョンに依存する方法で言語仕様のセマンティクスのギャップを埋めることが可能である。これらの実装の詳細に依存することでソフトウェアの移植性は失われてしまうが、そのソフトウェアが特定のランタイム以外で使用することが想定されない場合など、このような移植性が問題ではない場合もある。 未定義動作は、プログラムのクラッシュなどのほか、データのサイレントロスや誤った結果の生成など、検出することが難しく一見正常に動作しているように見える障害を引き起こす可能性がある。 利点ある操作を未定義動作として文書化することにより、コンパイラは、そのような操作が仕様に準拠したプログラムでは絶対に発生しないと想定することができる。これにより、コンパイラはコードに関するより多くの情報を得ることができ、この情報によってより踏み込んだ最適化を行うことができる可能性がある。 C言語での例: int foo(unsigned char x) {
int value = 2147483600; /* 32ビット int と8ビット char を仮定 */
value += x;
if (value < 2147483600) {
bar();
}
return value;
}
int foo(unsigned char x) {
int value = 2147483600;
value += x;
return value;
}
もしも符号付き整数型のオーバーフローにラップアラウンド動作(オーバーフローした値が一周してもとに戻る)があると規定されている場合、上記の変換は正当ではなくなる。 コードがさらに複雑だったり、インライン化など他の最適化が行われたりすると、このような最適化は見つけるのが難しくなる。たとえば、別の関数が上記の関数を次のように呼び出した場合、 void run_tasks(unsigned char *ptrx) {
int z;
z = foo(*ptrx);
while (*ptrx > 60) {
run_one_task(ptrx, z);
}
}
符号付き整数オーバーフローを未定義動作とすることのもう1つの利点は、ソースコード内の変数のサイズよりも大きいレジスタに変数の値を格納・操作できることである。たとえば、ソースコードで指定されている変数の型がレジスタのサイズよりも小さい場合(64ビットマシンで32ビット整数型を利用する場合など)、コンパイラは(定義された)動作を変更することなく、生成するマシンコード内の変数としてレジスタを安全に使用できる。もしプログラムが32ビット整数型のオーバーフローの動作に依存している場合、ほとんどのマシン命令のオーバーフロー動作はレジスタサイズに依存するため、コンパイラは64ビットマシン用にコンパイルするときに追加のロジックを挿入する必要がある[4]。 リスクCおよびC++の標準には、全体を通していくつかの未定義の動作が定められており、これによってコンパイラの実装とコンパイル時検証の自由度が増す一方、これらの未定義動作がプログラムに含まれていた場合、実行時に未定義なふるまいをすることになる。特に、C言語のISO規格には、未定義動作の一般的な要因を列挙した付録が存在する[5]。さらに、コンパイラが未定義動作に依存したコードを検出する必要はないため、未定義動作に依存したコードをプログラマが知らずに書いてしまう危険性がある。未定義動作に依存したコードは、異なるコンパイラや異なるコンパイル設定が使用されたときにはじめて明らかになる、潜在的なバグを生じ得る。予防的な対策として、Clangサニタイザなどの、動的な未定義動作の検査を有効にしてテストまたはファジングを行うことにより、コンパイラまたは静的解析によって検出されていない未定義動作を検出するのに役立つ可能性がある[6]。 また未定義動作は、ソフトウェアセキュリティの脆弱性につながる可能性もある。たとえば、主要なWebブラウザのバッファオーバーフローやその他の脆弱性は、未定義動作が原因である。2038年問題も符号付き整数のオーバーフローに起因するバグの一つである。GCCの開発者が2008年にコンパイラの動作を修正して、未定義動作に依存する特定のオーバーフローチェックを省略した際、CERTは新しいバージョンのコンパイラを使うことに対して警告を行った[7]。Linuxウィークリーニュースは、PathScale Cや、Microsoft Visual C++ 2005など複数のコンパイラで同じ動作が観察されたことを指摘したところ[8]、CERTは警告の内容を修正し、対象のコンパイラをこれらのコンパイラに拡大した[9]。 CおよびC++における例パスカル・クオックとジョン・レガーによれば、C言語における未定義動作は、大きく次のような種類に分類できる[10]。
C言語では、初期化される前に自動変数を使用すると、ゼロ除算、符号付き整数のオーバーフロー、配列の境界違反(バッファオーバーフローを参照)、またはヌルポインタのデリファレンスと同様の未定義動作が発生する。一般に未定義動作は、抽象化された実行マシンを不明な状態にするため、プログラム全体の動作を未定義にしてしまう。 文字列リテラルを変更しようとすると、未定義動作が発生する[11]。 char *p = "wikipedia"; // C言語では許可、C++98/C++03では非推奨、C++11から不適格
p[0] = 'W'; // 未定義動作
int x = 1;
return x / 0; // 未定義動作
特定の種類のポインタ操作は、未定義動作を引き起こす可能性がある[13]。 int arr[4] = {0, 1, 2, 3};
int *p = arr + 5; // 未定義動作(配列外読み込み)
p = 0;
int a = *p; // 未定義動作(ヌルポインタのデリファレンス)
CおよびC++では、オブジェクトへのポインタの比較(大小比較)は、ポインタが同じオブジェクトのメンバーである、もしくは同じ配列の要素を指している場合にのみ厳密に定義される[14]。 int main(void) {
int a = 0;
int b = 0;
return &a < &b; /* 未定義動作 */
}
return文に到達することなく値を返す( int f(void) {
} /* 未定義動作(関数の返り値が呼び出し元で使用された場合) */
2つのシーケンスポイント(英語: sequence point)の間でオブジェクトを複数回変更すると、未定義動作が発生する。[16]C++11の時点で、シーケンスポイントに関連して未定義動作を引き起こす要因にはかなりの変更が行われた[17]が、次の例では、CとC++の両方で未定義動作が発生する。 i = i++ + 1; // 未定義動作
2つのシーケンスポイントの間でオブジェクトを変更する場合、格納する値を決定する以外の目的でオブジェクトの値を読み取ることも、未定義動作となる[18]。 a[i] = i++; // 未定義動作
printf("%d %d\n", ++n, power(2, n)); // 同様に未定義動作
CとC++のビットシフト演算では、ビット演算子の右オペランド(被演算子)の値が負数あるいは格上げされた左オペランドのビット幅以上である場合、未定義動作が発生する[19]。 int num = -1;
unsigned int val = 1 << num; // 未定義動作(負数によるビットシフト)
num = 32; // もしくは31より大きな任意の整数
val = 1 << num; // リテラル「1」は32ビット整数型であるため、32ビット以上の(31ビットを超える)ビットシフトは未定義動作となる
num = 64; // もしくは63より大きな任意の整数
unsigned long long val2 = 1ULL << num; // リテラル「1ULL」は64ビット整数型であるため、64ビット以上の(63ビットを超える)ビットシフトは未定義動作となる
コンパイラに関係なく最も安全な回避方法は、ビット演算子 脚注注釈出典
関連項目参考文献
外部リンク
|
Portal di Ensiklopedia Dunia