プリミティブなデータ型の一次配列の初期化・代入・参照を確認する
#include<stdio.h> int main(void){ int array[10] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; array[1] = 5; printf("%d\n", array[8]); return 0; }
このコードを逆アセンブルすると次のようになる。
内容についてはコメントを参考にしてほしい。
0000057d <main>: # もし、main関数が引数を持っていた場合、ecxにそれらの先頭アドレスを保存① 57d: 8d 4c 24 04 lea 0x4(%esp),%ecx # アライメントを16byte境界にそろえる 581: 83 e4 f0 and $0xfffffff0,%esp # アライメントをそろえてespがリターンアドレスを指しているか分からなくなった # ecx-0x4のアドレスにはリターンアドレスが入っている # アライメント後のスタックにリターンアドレスをコピー 584: ff 71 fc pushl -0x4(%ecx) # 新しいスタックフレームの作成 587: 55 push %ebp 588: 89 e5 mov %esp,%ebp # レジスタの退避 58a: 53 push %ebx 58b: 51 push %ecx # ローカル変数の領域をまとめて確保 58c: 83 ec 30 sub $0x30,%esp # PICコードを生成するために必要な関数らしい... 58f: e8 96 00 00 00 call 62a <__x86.get_pc_thunk.ax> 594: 05 40 1a 00 00 add $0x1a40,%eax 599: 65 8b 0d 14 00 00 00 mov %gs:0x14,%ecx 5a0: 89 4d f4 mov %ecx,-0xc(%ebp) 5a3: 31 c9 xor %ecx,%ecx # arrayはebp-0x34からebp-0xdまでの40byte # int array[10] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; 5a5: c7 45 cc 00 00 00 00 movl $0x0,-0x34(%ebp) 5ac: c7 45 d0 01 00 00 00 movl $0x1,-0x30(%ebp) 5b3: c7 45 d4 02 00 00 00 movl $0x2,-0x2c(%ebp) 5ba: c7 45 d8 03 00 00 00 movl $0x3,-0x28(%ebp) 5c1: c7 45 dc 04 00 00 00 movl $0x4,-0x24(%ebp) 5c8: c7 45 e0 05 00 00 00 movl $0x5,-0x20(%ebp) 5cf: c7 45 e4 06 00 00 00 movl $0x6,-0x1c(%ebp) 5d6: c7 45 e8 07 00 00 00 movl $0x7,-0x18(%ebp) 5dd: c7 45 ec 08 00 00 00 movl $0x8,-0x14(%ebp) 5e4: c7 45 f0 09 00 00 00 movl $0x9,-0x10(%ebp) # array[1] = 5; 5eb: c7 45 d0 05 00 00 00 movl $0x5,-0x30(%ebp) # array[8] → edx 5f2: 8b 55 ec mov -0x14(%ebp),%edx # espの調節(よくわからない) 5f5: 83 ec 08 sub $0x8,%esp # push array[8] 5f8: 52 push %edx # 「%d\n」という文字列の先頭アドレスをedxに格納して # edxをpushする 5f9: 8d 90 fc e6 ff ff lea -0x1904(%eax),%edx 5ff: 52 push %edx # PICコードの処理かな???? 600: 89 c3 mov %eax,%ebx # printf("%d\n", array[8]); 602: e8 f9 fd ff ff call 400 <printf@plt> 607: 83 c4 10 add $0x10,%esp # main関数の戻り値の設定 60a: b8 00 00 00 00 mov $0x0,%eax # PICコードの処理 60f: 8b 4d f4 mov -0xc(%ebp),%ecx 612: 65 33 0d 14 00 00 00 xor %gs:0x14,%ecx 619: 74 05 je 620 <main+0xa3> 61b: e8 80 00 00 00 call 6a0 <__stack_chk_fail_local> # スタック領域が破壊されていないかのチェック 620: 8d 65 f8 lea -0x8(%ebp),%esp 623: 59 pop %ecx 624: 5b pop %ebx 625: 5d pop %ebp # 一番上の①で保存しておいたアドレスをespに復旧する 626: 8d 61 fc lea -0x4(%ecx),%esp # return 629: c3 ret
このアセンブリには、配列の先頭アドレスとオフセットを足して配列の要素を見つけ出しているのではなく、コンパイラがすでに配列の要素を指すアドレスをコードに埋め込んでいるような印象を受ける。
コンパイラは解析時に配列の各要素のサイズがわかるので当然ともいえるが、これはプリミティブ型だけによるものだろうか?
次は自分で作成したデータ型である「構造体」の一次配列について調べてみる。
プリミティブ型でないデータ型の一次配列の初期化・代入・参照を確認する
#include<stdio.h> typedef struct { int a, b; } TEST; int main(void){ TEST array[5] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; array[1].a = 5; array[1].b = 6; printf("%d\n", array[3].a); return 0; }
上のコードをコンパイルして、逆アセンブルすると次のようになる。
0000057d <main>: # もし、main関数が引数を持っていた場合、ecxにそれらの先頭アドレスを保存① 57d: 8d 4c 24 04 lea 0x4(%esp),%ecx # アライメントを16byte境界にそろえる 581: 83 e4 f0 and $0xfffffff0,%esp # アライメントをそろえてespがリターンアドレスを指しているか分からなくなった # ecx-0x4のアドレスにはリターンアドレスが入っている # アライメント後のスタックにリターンアドレスをコピー 584: ff 71 fc pushl -0x4(%ecx) # 新しいスタックフレームの作成 587: 55 push %ebp 588: 89 e5 mov %esp,%ebp # レジスタの退避 58a: 53 push %ebx 58b: 51 push %ecx # ローカル変数の領域をまとめて確保 58c: 83 ec 30 sub $0x30,%esp # PICコードを生成するために必要な関数らしい... 58f: e8 9d 00 00 00 call 631 <__x86.get_pc_thunk.ax> 594: 05 40 1a 00 00 add $0x1a40,%eax 599: 65 8b 0d 14 00 00 00 mov %gs:0x14,%ecx 5a0: 89 4d f4 mov %ecx,-0xc(%ebp) 5a3: 31 c9 xor %ecx,%ecx # arrayはebp-0x34からebp-0xdまでの40byte # TEST array[5] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; 5a5: c7 45 cc 00 00 00 00 movl $0x0,-0x34(%ebp) 5ac: c7 45 d0 01 00 00 00 movl $0x1,-0x30(%ebp) 5b3: c7 45 d4 02 00 00 00 movl $0x2,-0x2c(%ebp) 5ba: c7 45 d8 03 00 00 00 movl $0x3,-0x28(%ebp) 5c1: c7 45 dc 04 00 00 00 movl $0x4,-0x24(%ebp) 5c8: c7 45 e0 05 00 00 00 movl $0x5,-0x20(%ebp) 5cf: c7 45 e4 06 00 00 00 movl $0x6,-0x1c(%ebp) 5d6: c7 45 e8 07 00 00 00 movl $0x7,-0x18(%ebp) 5dd: c7 45 ec 08 00 00 00 movl $0x8,-0x14(%ebp) 5e4: c7 45 f0 09 00 00 00 movl $0x9,-0x10(%ebp) # array[1].a = 5; # array[1].b = 6; 5eb: c7 45 d4 05 00 00 00 movl $0x5,-0x2c(%ebp) 5f2: c7 45 d8 06 00 00 00 movl $0x6,-0x28(%ebp) # array[3].aをedxにコピー 5f9: 8b 55 e4 mov -0x1c(%ebp),%edx # espの調節 5fc: 83 ec 08 sub $0x8,%esp # push array[3].a 5ff: 52 push %edx # 「%d\n」という文字列の先頭アドレスをedxに格納して # edxをpushする 600: 8d 90 0c e7 ff ff lea -0x18f4(%eax),%edx 606: 52 push %edx # PICコードの処理かな???? 607: 89 c3 mov %eax,%ebx # printf("%d\n", array[3].a); 609: e8 f2 fd ff ff call 400 <printf@plt> 60e: 83 c4 10 add $0x10,%esp # main関数の戻り値の設定 611: b8 00 00 00 00 mov $0x0,%eax # PICコードの処理 616: 8b 4d f4 mov -0xc(%ebp),%ecx 619: 65 33 0d 14 00 00 00 xor %gs:0x14,%ecx 620: 74 05 je 627 <main+0xaa> 622: e8 89 00 00 00 call 6b0 <__stack_chk_fail_local> # 上のほうで退避したレジスタを復旧する 627: 8d 65 f8 lea -0x8(%ebp),%esp 62a: 59 pop %ecx 62b: 5b pop %ebx 62c: 5d pop %ebp # 一番上の①で保存しておいたアドレスをespに復旧する 62d: 8d 61 fc lea -0x4(%ecx),%esp # return 630: c3 ret
こちらのアセンブリも先ほどと同様、コンパイラが各要素のアドレスをコードに挿入するような形になっている。
自作のデータ型であってもコンパイラはコンパイル時にデータのサイズを知ることができるので、こういったコードに最適化できるのだろう。
二次配列の初期化・代入・参照を確認する
#include<stdio.h> int main(void){ int array[2][5] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; array[0][3] = 13; array[1][2] = 17; printf("%d\n", array[1][4]); return 0; }
逆アセンブルしたコードは次のようになる。
0000057d <main>: # もし、main関数が引数を持っていた場合、ecxにそれらの先頭アドレスを保存① 57d: 8d 4c 24 04 lea 0x4(%esp),%ecx # アライメントを16byte境界にそろえる 581: 83 e4 f0 and $0xfffffff0,%esp # アライメントをそろえてespがリターンアドレスを指しているか分からなくなった # ecx-0x4のアドレスにはリターンアドレスが入っている # アライメント後のスタックにリターンアドレスをコピー 584: ff 71 fc pushl -0x4(%ecx) # 新しいスタックフレームの作成 587: 55 push %ebp 588: 89 e5 mov %esp,%ebp # レジスタの退避 58a: 53 push %ebx 58b: 51 push %ecx # ローカル変数の領域をまとめて確保 58c: 83 ec 30 sub $0x30,%esp # PICコードを生成するために必要な関数らしい... 58f: e8 9d 00 00 00 call 631 <__x86.get_pc_thunk.ax> 594: 05 40 1a 00 00 add $0x1a40,%eax 599: 65 8b 0d 14 00 00 00 mov %gs:0x14,%ecx 5a0: 89 4d f4 mov %ecx,-0xc(%ebp) 5a3: 31 c9 xor %ecx,%ecx # arrayはebp-0x34からebp-0xdまでの40byte # int array[2][5] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; 5a5: c7 45 cc 00 00 00 00 movl $0x0,-0x34(%ebp) 5ac: c7 45 d0 01 00 00 00 movl $0x1,-0x30(%ebp) 5b3: c7 45 d4 02 00 00 00 movl $0x2,-0x2c(%ebp) 5ba: c7 45 d8 03 00 00 00 movl $0x3,-0x28(%ebp) 5c1: c7 45 dc 04 00 00 00 movl $0x4,-0x24(%ebp) 5c8: c7 45 e0 05 00 00 00 movl $0x5,-0x20(%ebp) 5cf: c7 45 e4 06 00 00 00 movl $0x6,-0x1c(%ebp) 5d6: c7 45 e8 07 00 00 00 movl $0x7,-0x18(%ebp) 5dd: c7 45 ec 08 00 00 00 movl $0x8,-0x14(%ebp) 5e4: c7 45 f0 09 00 00 00 movl $0x9,-0x10(%ebp) # array[0][3] = 13; # array[1][2] = 17; 5eb: c7 45 d8 0d 00 00 00 movl $0xd,-0x28(%ebp) 5f2: c7 45 e8 11 00 00 00 movl $0x11,-0x18(%ebp) # array[1][4]をedxにコピー 5f9: 8b 55 f0 mov -0x10(%ebp),%edx # espの調節 5fc: 83 ec 08 sub $0x8,%esp # push array[1][4] 5ff: 52 push %edx # 「%d\n」という文字列の先頭アドレスをedxに格納して # edxをpushする 600: 8d 90 0c e7 ff ff lea -0x18f4(%eax),%edx 606: 52 push %edx # PICコードの処理かな???? 607: 89 c3 mov %eax,%ebx # printf("%d\n", array[1][4]); 609: e8 f2 fd ff ff call 400 <printf@plt> 60e: 83 c4 10 add $0x10,%esp # main関数の戻り値の設定 611: b8 00 00 00 00 mov $0x0,%eax # PICコードの処理 616: 8b 4d f4 mov -0xc(%ebp),%ecx 619: 65 33 0d 14 00 00 00 xor %gs:0x14,%ecx 620: 74 05 je 627 <main+0xaa> 622: e8 89 00 00 00 call 6b0 <__stack_chk_fail_local> # 上のほうで退避したレジスタを復旧する 627: 8d 65 f8 lea -0x8(%ebp),%esp 62a: 59 pop %ecx 62b: 5b pop %ebx 62c: 5d pop %ebp # 一番上の①で保存しておいたアドレスをespに復旧する 62d: 8d 61 fc lea -0x4(%ecx),%esp # return 630: c3 ret
アセンブリを読んでもらえるとお分かりかと思うが、先ほどの二つの例と同様でコンパイラがアドレスを直接コードに挿入している。
つまり、二次配列などの多次元配列であっても、コンパイラは配列の先頭のアドレスからのオフセットを計算するコードを出力するのではなく、そのアドレスをコンパイル時に計算してしまってコードに即値として書き込んでしまう。
これらの実験は少し面白みに欠けたので、次は配列を関数に渡すことでコンパイラが配列のサイズを計算できないようにして同様の実験を行ってみる。
配列の情報をコンパイラに感知できないようにすると(配列を関数に渡す)
C言語の配列は式中ではすべて「配列の先頭要素へのポインタ」に読み替えられる。
ということは、配列を関数に渡すと配列の情報が抜け落ちただのポインタに成り下がる。
こうすることでコンパイラが配列の要素のアドレスを即値でコードに埋め込むことはできなくなるはずだ。
実際に実験しよう。
#include<stdio.h> // func(int array[]){ // func(int array[10]){ void func(int *array){ array[3] = 12; } int main(void){ int array[10] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; func(array); return 0; }
逆アセンブルすると
0000054d <func>: 54d: 55 push %ebp 54e: 89 e5 mov %esp,%ebp 550: e8 aa 00 00 00 call 5ff <__x86.get_pc_thunk.ax> 555: 05 83 1a 00 00 add $0x1a83,%eax # 引数のarrayの値をeaxにコピー 55a: 8b 45 08 mov 0x8(%ebp),%eax # intのサイズは4でarray[3]を参照したいので、 # 4 * 3 = 12 = 0xc 55d: 83 c0 0c add $0xc,%eax # array[3] = 12 560: c7 00 0c 00 00 00 movl $0xc,(%eax) 566: 90 nop 567: 5d pop %ebp 568: c3 ret 00000569 <main>: 569: 8d 4c 24 04 lea 0x4(%esp),%ecx 56d: 83 e4 f0 and $0xfffffff0,%esp 570: ff 71 fc pushl -0x4(%ecx) 573: 55 push %ebp 574: 89 e5 mov %esp,%ebp 576: 51 push %ecx 577: 83 ec 34 sub $0x34,%esp 57a: e8 80 00 00 00 call 5ff <__x86.get_pc_thunk.ax> 57f: 05 59 1a 00 00 add $0x1a59,%eax 584: 65 a1 14 00 00 00 mov %gs:0x14,%eax 58a: 89 45 f4 mov %eax,-0xc(%ebp) 58d: 31 c0 xor %eax,%eax 58f: c7 45 cc 00 00 00 00 movl $0x0,-0x34(%ebp) 596: c7 45 d0 01 00 00 00 movl $0x1,-0x30(%ebp) 59d: c7 45 d4 02 00 00 00 movl $0x2,-0x2c(%ebp) 5a4: c7 45 d8 03 00 00 00 movl $0x3,-0x28(%ebp) 5ab: c7 45 dc 04 00 00 00 movl $0x4,-0x24(%ebp) 5b2: c7 45 e0 05 00 00 00 movl $0x5,-0x20(%ebp) 5b9: c7 45 e4 06 00 00 00 movl $0x6,-0x1c(%ebp) 5c0: c7 45 e8 07 00 00 00 movl $0x7,-0x18(%ebp) 5c7: c7 45 ec 08 00 00 00 movl $0x8,-0x14(%ebp) 5ce: c7 45 f0 09 00 00 00 movl $0x9,-0x10(%ebp) # 配列の先頭アドレスをeaxに格納 # C言語では式中の配列名はすべて「配列の先頭要素へのポインタ」に読み替えられる 5d5: 8d 45 cc lea -0x34(%ebp),%eax 5d8: 50 push %eax 5d9: e8 6f ff ff ff call 54d <func> 5de: 83 c4 04 add $0x4,%esp 5e1: b8 00 00 00 00 mov $0x0,%eax 5e6: 8b 55 f4 mov -0xc(%ebp),%edx 5e9: 65 33 15 14 00 00 00 xor %gs:0x14,%edx 5f0: 74 05 je 5f7 <main+0x8e> 5f2: e8 89 00 00 00 call 680 <__stack_chk_fail_local> 5f7: 8b 4d fc mov -0x4(%ebp),%ecx 5fa: c9 leave 5fb: 8d 61 fc lea -0x4(%ecx),%esp 5fe: c3 ret
関数funcのアセンブラをみてもらうとわかると思うが、コンパイラが配列の要素の即値を埋め込むことができていない。
これは先ほどの三つの結果からは異なる結果となる。