【x86_64/Linux】アセンブリ言語でどのように関数を呼び出しているのかGDBで観察する

パソコンに搭載されるCPUはすっかり64ビットが当たり前になりました。そこで、古い知識をアップデートするためにC言語でシンプルなコードを書いてコンパイルし、GDBで逆アセンブルしつつステップ実行して動きを観察してみました。

下準備

次のようなシンプルなコードを用意しました。

#include <stdio.h>

void
func(int xx, int yy, int *pp) {
    *pp = xx + yy;
}

int
main(void) {
    int x, y, z, *p;

    x = 1;
    y = 2;
    p = &z;

    func(x, y, p);
}

コンパイルオプションは何も指定せず、gccでコンパイルします。このコマンドを実行すると a.out という実行ファイルが生成されます。

gcc foo.c

コンパイルしたらgdbで挙動を見ます。

gdb -q a.out

アセンブリコードを追いかける

main関数

まず最初にmain()の逆アセンブル結果を見てみます。

(gdb) disas main
Dump of assembler code for function main:
   0x0000000000001148 <+0>:     push   %rbp
   0x0000000000001149 <+1>:     mov    %rsp,%rbp
   0x000000000000114c <+4>:     sub    $0x20,%rsp
   0x0000000000001150 <+8>:     movl   $0x1,-0x4(%rbp)
   0x0000000000001157 <+15>:    movl   $0x2,-0x8(%rbp)
   0x000000000000115e <+22>:    lea    -0x14(%rbp),%rax
   0x0000000000001162 <+26>:    mov    %rax,-0x10(%rbp)
   0x0000000000001166 <+30>:    mov    -0x10(%rbp),%rdx
   0x000000000000116a <+34>:    mov    -0x8(%rbp),%ecx
   0x000000000000116d <+37>:    mov    -0x4(%rbp),%eax
   0x0000000000001170 <+40>:    mov    %ecx,%esi
   0x0000000000001172 <+42>:    mov    %eax,%edi
   0x0000000000001174 <+44>:    call   0x1129 
   0x0000000000001179 <+49>:    mov    $0x0,%eax
   0x000000000000117e <+54>:    leave
   0x000000000000117f <+55>:    ret
End of assembler dump.

最初の2つの処理はrbp(ベースポインタ)とrsp(スタックポインタ)の退避でお決まりの処理です。

   0x0000000000001148 <+0>:     push   %rbp
   0x0000000000001149 <+1>:     mov    %rsp,%rbp

次にrspを0x20減算しています。これはローカル変数をスタックに確保するための準備です。この時点でrbpには元のrspの値が保存されており、rspは最新のスタックポインタを指しています。

   0x000000000000114c <+4>:     sub    $0x20,%rsp

次の2つが「x = 1」「y = 2」に該当する処理で、スタック上に値を配置しています。

   0x0000000000001150 <+8>:     movl   $0x1,-0x4(%rbp)
   0x0000000000001157 <+15>:    movl   $0x2,-0x8(%rbp)

以下は「p = &z;」に該当する処理です。変数「z」はrbp - 0x14のメモリアドレスに配置され、そのメモリアドレスをraxに格納しています。そして、raxに格納した「z」のメモリアドレスをrbp - 0x10のメモリアドレスに格納しています。rbp - 0x10のメモリアドレスに格納されたものがポインタ変数の「p」です。

   0x000000000000115e <+22>:    lea    -0x14(%rbp),%rax
   0x0000000000001162 <+26>:    mov    %rax,-0x10(%rbp)

そしてようやくfunc()を呼び出します。

   0x0000000000001166 <+30>:    mov    -0x10(%rbp),%rdx
   0x000000000000116a <+34>:    mov    -0x8(%rbp),%ecx
   0x000000000000116d <+37>:    mov    -0x4(%rbp),%eax
   0x0000000000001170 <+40>:    mov    %ecx,%esi
   0x0000000000001172 <+42>:    mov    %eax,%edi

func()を呼び出す前に、x86_64で関数を呼び出す際の引数の指定のやり方を知っておく必要があります。32ビットの頃はスタックに引数を積んでcallすればよかったのですが64ビットになりレジスタを使う方法に変わりました。

今回のように整数・ポインタを引数にする場合は次のようになります。今回は引数が3つあるので rdirsirdxの3つのレジスタを使います。

  • 1番目の引数 rdi
  • 2番目の引数 rsi
  • 3番目の引数 rdx
  • 4番目の引数 rcx
  • 5番目の引数 r8
  • 6番目の引数 r9

これらを踏まえて見ていきましょう。

最初の mov -0x10(%rbp),%rdx は3番目の引数の設定です。これはポインタ変数「p」となりrbp - 0x14のメモリアドレスが格納されています。

次に mov -0x8(%rbp),%ecxmov -0x4(%rbp),%eax はそれぞれ「x」と「y」の値をレジスタに格納しています。そして mov %ecx,%esi で2番目の引数「y」を設定し %eax,%edi で1番目の引数を設定しています。

func()を呼び出す直前までプログラムを進めてからレジスタの値を見てみましょう。

(gdb) p $rdi
$15 = 1
(gdb) p $rsi
$16 = 2
(gdb) p/x $rdx
$17 = 0x7fffffffde1c
(gdb)

さらに、スタックの状態を見てみます。func()をcallする直前の状態です。

