完整cmm直譯器構造實踐(四):語義分析與程式碼生成
語義分析
語法分析只是分析了程式碼在語法上是不是合法的, 但是程式碼仍然有可能存在問題, 比如一些需要上下文才能分析的錯誤, 語法分析就不能分析出來. 比如下面的程式碼
a = 10;
從語法上來看, 這是一句合法的賦值語句. 但是從語義上看, 我們並不知道a
有沒有被宣告為變數, 型別是否和10
匹配. 這一次我們將介紹語義分析.
分析內容
不同的人做語義分析, 分析的內容都有不同, 比如有些人會把陣列越界作為分析的內容, 而我認為這並沒有意義, 因為我們的程式碼實際上是允許陣列下標為變數的, 不到執行時是沒法判斷的, 所以我將陣列越界作為執行時錯誤處理, 型別不匹配也是在執行時檢查. 我在語義分析中會檢查如下內容:
- 變數未宣告
- 除0
但我們的語義分析並非一個獨立的過程, 而是在程式碼生成的同時完成的.
程式碼生成
這裡的程式碼指的是中間程式碼, 雖然我不明白為什麼直譯器需要生成中間程式碼, 老師非得要求, 我也只好做了, 不做就是0分. 我的程式碼生成器其實是從最早的直譯器改造過來的, 所以只要搞清楚TreeNode
中儲存的資料的意義, 完全可以不進行程式碼生成直接解釋. 這裡按照需要生成中間程式碼的情況進行處理.
中間程式碼的定義
我發現這事其實比較坑, 沒有看到中間程式碼的詳細定義, 那麼我為了方便, 自己定義了一些中間程式碼, 雖然是有冗餘, 不過用起來比較方便就是.
下面是中間程式碼的所有種類:
public static final String JMP = "jmp";
public static final String READ = "read";
public static final String WRITE = "write";
public static final String IN = "in";
public static final String OUT = "out";
public static final String INT = "int";
public static final String REAL = "real" ;
public static final String ASSIGN = "assign";
public static final String PLUS = "+";
public static final String MINUS = "-";
public static final String MUL = "*";
public static final String DIV = "/";
public static final String GT = ">";
public static final String LT = "<";
public static final String GET = ">=";
public static final String LET = "<=";
public static final String EQ = "==";
public static final String NEQ = "<>";
挑幾個不容易一眼看懂的解釋一下:
- JMP指令如果是有條件跳轉, 那麼是條件為假的時候跳轉到指定目標.
- IN指令代表進入程式碼塊
- OUT指令代表出程式碼塊
- INT和REAL指令代表宣告變數
中間程式碼的形式是四元式, 比如JMP指令:
(jmp, 條件, null, 目標)
當條件為假的時候, 跳轉到目標.
又如ASSIGN指令:
(assign, 變數, null, 值)
將值賦給變數.
INT/REAL指令:
(int/real, null, 值/null, 變數名)
宣告某個名稱為變數, 如果第三個元素不為null, 代表是宣告該長度的陣列. 如果第二個元素不為null, 說明在宣告的同時給變數賦值, 此時宣告的一定是單個變數.
中間程式碼的定義主要還是靠自己, 我這裡只是用了自己比較喜歡的形式.
變數值實現
我們在程式碼生成階段沒有必要計算表示式的值, 但是依賴相關的程式碼, 所以在這裡一併把直譯器的值的實現解釋一下.
在我的cmm直譯器中, 所有的變數的值都存在一個叫Value
的類中, 這個類不僅可以存單個的整數或浮點數, 還可以儲存整數陣列或者浮點數陣列. 同時對Value
間的各種運算做了封裝, 使得呼叫時的程式碼非常簡單, 只是Value
的定義比較複雜.
下面是Value
的成員變數:
/**
* 儲存值物件的型別,常量儲存在Symbol中
*/
private int mType;
private int mInt;
private double mReal;
private int[] mArrayInt;
private double[] mArrayReal;
通過mType
可以判斷Value
中儲存的資料型別, 然後再呼叫相應的方法來獲取其中的值. 其他的四個成員變數則對應不同情況的值得儲存.
下面再看一下Value
間乘法的實現:
public Value MUL(Value value) throws InterpretException {
if (this.mType == Symbol.SINGLE_REAL) {
Value rv = new Value(Symbol.SINGLE_REAL);
if (value.mType == Symbol.SINGLE_INT) {
rv.setReal(this.mReal * value.mInt);
return rv;
} else if (value.mType == Symbol.SINGLE_REAL) {
rv.setReal(this.mReal * value.mReal);
return rv;
}
} else if (this.mType == Symbol.SINGLE_INT) {
if (value.mType == Symbol.SINGLE_INT) {
Value rv = new Value(Symbol.SINGLE_INT);
rv.setInt(this.mInt * value.mInt);
return rv;
} else if (value.mType == Symbol.SINGLE_REAL) {
Value rv = new Value(Symbol.SINGLE_REAL);
rv.setReal(this.mInt * value.mReal);
return rv;
}
}
throw new InterpretException("算數運算非法");
}
可以看出這個類的實現非常的蠢, 而正是因為蠢, 所以定義的思路比較簡單, 用起來更簡單.
符號的實現
符號也就是變數名, 一個變數名後其實有很多資訊, 比如變數型別, 變數名, 變數層次, 為了儲存這些資訊, 我們建立一個Symbol
類, 下面的程式碼片段摘自Symbol
類:
public static final int TEMP = -1;
public static final int SINGLE_INT = 0;
public static final int SINGLE_REAL = 1;
public static final int ARRAY_INT = 2;
public static final int ARRAY_REAL = 3;
//僅供value使用
public static final int TRUE = 4;
public static final int FALSE = 5;
private String name;
private int type;
private Value value;
private int level;
private Symbol next;
上面儲存了變數的型別, 不僅是資料型別, 連是否陣列都能區分. 下面是成員變數, 包括變數名, 變數型別, 對應的值, 變數層次, next
則指向一個同名的外層變數, 這個成員變數與符號表的構建有關.
符號表的實現
在生成程式碼的時候還需要構造一個符號表, 雖然不需要通過變量表存取值, 但是構造符號表可以檢查出變數未宣告的情況. 符號表的構造需要精心設計.
cmm中是有程式碼塊的存在的, 所以自然也就有變數層次. 變數不僅需要先宣告再使用, 而且在內層宣告的同名變數可以遮蔽外層的對應變數, 而同層次中變數不能重名. 考慮到這些情況再加上呼叫時存取值的方便性, 我的符號表的實現思路如下:
使用連結串列儲存符號表, 初始時符號表為空, 每宣告一個變數, 將其加入連結串列中. 為了更形象的表示, 我們在變數名後加一個括號, 填入變數宣告層次. 例如, 在程式碼最外層我們聲明瞭兩個變數fur, bar, 此時符號表的儲存如下:
fur(1)
|
bar(1)
假如此時進入了一個程式碼塊, 在程式碼塊中又宣告兩個變數, 分別是opp, bar, 此時符號表的儲存如下:
fur(1)
|
bar(2) -> bar(1)
|
opp(2)
注意我們這裡第二層的bar取代了第一層的bar, 但是我們讓第二層的bar的next變數指向第一層的bar. 注意遇到同名變數時一定要檢查變數層次, 層次更深(數值更大)的才能取代層次更淺(數值更小)的, 否則變數的宣告就是不合法的.
假如此時我們進入了第三層程式碼塊, 又聲明瞭兩個變數fur, bar, 那麼此時符號表變為:
fur(3) -> fur(1)
|
bar(3) -> bar(2) -> bar(1)
|
opp(2)
這樣的好處是如果我們需要知道bar對應的值, 只需要在連結串列中查詢bar並取出它的值即可, 因為bar已經把層次比它小的同名變數全部遮蔽了.
假如此時我們退出第三層程式碼, 那麼我們將連結串列中層次為3的變數全部刪掉, 如果它的next
指向了同名變數, 那麼用那個同名變數取代它, 如果next == null
, 則直接把它從連結串列中刪掉即可. 此時符號表如下:
fur(1)
|
bar(2) -> bar(1)
|
opp(2)
再退出第二層程式碼:
fur(1)
|
bar(1)
退出第一層程式碼(程式結束)後符號表為空.
生成程式碼
接下來就是遍歷語法樹生成程式碼, 注意我們要時刻跟蹤生成的程式碼行數, 因為跳轉命令需要行數序號作為目標. 針對不同的TreeNode
生成中間程式碼.
臨時變數
生成程式碼時有個很重要的問題是生成臨時變數, 我們使用簡單的規則, 在需要臨時變數時自動生成變數名, 要求不能在已有的變數中出現, 我使用的演算法就是將*temp
和數字n
組合起來, n
從1開始, 如果已經存在, 就執行n = n+1
再組合.
回填技術
在生成迴圈/條件語句的中間程式碼時, 我們需要使用JMP指令, 但是會存在我們不知道應該跳轉到第幾行的情況, 所以需要在後面的一定程式碼生成完畢之後才能確定值並填入, 一般稱之為回填, 但在程式碼上來看是很簡單的.
比如下面的程式碼
int a = 1;
while (a>=0) {
write a;//死迴圈,僅僅為了舉例
}
生成的中間程式碼如下:
0 : (int, 1, null, a)
1 : (>=, a, 0, *temp1)
2 : (jmp, *temp1, null, 7)
3 : (in, null, null, null)
4 : (write, null, null, a)
5 : (out, null, null, null)
6 : (jmp, null, null, 1)
在第1行(不是第0行)我們進行while條件的判斷, 如果條件為真繼續執行, 為假則跳轉到第7行, 注意在程式碼生成階段此時我們是不知道應該跳轉到第幾行的, 因為我們需要跳到while語句之後的那句那裡, 但是我們並不知道while語句之後那句在第幾行, 所以我們應該暫時不填這個7. 在第6行是我們的while迴圈體結束部分, 此時為了繼續迴圈, 我們跳轉到while條件判斷處, 也就是第2行, 直到此時, 我們才知道while語句之後的程式碼應該在第7行開始(本例中代表程式結束), 我們將7填入第2句中間程式碼的第四個元素, 回填完成.
其實中間程式碼生成除了前面那些準備工作, 此時也就剩下該如何生成了, 程式碼的寫法和遞迴下降子程式法類似, 主要確定中間程式碼的生成模板, 然後轉換成程式碼即可. 難點是如何將原來的邏輯分支和迴圈等結構轉換成線性的程式碼結構, 需要仔細思考JMP指令的使用.