x86アセンブリ言語での関数コール

はじめに

JavaScriptやC#などの高級言語であれば、

func(2, 4);

のように書けば関数を呼び出せるので、関数の呼び出しについてわざわざ説明するまでもありません。

ですが、アセンブリ言語では、そのような簡単な書き方はできません。 引数の準備をしたり、戻り値の渡し方を決めたりする必要があります。

このページでの目標は、アセンブリ言語で関数を読み書きできるようになることです。

まず引数の格納に使われるスタックについて復習してから、callret命令、ローカル変数の確保、leave命令、 関数呼び出しのルールである呼出規約について説明します。

説明の途中に、ブラウザで動く簡易アセンブラがあるので、ステップ実行してみてください。 スタックの内容がビジュアルに表示されるので、言葉だけの説明より理解しやすいと思います。

アセンブリ言語の表記には、Intel記法とAT&T記法がありますが、ここではIntel記法を使います。 コードは32ビットの環境であるとします。

関数呼び出しに必要な命令

スタック(PUSH・POP命令)

スタック は、一時的なデータの保存に使われるメモリ領域です。引数やローカル変数の保持に使われます。

英語のスタック(stack)には、「積み重ねる」「(干し草・書類などの)積み重ね」などの意味があります。 コンピュータのスタックも同じように、データを積み重ねたものです。

スタックに対してできる操作は、PUSHPOPの2つです。 PUSHでは、データをスタックに積み(スタックではデータを格納することを「積む」ともいう)、POPでは、データを取り出します。

スタックの特徴は、データを取り出すと、一番最後に格納したデータが取り出されることです。

言葉で説明するよりも、実際に動かしてみたほうが理解できるでしょうから、以下の簡易アセンブラでステップ実行してみてください。

「ステップ実行」ボタンを押すとアセンブリ言語のステップ実行がされ、「リセット」ボタンを押すとレジスタやメモリの内容がリセットされます。 プログラムの最後まで行って、「ステップ実行」ボタンを押しても動かない状態になったときに、「リセット」ボタンを押すと、 もう一度、最初からプログラムを実行することができます。

「レジスタ」欄には、このプログラムで使用するレジスタが表示されます ( レジスタ とは、CPU内の高速な保存領域です。高速ですが高価なので数は少ないです)。 必要ないレジスタは表示してません。

「.text」欄には、アセンブリ言語のプログラムが表示されます。

「スタック」欄には、スタックとして使用しているメモリの一部の領域が表示されます。

最初のPUSH命令で数値の1をスタックに積んでいます。 引数がデータだけであることに注意してください。 メモリの格納場所を示すアドレスは指定していません。 スタックでは、ESPレジスタでデータの格納場所が決まるのでアドレスを指定する必要がないのです。 上の例ではスタック欄にESPが矢印で表示されています。

ESP(Extended Stack Pointer) はスタックの一番上にあるデータを常に指しています。 なので、PUSH命令は以下の処理がされます。

  1. ESPをデータサイズ分だけ減らす
  2. ESPが指す場所にデータを格納する

データが積まれるほど、ESPは小さくなります。

POP命令は、以下のように逆の処理がされます。

  1. ESPが指す場所のデータを取り出す
  2. ESPをデータサイズ分だけ増やす

CALL・RET命令

CALL命令で、関数を呼び出すことができます。 やっていることは単純で以下のようにPUSHJMPの組み合わせと同じ処理をします。

  1. CALL命令の次のアドレスをPUSHする
  2. 呼び出す関数のアドレスへJMPする

最初にCALLの次のアドレスをスタックに積んでいるのは、呼び出した関数から戻る場所を記憶するためです。

呼び出された関数では、RET命令で呼び出し元に戻ることができます。 RET命令の処理は、以下のようにPOPJMPの組み合わせです。

  1. POPして戻り先のアドレスを取得する
  2. POPしたアドレスへJMPする

それでは、実際に引数も戻り値もない関数を呼び出してみましょう。

hltはCPUを休ませる命令です。nopは何もしないをする命令です。

start関数からfoo関数を呼び出しています。foo関数は何もしない関数です。 何か処理をするならnopの代わりに他の命令が並びます。

CALLを呼び出したあとに、CALLの次のアドレスである0x0105がスタックに積まれていることに注目してください。 RET命令が実行されるとこのアドレスへ戻ります。

