14日目: [x86] 関数呼出
この記事はひとりでCPUとエミュレータとコンパイラを作る Advent Calendar 2017の14日目の記事です。
今日は関数をやる。
関数呼出と聞くと高度で抽象的な処理を彷彿させるが、決してそんなことはない。 x86パートに入る前にFPGAでCPUを自作したが、実はあのショボいCPUでも関数は実装可能だ。 ジャンプ命令とスタックメモリさえあれば、関数はつくれる。
今日の目的は、Cのような高級言語における関数が機械語レベルでどのように実装されているのか理解し、call
、ret
、leave
命令を使って関数が書けるようになることだ。
関数の意義
そもそも、なぜ関数が必要なのか考えたい。 C言語で関数を使うメリットとして簡単に思いつくのは「可読性の向上」とか「モジュールの再利用性を高める」だと思う。 しかしそうした目的で関数を使う場合、代わりにコードをコピペしても動作するだろう。極論、関数は不要だ。
関数の重要な点は再帰呼出しにあると(僕は勝手に)思う。
「ある数字a
を再帰呼出しで求める。この処理をループに変形するには、a
の値をあらかじめ知っておく必要がある」
みたいな状況では、再帰関数を使わないと計算のしようがない。 そういう背景があるので、エミュレータとコンパイラが完成した暁には、フィボナッチ数を再帰呼び出しで計算するプログラムを動かす予定だ。 ちなみに4日ほど前にCPUを作った際も、最後にフィボナッチ数を計算したが、あれは再帰呼出しではなく「前回と前々回の値を保存」する方式で計算していた。
他のメリットとしては、関数を使えば実行ファイルのサイズが小さくなる(ことがある)。 デメリットとしては、関数を使わなかった場合と比べて実行速度が遅くなる(ことがある)1。
引数も返値もない関数
本題に移る。引数も返値もないC言語の関数を考える。
void FUNC(void) { いろいろやる; }
この関数の呼び出しを機械語で実装したいのだが、絵で説明すると
どうだゴチャゴチャしているだろう。 そしてさらにゴチャゴチャした話が今から展開される。 そこでまず太字の部分だけ読んで、流れを掴んで欲しい。
図の見方だが、上部が関数の呼出元で、下部が関数の本体に対応している。
次にニーモニックの列を見て欲しのだが、push
とpop
とmov
とjmp
命令しかない。
たった4種類の命令だけで関数は実現できる。
最上段を見ると、esp
とebp
のレジスタにはS
とB
が入っており、スタックメモリには----
が入っている。
esp
はスタックポインタなので、その値は----
の左端のメモリアドレスに一致している。
今から関数を使ってゴチャゴチャと処理を行うわけだが、 最終的に現在(関数呼出前)のespとebpとスタックメモリを復元する義務がある。
次にpush NEXT
という命令を実行すると、
esp=S-4
、ebp=B
となり、スタックにNEXT
の位置のメモリアドレスが入る。
スタックはpush
されると負の方向に伸びていくことを思い出してくれ。
重要なのは、このpush
命令は関数から帰還する場所をメモ (call)するための処理という点だ。
そして次の命令でFUNC
の位置にジャンプ (call)する。関数の中に入った。
FUNC
のラベルに移動したらpush ebp
が実行され、続いてmov ebp, esp
が実行される。
この2つの命令は、計算前のebpとespの値をメモするための処理である。
esp
の値をebp
にメモし、ebp
の値をスタックメモリ上にメモしたわけだ。
今後スタックに値を追加してesp
の値を変えたとしても、ebp
に書かれたスタックアドレスの情報は保持されるので、
復元の義務を果たすことができる。
レジスタとスタックの復元準備が整ったので、心置きなく計算できる。
スタックにガンガン値を追加して、レジスタも使って計算を実行しよう。
ただしebp
の値を変更すると元の状態を復元できなくなるので、変えてはならない。
計算を終えたら、まずmov esp, ebp
を実行してespの値を計算前に戻す (leave)。
この操作により、計算でグチャグチャになった(図中の????
の)スタックメモリを無視することができる。
次にpop ebp
を実行して、ebpの値を関数呼出前に戻す (leave)。
最後にpop eip
を実行してプログラムカウンタにNEXT
を入れることで、関数の呼出元に帰還しつつ、espの値が関数呼出前に戻る (ret)。
話をまとめると、以下のようなアセンブラコードを書けば関数呼出が実現できる。
call FUNC ; 帰還するアドレスをメモしてFUNCにジャンプ ... ; いずれここに帰還 ... ... FUNC: push ebp ; 呼出時のebpをメモ mov ebp, esp ; 計算前のespをメモ いろいろやる leave ; espを計算前に戻し、ebpを呼出時に戻す ret ; 帰還し、espを呼出時に戻す
引数と返値の処理
ここまで引数返値なしの関数を見てきたが、次は
int function(int a, int b, int c) { return a+b+c; }
という関数を考えたい。さっきと違って引数と返値がついている。
この関数を実現するには
push cの値 ; 3番目の引数をスタックに詰む push bの値 ; 2番目の引数をスタックに詰む push aの値 ; 1番目の引数をスタックに詰む call FUNC ; 帰還するアドレスをメモしてFUNCにジャンプ ... ; いずれここに帰還 ... ; 関数の返値はeaxに入る ... FUNC: push ebp ; 呼出時のebpをメモ mov ebp, esp ; 計算前のespをメモ a+b+cを頑張って計算 引数aの値は[ebp+8] で参照 引数aの値は[ebp+12]で参照 引数aの値は[ebp+16]で参照 計算結果をeaxに入れる leave ; espを計算前に戻し、ebpを呼出時に戻す ret ; 帰還し、espを呼出時に戻す
コードの1行目を見れば分かる通り、引数はスタックに積んで関数に渡す。順番には少し注意がいる。
注意してほしいのは、espとebpとスタックメモリは呼出前の状態に戻さないといけないが、eaxは変更されてもよいので、返値をeax
のレジスタに入れて呼び出し元に渡している。
値渡しと参照渡し
C言語で関数を呼び出す際に、「値渡し」と「参照渡し」がある。
int main(void) { int a = 1; hoge(a); // 値渡し piyo(&a); // 参照渡し }
今日書いた説明に基づくと、両者の違いは関数をcall
する前に
渡し方 | スタックに入る値 | 呼出先での値の取得 |
---|---|---|
値渡し | 1 | a の値は[ebp+8] で取得 |
参照渡し | aのメモリアドレス | a のメモリアドレスは[ebp+8] で取得 |
となる。
配列int a[10]
を関数に渡す時は、参照渡しでa[10]
に対応するメモリ領域の最初のアドレスをスタックに積み、関数本体にジャンプしている。
C言語では構造体を値渡しで関数に渡すことができる。 この場合、構造体のメンバをかたっぱしからスタックに積みまくった後に、関数本体にジャンプしている。 つまり機械語レベルでは、構造体を値渡しする関数は引数を大量にとる関数とよく似ている。
引数の数が増えると、ジャンプの前に引数をpush
する回数が増えるので、実行速度は遅くなりがちだ2。
その他
ここで説明した関数の呼出規約はcdeclと呼ばれるもので、x86のC言語の標準的な呼出規約になっている。 他にも様々な呼出規約があるので、コレが唯一の正解というわけではない。
今日までで、一通り x86の命令セット を解説し終えた。