コンピュータプログラムは、プログラミング言語仕様で特定の要件が規定されていないコードを含んでいるか、または実行している場合に、未定義動作(UB )を示します。 [ 1 ]これは、言語仕様で結果が規定されていない未指定動作や、プラットフォームの別のコンポーネント( ABIやトランスレータのドキュメントなど)のドキュメントに委ねられる実装定義動作とは異なります。
C言語プログラミングコミュニティでは、未定義動作は、 comp.std.cの投稿で未定義動作はコンパイラが「鼻から悪魔を飛ばす」ことさえも自由にできると説明されたことから、ユーモラスに「鼻の悪魔」と呼ばれることがある。[ 2 ]
プログラミング言語によっては、プログラムの実行中に未定義動作が発生しない限り、ユーザーが認識できる副作用が同じであれば、ソースコードとは異なる動作や制御フローを持つプログラムも許容されます。未定義動作とは、プログラムが満たしてはならない条件のリストのことです。
C言語の初期バージョンでは、未定義動作の主な利点は、多様なマシンに対応した高性能コンパイラを開発できることでした。特定の構文をマシン固有の機能にマッピングできるため、コンパイラはランタイム向けに、言語によって課せられた意味論に副作用を適合させるための追加コードを生成する必要がありませんでした。プログラムのソースコードは、特定のコンパイラとそれがサポートするプラットフォームに関する事前知識に基づいて記述されていました。
しかし、プラットフォームの標準化が進むにつれて、特に新しいバージョンのC言語では、この利点は薄れてきました。現在では、未定義動作のケースは、例えば配列の範囲外のインデックス付けなど、コード内の明確なバグを表すことが一般的です。定義上、ランタイムは未定義動作は決して発生しないと想定できるため、一部の無効な条件をチェックする必要がありません。コンパイラにとっては、これはさまざまなプログラム変換が有効になるか、その正当性の証明が簡略化されることを意味します。これにより、プログラムの状態がそのような条件を満たさないという前提に基づいて正当性が決まる、さまざまな種類の最適化が可能になります。コンパイラは、プログラマに通知することなく、ソースコードにあった可能性のある明示的なチェックを削除することもできます。例えば、未定義動作が発生したかどうかをテストすることで未定義動作を検出する方法は、定義上、必ずしも機能するとは限りません。このため、移植可能なフェイルセーフオプションをプログラムすることは困難、あるいは不可能になります(一部の構造では、移植不可能なソリューションも可能です)。
現在のコンパイラ開発では、汎用デスクトップやノートパソコン市場で広く使われているプラットフォーム(amd64など)であっても、マイクロ最適化を念頭に設計されたベンチマークを用いてコンパイラのパフォーマンスを評価・比較するのが一般的です。そのため、未定義動作はコンパイラのパフォーマンス向上に十分な余地を与えます。なぜなら、特定のソースコード文のソースコードは実行時に何にでもマッピングできるからです。
C および C++ の場合、コンパイラはこのような場合にコンパイル時診断を出すことが許可されていますが、必須ではありません。実装は、デジタル論理におけるドントケア項と同様に、このような場合にどのような処理を行っても正しいとみなされます。未定義動作を決して引き起こさないコードを書くのはプログラマの責任ですが、コンパイラの実装は、未定義動作が発生した場合に診断を出すことが許可されています。最近のコンパイラには、このような診断を有効にするフラグがあります。たとえば、gcc 4.9 [ 3 ]およびclang-fsanitize=undefinedでは、 「未定義動作サニタイザー」(UBSan)が有効になります。ただし、このフラグはデフォルトではなく、有効にするかどうかはコードをビルドする人の選択です。
状況によっては、未定義の動作に特定の制限が設けられる場合があります。例えば、CPUの命令セット仕様では、一部の命令の動作が未定義のままになっている場合がありますが、CPUがメモリ保護をサポートしている場合は、ユーザーがアクセスできる命令がオペレーティングシステムのセキュリティに穴を開けてはならないという包括的なルールが仕様に含まれるでしょう。そのため、実際のCPUは、そのような命令に応じてユーザーレジスタを破損することは許可されますが、例えば、スーパーバイザモードに切り替えることは許可されません。
ランタイムプラットフォームは、ツールチェーンまたはランタイムがソースコード内の特定の構造がランタイムで使用可能な特定の明確に定義されたメカニズムにマッピングされることを明示的に文書化している場合、未定義の動作に対する制限または保証を提供することもできます。たとえば、あるインタプリタは、言語仕様で未定義である一部の操作に対する特定の動作を文書化しているかもしれませんが、同じ言語の他のインタプリタやコンパイラはそうではないかもしれません。コンパイラは特定のABI用の実行可能コードを生成し、コンパイラのバージョンに応じて意味のギャップを埋めます。そのコンパイラのバージョンとABI仕様のドキュメントは、未定義の動作に対する制限を提供できます。これらの実装の詳細に依存するとソフトウェアの移植性は低下しますが、ソフトウェアが特定のランタイム以外で使用されることを想定していない場合は、移植性は問題にならないかもしれません。
未定義の動作は、プログラムのクラッシュや、検出が困難でプログラムが正常に動作しているように見える障害(例えば、データのサイレント損失や誤った結果の生成など)を引き起こす可能性があります。
プログラミング言語の設計において、エラーのあるプログラムとは、意味論が明確に定義されていないものの、言語実装がコンパイル時または実行時にエラーを通知する義務を負わないプログラムのことである。例えば、Adaでは次のようになる。
条件を「誤り」と定義するということは、言語実装が潜在的にコストのかかるチェック(例えば、グローバル変数がサブルーチンのパラメータと同じオブジェクトを参照しているかどうかなど)を実行する必要がないことを意味しますが、それでもプログラムのセマンティクスを定義する際に、その条件が真であることに依存する場合があります。
操作を未定義動作として文書化することで、コンパイラは、その操作が準拠プログラムでは決して発生しないと想定できます。これにより、コンパイラはコードに関するより多くの情報を得ることができ、その情報に基づいてより多くの最適化の機会を見つけることができます。
C言語の例:
int foo ( unsigned char x ) { int value = 2147483600 ; // 32ビット整数と8ビット文字を想定value += x ; if ( value < 2147483600 ) { bar (); } return value ; }の値はx負の値にはなり得ず、C言語では符号付き整数オーバーフローは未定義動作であるため、コンパイラは がvalue < 2147483600常に偽であると想定できます。したがって、のテスト式には副作用がなく、その条件が満たされることはないため、if関数 の呼び出しを含む ステートメントはbarコンパイラによって無視されます。したがって、このコードは意味的に次のコードと同等です。if
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 ); } }コンパイラは、値範囲分析をwhile適用することで、ここで - ループを最適化して削除することができます。 を検査することで、 が指す初期値は47 を超えることはできないことがわかります (それ以上になると で未定義の動作がトリガーされるため)。したがって、準拠プログラムでは の初期チェックは常に false になります。さらに、結果が使用されなくなり、副作用もなくなったため、コンパイラは をすぐに返す空の関数に最適化できます。が別のコンパイル済みオブジェクト ファイルで定義されている場合、 -ループが消えることは特に驚くべきことかもしれません。foo()ptrxfoo()*ptrx > 60zfoo()run_tasks()whilefoo()
符号付き整数オーバーフローを未定義にすることによるもう 1 つの利点は、ソース コード内の変数のサイズよりも大きいプロセッサ レジスタに変数の値を格納および操作できることです。たとえば、ソース コードで指定されている変数の型がネイティブ レジスタ幅よりも狭い場合 ( 64 ビットintマシンなど、よくあるシナリオ)、コンパイラは、コードの定義済み動作を変更することなく、生成するマシン コード内の変数に安全に符号付き 64 ビット整数を使用できます。プログラムが 32 ビット整数オーバーフローの動作に依存している場合、ほとんどのマシン命令のオーバーフロー動作はレジスタ幅に依存するため、コンパイラは 64 ビット マシン用にコンパイルする際に追加のロジックを挿入する必要があります。[ 5 ]
未定義動作は、コンパイラと静的プログラム解析の両方によるコンパイル時チェックを増やすことにもつながります。
C および C++ 規格には、未定義動作の形式が複数存在し、コンパイラの実装やコンパイル時のチェックの自由度を高める一方で、実行時に未定義動作が発生するという代償を伴います。特に、ISO規格の C には、未定義動作の一般的な原因を列挙した付録があります。[ 6 ]さらに、コンパイラは未定義動作に依存するコードを診断する必要はありません。そのため、経験豊富なプログラマであっても、誤って、あるいは単に数百ページにも及ぶ言語の規則に精通していないために、未定義動作に依存することがよくあります。これは、異なるコンパイラや異なる設定を使用した場合に明らかになるバグにつながる可能性があります。Clangサニタイザーなどの動的な未定義動作チェックを有効にしてテストやファジングを行うことで、コンパイラや静的アナライザーで診断されない未定義動作を検出できます。[ 7 ]
未定義の動作は、ソフトウェアのセキュリティ脆弱性につながる可能性があります。たとえば、主要なウェブ ブラウザのバッファ オーバーフローやその他のセキュリティ脆弱性は、未定義の動作が原因です。2008年にGCCの開発者がコンパイラを変更し、未定義の動作に依存する特定のオーバーフロー チェックを省略したとき、CERT は新しいバージョンのコンパイラに対して警告を発しました。[ 8 ] Linux Weekly News は、同じ動作がPathScale C、Microsoft Visual C++ 2005、およびその他のいくつかのコンパイラで確認されたことを指摘しました。[ 9 ]その後、警告はさまざまなコンパイラについて警告するように修正されました。[ 10 ]
C言語における未定義動作の主な形態は、大きく分けて次のとおり分類できます。[ 11 ]空間メモリの安全性違反、時間メモリの安全性違反、整数オーバーフロー、厳密なエイリアシング違反、アライメント違反、順序付けされていない変更、データ競合、および入出力も終了も実行しないループ。
C言語では、初期化される前の自動変数の使用は未定義動作を引き起こします。同様に、ゼロによる整数除算、符号付き整数オーバーフロー、配列の定義範囲外のインデックス指定(バッファオーバーフローを参照)、ヌルポインタの逆参照も未定義動作を引き起こします。一般に、未定義動作が発生すると、抽象実行マシンは未知の状態になり、プログラム全体の動作が未定義になります。
文字列リテラルは通常読み取り専用メモリに格納されるため、それを変更しようとすると未定義の動作が発生します。 [ 12 ]
char * p = "wikipedia" ; // 有効なC言語コード、C++98/C++03で非推奨、C++11以降は不正な形式p [ 0 ] = 'W' ; // 未定義の動作ゼロによる整数除算は未定義の動作を引き起こします: [ 13 ]
int x = 1 ; return x / 0 ; // 未定義の動作特定のポインタ操作では未定義の動作が発生する可能性があります: [ 14 ]
int arr [ 4 ] = { 0 , 1 , 2 , 3 }; int * p = arr + 5 ; // 範囲外のインデックス付けは未定義の動作になりますp = nullptr ; int a = * p ; // null ポインタの逆参照は未定義の動作になりますC および C++ では、オブジェクトへのポインタの関係比較(より小さいまたはより大きい比較) は、ポインタが同じオブジェクトのメンバー、または同じ配列の要素を指している場合にのみ厳密に定義されます。[ 15 ]例:
int main ( void ) { int a = 0 ; int b = 0 ; return & a < & b ; // C では未定義の動作、C++ では未指定の動作}return文なしで値を返す関数(を除く)の終わりに到達すると、main()関数呼び出しの値が呼び出し元によって使用される場合、未定義の動作が発生します。[ 16 ]
int f () {}int x = f (); // 未定義の動作2 つのシーケンス ポイント間でオブジェクトを複数回変更すると、未定義の動作が発生します。[ 17 ] C++11 以降、シーケンス ポイントに関連して未定義の動作を引き起こす要因には大きな変更があります。[ 18 ]最新のコンパイラは、同じオブジェクトに対する複数の順序付けられていない変更を検出すると警告を発することができます。[ 19 ] [ 20 ]次の例は、C と C++ の両方で未定義の動作を引き起こします。
int f ( int i ) { // 未定義の動作: i に対する 2 つの順序付けられていない変更return i ++ + i ++ ; }2 つのシーケンス ポイント間でオブジェクトを変更する場合、格納する値を決定する以外の目的でオブジェクトの値を読み取ることも未定義の動作です。[ 21 ]
a [ i ] = i ++ ; // 未定義の動作printf ( "%d %d \n " , ++ n , pow ( 2 , n )); // これも未定義の動作C/C++ では、負の数または値の合計ビット数以上のビット数だけ値をビット単位でシフトすると<<、未定義の動作になります。最も安全な方法 (コンパイラのベンダーに関係なく) は、シフトするビット数 (ビット>>演算子の右オペランド) を常に [ 0 , sizeof value * CHAR_BIT - 1 ] (左オペランド) の範囲内に収めることです。value
int num = -1 ; unsigned int val = 1 << num ; // 負の数によるシフト - 未定義の動作num = 32 ; // または 31 より大きい任意の数値val = 1 << num ; // リテラル '1' は 32 ビット整数として型付けされます。この場合、31 ビットを超えるシフトは未定義の動作です。num = 64 ; // または 63 より大きい任意の数値unsigned long long val2 = 1ULL << num ; // リテラル '1ULL' は 64 ビット整数として型付けされます。この場合、63 ビットを超えるシフトは未定義の動作です。C#では、コンテキストによっては未定義の動作が呼び出されることがありますunsafe。
Systemを使用します。unsafe { int * p = ( int * ) 0x12345678 ; Console . WriteLine ( * p ); // 任意のメモリ アドレスを読み取る}スタックメモリの解放後使用も、未定義の動作を引き起こします。
Systemを使用します。unsafe int * GetPointer () { int x = 100 ; return & x ; }int * p = GetPointer (); Console.WriteLine ( * p ); // 無効になったスタックメモリへのポインタを取得しますJavaでは、ネイティブな相互運用性とデータ競合が、未定義動作が発生する最も顕著な例である。
以下のデータ競合は、 Javaメモリモデルに違反することで未定義の動作を引き起こす可能性があります。
int x = 0 ; boolean ready = false ;Thread t1 = new Thread (() -> { x = 33 ; ready = true ; });Thread t2 = new Thread (() -> { if ( ready ) { System . out . println ( x ); // 33 または 0 が出力される可能性があります} });t1.start ( ) ; t2.start ( ) ;Java Native Interface の呼び出しでは、未定義のメモリが発生し、未定義の動作を引き起こす可能性があります。C 言語から:
#include <jni.h>JNIEXPORT jint JNICALL Java_Crash_boom ( JNIEnv * env , jclass cls ) { int * p = NULL ; return * p ; // ヌルポインタの逆参照}Javaの場合:
パッケージorg.wikipedia.examples ;public class Crash { static { System.loadLibrary ( "crash " ) ; }private static native int boom ();public static void main ( String [] args ) { System . out . println ( boom ()); } }一般的に安全なRustでは未定義動作は発生しないと予想されますが、不適切な安全でないコードは、健全性の穴と呼ばれる箇所で安全なコードに UB をさらす可能性があります。[ 22 ]
例えば、Rustの多くのデータ型は、有用な最適化を可能にする不変条件を利用しています。参照はその一例で、基本的には生ポインタと同じ表現を持ちながらも、null 、アラインメントされていない、あるいは無効な宛先を指すといった事態は決して起こりません。したがって、これらの不変条件のいずれかを破ると、結果として得られる参照がどのように使用されるかに関わらず、結果は未定義となります。
use std :: mem ;/// null参照を構築します。pub const fn null_ref < T : ? Sized > () -> & T { unsafe { mem :: zeroed () } }null_ref関数自体はunsafe fnアイテムではなく、安全なコードから呼び出すことができるにもかかわらず、すべての参照型によって課される不変条件のために、呼び出しは常に不正な形式になります。
さらに、ヌルポインタの逆参照は未定義ですが、多くのホストシステムは依然としてそのようなケースをセグメンテーション違反として処理するように設計されています。
use std :: ptr ;fn main () { let p : * const i32 = ptr :: null ();// 安全性: `p` は null であり、逆参照することはできません。unsafe { * p }; }