戻り値を受け取る

引数なし、戻り値なしの関数の次は、戻り値を受け取る関数を呼び出してみましょう。

戻り値はEAXレジスタでやりとりすることにします。

nopmov eax, 2に変わっただけで前回のとほぼ同じです。 関数呼び出し後にEAXの値が2になっていることに注目してください。 これがpeace関数の戻り値です。

今回は、説明のために書いているので戻り値を使わずにそのままhltで停止しています。

引数を渡す

引数を1つ渡す

次は、関数に引数を1つ渡してみましょう。

関数名はplus1で、受け取った引数にプラス1してEAXに格納して返します。

引数は、レジスタで渡すこともできますが、今回はスタックを使って渡すことにします。 x86はレジスタが少ないのでスタックで引数をやりとりするのが普通だからです。

スタックから引数を受け取る方法はアセンブリ言語でいろいろな書き方ができますが、下の例では通常のコンパイラが出力するのと同じような方法で書いています。

最初は、複雑に見えるかもしれません。下の解説を読みながらステップ実行で動作を確かめてください。

最初の行は、引数をスタックに積むためのPUSH命令です。 次のcall命令でplus1関数を呼び出します。

ステップ実行で、plus1関数の先頭の命令(push ebp)まで実行してみてください。 スタックの内容は、下から引数1とcallの戻り先アドレスになります。

plus1は、引数に1を足したいので、まず引数をEAXレジスタに入れてみましょう。 なぜ、EAXレジスタに引数に入れるかというと、スタックの内容に直接足し算できないからです。

plus1の中から引数を取得するには、アドレス位置ESP+4のメモリから内容を取り出す必要があります。 しかし、ここで以下のような問題があります。

[ESP+4]でメモリアクセスする場合の問題点>

最初の問題は、関数内でPUSHCALL命令を使うとESPの位置変わってしまうことです。 たとえば、関数の先頭では、1番目の引数のアドレスはESP+4だったのに、PUSHをした後だとESP-4されるので、 引数1のアドレスはESP+8に変わってしまいます。 コンパイラが自動で計算するのならこの方法でできなくもないです。 しかし、デバッグでアセンブリ言語を読まないといけないなら同じ引数が、使われる場所によって「ESP+4」になったり「ESP+8」になったりしていては大変です。

<plus1関数 1~2行目>

push ebp
mov ebp, esp

この2つの問題を回避するため、通常、ESPEBPにコピーするという方法が使われます。 これなら、mov eax, [ebp + 4]のようにして引数を取得できるからです。 それに、ESPが変わってもEBPからの引数1の位置は変わりません。 あとで説明しますが関数の作り方のルールがあって、それによるとEBPレジスタの内容は関数呼び出しの前と後で値が同じでなければいけません。 つまり、「plus1関数を呼び出したらEBPの内容が変わってた」ということがあってはいけないということです。 なので、ESPEBPにコピーする前に、EBPPUSHします。 これらが上の例でplus1関数の先頭2行でしていることです。

<plus1関数 3~4行目>

mov eax, [ebp + 8]
add eax, 1

次の行で実際に引数をEAXに代入しています。 EBP + 8で引数1にアクセスしていますが、+8は退避したEBP(4バイト)とcall戻り先(4バイト)を飛び越すためです。 その次の行でEAX1を足して戻り値とします。

plus1関数 5~6行目>

mov esp, ebp
pop ebp

これは、plus1関数の1~2行目の逆の操作です。ESPEBPを元に戻します。

start関数 3行目>

add esp, 4

retで戻ってきた状態だと、スタックにはまだ引数が積まれたままです。 この引数をスタックから取り除きます。 POPを実行すれば取り除けますが、ここでは同じ効果があるadd esp, 4という命令を実行しています。 add esp, 4ESP自体に対する演算はできます。 この方法は、スタックの内容をいくつか捨てる場合に使われます。 このやり方の利点は、引数が2つ以上でもいっきにスタックの内容を破棄することができることです。 たとえば、引数を3つ捨てる場合はadd esp, 12とします。 POPを3回実行するよりも簡単で速くなります。

<引数の後始末はどっちがするのか?>

今回は、関数を呼び出した側で引数の後始末(スタックにある引数を取り除く)をしましたが、関数を呼び出された側で引数の後始末をすることもできます。 どちらでするかは、あとで説明する「呼出規約」というルールで決まっています。

