C編譯器剖析_2.5 UCC編譯器的符號表管理
這一節,我們準備初步討論一下UCC編譯器的符號表管理,與符號表管理相關的程式碼主要在ucl\symbol.h和ucl\symbol.c中。UCC編譯器內部需要對用到的所有符號進行分類,並建立相應的資料結構來記錄與符號相關的資訊。圖2.5.1第25行的結構體struct symbol用於記錄與符號相關的資訊,這些資訊如圖第7至23行所示。第8行的kind用於區別不同類別的符號,其取值範圍如圖第1至5行所示;第9行的name用於記錄符號的名稱;第11行的ty用於記錄符號的型別,第23行的pcoord用於記錄符號所在的檔案位置,在除錯時方便定位。其餘各行的作用我們以後會結合上下文進行討論。
圖2.5.1 struct symbol
下面,我們結合一個具體的C程式來熟悉一下UCC編譯器內部對符號所做的分類,如圖2.5.2所示。
圖2.5.2 各種不同型別的符號
在圖2.5.2的第1至18行,是一個簡單的C程式,而第20至30行是由UCC編譯器產生的中間程式碼。結合圖2.5.1和圖2.5.2,我們能看到以下幾種不同的符號:
(1) 結構體名Data和列舉名Color,對應的類別為SK_Tag;
(2) 由關鍵字typedef給已有型別int取的別名INT32,對應的類別為SK_TypedefName;
(3) 列舉常量RED、GREEN和BLUE,對應的類別為SK_EnumConstant;
(4) 常量3,對應的類別為SK_Constant;
(5) 全域性變數名dt、a、b、c、d和區域性變數hi,對應的類別為SK_Variable;
(6) 臨時變數t0、t1和t2,對應的類別為SK_Temp;
(7) 用於訪問結構體成員的dt.num2,經UCC編譯器處理後,我們更關注域成員num2
在結構體Data中的偏移,因為結合dt的首地址和偏移4,我們就能定位dt.num2的具體位置。在UCC編譯器內部,為dt.num2取的名字為dt[4],對應的類別為SK_Offset;
(8) 第21行的str0,是UCC編譯器內部為第11行的字串”Hello World”所取的名字,
對應的類別為SK_String;
(9) 標號名Begin,對應的類別為SK_Label;
(10) 函式名main,對應的類別為SK_Function;
UCC編譯器把彙編程式碼中出現的形如”%eax”這樣的暫存器名也當作符號來管理,對應的類別為SK_Register。這11種符號正是圖2.5.1第3和第4行所列出的各種不同類別的符號。實際上,當暫存器eax中存放的是地址,而非一般數值時,我們是把暫存器當指標變數來使用,在彙編程式碼中表示為”(%eax)”,在UCC內部會對應一個特殊的類別SK_IRegister,是IndirectRegister的縮寫,代表通過暫存器進行間接定址。所以,UCC編譯器內部要管理的符號共有12種。這12種符號中,有些符號的相關資訊用struct symbol物件就可以記錄;而有些符號需要記錄更多的資訊,比如SK_Variable、SK_Temp和SK_Offset類別的符號,就需要用到圖2.5.3第73行所示的struct variableSymbol。
圖2.5.3 variableSymbol
在圖2.5.3的第75行,我們再次看SYMBOL_COMMON,這說明我們可以把structvariableSymbol當成是struct symbol的子類。第79行的offset用於記錄SK_Offset類別的符號的偏移值,例如上述的dt[4]中的4。第76行記錄用於變數初始化的值。這裡,我們重點關注一下第77行的def和第78行的uses的作用。為表述方便,從圖2.5.2中抽取出部分程式碼,如圖2.5.4所示。
圖2.5.4 公共子表示式
在圖2.5.4中,我們可以發現第2行的”c = a+b;”中的子表示式”a+b”確實進行了計算,其結果存到第7行的臨時變數t1中,但是第3行的”a+b”則沒有必要進行再次計算,因為此時a和b都沒有發生變化,我們可以延用臨時變數t1中的儲存的值。但是第4行對a進行了修改,導致在t1中儲存的”a+b”失效,此時第5行的”a+b”就需要重新進行計算。UCC編譯器期望能其產生的彙編程式碼能減少不必要的計算,所以引入了圖2.5.3第57行的struct valueDef,是”valuedefinition”的縮寫,當我們進行a+b的計算後,就產生了一個新的value,我們用valueDef結構體中的src1來記錄運算元a,用src2來記錄運算元b,用op來記錄運算子’+’,而用dst來記錄運算結果t1,圖2.5.3第63行的ownBB則用來記錄這個value是在哪個基本塊中產生的。如果要在不同基本塊之間重用已經計算出來的形如”a+b”的值,則需要進行較複雜的資料流分析。UCC編譯器為了簡單起見,限於人力物力,僅在同一個基本塊內,對形如”a+b”這樣的公共子表示式進行優化。下面,我們以圖2.5.4的第7行為例,來說明由valueDef和valueUse所形成的資料結構,如圖2.5.5所示。
圖2.5.5 valueDef和valueUse
從圖2.5.5,我們可以看到,變數a對應的uses域實際上是個由若干個structvalueUse物件構成的一個連結串列。每個structUse物件的def域記錄了變數a在哪個子表示式中被使用。當圖2.5.4第4行給a重新賦值為3後,則我們可以使a的uses鏈上的每個struct valueUse物件記錄的valueDef失效。這正是ucl\gen.c中的函式TrackValueChange()所要完成的功能,如圖2.5.6所示。圖2.5.6第55行的程式碼使valueDef物件中的dst域為NULL,這就使得公共子表示式”a+b”不再有效。當然不論變數a的內容如何變化,在變數a的生命週期內,其地址是不會變化的,所以子表示式”&a”會一直有效,這正是圖2.5.6第54行的if條件所要檢測的情況,即當valueDef物件中的op域不是取地址運算時,才使valueDef物件失效。
圖2.5.6 TrackValueChange()
從圖2.5.5中可以發現,到目前為止,我們還沒有解決structsymbol物件a、b和t1如何存放的問題,及如何快速找到valueDef物件的問題。這需要我們引入圖2.5.7的兩個結構體。
圖2.5.7 struct table
圖2.5.7的第82行定義了struct functionSymbol用來描述SK_Function類別的符號的相關資訊,這實際上是個函式名,所以在第85行的params記錄了函式形參構成的單向連結串列,而第86行的locals用於記錄區域性變數構成的單向連結串列,第88行的nbblock代表函式體包含的基本塊的個婁和,第89行的entryBB是入口的基本塊,而exitBB是出口的基本塊。而第92行的valNumTable[16]則是一個雜湊表,用於快速查詢形如”a+b”的公共子表示式,如圖2.5.8所示。圖中標為struct symbol的物件可能是struct symbol物件,也可能是其“子類”的物件,例如struct variableSymbol物件。
圖2.5.8 valueNumTable雜湊表
為了查詢雜湊表,我們還需要定義相應的雜湊函式。UCC原始碼中的ucl\gen.c的TryAddValue()函式給出了相關程式碼,如圖2.5.9所示。圖中第4行就是一個簡單的雜湊函式,因為雜湊表valueNumTable中只定義了16個連結串列,所以整數h的值要落在區間[0,15]之間。如果在程式碼中訪問全域性變數a的地址,例如使用過”ptr = &a;”,則即可以通過”*pt=3;”也可以通過”a = 3;”來改變a的值,此時要判斷變數a的內容是否發生變化就需要做更復雜的分析。UCC編譯器採取的策略很簡單,此時不再去使用”a+b”這樣的已計算過的值,而是重新進行計算。圖中第9行的if語句就是針對這種情況進行判斷。從這裡,我們也能再次看到UCC編譯器在優化上還有待加強。
圖2.5.9 TryAddValue()
圖中第12至19行用於在雜湊表中查詢是否存在已經在同一個基本塊中計算過的相同的子表示式。在第18行我們看到了對def->dst是否為NULL的判斷,而在圖2.5.6的TrackValueChange()函式中,我們正是通過”use->def->dst= NULL;”來使子表示式”a+b”無效。如果能找到,則從圖2.5.9第19行返回,否則我們需要重新計運算元表示式,並把結果儲存到第22行建立的新的臨時變數中,之後還要在第24至26行把新生成的valueDef物件加入到雜湊表中。
以上我們解決了如何快速查詢形如”a+b”的公共子表示式的問題。另一個問題就是大量的structsymbol物件要如何存放的問題。圖2.5.7中的第94行的struct table正是用來做這個事情的。這也是我們本節標題中所說的符號表。檢視ucl\symbol.c的函式AddSymbol(),我們不難發現,符號表仍然是採用“雜湊表”的資料結構,如圖2.5.10所示。
圖2.5.10 AddSymbol()
所採用的雜湊函式在第120行,而巨集SYM_HASH_MASK的值為127,由第126行我們可知,符號表struct table共有128個雜湊桶(bucket)。第124至130行在雜湊表為空時,從堆空間分配128個雜湊桶。之後在第132至134行,用於往符號表裡新增一個新的符號。下面,我們再結合一個簡單的C程式,來討論一下圖2.5.7第98行outer成員的作用。
圖2.5.11 C語言的作用域
如圖2.5.11所示,在第2行定義了一個全域性變數a,在第5行定義了一個同名的區域性變數a,而在第8行又定義了一個區域性變數a。在C語言中,一對大括號一般代表一塊新的作用域。當然,第6行的大括號是用於初始化陣列,並不代表一個作用域。由標準C的文法,我們可知一對大括號包括的語句實際上對應的是複合語句compound-statement,以左大括號開始,之後跟著若干個宣告,再跟上由若干條statement,最後是右大括號。在C語言中,函式體實際上就是一個複合語句,如圖2.5.11的第3至第10行所示。當然,複合語句內部還可以包含新的複合語句,如上圖的第7至第10行所示。
compound-statement:
{ declaration-listopt statement-listopt }
每一個複合語句對應一個新的作用域,在UCC編譯器內部,每當進入一個新的作用域時,我們就會建立一張新的符號表,用來記錄在該作用域中宣告的符號。圖2.5.12給出了UCC編譯器為圖2.5.11所建立的符號表。
圖2.5.12 多個作用域的符號表
由圖2.5.12,我們可知,上述程式中”int a = 10;”對應符號a是存放在level為0的符號表中,而在level為1的符號表中,我們並沒有定義新的區域性變數,但在level為2的符號表中,我們又定義了”int a = 20;”,在level為3的符號表中,定義了”int a = 30;”。當程式執行到圖2.5.11的第13行時,當前符號表是level為1的符號表,第13行”a = 60;”需要訪問符號a,我們要檢查一下當前符號表中是否符號a,如果沒有,就通過outer指標查詢外層的符號表,此時會在level為0的符號表中查詢到全域性變數a。通過圖2.5.12的多級符號表,我們實際上實現了C語言中“作用域scope”的概念。