Programming Field

スタック

このページでは、アセンブリ(機械語)を使う上で重要な「スタック」について扱います。「スタック」自体はアセンブリに限らず、「先入れ後出し」(First In Last Out; FILO) あるいは「後入れ先出し」(Last In First Out; LIFO) の仕組みを持つもの全体を指しますが、ここでは push / popcall / retesp レジスタを中心としたアセンブリ プログラミングで使うスタックについて記述します。

なお、以下では Intel 系の 32 ビットプロセッサ(x86)を想定しています。

スタックの概要

変更前
-14H-10H-0CH-08H-04H+00H+04H
00000address0
1. 「mov eax, 123h」→「push eax」
-14H-10H-0CH-08H-04H+00H+04H
00000x123address0
2. 「mov ecx, 456h」→「push ecx」
-14H-10H-0CH-08H-04H+00H+04H
0000x4560x123address0
3. 「pop ebx」
-14H-10H-0CH-08H-04H+00H+04H
0000x4560x123address0
(ebx ← 0x456)
4. 「pop edx」
-14H-10H-0CH-08H-04H+00H+04H
0000x4560x123address0
(edx ← 0x123)
5. 「mov ecx, 408142h」→「call ecx」
(call は 0x00404053 にある命令)
-14H-10H-0CH-08H-04H+00H+04H
0000x4560x00404055address0
(eip ← 0x00408142)
6. 「mov eax, dword ptr[esp]」
(mov は 0x00408142 にある命令)
-14H-10H-0CH-08H-04H+00H+04H
0000x4560x00404055address0
(eax ← 0x00404055)
7. 「ret」
-14H-10H-0CH-08H-04H+00H+04H
0000x4560x00404055address0
(eip ← 0x00404055)
8. 「sub esp, 4」
-14H-10H-0CH-08H-04H+00H+04H
0000x4560x00404055address0
9. 「pop ecx」
-14H-10H-0CH-08H-04H+00H+04H
0000x4560x00404055address0
(ecx ← 0x00404055)
10. 「sub esp, 8」
-14H-10H-0CH-08H-04H+00H+04H
0000x4560x00404055address0
11. 「mov eax, 789h」
→「mov dword ptr[esp], eax」
-14H-10H-0CH-08H-04H+00H+04H
0000x7890x00404055address0
11'. 「mov eax, 0ABCh」
→「mov dword ptr[esp + 4], eax」
-14H-10H-0CH-08H-04H+00H+04H
0000x7890xABCaddress0
12. 「mov ecx, 408180h」→「call ecx」
(call は 0x00404072 にある命令)
-14H-10H-0CH-08H-04H+00H+04H
000x004040740x7890xABCaddress0
(eip ← 0x00408180)
13. 「ret」
(ret は 0x00408180 にある命令)
-14H-10H-0CH-08H-04H+00H+04H
000x004040740x7890xABCaddress0
(eip ← 0x00404074)
14. 「add esp, 8」
-14H-10H-0CH-08H-04H+00H+04H
000x004040740x7890xABCaddress0
15. 「ret」
-14H-10H-0CH-08H-04H+00H+04H
000x004040740x7890xABCaddress0
(eip ← address)

スタックとは、プログラムごとに割り当てられるメモリ領域の一つで、関数の呼び出しや関数内である程度のサイズのデータを扱う際、この領域を利用してデータをキープしたり、関数にデータを渡したりします。スタックを扱うために esp レジスタというものがあり、このレジスタには現在スタックがどのアドレスを指しているかを保持しています。スタックにデータを入れるには、形式的には push / pop をして行います。push をすると現在のスタック位置(esp)がマイナスされ、その位置にデータが書き込まれます。逆に pop するとその位置のデータが読み込まれ、スタック位置は増えます。

右の表(図)は、push / pop 命令や、call / ret 命令でスタックにどのような変化が起こるかを流れで示しています。黄色の背景の部分は現在のスタック位置(esp が保持しているアドレス)を表しています(最初はスタック位置が 00H を指しており、マイナスのアドレスが存在するとしています)。この一連の流れは1つの関数であると仮定し、+00H の位置の「address」はその関数を呼び出した元のアドレスを指しています。

例として、右の表に対応する以下のようなアセンブリコードを想定します。太字は esp レジスタの値を変更している命令です。

; 手順 1
  ; at 0x00404040
    mov   eax, 123h
    push  eax
; 手順 2
    mov   ecx, 456h
    push  ecx
; 手順 3
    pop   ebx
; 手順 4
    pop   edx
; 手順 5
    mov   ecx, 408142h
  ; at 0x00404053
    call  ecx
; 手順 8
  ; at 0x00404055
    sub   esp, 4
; 手順 9
    pop   ecx
; 手順 10
    sub   esp, 8
; 手順 11
    mov   eax, 789h
    mov   dword ptr[esp], eax
    mov   eax, 0ABCh
    mov   dword ptr[esp + 4], eax
; 手順 12
    mov   ecx, 408180h
  ; at 0x00404072
    call  ecx
; 手順 14
  ; at 0x00404074
    add   esp, 8
; 手順 15
    ret
      .
      .
      .