引数を2つ渡す

次は、関数に引数を2つ渡してみましょう。

関数名はmyaddで、受け取った引数2つを足しあわせてEAXに格納して返します。

引数をスタックに積む順番は、引数2、引数1という順番で積みました。これは、あとで説明するCDECLというルールに従ったやり方です。

start関数4行目のadd esp, 8によってスタックに積んである2つの引数を破棄しています。

ローカル変数の確保

関数呼び出しとは関係ありませんが、ここでローカル変数の確保の仕方を見ておきましょう。

ローカル変数も引数と同じようにスタックへ積まれます。

実際に動かしながら見てみましょう。

foo関数は、引数を変数に代入するだけの意味のないものです。

foo関数の3行目を見てください。sub esp, 4ESP4減ることにより、スタック領域が4バイト確保されます。 これが変数を格納する場所になります。 push 0としても、0に初期化されることを除けば効果は同じです。 ですが、ESPを直接引くやり方のほうは、たとえばsub esp, 12とすることで3つの変数を一度に確保することができます。

変数の参照はアドレスebp - 4にアクセスすることでできます。引数の参照はebpに加算していましたが、変数の参照は減算になります。

enterとleave

デバッグや解析のためにアセンブラのリストを見ることがあるかもしれませんが、そのときに関数がretする前にleave命令があるのを見つけるかもしれません。

leave命令は、以下の2つの命令を組み合わせたのと同等の処理をします。

mov esp, ebp
pop ebp

どこかで見たことありませんか?

以前の例で、RETで戻る前にしていた処理と同じです。

この2つの命令の組み合わせはよくされるので、leaveという1つの命令ができたのでしょう。 leaveは1982年に作られたCPU「80186/80188」から導入されたようです。

leaveの逆をするenter命令というのもあります。 以下の命令と同じような処理をします。

push ebp
mov ebp, esp
sub esp, N

Nは確保する変数のサイズです。 enterはパフォーマンス上の問題からコンパイラの出力ではあまり見かけないと思います。

以降の例では、何をやっているのかわかりやすくするため、enterleaveも使わないことにします。

呼出規約

他の人が作った関数を使わず、全部自分で作るのなら、関数への引数の渡し方や戻り値の受け取り方を自由に決めることができます。

しかし、他の人が作った関数とやりとりする場合には、事前にどのように引数を渡すのかなどを決めておかなければなりません。 そのルールを呼出規約といいます。

呼出規約は、主に以下の3つについてのルールを決めます。

それぞれのルールは以下の様なことを決めます。

引数に関するルール

戻り値に関するルール

レジスタ値保持に関するルール

呼び出し規約には、いくつかの種類があります。 詳しいことは、呼出規約をみてもらうことにして、 ここでは2つの主要な呼出規約である、CDECLとSTDCALLについて述べます。

CDECL

x86のC、C++コンパイラで、使われることが多い規約です。いままでの例で使っていたのは、この規約です。 なのであまり説明することはありません。 関数の呼び出し前と後で、EAX, ECX, EDXレジスタの内容が違ってもOKです。 それ以外のレジスタを変更する場合は、前の例でEBPを退避・復元したのと同じように、使用するレジスタを退避・復元する必要があります。

まとめると以下のようになります。

STDCALL

Windows APIで標準的に使われる規約です。 ほとんどCDECLと同じですが、引数の後始末を、呼び出された関数側でするという違いがあります。

以下は「引数を2つ渡す」で使用した例を、STDCALLに書きなおしたものです。

違いは、start関数内でmyadd呼び出し後の引数後始末を削除したことと、ret命令のあとに8という引数を渡していることです。

RETに引数を与えると、呼び出し元に戻ると同時に、スタックを引数で指定されたバイト分だけ加算します。 なので、以下の命令と同じ処理になります。

ret
add esp, 8

実践

実際に、コンパイラが出力するアセンブリを見てみるとより理解が深まるかもしれません。 たとえば、gccにアセンブリ言語で出力させる場合は以下のようなコマンドを打ちます。

gcc -S -fverbose-asm -masm=intel -o test.s test.c

またはディスアセンブラを使って実際の実行ファイルを覗いてみるのもいいでしょう。