(gdb) x/10g $rsp
0x7fffffffde10: 0x0000000000000000      0x00007ffff7fe6e10
0x7fffffffde20: 0x00007fffffffde1c      0x0000000100000002
0x7fffffffde30: 0x0000000000000001      0x00007ffff7df318a
0x7fffffffde40: 0x00007fffffffdf30      0x0000555555555148
0x7fffffffde50: 0x0000000155554040      0x00007fffffffdf48
(gdb)

func()

最初にfunc()を逆アセンブルした結果を見ておきます。

(gdb) disas func
Dump of assembler code for function func:
   0x0000000000001129 <+0>:     push   %rbp
   0x000000000000112a <+1>:     mov    %rsp,%rbp
   0x000000000000112d <+4>:     mov    %edi,-0x4(%rbp)
   0x0000000000001130 <+7>:     mov    %esi,-0x8(%rbp)
   0x0000000000001133 <+10>:    mov    %rdx,-0x10(%rbp)
   0x0000000000001137 <+14>:    mov    -0x4(%rbp),%edx
   0x000000000000113a <+17>:    mov    -0x8(%rbp),%eax
   0x000000000000113d <+20>:    add    %eax,%edx
   0x000000000000113f <+22>:    mov    -0x10(%rbp),%rax
   0x0000000000001143 <+26>:    mov    %edx,(%rax)
   0x0000000000001145 <+28>:    nop
   0x0000000000001146 <+29>:    pop    %rbp
   0x0000000000001147 <+30>:    ret
End of assembler dump.
(gdb)

ではfunc()に入ったところまで進めてスタックの状態を見てみます。

(gdb) si
0x0000555555555129 in func ()
(gdb) x/10g $rsp
0x7fffffffde08: 0x0000555555555179      0x0000000000000000
0x7fffffffde18: 0x00007ffff7fe6e10      0x00007fffffffde1c
0x7fffffffde28: 0x0000000100000002      0x0000000000000001
0x7fffffffde38: 0x00007ffff7df318a      0x00007fffffffdf30
0x7fffffffde48: 0x0000555555555148      0x0000000155554040
(gdb) 

スタックの先頭に「0x0000555555555179」が追加されています。これは何でしょうか?該当するメモリアドレスのアセンブリコードを見てみます。

(gdb) x/10i 0x0000555555555179
   0x555555555179 :    mov    $0x0,%eax
   0x55555555517e :    leave
   0x55555555517f :    ret
   0x555555555180 <_fini>:      sub    $0x8,%rsp
   0x555555555184 <_fini+4>:    add    $0x8,%rsp
   0x555555555188 <_fini+8>:    ret
   0x555555555189:      add    %al,(%rax)
   0x55555555518b:      add    %al,(%rax)
   0x55555555518d:      add    %al,(%rax)
   0x55555555518f:      add    %al,(%rax)
(gdb)

「0x0000555555555179」はcall命令でスタックに積まれたリターンコードでした。

それではfunc()の中を見てみます。最初の2つの処理はrbp(ベースポインタ)とrsp(スタックポインタ)の退避でお決まりの処理です。

   0x0000000000001129 <+0>:     push   %rbp
   0x000000000000112a <+1>:     mov    %rsp,%rbp

次の3つの処理は引数をスタックに配置しているところです。

   0x000000000000112d <+4>:     mov    %edi,-0x4(%rbp)
   0x0000000000001130 <+7>:     mov    %esi,-0x8(%rbp)
   0x0000000000001133 <+10>:    mov    %rdx,-0x10(%rbp)

add で計算し、計算結果を edx に格納します。この処理はC言語の「xx + yy」に該当します。

   0x0000000000001137 <+14>:    mov    -0x4(%rbp),%edx
   0x000000000000113a <+17>:    mov    -0x8(%rbp),%eax
   0x000000000000113d <+20>:    add    %eax,%edx

rbp - 0x10のメモリアドレスをRAXに格納します(raxに格納したメモリアドレスはポインタ変数「pp」です)。そして mov %edx,(%rax) で計算結果ポインタ変数「pp」が指すメモリアドレスに格納します。わざわざこんな風に処理しているのはC言語のコードでポインタを使っているためです。

   0x000000000000113f <+22>:    mov    -0x10(%rbp),%rax
   0x0000000000001143 <+26>:    mov    %edx,(%rax)

そして最後にスタックから値を取り出してrbpに格納します。一番最初に rbp をpushしていますから、rbpの値をfunc()に入った時点に戻すというわけです。

   0x0000000000001145 <+28>:    nop
   0x0000000000001146 <+29>:    pop    %rbp

そして、最後にretでmain()に戻ります。retを実行する時点でスタックの先頭にはリターンアドレスが格納されていますから、retで戻るのはそのリターンアドレスとなります。

0x0000000000001147 <+30>:    ret

このあとは特に見る箇所はないので、ここまでにします。

まとめ

たったこれだけのC言語のコードでもアセンブリレベルで追いかけるのは疲れますが、アセンブリコードはパズルを解くような面白さがあるので楽しかったです。

アセンブリ言語は知っていて損は無いので、興味のある方はぜひデバッガや逆アセンブラを使って遊んでみると良いと思います。

この記事は役に立ちましたか?

もし参考になりましたら、下記のボタンで教えてください。

関連記事