パソコンに搭載される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 0x11290x0000000000001179 <+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つあるので rdi
、rsi
、rdx
の3つのレジスタを使います。
- 1番目の引数
rdi
- 2番目の引数
rsi
- 3番目の引数
rdx
- 4番目の引数
rcx
- 5番目の引数
r8
- 6番目の引数
r9
これらを踏まえて見ていきましょう。
最初の mov -0x10(%rbp),%rdx
は3番目の引数の設定です。これはポインタ変数「p」となりrbp
- 0x14のメモリアドレスが格納されています。
次に mov -0x8(%rbp),%ecx
と mov -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言語のコードでもアセンブリレベルで追いかけるのは疲れますが、アセンブリコードはパズルを解くような面白さがあるので楽しかったです。
アセンブリ言語は知っていて損は無いので、興味のある方はぜひデバッガや逆アセンブラを使って遊んでみると良いと思います。
この記事は役に立ちましたか?
もし参考になりましたら、下記のボタンで教えてください。