標籤:c編譯器 函數調用 語義檢查 符號表
4.2.4 函數調用的語義檢查
在這一小節中,我們來討論一下函數調用的語義檢查,文法上,函數調用對應的運算式屬於尾碼運算式PostfixExpression,UCC編譯器exprchk.c的函數CheckFunctionCall()完成了對函數調用的語義檢查,4.2.18所示。在閱讀這份代碼時,需要對文法分析後為函數調用構造的文法樹有較好認識,請先參照”圖3.1.21尾碼運算子對應的文法樹”或者先預覽一4.2.19。
圖4.2.18 CheckFunctionCall()
對形如f(a,b,c)的函數調用進行語義檢查時,我們需要先尋找符號表,看看函數f是否已經聲明過,圖4.2.18第6和第7行進行了這個判斷。按C標準的規定,如果f未經聲明就使用,則將函數f視為舊式風格的函式宣告,相當於f的聲明為int f()。我們在“2.4節C語言類型系統”時已做過介紹,舊式風格函數會帶來令人抓狂的噩夢。這也應是C++編譯器禁止“函數未聲明就使用”的原因。從這一點,我們也能再次體會,C++並非是C語言的超集,C++只是儘可能地去相容已有的C,對於實在看不下去的部分也採取了“摒棄”的策略。第8行的DefaultFunctionType則代表“形如int f()的舊式風格函式宣告所對應的類型”,第9至11行把這個隱式聲明的int f(),通過函數AddFunction添加到全域符號表中。當然,如果我們之前已經對函數f做了形如int f(int,int,int)的聲明,此時就能在符號表中找到函數f的相關資訊,或者當我們是通過形如(*ptr)(a,b,c)的方式來調用函數,此時運算式(*ptr)不是op域為OP_ID的運算式結點,則在第13行調用CheckExpression函數來對錶達式f或者(*ptr)進行語義檢查。經過第15行的Adjust()函數的類型調整後,如果“f或者(*ptr)對應結點”的類型不是“指向函數”的指標,那我們在第18至20行進行錯誤處理;否則,我們在第22行記下函數的類型資訊。對於圖4.2.19中的f對應的結點來說,其類型為“指向函數int(int,int,int)”的指標,即int(*)(int,int,int),因此,我們在圖4.2.18第22行記下的就是形如int(int,int,int)的類型資訊,第56行則記下了函數返回值的類型,即運算式f(30,40,50)的類型為int。關於函數類型的資料結構,請參考第2章的”圖2.4.9 函數的類型結構”。
圖4.2.18第24至39行用於對函數調用中的各個實參進行檢查,主要的工作由第30行的CheckArgument()來處理,檢查的內容包括實參個數是否與形參個數吻合,實參與形參在類型上是否匹配,這相當於要檢查能否把實參賦值給形參。第40至55行在實參個數和形參個數不一致時,會給出警告或者錯誤提示。對於形如int f()的舊式風格的函式宣告來說,形參列表並不是其函數介面的一部分,即第42和50行的hasProto為0(不存在原型prototype)。原型prototype的意思是“這是範本,我們得依樣畫葫蘆,範本有幾個形參,調用時就得有幾個實參”,此時我們仿照Clang編譯器的做法,給出警告,如第47和53行所示。對於形如int f(int ,int,int)的新式風格函式宣告,我們就得照著原型來進行函數調用了,不然就要報錯了,如第44和51行所示。圖4.2.18第36至39行的代碼用於檢查多餘的實參,例如函數調用f(30,40,50,60,70),因為在新式風格的聲明int f(int,int,int)中,我們只聲明了3個形參。第30行的CheckArgument()在處理新式風格函數的參數時,如果發現已經檢查完3個實參了,就會置第27行的變數argFull為1,此後不必再執行第29行的while迴圈。由於語義檢查時,文法樹結點會發生變化,甚至是重新構建,所以我們要在第30行在記錄下CheckArgument()的返回值,而對於多餘的實參,則只是在第37行調用CheckExpression()檢查一下,此時實際上已經遇到“形參個數與實參個數不一致”的錯誤了。
圖4.2.19 函數調用的文法樹
接下來我們來分析一4.2.18第30行的CheckArgument函數,4.2.20所示。第5行用於擷取新式風格的函數的形參個數,第6行仍然是遞迴地調用CheckExpression函數進行實參運算式的語義檢查,如果新式風格的函式宣告形如f(void),即不存在參數,則在第8行設定相應標誌位為1,表示對新式風格的函數實參已檢查完畢,然後從第9行直接返回。對於形如f(int,int,int)的函式宣告來說,f不是變參函數,若當前要檢查的實參是最後一個,則第11行的條件成立,此時在第12行置相應標誌位為1。而對於形如int f()的舊式風格的函數來說,形參列表並不是函數介面的一部分,我們需要對函數調用中的每個實參進行實參提升的操作,這由第15行的PromoteArgument()函數來完成。對於新式風格的函數,我們需要檢查一下實參能否賦值給對應的有名形參,第20行的CanAssign()函數用來做這個判斷。出於記憶體對齊的考慮,C編譯器通常會把小於int類型的實參(例如char或者short)轉換成int後入棧,第23行執行了這個由編譯器隱式進行的轉型操作。但是,對於新式風格函數中的float類型實參,並沒有進行提升到double類型的動作,這與PromoteArgument()是有所區別的。因為進行過第20行的CanAssign()的判斷,所以在第25行,我們在進行實參給形參賦值時,可以進行安全的類型轉換。第28行則是用於處理新式風格函數中的變參函數,用於對“無名參數”進行實參提升的操作。閱讀CheckArgument()的代碼時,若對函數的類型結構不是太清楚,請參考”圖2.4.9 函數的類型結構”。
圖4.2.20 CheckArgument()
圖4.2.20第20行的CanAssign()函數幾乎是嚴格按照C標準文檔ansi.c.txt第” 3.3.16.1Simple assignment”節來編寫的,該小節的文檔規定了哪些情況下可以進行賦值操作,相關代碼4.2.21所示。我們有意在第9至12行保留了一段來自ansi.c.txt中的語義規則,第13行的if語句實現了這個判斷,即如果賦值運算的左右運算元都是算術類型,則可以進行賦值。實參賦值給形參時可能需要的強制轉型操作,我們已在圖4.2.20第22至25行完成。
圖4.2.21 CanAssign()
根據賦值運算的左運算元和右運算元的類型,我們在以下情況下可以進行賦值操作,對這些情況的判斷是按以下排列依次進行的。
(1) 兩者類型一致,對應圖4.2.21第6行。
(2) 兩者都是算術類型,對應第13行。
(3) 兩者是相容的指標類型,例如T1 * 和T2 *,其中T1和T2的類型相容,函數IsCompatibleType()會對類型是否相容進行判斷,我們會在後續章節對這個函數進行分析。若T1和T2在限定符上也一致(即有相同的const或volatile)。第16行對此進行判斷
(4) 兩者都是指標類型,形如T1 * 和T2 *,其中T1和T2在限定符上要一致,且其中一個是void,但另一個不能是函數類型(即要求是結構體和double等描述資料的物件類型),對應第19行。
(5) 左運算元的類型為指標類型,右運算元為常數0,對應第23行。
(6) 兩者都是指標類型,對應第26行,此時在第27行給出一個警告。
(7) 一個是指標類型,另一個是整數類型,但兩者占同樣大小的記憶體空間,對應第30行,此時在第32行給出一個警告。
從中,我們可以發現,不同類型的結構體對象是不能進行賦值的。接下來,我們來分析
一4.2.20第23行用到的Cast()函數,其相關代碼4.2.22所示。真正構建轉型運算OP_CAST結點的代碼在圖中第31行的CastExpression(),第37行建立一個文法樹結點,第39行置其op域為OP_CAST,第40行記錄轉型後的結點類型,第41行則記錄轉型前的運算式。當然,如果是對形如第34行的常量3進行轉型,則可以在編譯時間進行簡化,我們直接取3.0f即可,這個工作由FoldCast()函數完成,該函數在fold.c中,應該較好理解,我們不再囉嗦。圖4.2.22第48至53注釋中列出了UCC編譯器內部的各種資料類型,第2行的I4表示要佔用4個位元組的有符號整數,在32位系統上對應int或者long;U4表示要佔4個位元組的不帶正負號的整數;F4表示佔4位元組的浮點數,對應float;F8表示要佔8位元組的浮點數,V表示VOID,而B則代表Block對象,對應聯合體、數組或結構體對象。第55行的optypes[]用於記錄這樣的映射關係,這樣通過調用第44行的函數TypeCode,我們可以快速地得到與類型結構對應的形如I4這樣的類型編碼。第2行注釋裡的類型編碼先後次序,是有意進行安排的,按照這樣的順序,我們能比較快捷地進行類型判斷,例如第8至9行的if語句就是用來判斷“兩個類型是否都為佔據相同大小記憶體空間的整型”,例如short和unsigned short就滿足這個條件。
圖4.2.22 Cast()
對於佔據相同記憶體大小的整數類型來說,例如char和unsigned char, short和unsigned short,當進行有符號char和unsigned char之間的類型轉換時,存放在記憶體單元的資料並沒有發生任何變化,我們只需要記錄該單元的類型發生了改變。在這種情況下,我們只要執行圖4.2.22第17行的代碼,在相應文法樹結點上記下轉型後的新類型即可,這個新類型會影響我們在代碼產生階段時的指令選擇。例如,對於有符號整數的右移,我們要在最高位補上符號位,我們選擇的彙編指令為sar;而對於不帶正負號的整數的右移,我們在最高位補0即可,我們選擇的彙編指令是shl。在UCC編譯器中,即使是在把char類型的變數c1強制轉型為float時,即(float) c1,我們也是分兩步走,第一步先把char提升為int,之後再進行int到float的轉型運算元。在早期,C語言的int類型的大小反映的是CPU通用資料寄存器的大小,CPU當然期望運算元恰好就放在其資料寄存器裡。圖4.2.22第20至23行完成了第一步由char到int的提升,而第28行則進行了第二步由int到float的轉換。反之,如果要把float類型的變數f強制轉型成char類型,我們也分兩步走,第一步執行float到int的轉換,第二步再進行int到char的轉換,第25至28行完成了這兩步的工作。而第5至第7行的代碼則用於把運算式強制轉換成void,這通常用於以下情況,對於未在函數體中被使用的參數arg,有些編譯器會給出一個警告,避免這個警告的一個做法就是加上(void) arg的語句。
void f(int arg){
(void) arg;
// …
}
而圖4.2.22第11至16行的代碼則相對比較微妙,下面,讓我們結合一個具體的例子來解釋這些代碼,4.2.23所示。在UCC編譯器中,參與算術運算的char或short都會被先提升為int類型,再進行算術運算,例如對圖4.2.23第8行的算術右移來說,short類型的運算元s會先被轉型為int,如第19至20行的文法樹(cast int s)所示,之後再把int型的結點按照第8行的C語句的要求轉型為unsigned int。由於int和unsigned int同樣佔4個位元組,如果沒有圖4.2.22第11至16行的代碼,則我們不會調用CastExpression函數去構造一個OP_CAST運算的文法樹結點,而只是把s>>1對應結點的類型設定為unsigned int,但是圖4.2.23第8行的(int)轉型又會把s >> 1對應結點的類型設定為int。在UCC編譯器的彙編代碼產生時,例如ucl\x86.c的函數EmitAssign (IRInst inst)中,我們是根據中間代碼inst的類型來決定選用sar還是shr指令,而inst的類型又來源於文法樹結點s>>1的類型,我們在討論中間代碼產生的函數TranslateBinaryExpression()時就能看到這一點。這會導致我們錯誤地把s >>1結點的類型當作有符號數int,從而在代碼產生時選用算術右移指令sar,從而產生錯誤的結果。按照第8行的C語句,我們應該選用邏輯右移指令shr,在最高位補0而補上符號位。由於這樣的原因,當進行I4和U4之間的類型轉換時,我們調用圖4.2.22第15行的CastExpression()函數來顯式地構造一個轉型運算的文法樹結點,4.2.23第18行所示。
圖4.2.23 轉型所對應的文法樹
稍微小結一下,在UCC編譯器中,參與算術運算的小於int的運算元(char,unsigned char,short或unsigned short)都會被提升到int,例4.2.23第8行的short類型的變數s。要注意的是即使是進行兩個char類型變數的加法,例如c1+c2,我們也是先會c1和c2提升為int,然後做32位的加法運算。而進行強制類型轉換時,例4.2.23第9行的(float) c1和第10行的(char) f,我們也是以int類型作為中轉,4.2.23第22至30行的文法樹所示。以int型作為運算元,實際上意味著我們總是試圖充分利用CPU的通用資料寄存器。理解了Cast()函數,那麼再理解前文提及的用於實參提升函數PromoteArgument,就是件很容易的事情了。
static AstExpressionPromoteArgument(AstExpression arg){
Typety = Promote(arg->ty);
returnCast(ty, arg);
}
Type Promote(Type ty){
return ty->categ< INT ? T(INT) : (ty->categ == FLOAT ? T(DOUBLE) : ty);
}
而對於前文提及的用於判斷兩個類型是否相容的函數IsCompatibleType(),我們會在後
續章節中進行討論。要理解這個函數,我們需要對“2.4節 C語言的類型系統”中介紹的各種類型結構有感性的認識,還是那句話,看起來很笨,但很有效辦法是用一個紙制的筆記本,把我們在2.4節給出的類型結構和在第3章構造的文法樹畫在紙上,對照著這些圖來閱讀代碼,才不至於在龐大的文法樹上和複雜的類型結構裡迷失方向。
前文中,另兩個比較微妙的函數是圖4.2.18第7行的LookupFunctionID函數和第10行的AddFunction函數。讓我們結合圖4.2.24中的例子來對此進行討論。在函數f的函數體中,我們在圖4.2.24第3行中對函數h進行了聲明,這導致我們無法在第5行中再次聲明int類型的變數h。但是,在函數體f中,對函數h的聲明不僅要在局部符號表中佔據一項(由此,當第5行的局部變數h企圖填入局部符號表時,我們在對聲明進行語義檢查時就能報錯),而且還要在全域符號表中佔據一項(這樣,我們可以第8行成功調用h(3,4))。第3行對函數h的聲明,與第4行對局部變數a的聲明有較大不同,第4行的a在函數f的函數體外是無法進行訪問的。
圖4.2.24 函數體中的函式宣告
另外,對於以下兩個函式宣告來說,C編譯器會把它們視為彼此相容Compatible的函式宣告,函數IsCompatibleType()會用來檢測兩個類型是否相容。
int g(int (*)(), double (*)[3]);
int g(int (*)(char *), double (*)[]);
在這種情況下,C編譯器並不會報錯,而是為這兩個相容的宣告類型,構造一個“相當於最大公因子的”類型,這個“最大公因子”在C標準文檔中被稱為合成類型CompositeType,對以上兩個聲明來說,最終合成的最大公因子如下所示。UCC編譯器type.c中的函數CompositeType(ty1,ty2)用於實現這個合成操作。我們會在後續章節對IsCompatibleType和CompositeType等與類型系統相關的函數做進一步分析。
int g(int (*)(char *), double (*)[3]);
由於有這樣的微妙的語義,我們期望只在全域符號表中存放函式宣告的實際類型資訊,這樣但要通過CompositeType()函數來改變已有函式宣告g的類型資訊時,我們只要改變全域符號表中的相應符號就可以,而不用去改動其他的符號表。而在局部符號表中,我們只放置一個預留位置,以便能檢測出形4.2.24第5行注釋所示的重定義錯誤。UCC編譯器中的AddFunction函數實現了往全域符號表中添加函數的操作。UCC編譯器原始的ucc162版本中,並沒有LookupFunctionID函數,原有的代碼中只有LookupID函數。為了實現上述函式宣告的語義,同時能盡量少地對原來的代碼進行改動,我們在ucc162.2版中,添加了LookupFunctionID函數,當然,得很不好意思地承認,其中的代碼較為晦澀,這個補丁打得相當之ugly,在後續版本中,我們需要再做改進。原有LookupID函數對符號表的檢索是從當前符號表開始,如果找不到,則再尋找外層的符號表,直到全域符號表為止,其代碼4.4.25所示。對符號表的查詢操作實際上由第1行的DoLookupSymbol函數來完成,第3行計算出雜湊值,第6至9行的for迴圈根據這個雜湊值在相應的雜湊桶上進行尋找。如果存在更外層的符號表,且我們想尋找外層的符號表,則第11行的while條件會成立。
圖4.4.25 LookupId()函數
在UCC編譯器的內部,用來存放符號的資料結構主要有兩種,一種是雜湊表,一種是單鏈表,ucl\symbol.c中定義的一些變數用於此目的,4.4.26所示。第3行的雜湊表GlobalTags用於存放在函數體的外部聲明的結構體struct、聯合體union和枚舉enum的名稱,這些名稱在C標準文檔中被稱為Tag。這個單詞常被譯為“標籤”,而語句gotoAgain中的標號Again對應的英文單詞為label。較易混淆的是label這個詞也常被譯為標籤。而第5行的GlobalIDs用於存放全域的變數名和函數名,常量(例如123)則存放於第7行的雜湊表Constants中。由此,我們可以看到,即使是在同一範圍中聲明的結構體名struct Data和變數名int abc,也是存放在不同的雜湊表中的。第9行的指標Tags指向了當前範圍的用於結構體名的符號表,而第11行的指標Identifiers則指向了當前範圍的存放變數名和函數名的符號表。為了後續階段產生代碼的方便,我們還會把函數名對應的符號連結在一起,其鏈首為第16行的Functions,而全域變數和靜態變數所在符號鏈的鏈首為第18行的指標Globals。第21行的Strings和第23行的FloatConstants分別為字串和浮點數的符號鏈的鏈首。而第14行的FunctionTails等指標則始終指向相應符號鏈的鏈尾,由此可方便地進行插入操作。
圖4.2.26 與符號有關的資料結構
對於通過typedef關鍵字建立的類型名,例如如下所示的typedef int Data,UCC編譯器也把Data存入變數名a所對應的符號表中,即由圖4.2.26第11行的Identifiers所指向的當前符號表,而struct Data中的結構體名Data則存入由圖4.2.26第9行Tags所指向的符號表。且在C語言中,使用結構體名時,我們需要帶上struct關鍵字,因此,在以下代碼中,我們可以無歧義地把局部變數a聲明為Data類型,即int型,而非struct Data類型。而在C++中,使用結構體或類名時,可以不需要struct或class關鍵字,反而引起“在Data a = 3中到底用哪個Data”的二義,因此C++編譯器會對以下代碼報錯。
void f(void){
struct Data{
int a;
};
typedef int Data;
Data a = 3;
}
函數AddFunction()的代碼4.2.27所示。圖4.2.27第4至11行建立了一個類別為SK_Function的符號,UCC編譯器會把函數名對應的符號通過第15行的AddSymbol函數加入到全域符號表GlobalIDs中。UCC編譯器還用一個單鏈表來記錄所有的函數名對應的符號,其鏈首為圖4.2.26第16行的Functions變數。由於同一個符號對象即可能在單鏈表中,又可能在雜湊表中,所以在與第4行FunctionSymbol對應的結構體struct functionSymbol對象中,next域用於形成單鏈表,而link域則用於形成雜湊桶中的鏈表。
圖4.2.27AddFunction()
而在使用圖4.2.27第19行的LookupFunctionID()函數來檢索當前符號表時,參數placeHolder決定我們是否需要在當前符號表中添加一個預留位置。由於預留位置的存在,當刪去圖4.2.24第5行時的注釋號//時,UCC編譯器就會檢測到重定義的錯誤” error: redefinition of h”。當前符號表可能是全域符號表,也可能是局部符號表,符號表的嵌套結構請參見第2章的” 圖2.5.12 多個範圍的符號表” 。文法上,C語言複合陳述式的一對大括弧就代表了一個新的範圍,對應一張新的符號表。圖4.2.27第26至31行完成了往局部符號表中添加一項類型為DefaultFunctionType類型函數的符號。由於我們把函式宣告真正的類型資訊都存放在全域符號表中,所以我們會在第33和38行來完成對全域符號表GlobalIDs的檢索。由LookupFunctionID函數返回的符號不一定函數類型,也可能是普通的變數,LookupFunctionID函數的調用者會根據其調用時的上下文,來決定要根據返回值來做何處理。通過SourceInsight來查看函數LookupFunctionID在何處被使用,結合其上下文就能較好地理解,這裡不再重複。想囉嗦的是,閱讀代碼時,有時需要結合其調用的上下文才能更好地理解其含義。函數LookupFunctionID是作為LookupID函數的補丁,在ucc162.2.tar.gz版本中貼上去的,勉強補漏而已,談不上美觀,與晦澀倒是沾點邊。其中的部分原因是,在C原始碼中,可能存在多個對彼此相容的函式宣告,我們希望只用一個struct symbol對象來記錄一個函數名的類型資訊。按UCC編譯器的現有代碼,這個struct symbol對象只存於一個雜湊表中,因為struct symbol的結構體中只有一個link域用來構成雜湊桶的鏈表。如果要讓一個struct symbol對象可以同時存在於多個雜湊表中,我們可以乾脆不用struct symbol中定義的link域,而是在把一個符號通過AddSymbol()函數插入雜湊桶時,產生一個如下所示的struct BucketLinker對象,由sym域來指向要插入的struct symbol對象,而linker域則用來構成雜湊桶中的鏈表。在後續版本中,或許我們可按這個思路進行改進。
struct BucketLinker{
struct BucketLinker * linker;
struct symbol * sym;
};
C編譯器剖析_4.2 語義檢查_運算式的語義檢查(4)_函數調用