しかくいさんかく

解答略のメモ

14日目: [x86] 関数呼出

この記事はひとりでCPUとエミュレータとコンパイラを作る Advent Calendar 2017の14日目の記事です。

今日は関数をやる。

関数呼出と聞くと高度で抽象的な処理を彷彿させるが、決してそんなことはない。 x86パートに入る前にFPGAでCPUを自作したが、実はあのショボいCPUでも関数は実装可能だ。 ジャンプ命令とスタックメモリさえあれば、関数はつくれる。

今日の目的は、Cのような高級言語における関数が機械語レベルでどのように実装されているのか理解し、callretleave命令を使って関数が書けるようになることだ。

関数の意義

そもそも、なぜ関数が必要なのか考えたい。 C言語で関数を使うメリットとして簡単に思いつくのは「可読性の向上」とか「モジュールの再利用性を高める」だと思う。 しかしそうした目的で関数を使う場合、代わりにコードをコピペしても動作するだろう。極論、関数は不要だ。

関数の重要な点は再帰呼出しにあると(僕は勝手に)思う。

「ある数字a再帰呼出しで求める。この処理をループに変形するには、aの値をあらかじめ知っておく必要がある」

みたいな状況では、再帰関数を使わないと計算のしようがない。 そういう背景があるので、エミュレータコンパイラが完成した暁には、フィボナッチ数を再帰呼び出しで計算するプログラムを動かす予定だ。 ちなみに4日ほど前にCPUを作った際も、最後にフィボナッチ数を計算したが、あれは再帰呼出しではなく「前回と前々回の値を保存」する方式で計算していた。

他のメリットとしては、関数を使えば実行ファイルのサイズが小さくなる(ことがある)。 デメリットとしては、関数を使わなかった場合と比べて実行速度が遅くなる(ことがある)1

引数も返値もない関数

本題に移る。引数も返値もないC言語の関数を考える。

void FUNC(void) {
  いろいろやる;
}

この関数の呼び出しを機械語で実装したいのだが、絵で説明すると

f:id:kaitou_ryaku:20171212031332p:plain

どうだゴチャゴチャしているだろう。 そしてさらにゴチャゴチャした話が今から展開される。 そこでまず太字の部分だけ読んで、流れを掴んで欲しい

図の見方だが、上部が関数の呼出元で、下部が関数の本体に対応している。 次にニーモニックの列を見て欲しのだが、pushpopmovjmp命令しかない。 たった4種類の命令だけで関数は実現できる。

最上段を見ると、espebpレジスタにはSBが入っており、スタックメモリには----が入っている。 espはスタックポインタなので、その値は----の左端のメモリアドレスに一致している。

今から関数を使ってゴチャゴチャと処理を行うわけだが、 最終的に現在(関数呼出前)のespとebpとスタックメモリを復元する義務がある。

次にpush NEXTという命令を実行すると、 esp=S-4ebp=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と呼ばれるもので、x86C言語の標準的な呼出規約になっている。 他にも様々な呼出規約があるので、コレが唯一の正解というわけではない。

今日までで、一通り x86の命令セット を解説し終えた。

明日からは、いよいよx86エミュレータを作るぞ。


  1. 優秀なコンパイラが最適化すると、この辺のメリットデメリットの話は何とも言えなくなる。

  2. しかしそのへんはコンパイラの最適化が働き、引数の数が少なければスタックの代わりにレジスタに値を割り当てることがある。