先週は、簡単なコンパイラを作成した。そして、そのコンパイラを 中間木を生成する方法に変換した。
今週の課題は、問3について、
Subject: compiler report 11/20というSubjectのメールにして、kono@ie.u-ryukyu.ac.jp まで送ること。 また授業を受けなかったものは、問1についても、
Subject: compiler lecture 11/20というサブジェクトで送ること。また、Micro-C のソースリストを配布した ので、それも取りに来ること。
Makefile どのようにcompileするかの指示 s-compile.c compiler本体(interpreterとほとんど同じ) s-code-09.c Code Generator ending.09 Code Generator Additional File s-code-rs6k.c Code Generator for RS6000 (etc...) s-token.c Tokenizer s-tree-compile.c Intermidiate Tree Version
必ず Makefile を 作り、make を使うことにしよう。つまらないタイプミスから開放される。 以下は Makefile の一部である。実行するコマンドの部分はタブで始める ことに注意よう。
TEST = s-09 CC = cc COMPILER = s-compile.o s-token.o CFLAGS = -g MC09EMU = ../emulator/util09/v09 MC09ASM = ../emulator/util09/a09 test-09: s-09 ./s-09 < s-input.txt > s-output.a09 $(MC09ASM) s-output.a09 $(MC09EMU) s-output s-09: s-compile.h $(COMPILER) s-code-09.o $(CC) $(CFLAGS) -o s-09 $(COMPILER) s-code-09.o test: $(TEST) $(TEST) < s-input.txt > s-output.s $(CC) s-output.s a.out s-rs6k-r: s-compile.h $(COMPILER) s-code-rs6k-r.o $(CC) $(CFLAGS) -o s-rs6k-r $(COMPILER) s-code-rs6k-r.oここで、make test-09 とすることにより、MC6809 emulator を使った テストをすることができる。
最初の6行は変数の定義。ここではMC6809のemulatorとassemblerを指定している。 その後の$(COMPILER)などは最初に指定した変数で置き換えられる。
3行目では test-09 の作り方を説明している。:のすぐ後には、s-09 つまり、 s-09 が test09 を作るために必要なことを示している。s-09 の作り方は、 最後の2行に書いてある。ここでも : の後に、s-09 を作るための原料が書いてある。 s-compile.o と s-compile.h と s-code-09.o が必要である。*.o は object file であり、*.c があれば、それから自動的に作られる(と make は考えて いる。実際には、これらの暗黙のルールがライブラリなどに置いてある)。
実際の作り方は、: の行のあとにタブを先頭に持つ行を続けて書く。スペース ではいけない。良くある間違いなので気を付けよう。この 行が指定され変数を置換された後、 sh に渡されて実行される。test-09 では、s-09 が既に作られていれば、それを使ってs-input.txt をcompile して、assembler で assemble し、結果を emulator で emulate する。
make は、Makefile を参照しながら、ファイルの時間を見る。そして、生成 されたものよりも、新しい変更時間を持つ原料があれば、その生成をやり直す。 もちろん、原料の作り直しが必要かどうかも自動的に判断する。これは、 一種の interpreter である。
Makefile の中で、
TEST = s-rs6k-r COMPILER = s-compile-tree.o s-token.oとすると、rs6k の tree version が生成されてテストされる。すべての*.o が、まだ作成されていない時に、make test とすると、どのような順序で、 どのようなコマンドが実行されるかを記述せよ。そのコマンドが成功したとして 、さらにs-compile-tree.c を編集したする。その後、make test としたら、 コマンドはどのような順序で実行されるかを記述せよ。また、なぜ、その 順序で実行されたのかを Makefile の中のルールに合わせて、 簡単に説明せよ。
もし、8進、16進を許すだけならば、変更はs-compile.cだけで良い。実は これがもっとも簡単な変更である。
例えば、論理演算 & と | を付け加えたいとしよう。Parser(構文解析)部分の 変更は、Interpeterとの場合とほとんど変わらない。例えば、+ と同じ レベルに|を付け加えるとすると、(これは安易すぎる不便な構文である)
void aexpr() { int d; emit_comment(); mexpr(); while(last_token!=EOF) { switch(last_token) { case '|': emit_push(); mexpr(); emit_calc(O_OR); break; case '&': emit_push(); mexpr();O_ORは s-compile.h で定義する。ここで OR を計算するcodeを生成しなければ ならない。そのためには、code generator emit_calc を拡張しなくてはならない。
void emit_calc(op) int op; { if(op==O_SUB) { printf("\t%s ,S++\n",opcode[op]); printf("\tNEGA\n"); printf("\tNEGB\n"); printf("\tSBCA #0\n"); } else if(op==O_ADD) { printf("\t%s ,S++\n",opcode[op]); } else if(op==O_SUB_R) { printf("\tsubd ,S++\n");などとなっているので、ここにORをおこなうcodeを付け加えれば良い。 ANDD ,S++ というのがあればO_ADDなどと同じ処理で良い。しかし、 残念ながら6809には、そういう命令はない。(前回の付録参照)
しかし、ちょっと考えれば引き算と同様、Register AとRegister Bを 順に計算すれば良いことがわかる。Stack 上の順番は、Bigendian である ことを考えれば、
ORA ,S+ ORB ,S+という code を出せば良い。(S++でなく、S+であることに注意) このためには、
} else if(op==O_OR) { printf("\tORA ,S+\n"); printf("\tORB ,S+\n");とすれば良い。ANDに関しても、全く同じように実現できる。
このように MC6809では、D or A,B というAccumulatorとMemory間で計算を行うのが 普通である。これは、Register-Memory Computation 中心の Architecture である。一方、Registerをたくさん持っている場合は、まず必要なデータを loadし、Register-Register Computation をおこない、その後、結果を store するというのが一般的である。これは、Load-Store Architecture と呼ばれる。
例題のdirectoryにある compile.c では、さらにいろいろな拡張を試みている。 参考にして見よう。
今までの簡単なコンパイラでは、Register-Memory Architecture を使い、 Register 1つと、Stackの先頭の演算に式をコンパイルしていた。ここでは、 さらに、Load-Store Architecture を想定して、Register 上のでの演算に コンパイルして見よう。
最近ではRegisterの数は32が多く、その中で自由に使えるものは10-20程度 ある。通常の式であれば、スタックの深さは10以下であり、すべてRegister 上で計算できる。この部分を記述するのは簡単である。もっと難しいのは、 複数の式でRegisterを共有する場合であり、この時には、共有する変数は 非常に大量になる。これに対して適切なRegister割当をおこなうのがコンパイラ の最適化として重要である。この割当の善し悪しは、関数呼出時の手間や Cache(キャッシュ)の 占有度に影響し、最終的にプログラム全体のPerformanceに影響する。この 問題は、授業の後半で議論することにしよう。
さて、Registerを使った式の計算は、スタックに変数を取っておく代わりに Registerに取っておくだけである。したがって、コンパイラを変更しないで、 コード生成系のみを変更するようにしよう Register 割当をおこなう s-code-sparc.c を見てみよう。
emit_push() でスタックにaccumlatorの値をsaveする代わりに、新しく accumlator として使う Register を割り当てれば良い。このために、 get_register() という手続きを考える。
char *reg_name[] = { "%g0","%g1","%g2","%g3","%g4","%g5","%g6","%g7", "%i0","%i1","%i2","%i3","%i4","%i5","%i6","%i7", "%l0","%l1","%l2","%l3","%l4","%l5","%l6","%l7", "%o0","%o1","%o2","%o3","%o4","%o5","%o6","%o7" }; #define O0RN 24 #define MAX_REGISTER 32 char regs[MAX_REGISTER]; char reg_stack[MAX_REGISTER]; int get_register() { int i; for(i=0;i <MAX_REGISTER;i++) { if (! regs[i]) { regs[i]=1; return i; } } error("Expression too complex (register overflow)"); return -1; }regs[]という配列に、そのRegisterが使われているかどうかを示すflagを いれておく。get_register()では、最初のflagが開いているRegisterの 番号を返す。もちろん、番号とassemblerの中でのRegister名との対応を 示すものが必要なので、それは、reigster_name[]という配列を作って おく。このうちいくつかは、最初から使用不可(システムやarchitectureが 使用している、例えば、SP, RTOC など)である。
どのRegisterをsaveに使ったかはコンパイラが自分で覚えてなくてはいけない。 これにはスタックを使う。これは、ちょうど実行時のスタックをコンパイル時に 部分計算していると考えることもできる。先に計算できる部分なわけである。 accumlatorを表す Registerの番号を current_register としよう。
void emit_push() { reg_stack[reg_sp++] = current_register; lrn = reg_name[current_register]; current_register = get_register(); crn = reg_name[current_register]; }ここで、lrn は last regster name, crn は current register name につもり である。どうせ、すぐ使うのだから、ここで代入しておこう。emit_load, emit_store, emit_value は、current_register に値をいれるだけなので、以下のように簡単に 書くことができる。
void emit_value(d) int d; { if(-4096 < d && d < 4096) { printf("\tmov %d,%s\n",d,crn); } else { printf("\tsethi %%hi(%d),%s\n",d,crn); printf("\tor %s,%%lo(%d),%s\n",crn,d,crn); } } void emit_push() { reg_stack[reg_sp++] = current_register; lrn = reg_name[current_register]; current_register = get_register(); crn = reg_name[current_register]; } void emit_load(assign) int assign; { printf("\tsethi %%hi(_variable+%d),%%g2\n",assign*4); printf("\tld [%%g2+%%lo(_variable+%d)],%s\n",assign*4,crn); } void emit_store(assign) int assign; { printf("\tsethi %%hi(_variable+%d),%%g2\n",assign*4); printf("\tst %s,[%%g2+%%lo(_variable+%d)]\n",crn,assign*4); }
実際の計算を行うところでは、stackを一つ下げることをおこなう必要が ある。ここではRegister-Register演算をおこない、入らなくなった Registerの regs[] に 0 を入れれば良い。これは pop_register() という手続きにしよう。
void emit_calc(op) int op; { char *orn; orn = pop_register(); if(op==O_MUL) { printf("\tmov %s,%%o1\n",lrn); printf("\tcall .umul,0\n"); printf("\tmov %s,%%o0\n",orn); printf("\tmov %%o0,%s\n",crn); } else if(op==O_DIV) { printf("\tmov %s,%%o0\n",lrn); printf("\tcall .div,0\n"); printf("\tmov %s,%%o1\n",orn); printf("\tmov %%o0,%s\n",crn); } else if(op==O_DIV_R) { printf("\tmov %s,%%o0\n",orn); printf("\tcall .div,0\n"); printf("\tmov %s,%%o1\n",lrn); printf("\tmov %%o0,%s\n",crn); } else if(op==O_SUB) { printf("\t%s %s,%s,%s\n", opcode[op], lrn, orn, crn); } else { printf("\t%s %s,%s,%s\n", opcode[op], orn, lrn, crn); } } char * pop_register() { int i,j; j = current_register; i = reg_stack[--reg_sp]; lrn = reg_name[i]; regs[j]=0; current_register = i; crn = reg_name[i]; return reg_name[j]; }ここでは、スタックを使う場合のように、最後の結果が最初のRegisterに なるように工夫している。
これにより、スタック上での演算よりも確実に高速なコードを出すことが できる。例えば、 0+(1+(2+(3+(4+(5+(6+(7+8)))))))-(0+(1+(2+(3+(4+(5+(6+(7+8)))))))) のような場合である。しかし、これを 0+1+2+3+4+5+6+7+8-(0+1+2+3+4+5+6+7+8)と書き換えるとReisterは一つ しか使わないで済む。もちろん、中間木を使ってコンパイル時に定数計算を してしまえば、もっと簡単になる。
このような書き換えは簡単だと思うかも知れないが、実際には同等な式への 変換は無数にあり、もっともRegisterが少なくなるような式の変形は自明 ではない。このような変形を行うためには中間木への変換が必ず必要となる。 このような式の変換に付いても後で議論することにしよう。
0+(1+(2+3))-(0+(1+(2+3))) の式の計算を上の2種類のcompiler によりSPARCの命令に変換した結果を記述せよ。0+1+2+3-0+1+2+3 の場合はどうなるか?
s-code-sparc.c を使った、 s-sparc について、論理演算 &,| を拡張して見よう。余裕が あれば他の拡張にも挑戦して見よう。
これから、Micro-C をざっと読みながら、より詳細な技術について解説して いく。そのまえに、Micro-C の全体構成をざっと見て見よう。
Micor-C の全体構成