; 手順 6
  ; at 0x00408142
    mov   eax, dword ptr[esp]
; 手順 7
    ret
      .
      .
      .
; 手順 13
  ; at 0x00408180
    ret

まず、手順 1, 2 が実行されると、スタックには数値がキープされ、esp の値は減算されます。この状態で手順 3, 4 を実行すると、指定したレジスタに値が戻り、esp の値は加算されます。注意する点は、push先に esp の値を変えてからそのアドレスの位置に値を設定し、pop先に値を取り出してから esp の値を変更することです。

次に手順 5 では、call 命令で関数/プロシージャ(手続き)を呼び出していますが、実際の処理としては、呼び出し元のアドレス(※ 正しくは ret 命令の後に戻るべきアドレス)をスタックにキープしてから、指定したアドレスまたはレジスタの値(この例の場合は ecx の値)が指すアドレスに処理を移します。すると、esp の値が指すアドレスには関数を終えたときに戻るべきアドレスが入っているため、dword ptr[esp] とすることでそのアドレスを取得することが出来ます(手順 6)。関数を終えるには、ret 命令を使います(手順 7)。この命令は、現在のスタックからアドレスを取得して実行位置をそれに設定し、esp を加算します。callpush と対応させるなら、retpop と対応します。

esp の値は直接書き換えることが出来ます。例えば手順 8 では、スタックの位置のみを変更しています。この状態で pop を実行する(手順 9)と、そのスタックの位置に入っている値を取得することが出来ます。esp の値を直接変更する手順は、通常スタック上に一時的にデータをキープしたいときに、そのデータが書き換わらないように行います。例えば手順 10~14 では、sub で esp の値のみを変更し、add で戻しています。この間ではスタックの -04H、-08H の値は callpush で書き換わることが無いため、正しいプログラムであればその値をキープすることが出来ます(※ 正しくないプログラム…push していないのに pop している、など)。

このコードでは、最後に ret 命令で元のアドレス(address)に戻り、この関数を終了しています。

関数の引数と呼び出し規約

※ x64では関数呼び出しにおける引数の受け渡し方法がx86と異なります。

スタックは、関数を呼び出す際に引数を渡すためにも使用します。上述の通り、call 命令直後では、dword ptr[esp] を参照すると ret 後の実行アドレスが入っています。そこで、call の前に push を行うと、dword ptr[esp + 4] 以降にその値が入ることになるので、これを「関数の引数」として機能させることが出来ます。

例えば以下のコードでは、関数 MyFunc に 2 つの引数を渡しています。(MyFunc は cdecl 呼び出し規約で定義・コンパイルされた関数とします。)

    mov   eax, 8
    push  eax
    xor   eax, eax    ; eax = 0
    push  eax
    call  MyFunc
    add   esp, 8
    .
    .
    .
; 関数 MyFunc : void __cdecl MyFunc(int arg1, int arg2)
    .
    .
    .
    ret
変更前
-14H-10H-0CH-08H-04H+00H+04H
?????address?
MyFunc 呼び出し直前
-14H-10H-0CH-08H-04H+00H+04H
???08address?
MyFunc 呼び出し直後
-14H-10H-0CH-08H-04H+00H+04H
??return08address?
MyFunc から返った直後
-14H-10H-0CH-08H-04H+00H+04H
??return08address?

右の表はスタックの遷移を示しています。MyFunc 呼び出し直後では、esp が指すアドレスには関数から帰る先のアドレスを指していますが、esp + 4 (= -08H)、esp + 8 (= -04H) には関数を呼び出す前に push で積んだ(スタックに入れた)値が入っています。したがって、dword ptr[esp + 4] を第一引数、dword ptr[esp + 8] を第二引数として扱うことができます。

MyFunc が ret 命令で戻った直後は、esp は MyFunc を呼び出す直前の状態になります。このまま esp を放置しておくと、ret 命令が来たときに実行アドレスが address ではなく「0」となってしまうため、エラーが起きてしまいます。したがって、add 命令で esp の値を変更前の状態に戻す必要があります(pop でも OK)。

ここで、MyFunc は cdecl 呼び出し規約で定義・コンパイルされた関数としましたが、もし MyFunc の引数の数が分かっていて、頻繁に呼び出す関数である場合、add 命令の分コードのサイズが大きくなってしまい勿体無いので、標準 (stdcall) 呼び出し規約で定義することで若干サイズを減らすことが出来ます。

標準呼び出し規約を使ってみると、以下のようなコードになります。

    mov   eax, 8
    push  eax
    xor   eax, eax    ; eax = 0
    push  eax
    call  MyFunc2
    ; add 命令は不要
    .
    .
    .
; 関数 MyFunc2 : void __stdcall MyFunc2(int arg1, int arg2)
    .
    .
    .
    ret   8

変わった箇所は、MyFunc2 の ret 命令に数値が付いたことです。この数値は通常の ret の動作で戻るべきアドレスを取得して esp を変更した後、さらに esp に加算する数値を表しています。つまり、この数値が add 命令の代わりになっています。ちなみに、「add esp, 8」の命令は 3 バイト必要なのに対し、「ret 8」は 2 バイトで足りてしまいます。

関連項目

最終更新日: 2008/03/04