Stack Machineについて

  これからは、このインタープリターでつくったtiny Cについて、コンパイラを作っていくことにする。最終的には、マシンコードを直接出力するコンパイラを作るが、コード生成の考え方を簡単にするために、初回に紹介したスタックマシンをターゲットにする。スタックマシンではレジスタを扱わなくても良いため簡単になる。初回では単純な数式のコンパイルを考えたが、言語を実行するためにはインタプリターでやったように関数呼び出しやローカル変数をどのように作るかを考えなくてはならない。コンパイラのターゲットの仮想マシンの解説からはじめることにしよう。

仮想スタックマシンの命令

tiny Cのターゲットとして考えるマシンの命令は、以下の20個の命令である。

なお、この命令を持つ仮想マシンのインタプリタを作ってみた。st_machine.cを参照のこと。POPや、PUSHI, 演算ADD,SUBなどは、1回目の講義で解説した通り、スタックに値をセットしたり、演算したりする命令である。コンパイルでは、このスタックマシンのコードを使って、式を実行するコード列を作る。その手順は、

  1. 式が数字であれば、その数字をpushするコードを出す。
  2. 式は変数であれば、その値をpushするコードをだす。
  3. 式が演算であれば、左辺と右辺をコンパイルし、それぞれの結果をスタックにつむコードを出す。その後、演算子に対応したスタックマシンのコードを出す。

さて、変数はパラメータや局所変数があるが、どこからとってくればいいのであろうか。(ここでは、大域変数は考えないことにする。)

関数呼び出しの構造

式だけを評価するならば、これでいいが、関数が使えるようにするためには、スタックの使い方を工夫しなくてはならない。スタックマシンは以下の3つのレジスタを持つ。

関数の呼び出しの手順は、以下のようにする。

  1. スタック上に引数を積む。
  2. 現在のPCの次のアドレスをスタック上に保存(push)し、関数の先頭のアドレスにjumpする。(CALL命令)
  3. 現在のFPをスタック上に保存し(push)し、ここを新たなFPとする。FPから、上の部分を局所変数の領域を確保し、ここを新たなスタックの先頭にする。(FRAME命令)
  4. 式の評価のためのstackはここから始まる。
  5. 引数にアクセスするためには、FPから2つ離れたところにあるので、ここからとればよい。(LOADA/STOREA命令)
  6. 局所変数にアクセスするためには、FPの上にあるので、FPを基準にしてアクセスする。(LOADL/STOREL命令)
  7. 関数から帰る場合には、stackに積まれている値を戻り値にする。元の関数に戻るためには、FPのところにSPを戻して、まず、前のFPを戻して、次に戻りアドレスを取り出して、そこにjumpすればよい。(RET命令)
  8. 戻ったら、引数の部分をpopして、関数の戻り値をpushしておく。(POPR命令)

このような構造を、関数フレームと呼ぶ。このような規則を関数のリンク規則(linkage convensionあるいはcalling sequence)とよび、各マシンごとに定められている。

さて、関数定義に対するコードは以下のようになる。

  ENTRY foo

FRAME ローカル変数の個数

  .... 関数本体のコード....

RET

また、関数呼び出しは、

  引数1のpush ...

引数2push ...

....

CALL foo

POPR pushした引数の個数

関数のコンパイル(CompileFuncDef)は、以下のようになる。

  1. まず関数の名前を取り出して、ENTRY funcを生成する。
  2. パラメータ変数に番号をつける。関数が呼ばれた場合にはこの順番でスタックに積まれていることになる。これをEnvをいれておく。
  3. 関数の本体をコンパイルする。
  4. 実行されると関数の本体の値がスタックに積まれているはずなので、ここでRET命令を生成する。

 パラメータの変数や局所変数は、スタック上にその領域が確保されるが、どこに確保されるかを数えておかなくてはならない。この変数がどこに割り当てられているかを覚えておくために、インタプリータで使った環境Envと同じようなデータ構造をつかう。コンパイラでは、Envでコンパイルしているときにどの変数がスタック上のどこに割り当てられているかを覚えておく。パラメータについては、パラメータの何番目かについて、Envに登録しておく。

 関数の本体にblock文(Cの{}にあたる)がある場合には、局所変数が定義される可能性がある。block文をコンパイルするときには、local_var_posという変数を使って数えて、これでスタック上の何番目に割り当てるかを決める。本体のコンパイルが終わると、局所変数が何個合ったかがわかるので、これを使って関数の先頭で、FRAME命令を生成しなくてはならない。そのため、生成されたコードを配列(Codes)にとっておき、ENTRY命令の後にFRAME命令を生成し、とっておいた残りの命令を出力する。

 関数呼び出しのコンパイル(compileFuncCall)は、引数をスタックに積んで、CALL命令を出す。引数をスタックに積むのは、式の実行が終わるとスタック上につまれるはずなので、単に引数をコンパイルすればよい。その後に、CALL命令を生成し、その後で、引数をスタックからpopして、結果をpushする命令POPR命令を生成しておく。スタックに積む順番は、引数の最後からなので、引数の最後からコンパイルしなくてはならないことを注意しよう(CompileArgs, この関数は同時に引数の個数を数えている)。

