逆アセンブルで遊んでみる(6)関数内の処理を追いかけてみた
今回は、関数呼出しにおけるスタックの実装について、逆アセンブルを執拗に解析していきます。
スタックという概念については、「積み上げられた本を取り出す時、一番上にある本から優先して取り出しが行われる」というたとえ話で説明されることが多いですが、LIFOの概念自体は、基本情報の試験でサービス問題になっているぐらい、バカみたいに簡単です。
で、スタックの何が難しいかというと、「関数呼出におけるスタックの実装」を追いかける時です。
ポイントとしては、
・スタックは、アドレスの上位から下位に向かって伸びていく(「水深〜メートル」をイメージすると、覚えやすいかも)。 ・PUSH/POP命令を行うと、スタックポインタの値が、-1/+1される。 ・呼び出された関数内で、真っ先にやることは、ベースポインタの退避とスタックフレームの設定である。 ・呼出元関数に戻る時には、退避していたベースポインタの復元(スタックフレームの解放)を行う。 ・スタックには、呼出元のアドレスや引数の値、ベースポインタの値など、様々なデータが格納されるが、ベースポインタ(ebp)および スタックポインタ(esp)は、つねに「スタックの特定位置へのポインタ値」が格納される。これが、スタックの実装を追いかける際の 混乱の元となる。
以上を踏まえた上で、下記のソースコードを、逆アセンブルで解析してみます。
1 /* hoge.c */ 2 int func(int n) 3 { 4 int a = 2; 5 int b = 3; 6 int ret; 7 8 if(n == 0){ 9 ret = a + b; 10 }else{ 11 ret = a * b; 12 } 13 14 return ret; 15 }
$ gcc -Wall -O0 -c hoge.c $ objdump -d hoge.o $
[逆アセンブルの結果]
0: 55 push %ebp 1: 89 e5 mov %esp,%ebp 3: 83 ec 10 sub $0x10,%esp 6: c7 45 f8 02 00 00 00 movl $0x2,-0x8(%ebp) d: c7 45 fc 03 00 00 00 movl $0x3,-0x4(%ebp) 14: 83 7d 08 00 cmpl $0x0,0x8(%ebp) 18: 75 0d jne 27 <func+0x27> 1a: 8b 45 fc mov -0x4(%ebp),%eax 1d: 8b 55 f8 mov -0x8(%ebp),%edx 20: 01 d0 add %edx,%eax 22: 89 45 f4 mov %eax,-0xc(%ebp) 25: eb 0a jmp 31 <func+0x31> 27: 8b 45 f8 mov -0x8(%ebp),%eax 2a: 0f af 45 fc imul -0x4(%ebp),%eax 2e: 89 45 f4 mov %eax,-0xc(%ebp) 31: 8b 45 f4 mov -0xc(%ebp),%eax 34: c9 leave 35: c3 ret
長いので、処理を分割しつつ、アセンブリコードを追っかけてみます。
スタックフレームの設定
0: 55 push %ebp 1: 89 e5 mov %esp,%ebp
ebpレジスタの内容をスタックに退避して、現espのポインタ値を、ebpにセットしています(スタックフレームの設定)。この操作により、スタックトップには、旧ebpのアドレスが格納されます。つまり、espとebpは同じ場所(スタックトップ)をポイントすることになります。
まず、上の操作が実行されるよりも前(関数呼出直後)、スタックは以下のようになっています。
ebp | Stack | esp |
---|---|---|
呼出元でのeipの値 | ← | |
引数nの値 | ||
… | ||
→ | 呼出元のスタックベース |
スタックトップにある「eip(インストラクションポインタ)の値」は、呼出元でスタックに退避されたものですが、これは、呼出元関数へ戻る時に使用します(後述)。
そして冒頭のPUSH命令で、ebpレジスタの内容がスタックに退避されます。PUSH命令により、espの値も変化していることに注意して下さい。
ebp | Stack | esp |
---|---|---|
★旧ebpの値 | ←★ | |
呼出元でのeipの値 | ||
引数nの値 | ||
… | ||
→ | 呼出元のスタックベース |
なお、PUSH命令(push %ebp)は、
mov %ebp,0x0(%esp) dec %esp
と同じことです。スタックは、高位アドレスから低位アドレスに向かって伸張するため、スタック拡張時は、スタックポインタに対してDEC命令を使っていることに注意して下さい。
関数呼出し時にスタックフレームを設定するのは、(1)呼出元でスタックに詰まれた仮引数を参照するためと、(2)現関数の局所変数を、スタックフレーム内に領域確保するためです。これらの場面で、スタックベース(ebp)からの相対位置でアクセスを行うのが、定石になっているようです。
最後に、現スタックポインタ(esp)の内容を、ベースポインタ(ebp)にセットしています。この時点で、スタックの内容は、以下のようになっています。
ebp | Stack | esp |
---|---|---|
★→ | 旧ebpの値 | ← |
呼出元でのeipの値 | ||
引数nの値 | ||
… | ||
呼出元のスタックベース |
この処理を、「スタックフレームの設定」と呼称しています。スタックフレームとは、「その関数内で汚してもいい、スタックから借りた土地」のようなものです。スタックもスタックフレームも実装上の概念であり、計算機としてそのような機能が存在するわけではないのですが。
以上で、前準備が完了しました。
局所変数の領域確保と代入
3: 83 ec 10 sub $0x10,%esp 6: c7 45 f8 02 00 00 00 movl $0x2,-0x8(%ebp) d: c7 45 fc 03 00 00 00 movl $0x3,-0x4(%ebp)
ここでは、(1)局所変数を格納するために、スタック上に空間を作り、(2)その空間に、値を代入しています。
まず、スタックポインタ(esp)を16バイト分減算することで、スタックに16バイト分の空間を論理的に作っています。この空間が、局所変数の領域の実体となります。広域変数やstatic指定子のついた局所変数は、DATAセグメント(初期値のある場合)またはBSSセグメント(初期値が0やNULLか、または初期値が無い場合)に、コンパイル時に領域が確保されますが、static指定子のない局所変数は、スタックに動的に確保されるので、nmコマンドでシンボルテーブルをダンプしても、シンボル情報は表示されません(ELFフォーマットでは、局所変数はサイズ情報のみを持つ)。この時点で、スタックは以下のようになっています。
ebp | Stack | esp |
---|---|---|
★(空き) | ←★ | |
★(空き) | ||
★(空き) | ||
★(空き) | ||
→ | 旧ebpの値 | |
呼出元でのeipの値 | ||
引数nの値 | ||
… | ||
呼出元のスタックベース |
その空いた空間に、即値(0x2と0x3)を突っ込んでいます(以下図)が、代入時の宛先は、スタックベースからの相対位置(-0x8(%ebp),-0x4(%ebp))が指定されています。注意したいのは、局所変数(a,b)はint型(つまり4バイト)なので、互いにスキマの無い連続領域に格納されている、ということです。また、自動変数の格納領域が16バイト単位で確保されているため、8バイト分の空きが発生しています。これは、アラインメントを考慮したものでしょうか?
ebp | Stack | esp |
---|---|---|
(空き) | ← | |
(空き) | ||
★0x2 | ||
★0x3 | ||
→ | 旧ebpの値 | |
呼出元でのeipの値 | ||
引数nの値 | ||
… | ||
呼出元のスタックベース |
条件分岐
14: 83 7d 08 00 cmpl $0x0,0x8(%ebp) 18: 75 0d jne 27 <func+0x27>
ここでは、値0と、「スタックベースから+8バイトの領域にある値」を比較して、「等しくない場合は、オフセット0x27番地にジャンプしろ」という条件判定を行っています。
「スタックベースから+8バイトの領域にある値」とは、何のことでしょうか。改めて、スタックの内容を見てみると…
ebp | Stack | esp |
---|---|---|
(空き) | ← | |
(空き) | ||
0x2 | ||
0x3 | ||
→ | 旧ebpの値 | |
呼出元でのeipの値 | ||
★引数nの値 | ||
… | ||
呼出元のスタックベース |
★で示したのがその場所ですが、つまりは「引数nの値」のことのようです。以上から、この比較命令は、Cのソースコード「if(n==0)」に相当します。
ADD命令
n==0が成立した場合、こちらの処理が行われます。1a: 8b 45 fc mov -0x4(%ebp),%eax 1d: 8b 55 f8 mov -0x8(%ebp),%edx 20: 01 d0 add %edx,%eax 22: 89 45 f4 mov %eax,-0xc(%ebp) 25: eb 0a jmp 31 <func+0x31>
1〜2行目にある「ベースポインタから-4バイト/-8バイトの領域に格納された値」…とは、自動変数に代入された0x2/0x03(以下図★印)のことですね。
ebp | Stack | esp |
---|---|---|
(空き) | ← | |
(空き) | ||
★0x2 | ||
★0x3 | ||
→ | 旧ebpの値 | |
呼出元でのeipの値 | ||
引数nの値 | ||
… | ||
呼出元のスタックベース |
1〜2行目で、0x2はeaxレジスタへ、0x3はedxレジスタへと、それぞれ格納しています。そして、3行目のADD命令により、「edx(0x3) + eax(0x2)」の結果(=0x5)が、eaxレジスタへ格納されています。4行目で、eaxレジスタの内容を、「ベースポインタから-0xCの領域」に格納しています。この時点でのスタックは、以下のようになります。
ebp | Stack | esp |
---|---|---|
(空き) | ← | |
★0x5 | ||
0x2 | ||
0x3 | ||
→ | 旧ebpの値 | |
呼出元でのeipの値 | ||
引数nの値 | ||
… | ||
呼出元のスタックベース |
5行目のJMP命令は無条件ジャンプ命令ですが、(n==0)が成立"しなかった"時のブロックを飛び越える、という意図でしょうね。
IMUL命令
n==0が成立しなかった場合、こちらの処理が行われます。27: 8b 45 f8 mov -0x8(%ebp),%eax 2a: 0f af 45 fc imul -0x4(%ebp),%eax 2e: 89 45 f4 mov %eax,-0xc(%ebp)
1.「ベースポインタから-0x8バイト先の領域にある値(★)」をeaxレジスタに代入
2.「ベースポインタから-0x4バイト先の領域にある値(☆)」とeaxレジスタの値を掛け算(IMUL命令)した結果を、eaxレジスタに格納する。
3.eaxレジスタの値を、「ベースポインタから-0xCバイト先の領域」に格納する。
…ということですね。つまり、2x3の結果(0x6)を、冒頭で空けた空間(●)に突っ込んでいます。
ebp | Stack | esp |
---|---|---|
(空き) | ← | |
●0x6 | ||
★0x2 | ||
☆0x3 | ||
→ | 旧ebpの値 | |
呼出元でのeipの値 | ||
引数nの値 | ||
… | ||
呼出元のスタックベース |
ちなみにIMUL命令とは、「符号つき乗算を行う命令」とのことです。
リターン処理
ここでは、復帰値をセットして、関数の呼出元に復帰します。Cのソースでは、「return ret;」に相当します。31: 8b 45 f4 mov -0xc(%ebp),%eax 34: c9 leave 35: c3 ret
くどいですが、1行目の「ベースポインタから-0xCバイト先の領域にある値」とは、条件分岐の各ブロック内で格納された計算結果(2+3=5または2*3=6のいずれか)ですね。それを、復帰値を格納するレジスタである、eaxレジスタに格納します。
ebp | Stack | esp |
---|---|---|
(空き) | ← | |
★0x5または0x6 | ||
0x2 | ||
0x3 | ||
→ | 旧ebpの値 | |
呼出元でのeipの値 | ||
引数nの値 | ||
… | ||
呼出元のスタックベース |
あとは、leave命令で、今回使ったスタックフレームを開放し、ret命令で呼出元にジャンプします。
LEAVE命令については、Intelのマニュアルでは、以下のように定義されています。
6.5.2. LEAVE 命令
LEAVE命令は、直前の ENTER命令と逆の動作を行う。LEAVE命令はオペランドは持たず、EBPレジスタの内容を ESPレジスタにコピーし、プロシージャに割り当てられたスタック空間すべてを開放する。次に、スタックから EBPレジスタの古い値をリストアする。このとき同時に、ESPレジスタも元の値にリストアされる。したがって、LEAVE命令の後で RET命令を使用すれば、プロシージャで使用するためにコール元プログラム上でスタックにプッシュしておいた引き数とリターンアドレスを削除できる。(『IA-32 インテルアーキテクチャ・ソフトウェア・デベロッパーズマニュアル上巻』
「6.5. ブロック構造言語でのプロシージャ・コール」より引用)
アセンブリコードで表現すると、こういうことでしょうか。
mov %ebp,%esp pop %ebp
最初のMOV命令で、スタックポインタが★の位置に移動して、
ebp | Stack | esp |
---|---|---|
(空き) | ||
0x5または0x6 | ||
0x2 | ||
0x3 | ||
→ | 旧ebpの値 | ←★ |
呼出元でのeipの値 | ||
引数nの値 | ||
… | ||
呼出元のスタックベース |
次のPOP命令で、スタックポインタが指す領域の値(旧ebpの値)を取り出して、ebpレジスタにセットするため、ベースポインタは☆の位置に移動します。また、スタックポインタは、★の位置に下がります。
ebp | Stack | esp |
---|---|---|
(空き) | ||
0x5または0x6 | ||
0x2 | ||
0x3 | ||
旧ebpの値 | ||
呼出元でのeipの値 | ←★ | |
引数nの値 | ||
… | ||
☆→ | 呼出元のスタックベース |
となりますね。これで、関数呼出直後のスタックの状態に戻りました。この状態でRET命令を実行すると、「呼出元のeipの値」がPOPされ、インストラクションポインタ(eip。次に実行される命令が格納される)に格納される。これが、「呼出元に復帰する」ということですね。
最終的に、スタックは以下のようになります。
ebp | Stack | esp |
---|---|---|
(空き) | ||
0x5または0x6 | ||
0x2 | ||
0x3 | ||
旧ebpの値 | ||
呼出元でのeipの値 | ||
引数nの値 | ←★ | |
… | ||
→ | 呼出元のスタックベース |