私は最近、CおよびC++での符号付き整数オーバーフローが未定義の動作を引き起こすことを読みました:
式の評価中に結果が数学的に定義されていないか、その型の表現可能な値の範囲にない場合、動作は未定義です。
私は現在、ここで未定義の動作の理由を理解しようとしています。整数が基礎となる型に収まらないほど大きくなると、整数がそれ自体の周りのメモリを操作し始めるため、ここでは未定義の動作が発生すると考えました。
そこで、次のコードでその理論をテストするために、Visual Studio 2015で小さなテストプログラムを書くことにしました。
#include <stdio.h>
#include <limits.h>
struct TestStruct
{
char pad1[50];
int testVal;
char pad2[50];
};
int main()
{
TestStruct test;
memset(&test, 0, sizeof(test));
for (test.testVal = 0; ; test.testVal++)
{
if (test.testVal == INT_MAX)
printf("Overflowing\r\n");
}
return 0;
}
ここでは、スタック変数の一時的なパディングなど、デバッグモードでのVisual Studioの保護事項を防ぐために構造体を使用しました。無限ループはtest.testVal
のいくつかのオーバーフローを引き起こすはずですが、実際には、オーバーフロー自体以外の影響はありません。
オーバーフローテストの実行中にメモリダンプを確認したところ、次の結果が得られました(test.testVal
のメモリアドレスは0x001CFAFC
でした):
0x001CFAE5 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x001CFAFC 94 53 ca d8 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
ご覧のように、継続的にオーバーフローしているintの周りのメモリは「破損していない」ままでした。私はこれを数回同じような出力でテストしました。オーバーフローしているintの周りのメモリが破損したことはありません。
ここでは何が起きるのですか?なぜ変数test.testVal
の周りのメモリに損傷はないのですか?これにより、未定義の動作が発生する可能性がありますか?
私は自分の間違いを理解しようとしていますが、整数オーバーフロー中にメモリ破損が発生しないのはなぜですか?
未定義の動作の理由を誤解しています。その理由は、整数の周りのメモリ破壊ではありません-それは常に整数が占めるのと同じサイズを占めます-基礎となる算術です。
符号付き整数は2の補数でエンコードする必要がないため、オーバーフローしたときに何が起こるかについての具体的なガイダンスはありません。エンコーディングやCPUの動作が異なると、たとえば、トラップによるプログラムの強制終了など、オーバーフローの結果が異なる可能性があります。
すべての未定義の動作と同様に、ハードウェアの算術演算に2の補数を使用し、オーバーフローのルールを定義している場合でも、コンパイラーはそれらに拘束されません。たとえば、GCCは長い間、2の補数環境でのみ真になるチェックを最適化していました。たとえば、if (x > x + 1) f()
は最適化されたコードから削除されます。これは、符号付きオーバーフローが未定義の動作であり、決して発生しないことを意味します(コンパイラの観点から、プログラムに未定義の動作を生成するコードが含まれることはありません)。つまり、x
はx + 1
を超えることはできません。
一部のハードウェアプラットフォームは、結果が予測できない可能性のある方法でトラップする可能性があるため、標準の作成者は未定義の左整数オーバーフローを定義していません(ランダムコードの実行と結果として生じるメモリ破損を含む可能性があります)。予測可能なサイレントラップアラウンドオーバーフロー処理を備えた2の補数のハードウェアは、C89標準が公開されるまでに標準として確立されていました(私が調べた多くの再プログラム可能なマイクロコンピューターアーキテクチャのうち、何も使用していません)。古いマシンでC実装を作成することを誰も妨げたくありませんでした。
ありふれた2の補数のサイレントラップアラウンドセマンティクスを実装した実装では、次のようなコード
int test(int x)
{
int temp = (x==INT_MAX);
if (x+1 <= 23) temp+=2;
return temp;
}
iNT_MAXに1を追加するとINT_MINが得られるため、INT_MAXの値が渡されると、100%確実に3が返されます。これはもちろん23未満です。
1990年代に、コンパイラーは整数のオーバーフローが2の補数のラッピングとして定義されるのではなく、未定義の動作であるという事実を利用して、オーバーフローした計算の正確な結果を予測することはできなかったが、そうでない動作の側面をさまざまな最適化を可能にしました正確な結果に依存せず、Railsにとどまります。上記のコードが与えられた1990年代のコンパイラは、INT_MAXに1を加えると数値的にINT_MAXよりも1大きい値を返し、関数が3ではなく1を返すか、古いコンパイラのように動作して3を生成するかのように扱う可能性があります。上記のコードでは、(x + 1 <= 23)は(x <= 22)と同等であるため、このような処理により多くのプラットフォームで命令を節約できます。コンパイラは、1または3の選択に一貫性がない場合がありますが、生成されたコードは、これらの値の1つを生成する以外は何もしません。
しかし、それ以来、コンパイラが標準の失敗を使用して整数オーバーフローの場合にプログラムの動作に要件を課すことがよりファッショナブルになりました(結果が本当に予測できないハードウェアの存在に起因する失敗)。 Railsから完全にコードを起動します。最新のコンパイラは、x == INT_MAXの場合、プログラムが未定義の動作を呼び出すことに気づき、関数にその値が渡されないことを結論付けます。 。関数にその値が渡されない場合は、INT_MAXとの比較を省略できます。上記の関数がx == INT_MAXを使用して別の変換単位から呼び出された場合、0または2を返す可能性があります。同じ変換単位内から呼び出された場合、コンパイラーがxに関する推論を呼び出し元に戻すため、この影響はさらに奇妙なものになる可能性があります。
オーバーフローがメモリの破損を引き起こすかどうかに関しては、一部の古いハードウェアで発生する可能性があります。最新のハードウェアで実行されている古いコンパイラでは、そうなりません。ハイパーモダンコンパイラでは、オーバーフローは時間と因果律の構造を無効にするため、すべての賭けは無効になります。 x + 1の評価におけるオーバーフローは、INT_MAXに対する以前の比較で確認されたxの値を効果的に破壊し、メモリ内のxの値が破壊されたかのように動作する可能性があります。さらに、このようなコンパイラの動作は、他の種類のメモリ破損を防ぐ条件付きロジックを削除することが多く、任意のメモリ破損が発生する可能性があります。
整数オーバーフローの動作は、C++標準では定義されていません。つまり、C++の実装は自由に実行できます。
実際には、これは、実装者にとって最も便利なものを意味します。また、ほとんどの実装者はint
を2の補数の値として扱うため、現在最も一般的な実装は、2つの正の数のオーバーフローした合計が負の数であり、真の結果と何らかの関係があることです。これは間違った答えであり、標準では何でも許可されているため、標準で許可されています。
ゼロによる整数除算のように、 整数オーバーフローはエラーとして扱われるべきです と言う議論があります。 '86アーキテクチャには、オーバーフロー時に例外を発生させるINTO
命令さえあります。ある時点で、その引数は、主流のコンパイラに組み込むのに十分な重みを獲得する可能性があり、その時点で整数オーバーフローがクラッシュを引き起こす可能性があります。これはC++標準にも準拠しているため、実装は何でも実行できます。
数字がリトルエンディアン形式でnullで終了する文字列として表現され、ゼロバイトが「数字の終わり」を表すアーキテクチャを想像できます。ゼロバイトに達するまで、バイトごとに追加することで追加できます。このようなアーキテクチャでは、整数オーバーフローにより後続ゼロが1で上書きされ、結果が遠く、はるかに長く見え、将来的にデータが破損する可能性があります。これは、C++標準にも準拠しています。
最後に、他のいくつかの返信で指摘されているように、コードの生成と最適化の多くは、生成するコードとそれがどのように実行されるかをコンパイラが推論することによります。整数オーバーフローの場合、コンパイラーは(a)大きな正数を追加したときに負の結果を与える追加用のコードを生成すること、および(b)大きな正数を追加することをコード生成に通知することは完全に合法です肯定的な結果が得られます。したがって、たとえば
if (a+b>0) x=a+b;
コンパイラがa
とb
の両方が正であることを知っている場合、テストを実行するのは面倒ではありませんが、無条件にa
をb
に追加して結果をx
に入れます。 2の補数のマシンでは、コードの意図に明らかに違反して、負の値がx
に入れられる可能性があります。これは完全に規格に準拠します。
未定義の動作は未定義です。プログラムがクラッシュする可能性があります。それは何もしないかもしれません。期待どおりの結果が得られる可能性があります。鼻の悪魔を呼び出すことがあります。それはあなたのすべてのファイルを削除するかもしれません。コンパイラーは、未定義の動作に遭遇したときに、自由にコードを自由に出力できます(またはコードをまったく出力できません)。
未定義の動作のインスタンスがあると、プログラム全体が未定義になります-未定義の操作だけでなく、コンパイラーはプログラムの任意の部分に対して必要なことを何でも実行できます。タイムトラベルを含む:未定義の動作はタイムトラベルになる可能性があります(とりわけ、タイムトラベルが最もファンキーです)。
未定義の動作に関する多くの回答とブログ投稿がありますが、以下は私のお気に入りです。このトピックについて詳しく知りたい場合は、これらを読むことをお勧めします。
難解な最適化の結果に加えて、最適化されていないコンパイラが生成すると単純に期待するコードであっても、他の問題を考慮する必要があります。
アーキテクチャが2の補数(またはその他)であることがわかっている場合でも、オーバーフローした操作ではフラグが期待どおりに設定されない可能性があるため、if(a + b < 0)
のようなステートメントは誤った分岐を行う可能性があります。一緒に追加すると、オーバーフローして結果が出るため、2の補数の純粋主義者は否定しますが、追加命令は実際には否定フラグを設定しない場合があります)
マルチステップ操作は、各ステップで切り捨てられることなく、sizeof(int)よりも広いレジスタで行われた可能性があるため、(x << 5) >> 5
のような式は、想定どおりに左5ビットを切り捨てない場合があります。
乗算および除算演算では、積と被除数の追加ビット用にセカンダリレジスタを使用できます。乗算が「できない」オーバーフローの場合、コンパイラーは2次レジスターがゼロ(または負の積の場合は-1)であると自由に想定し、除算する前にリセットしません。したがって、x * y / z
のような式では、予想よりも広い中間積が使用される可能性があります。
これらの一部は追加の精度のように聞こえますが、予想外で予測も信頼もできない追加の精度であり、「各操作はNビットの2の補数オペランドを受け入れ、最下位Nを返すというメンタルモデルに違反しています。次の操作の結果のビット」
int
で表される値は定義されていません。あなたが思ったようなメモリの「オーバーフロー」はありません。