局所変数のコンパイル

 block文では、局所変数が宣言されることがあるが、以下のようにしてコンパイルする(comileBlock)。

  1. 局所変数について、どこに割り当てるかを決める。割り当てるスタック上の場所の番号をつけるための数えている変数が、local_var_posである。割り当てるときには、local_var_posを1つ加えてこの値がスタック上の局所変数の位置になる。
  2. これがきまったら、変数のシンボルとこのスタック上をペアにして、Envに登録しておく。
  3. blockの本体をコンパイルする。各文に対応する式をコンパイルしたコードは、実行されると結果をスタック上に置くので、途中の文(式)に関しては、結果をPOPしておくために、POP命令を生成しておかなくてはならない。最後の文はblock文の値になるので、POP命令は入れない。
  4. 本体のコンパイル中に局所変数が現れて場合には、Envを探して、どこに割り当てられているかによって、LOADA/LOADL/STOREA/STOREL命令を生成する。
  5. blockの全部のコンパイルが終わったら、局所変数について変化させたEnvを元にもどしておく。これによって、局所変数に使われた領域は参照されなくなる。

なお、代入文(式)も、値を持つ。したがって、STOREA/STORELはスタックのtopの値を変数(の位置)に格納するだけで、値はそのままスタックに残しておいていることに注意。

制御文のコンパイル

JUMP命令は、LABEL文で示されたところに制御を移す命令である。このスタックマシンは分岐命令は、BEQ0命令しかない。この命令は、スタック上の値をpopして、これが0だったら、分岐する命令である。これを組みああわせてIF文をコンパイルする。コードは次のようになる。

  ...条件文のコード...

  BEQ0 L0 /* もし、条件文が実行されて、結果が0だったら,Lに分岐*/

...thenの部分のコード...

JUMP L1

LABEL L0

... elseの部分のコード...

LABEL L1

IF文のコンパイルは以下のようになる。

  1. 条件式の部分のコンパイルする。これが実行されるスタック上には、条件式の結果が積まれているはずである。
  2. ラベルL0を作って、BEQ L0を生成。
  3. then部分の式をコンパイルする。
  4. これが終わるとIF文を終わるため、ラベルL1を作って、ここにJUMPする命令を生成する。
  5. 条件文が0だったときに実行するコードを生成する前に、LABEL L0を生成する。
  6. else部の式をコンパイル。
  7. then部の実行が終わったときに飛ぶ先L1をここにおいておく。

なお、else部がないときには、IF文の値が0とするために、PUSHI 0をおいておかなくてはならないことを注意。

 return文のコンパイルは

  1. 式をコンパイル。結果は、スタック上に結果が残るはずである。
  2. これで、RET命令を生成する。

だけで、よい。

 課題で、WHILE文を考えてみよう。

出力文のprintlnは、ちょっと変則なので、特別に扱ってある。

コンパイラとスタックマシンの実行

 さて、web上にあるプログラムをコンパイルするとコンパイラtiny_ccとスタックマシンのインタプリターst_machineができる。tiny_ccは、標準入力から呼んで、コンパイルの結果のコードを標準出力に出力するようになっている。例えば、プログラムfoo.cをコンパイルして、コードfoo.iを作るには、

 %tiny_cc < foo.c > foo.i

とすればよい。st_machineもコードは標準入力から読むようになっているので、

 %st_machine < foo.i

とすればよい。もしも、連続して動かす場合には、

 %tiny_cc < foo.c | st_machine

としてもよい。

演習課題7:

 www上においてあるtiny Cのコンパイラではwhile文をわざと抜いてある。while文をコンパイルする部分を付け加え、以下のプログラム(fac.c)をコンパイルできるようにし、実行せよ。提出は、修正したところのみでよい。

 余裕のある人は、for文のコンパイルを考えてみよ。

main()

{

println("fac is %d", fac(10));

}

fac(n){

var i,s;

i = 1;

s = 1;

while(i < n){

s = s * i;

i = i + 1;

}

return s;

}