ループの最後の実行として、に書き込みarray[10]
ますが、配列には10から9までの番号が付けられた10個の要素しかありません。C言語仕様では、これは「未定義の動作」であると記載されています。これが実際に意味することは、プログラムがint
メモリ内の直後にあるサイズのメモリに書き込もうとすることarray
です。次に何が起こるかは、実際にはそこにあるものに依存し、これはオペレーティングシステムだけでなく、コンパイラ、コンパイラオプション(最適化設定など)、プロセッサアーキテクチャ、周囲のコードにも依存します。など、実行ごとに異なる可能性もあります。たとえば、アドレス空間のランダム化が原因です(おそらくこのおもちゃの例ではありませんが、実際には起こります)。いくつかの可能性が含まれます:
- 場所は使用されませんでした。ループは正常に終了します。
- ロケーションは、たまたま値0を持つ何かに使用されました。ループは正常に終了します。
- 場所には、関数の戻りアドレスが含まれていました。ループは正常に終了しますが、プログラムはアドレス0にジャンプしようとするためクラッシュします。
- 場所には変数が含まれます
i
。ループはi
0 から再開するため、終了することはありません。
- 場所には他の変数が含まれています。ループは正常に終了しますが、「興味深い」ことが起こります。
- 場所が無効なメモリアドレスです。たとえば
array
、仮想メモリページの最後にあり、次のページがマップされていないためです。
- 悪魔はあなたの鼻から飛び出します。幸い、ほとんどのコンピューターには必要なハードウェアがありません。
Windowsで観察したのは、コンパイラが変数i
をメモリ内の配列の直後に配置することを決定したため、array[10] = 0
最終的にに割り当てられたことi
です。UbuntuとCentOSでは、コンパイラはi
そこに配置されませんでした。ほとんどすべてのCの実装は、ローカル変数をメモリ内のメモリスタックにグループ化します。ただし、1つの大きな例外があります。一部のローカル変数は完全にレジスタに配置できます。変数がスタック上にある場合でも、変数の順序はコンパイラーによって決定され、ソースファイル内の順序だけでなくそれらの型にも依存する可能性があります(メモリを無駄に配置して制約の制約に穴を開けないようにするため)。 、名前、コンパイラの内部データ構造で使用されるハッシュ値など
コンパイラが決定したことを知りたい場合は、アセンブラコードを表示するように指示できます。ああ、アセンブラを解読する方法を学びましょう(それを書くよりも簡単です)。GCC(および他の一部のコンパイラ、特にUnixの世界)では-S
、バイナリの代わりにアセンブラコードを生成するオプションを渡します。たとえば、amd64でGCCを使用してコンパイルし、最適化オプション-O0
(最適化なし)を使用して、手動でコメントを追加したループのアセンブラースニペットを次に示します。
.L3:
movl -52(%rbp), %eax ; load i to register eax
cltq
movl $0, -48(%rbp,%rax,4) ; set array[i] to 0
movl $.LC0, %edi
call puts ; printf of a constant string was optimized to puts
addl $1, -52(%rbp) ; add 1 to i
.L2:
cmpl $10, -52(%rbp) ; compare i to 10
jle .L3
ここでは、変数i
はスタックの最上部から52バイト下にありますが、配列はスタックの最上部の下に48バイトあります。したがって、このコンパイラはたまたまi
配列の直前に配置されています。i
に書き込みを行った場合は上書きしますarray[-1]
。に変更array[i]=0
するとarray[9-i]=0
、これらの特定のコンパイラオプションを使用して、この特定のプラットフォームで無限ループが発生します。
では、プログラムをでコンパイルしましょうgcc -O1
。
movl $11, %ebx
.L3:
movl $.LC0, %edi
call puts
subl $1, %ebx
jne .L3
それは短いです!コンパイラーはスタックの場所を割り当てることを拒否したi
だけでなく、レジスターに格納されるだけebx
ですがarray
、メモリーを割り当てたり、要素を設定するコードを生成したりする必要はありません。使用されています。
この例をわかりやすくするために、最適化できないものをコンパイラーに提供することによって、配列の割り当てが確実に実行されるようにしましょう。分割コンパイルの、コンパイラは(それがリンク時に最適化し、その場合を除き、別のファイルに何が起こるかわからないので、 -それを行うための簡単な方法は、別のファイルから配列を使用することであるgcc -O0
かgcc -O1
ありません)。use_array.c
を含むソースファイルを作成する
void use_array(int *array) {}
ソースコードを次のように変更します
#include <stdio.h>
void use_array(int *array);
int main()
{
int array[10],i;
for (i = 0; i <=10 ; i++)
{
array[i]=0; /*code should never terminate*/
printf("test \n");
}
printf("%zd \n", sizeof(array)/sizeof(int));
use_array(array);
return 0;
}
でコンパイル
gcc -c use_array.c
gcc -O1 -S -o with_use_array1.c with_use_array.c use_array.o
今回は、アセンブラコードは次のようになります。
movq %rsp, %rbx
leaq 44(%rsp), %rbp
.L3:
movl $0, (%rbx)
movl $.LC0, %edi
call puts
addq $4, %rbx
cmpq %rbp, %rbx
jne .L3
これで、配列はスタック上にあり、上から44バイトです。どうi
ですか?どこにも現れない!ただし、ループカウンタはレジスタに保持されrbx
ます。正確i
ではありませんが、のアドレスですarray[i]
。コンパイラーは、の値がi
直接使用されなかったため、ループの各実行中に0を格納する場所を計算する算術を実行しても意味がないと判断しました。代わりに、そのアドレスはループ変数であり、境界を決定するための計算は、部分的にコンパイル時に実行され(11回の反復に配列要素ごとに4バイトを乗算して44を取得)、部分的に実行時に実行されますが、ループが開始する前にすべて(減算を実行して初期値を取得します)。
この非常に単純な例でも、コンパイラオプションを変更する(最適化をオンにarray[i]
するarray[9-i]
)か、マイナーなものを変更する(to )か、明らかに無関係なものを変更する(への呼び出しを追加するuse_array
)だけで、実行可能プログラムが生成するものに大きな違いをもたらすことがわかりましたコンパイラが行います。コンパイラの最適化は、未定義の動作を呼び出すプログラムでは直感的に見えない可能性がある多くのことを実行できます。そのため、未定義の動作は完全に未定義のままになります。実際のプログラムでは、トラックから少しでも逸脱すると、経験豊富なプログラマーであっても、コードの動作と実行すべき動作の関係を理解するのが非常に困難になる可能性があります。