逆アセンブルで遊んでみる(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の値 ←★
呼出元のスタックベース