レジスタのあるマシンへのコンパイラ

  前回は、スタックマシンにコンパイルする方法を解説した。今回は、実際のマシン、MIPS R5000へコンパイルすることにする。スタックマシンではコンパイラが作り安いマシンであるが、実際のマシンではレジスタがあり、これらを使ったコードを生成しなくてはならない。

MIPSプロセッサ

R5000については他の講義で学習していると思うが、簡単に解説しておく。

関数の呼び出し規則

 スタックマシンではコンパイラに都合が良いように呼び出し規則を考えたが、実際のマシンでは呼び出し規則は決められており、命令を組み合わせて行わなくてはならない。命令としては、次のように使う。

呼び出し側では、jal 命令を用いる。

  jal foo

ラベルfooにjumpした時には、$31に戻り番地が入る。従って、関数の先頭ではこの$31をフレームのどこかにとっておかなくてはならない。関数から戻るときに、この戻り番地をとりだし、元に戻る。

  foo: sw $31, フレームのどこか

     ... 本体 ....

lw $31, 格納したところ

      jr $31

また、このプロセッサの規則では4個までの引数はレジスタ$4-$7に入れて渡すことになっている。これ以上の引数がある場合には、スタックに積んで渡すが、ここでは4個までの引数のみにすることにする。他の関数を呼び出さない場合には、レジスタ上においておいても良いが、他の関数をさらに呼び出すときには、このレジスタをつかわなくてはならないので、通常は関数の先頭でスタック上に保存しておく。

 さて、フレームの構造は図のようにする。

スタックマシンの場合には、フレームポインターを使ったが、フレームのサイズはかわらないので、フレームポインターが必要なくなっていることに注意。

 さて、x = foo(1,2)と関数呼び出しをするコードは、以下のようになる。

  lw $4, 1

lw $5, 2

jal foo

sw $2,xのアドレス

関数の本体は、

  foo: subu $sp,$sp,フレームサイズ ; 関数フレームの確保

     sw $31,戻り番地の退避領域

     ... 引数の保存 ...

... 本体 ....

move $2, 関数の返り値

     lw $31,戻り番地の退避領域

     addu $sp,$sp,フレームサイズ ;関数フレームの開放

     jr $31

となる。

コンパイラの中間コード

 一般的に、コンパイラはコンパイラが作り安いように中間コードを設計し、構文解析によって得られた構文木を中間コードに変換する。ここで最適化などの解析を行い、最終的にマシンコードに変換する。中間コードを適当に設計することによって、実際のマシンから独立したものになり、いろいろなマシンに対応できるようにもなる。

 tiny Cのターゲットとして考える中間コードは、以下のコードである。

なお、このようにop dst,src1,src2というような形式のコードを、四つ組と呼ばれる。このほかに、命令に近い形に表現するRTL(Register Transfer Language)をいう形式もある。変数rといっているのは、いわゆる局所変数ではなく、レジスタが無限にあるとして考えた時の仮想的なレジスタというべきものである。コード生成のフェーズにおいて、実際のレジスタが割り当てられる。

中間コードへの変換

 さて、構文木を変換することを考える。Lispからは離れて、これからは文と式を区別考えることにする(Lispでは、式と文の区別がなく、文でも値が必要であったが、これからは通常のCと同じように、式と文は区別する)。関数のコンパイルする関数compileFuncDefはスタックマシンのものとほとんど同じである。だだし、本体はblockのはずなので、compileBlockを呼び出している。compileBlockでは、compileStatementを呼び出している。compileStatementでは、if文やwhile文、return文などの処理を呼び出している。制御文などで、分岐命令のコードを出すのはスタックマシンの場合とほとんど同じである。

 式のコンパイルは、compileExpressionで行う。この関数では、呼び出す側でターゲットとなる変数を作って、これを引数にして呼び出している。文のtoplevelから呼び出され、値を必要としない場合には、ターゲットを-1としている。変数を作るのは、tmp_counterを使って新しい変数の番号を生成する。式のコンパイルは以下のような手順である。

  1. 式が数字であれば、その数字をターゲットにセットするLOADIコードを出す。
  2. 式は変数であれば、その値をロードする命令を出す。
  3. 式が演算であれば、左辺と右辺に対する変数を作って、それをターゲットにコンパイルし、ターゲットに演算をするコードを出す。

中間コードからマシンコードの生成

 実際のコンパイラでは、この中間コードについて様々な最適化をし、最後にこれをマシンコード(アセンブリ言語)を出力する。マシンコードに変換するために最低限必要なのは、コンパイラで作り出した変数(仮想レジスタ)に実際のレジスタを割り当てる作業(register allocation)である。レジスタ割り当てには、実際のレジスタにどの変数が割り当てられているかを示すtmpRegStateという配列と変数がレジスタになくレジスタ退避領域にある変数を示すtmpRegSaveという配列を用いている。tmpRegStateは$8-$11のレジスタ、tmpRegSaveは退避領域に対応している。以下の関数を用意した。

これを使ってたとえば、ADD r, r1, r2の中間コードについては以下のようにしてコードを生成する。

  1. r1,r2について、useRegで現在割り当てられているレジスタを求める。これをR1,R2とする。
  2. R1、R2をfreeRegで開放する。
  3. getRegでrにレジスタを割り当てる。これをRとする。
  4. addu R,R1,R2のコードを生成する。

なお、中間コードの生成では変数は一回しか使われないようにしている。従って、使ってしまえば、開放してよい。しかし、実際のコンパイラではこのような条件は必ずしも成立しないことがあるので、レジスタの開放はこの命令以降、レジスタが使われないことを確かめなくてはならない。

 CALL命令では、saveTmpRegsで現在使われているレジスタを退避させなくてはならないことに注意。

コンパイラと実行

 さて、web上にあるプログラムをコンパイルするとコンパイラtiny_ccができる。tiny_ccは、これまでと同じく標準入力から呼んで、コンパイルの結果のコードを標準出力に出力するようになっている。例えば、プログラムfoo.cをコンパイルして、コードfoo.iを作るには、

 % tiny_cc < foo.c > foo.

とすればよい。printlnはライブラリ関数なので、println.cにある。実行ファイルをつくるには、これをリンクして、コンパイルする。

 % cc foo.s println.c

 % a.out

とすれば、実行できる。

演習課題8:

 これまでのtiny Cのコンパイラでは大域変数は配列宣言を処理していない。配列宣言と配列参照を処理できるように拡張して、以下のプログラム sample.c(課題6のプログラムsample3.c)をコンパイルしなさい。提出は、拡張、修正したところのみでよい。

ヒント:

lw $8,iのアドレス、

sll $8,$8,2

la $9,a

addu $8,$8,$9

lw $8,($8)

最終課題レポート(予告):

 これまで、取り上げてきたLisp(tiny C)のインタプリタ、スタックマシンのコンパイラ、MIPSのコンパイラのいずれか1つについて、工夫を加え、提供するテストプログラムを用いて実行時間を測定し、どのような工夫をしたか、どの位性能が向上したかについて、レポートを提出しなさい。