FromNandの日記

自分的備忘録

【C言語】配列にアクセスするコードをアセンブラレベルで覗いてみる

プリミティブなデータ型の一次配列の初期化・代入・参照を確認する

#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のアセンブラをみてもらうとわかると思うが、コンパイラが配列の要素の即値を埋め込むことができていない。
これは先ほどの三つの結果からは異なる結果となる。