Compiler Construction Lecture 1/22
Menu Menu
Micro-C の全体構成
全体構成をつかみ、Cの文法を思い出しながら、個々の関数を読んでいくことにより、実際的なcompilerの詳細を理解して欲しい。自分で、すべてを理解しなくては、どんなプログラムでも完成させることはできない。あてずっぽうにコードを書いても、それは絶対に動くことはないし、それは、他のコードに影響を与え、正しい部分まで汚染してしまう。バグは避けられないものではあるが、自分で書いたコードを完全に理解していれば、どのようなバグにも対処できる。mc のコンパイル
/usr/open から Micro-C のソースをとってきてコンパイルしよう。cp -r /usr/open/lectures/kono/compiler/mc-intel . cd mc-intel make
Mule の gdb mode
Mule (Emacs) には、gdb mode というのが付いている。コンパイラの動作をこれを使って調べる。
M-X gdb
モードに依存する文法
文法規則だけでは解釈する情報が足りない場合がある。
例えば、Micro-C では、Cの以下の宣言を一つの文法で済ましている。
- global変数/関数の宣言
- 関数の引数の宣言
- local変数の宣言
- 構造体の中の宣言
45 #define TOP 0 46 #define GDECL 1 /* global 変数 */ 47 #define GSDECL 2 /* global struct */ 48 #define GUDECL 3 /* global union */ 49 #define ADECL 4 /* argument 変数 */ 50 #define LDECL 5 /* local 変数 */ 51 #define LSDECL 6 /* local struct */ 52 #define LUDECL 7 /* local union */ 53 #define STADECL 8 /* static */ 54 #define STAT 9 /* function の最初 */ 55 #define GTDECL 10 /* global typedef */ 56 #define LTDECL 11 /* local typedef */によって区別されている。getsym() では、このmodeを見ながら、どの処理を変えている。ただし、このmodeによる区別はプログラムの見通し悪くするので使い方には気を付けよう。
Symbol Table
Tokenizerでは、記号の切り出しとkeywordの切り出しをおこなう。同時に、名前の登録をおこなう必要がある。実際、Tokenizerが、普通の名前、(例えば英字で始まる英数字の列などだが)に出会ったとしよう。それは、名前(name)、つまり、予約語(reserved word)か、変数名か関数名である。Micor-C では、getsym() という関数がTokenizerである。これらは compiler 内部の表に登録されなければならない。この表を Symbol Tableという。Interpreter の場合でもSymbol Tableは必要である。Compilerが出力したコードでは、Symbol Tableは不要である。しかし、分割compile (Separate compilation)の場合は、相互のSymbol Tableを接続する必要があるので、Symbol Table そのものも出力しなくてはならない。この問題は、異なるcomputer上で分散計算を行う場合にも出て来る。
名前には様々な属性がある。例えば、ある名前はlocalであり、ある関数や、関数の一部でしか有効でない。ある名前で指し示される(実体/objectゥ)ものは、名前で呼ばれなくなった後も存在し続ける場合がある。あるいは、そのようなものを別な名前で呼びたい時もある。このような属性には以下のようなものがある。
- 名前の有効範囲 (scope)
- objectの寿命 (extent)
- objectの型 (type)
- local 名前は{}の中で有効であり、その外では使えない。object も、その外では消滅してしまうのでアクセスしてはならない。{}は入れ子(nest)することができるので、名前の有効範囲もnestする必要がある。しかし、C では実際には、nest は処理されずに、local 変数は、手続の中で一意な名前でなければならない。自動的にobjectが作られ、自動的に消滅するので、自動変数(auto)とも呼ばれる。Cではこちらの呼び方の方が正式である。local variableのobjectは通常はstack上に取られる。したがって、特に、pointer を使ってlocal 変数を手続き呼び出しから返してはならない。Cでは、local変数の初期化を行うことができる。Cにはlocalな手続き名は存在しない。
- global 名前とobjectは、プログラム全体で有効であり、存在する。C++では、この名前の有効範囲を class 単位にすることができる。分割 compile する場合は、この名前はコード中に出力する必要がある。分割 compile 後、さらに、link された完全なコードには symbol table は必要ない。しかし、debug のためにわざと情報を残すこともある。(-g flag in cc)。最近のOSでは、kernel levelで library の dynamic loadingを行う。場合もあり、この場合もlinkのための情報を残す必要がある。別なファイルのglobalを参照したい時には、externを使う。
- static objectは、プログラム全体で有効であり、存在する。しかし、名前はファイル内部でしか有効ではない。localなstaticという名前もあり、この場合は、名前の有効範囲は関数または{}の内部ということになる。
- macro マクロ(macro)名は、通常compilerが処理する前に変換されてしまう。したがって、compilerからも見えない名前である。しかし、Micor-C ではマクロも1 pathで処理するために、マクロ名も自分で管理する。この名前の有効範囲は分割compile単位である。マクロには引数があることがあるので、その処理も必要だが、Micro-C自身には、その機能はない。しかし、付加することはそれほど難しくはない。
compilerにとって問題なのは変数の有効範囲であり、大域名(global)と局所名(local)が衝突した場合には大域名を優先する必要があり、局所名が有効でなくなれば、その名前を再利用する必要がある。
名前には、その名前が指すもの(object)の型がある。一つの名前には一つの型がある言語(Cはそうだ)もあるが、一つの名前を複数の型に使うことができるもの(Perlなど)もある。前者の場合は、名前を検索することにより型が分かる。しかし、後者の場合には、型は名前とは別に指定する必要がある。後者の方がどちらかといえば優れているようである。Cには以下のような型がある。
- long, int, short, char 基本的な型。アセンブラでのword,2byte,1byteに相当する。
- unsinged/singed 符号が付くかどうか。引き算などで2の補数として扱うかどうかに相当する。Micro-Cには、signed char, unsinged char などは存在しない。
- float, double Micro-Cにはない。
- * pointer Cを特徴づける型。直接にobjectを指すのではなく、object のaddressを表す型である。単なるaddressではなく、足し算が定義されている。1を加えることにより、指し示すobjectの大きさの分だけaddressが加算される。大きさは、sizeof()という内蔵関数で取り出すことができる。sizeof(char) == 1であるけれど、sizeof(int)==sizeof(int *)であるとは保証されない、ということを覚えておこう。pointerのpointerという型もある。これを理解しているかどうかでCを理解したかどうかが決まる重要な部分である。
- array[10],array[],array[10][20] 配列。pointerを使ってアクセスされることが多い。2次元配列と、pointerの配列の違いを理解しよう。
- struct, union ユーザ定義可能な型。レコード型などと呼ばれることもある。複数の型を合わせて一つのobjectとして扱うことができる。
さらに、Cには、cast という概念があり、型を変換することができる。この時の構文はなかなか面白い。このあたりのcompileの詳細は、また後日、追求することにしよう。
Implementation of Symbol Table
Symbloc Table(記号表)の実装は、さまざまな方法があるが、hash tableと、stack を組み合わせるのが容易である。localな名前は、stack上に作られる。入れ子(nest)になったものを実現する場合には常にstackを使う。localな名前が作れる度に名前はstackにつまれる。そして、compileが、そのscope(手続き、または{}(statement))から抜けると、必要な所までstackを開放する。検索を、localな名前を格納したstack そして、大域名を格納したhash tableという順番に探すことにより、Cの変数の有効範囲を実現することができる。
名前にはreserved word(予約語)が取られることがある。例えば、if, for, continue などはCの予約語である。これらの語を字句解析のレベルで切り分ける手もあるが、ユーザの定義よりも先に表に登録してしまうという手もある。すると、reserved wordかどうかは、一種の名前の型となる。 </p>
さて、Micro-CではSymbol tableは以下のように定義されている。
168 typedef struct nametable { 169 char nm[9]; 170 int sc,ty,dsp; } NMTBL; 171 172 NMTBL ntable[GSYMS+LSYMS],*nptr,*gnptr,*decl0(),*decl1(),*lsearch(),*gsearch();
9文字しか変数名は見ないらしい。逆に言えば、iという変数に対しても9文字分のデータが取られている。実用的にはこんなものかも知れない。GSYMS,LSYMSは、global, local の記号の最大値である。定数は、このように大文字のマクロで書くのが C 流である。sc は、たぶん、symbol class の意味で、以下のどれかである。これと ty との値で型が決まる。ty には、struct, unioon を表す木構造(tree)か INT が入る。
- EMPTY
なんだか分からないもの
- RESERVE
予約語
- FUNCTION
手続名
- LVAR
local variable
- TYPE
型名
- TAG
struct, unicon のfieldの定義
- FIELD
struct, unicon のfieldの参照
- FLABEL,BLABEL
label (未定義=forward label)
- MACRO
macro で定義された名前
さて、getsym()を見てみよう。
2520 getsym() 2521 {NMTBL *nptr0,*nptr1; 2522 int i; 2523 char c; 2524 if (alpha(skipspc())) 2525 { i = hash = 0; 2526 while (alpha(ch) || digit(ch)) 2527 { if (i <= 7) hash=7*(hash+(name[i++]=ch)); 2528 getch(); 2529 } 2530 name[i] = '\0'; 2531 nptr0 = gsearch(); 2532 if (nptr0->sc == RESERVE) return sym = nptr0->dsp;ここでは、hash table という技術を使っている。名前によって決まったrandamな値により、表を引く。このようにすることにより、randamな値が重ならなければ一度だけで記号に対応する値を取りだすことができる。2527ではhashを計算するだけで、実際の検索は、gsearch(),lsearch() でおこなう。
2661 NMTBL *gsearch() 2662 {NMTBL *nptr,*iptr; 2663 iptr=nptr= &ntable[hash % GSYMS]; 2664 while(nptr->sc!=EMPTY && neqname(nptr->nm)) 2665 { if (++nptr== &ntable[GSYMS]) nptr=ntable; 2666 if (nptr==iptr) error(GSERR); 2667 } 2668 if (nptr->sc == EMPTY) copy(nptr->nm); 2669 return nptr; 2670 } 2671 NMTBL *lsearch() 2672 {NMTBL *nptr,*iptr; 2673 iptr=nptr= &ntable[hash%LSYMS+GSYMS]; 2674 while(nptr->sc!=EMPTY && neqname(nptr->nm)) 2675 { if (++nptr== &ntable[LSYMS+GSYMS]) nptr= &ntable[GSYMS]; 2676 if (nptr==iptr) error(LSERR); 2677 } 2678 if (nptr->sc == EMPTY) copy(nptr->nm); 2679 return nptr; 2680 }このsearchは、もし表になかった場合には登録も行う。これは標準的な実装である。lsearch()とgsearch()の差はどこにあるか考えてみよう。
2532 if (nptr0->sc == RESERVE) return sym = nptr0->dsp; 2533 if (nptr0->sc == MACRO && !mflag) 2534 { mflag++; 2535 chsave = ch; 2536 chptrsave = chptr; 2537 chptr = (char *)nptr0->dsp; 2538 getch(); 2539 return getsym(); 2540 } 2541 sym = IDENT; 2542 gnptr=nptr=nptr0; 2543 if (mode==GDECL || mode==GSDECL || mode==GUDECL || 2544 mode==GTDECL || mode==TOP) 2545 return sym; 2546 nptr1=lsearch(); 2547 if (mode==STAT) 2548 if (nptr1->sc == EMPTY) return sym; 2549 else { nptr=nptr1; return sym;} 2550 nptr=nptr1; 2551 return sym; 2552 }検索が修了すると、その後で予約語とマクロの処理を行う。どちらでもなければglobalかlocalかどちらかである。この後、その記号のscとtyが決まることになる。これらは、構文解析の途中で決定するので、そこで代入される。mode により、記号の扱いが変わることに注意しよう。
左辺値と右辺値 left value and right value
ポインタや構造体が出て来ると、代入文も複雑になる。代入文は以下のような構造をしている。left value = right values-compile.c では、left value は単純(英字一文字!)だったが、今度は配列の引数や構造体へのポインタなどを計算しなくては代入ができない。
これらは、最終的にはload/storeの機械語に落ちるはずである。つまり、どのアドレス(address)から、どれくらい(size)を loadして、どれくらいをstoreするのかを決めれば良い。
Micro-C では、i386の汎用Registerを使ってaddressを計算する。実際には、%ebxなどの3種類のポインタを使い分けている。
- %ebx -- 汎用のポインタ
- %ebp -- 局所変数を指し示すポインタ
- address -- 大域変数
- movl (%ebx),%eax ------ 任意のアドレスからのload
- movl %eax,count -------- count という大域変数へのstore
- movl -16(%ebp),%eax ------- 4番目の局所変数からのload
left valueもright valueもアドレスを計算するところまでは同じである。そこで、Micro-C では expr はアドレスまで中間木を生成し、必要な時にrvalue()という手続きにより値をloadする中間木を生成している。もちろん、定数などはアドレスを計算しなくても直接にloadされているので、rvalue() は何もしない。
ravlue() は短いが、それを理解するためには、Micro-C の中間木を理解しなくてはならない。
Micro-C の中間木
Micro-C での木を扱う手続きには奇妙な名前をした関数(function)が使われている。
2836 car(e) 2837 int e; 2838 { return heap[e]; 2839 } 2840 cadr(e) 2841 int e; 2842 { return heap[e+1]; 2843 } 2844 caddr(e) 2845 int e; 2846 { return heap[e+2]; 2847 } 2848 cadddr(e) 2849 int e; 2850 { return heap[e+3]; 2851 } 2852 list2(e1,e2) 2853 int e1,e2; 2854 {int e; 2855 e=getfree(2); 2856 heap[e]=e1; 2857 heap[e+1]=e2; 2858 return e; 2859 } 2860 list3(e1,e2,e3) 2861 int e1,e2,e3; 2862 {int e; 2863 e=getfree(3); 2864 heap[e]=e1; 2865 heap[e+1]=e2; 2866 heap[e+2]=e3; 2867 return e; 2868 } 2869 list4(e1,e2,e3,e4) 2870 int e1,e2,e3,e4; 2871 {int e; 2872 e=getfree(4); 2873 heap[e]=e1; 2874 heap[e+1]=e2; 2875 heap[e+2]=e3; 2876 heap[e+3]=e4; 2877 return e; 2878 } 2879 getfree(n) 2880 int n; 2881 {int e; 2882 switch (mode) 2883 {case GDECL: case GSDECL: case GUDECL: case GTDECL: 2884 e=gfree; 2885 gfree+=n; 2886 break; 2887 default: 2888 lfree-=n; 2889 e=lfree; 2890 } 2891 if(lfree<gfree) error(HPERR); 2892 return e; 2893 } 2894 rplacad(e,n) 2895 int e,n; 2896 { heap[e+1]=n; 2897 return e; 2898 }これらの関数名は、LISPというLIST処理専門に設計されたプログラミング言語から来ている。しかし、Micro-CでのLISTは、やや簡略化された形で実装されている。本来は、list2()などは、配列ではなくLISTを返すべきだ。これは Micro-Cでは8bit CPUつまり、64kbyteのメモリ空間を想定しているので、LIST(2進木)を使うことによりメモリをより多く消費することを嫌ったのだと思われる。
この(簡略化された)LIST(専門的にはcdr codingというのだが、そんなことはどうでもいいことなのだが...)の、最初の要素をcar()、次の要素をcadr()、次の次の要素をcaddr() という。(最近のLISPでは、first(), second(), third() を使うのが普通なようだ) 木には葉(leaf)と幹(node)があるのだが、car()の値によって、それらを区別している。cadr()とかの内容はcar()によって異なる。これはかなり複雑だが、実際には、car()の値によって処理を変えれば良いだけだ。
どのような中間木のノードがあるかは、list2 などをgrepしてみれば簡単にわかる。(が、かなりあるので、ここでは省略する) Micro-C では、変数の型も木で表している。したがって、式を表す木と型を表す木の二つの木を扱うことになる。型は常にtype という変数に入っている。この二種類の木は、違う構造をしているので気をつけて扱わなくてはならない。
問題1 以下のCのstatementに対応するMicro-Cの中間木と生成されるi386のコードについて考察せよ。ただし、以下のint *a; という型を仮定する。
- *a = 1;
- a = &a[3];
- *a = *(a + a[3] + 2);
- a = *( &a + 3);
問題2 &a = (int *)3; というのは、Micro-C ではエラーになる。なぜ、エラーなのかを説明せよ。