編譯器構造
一、 編譯器簡介
前面談到靜態鏈接器構造的基本流程,最后提到所構造的鏈接器若要能正常工作的前提是需要構造一個能生成符合鏈接器輸入文件格式的編譯器,本文構造一個符合這種具體格式要求編譯器。但是編譯器的直接編譯的結果一般是匯編語言文件,這種文件是不能滿足上述靜態鏈接器的需求的,因此在它們之間還需要一個匯編語言程序將匯編語言轉換為二進制文件作為鏈接器的輸入。恰如圖1-1所示,

圖 1-1 靜態編譯步驟
上次引用這張圖是為了說明靜態編譯器的整體結構,而這次我們側重於編譯程序的構造的流程,在具體展開編譯器構造的討論之前,我們先簡單介紹一下編譯器的基本知識。
編譯從本質上講就是語言翻譯程序,它是把一種語言(稱作源語言)書寫的程序翻譯為另一種語言(稱作目標語言)的等價程序。源語言作為編譯器的輸入,必須讓編譯器“知道”自己的語法結構——文法,這樣編譯器才能正確處理語言的結構。所以編譯器設計的第一步應該是源語言文法定義。
編譯器要處理源語言文件(源文件),必須掃描文件內容,提取出文件內的語法基本單元,比如標識符,關鍵字,界符等,這一步在編譯中稱為詞法分析,通過這一步,編譯器能獲得源文件表達的所有語言單位。
接下來,編譯器需要分析這些語言單位的組合的合法性以及整體結構,這里編譯原理提供了很多成熟的分析算法,這步成為語法分析,語法分析將合法的程序轉換為一個邏輯上的語法樹形式,方便后邊的處理。
另外,由於程序設計語言雖然是結構上是上下文無關的文法,但是實際應用中程序中每個語句並不是獨立的,那么如何反應這種聯系的存在,語義處理的工作就顯得非常必要,它驗證了語法模塊之間的關聯的合法性。
通過以上的步驟,編譯器就能判斷源程序的合法性,如果是合法程序,編譯器就會進行最后一步關鍵的工作——代碼生成,這一步在現代編譯器中實現方式多樣,例如gcc會先生成中間代碼,經過優化后再生成匯編語言,但是本文為了簡化編譯的流程,直接從語法樹過渡到代碼生成,按照語法樹結構產生源文件對應的匯編代碼。
貫穿整個編譯流程中,符號表具有很重要的作用,它記錄編譯過程中許多關鍵的數據結構,方便編譯器存取符號相關信息。最后,錯誤處理模塊會在合適的地方報告編譯的錯誤信息。
圖 1-2 直接編譯步驟
為了和前述的靜態鏈接器結構保持兼容,這里編譯器的設計結構需要作特殊說明。鏈接器需要多個目標文件作為輸入,因此,編譯器生成的匯編文件就應該是多個,每個匯編文件會映射為一個目標文件。這樣,編譯器就不能采用前邊所述的直接編譯生成一個孤立文件的方式,圖1-2,而是采用多文件分別處理的方式進行。由於之前實現了一個直接編譯方式的編譯器,所以必須對編譯器結構進行修改以滿足鏈接器的需要。
既然是對單個的源文件進行編譯,就必須要求編譯器能處理引用的外部變量和函數,這里主要集中在extern變量和函數聲明的語法結構上。為了清晰的闡述編譯器的設計過程,下邊就按照上述編譯器設計的基本步驟闡述每個具體細節,圖1-3展示了編譯器的設計結構。
圖 1-3 編譯器結構設計
二、 文法定義
一個程序設計語言是一個記號系統,它的完整定義包含語法和語義兩個方面。語法規定了語言的書寫規則,而語義定義了語言上下文之間的聯系。因此,語言的形式化定義必須通過語法規則來表達,而語法規則就是所謂的文法。
Chomsky於1956年建立了形式語言的描述,他把文法分為四種類型,即0型、1型、2型、3型。這四種文法的類型的范圍是依次縮減的,其中2型文法(亦稱為上下文無關文法)能很好的表達現代程序設計語言的結構,所以,一般程序設計語言都滿足2型文法的規則。
作為編譯器處理的核心對象,高級語言的結構直接關系着編譯系統的結構。本系統處理的高級語言主體是C語言的子集 ,另外對標准C語言的語法進行了適當的刪減和擴充。
自定義高級語言基本特性:
(1)類型:支持int、char、void基本類型和復雜的string類型。
(2)表達式:支持四則運算,簡單關系運算和字符串連接運算。
(3)語句:賦值、while循環、if-else條件分支、函數調用、return、break、continue、輸入in>>、輸出out<<語句。
(4)聲明和定義:變量、函數聲明定義,外部變量聲明extern。
(5)其它:支持多文件、默認類型轉換、單行/多行注釋等。
自定義語言盡可能接近C語言的格式,以使得編譯器的重點放在處理高級語言的過程上,而不過多關心復雜的語言細節,下邊給出了自定義的語言的文法定義,見表2-1。
表 2-1 文法規則

文法定義中^表示空符,<>內表示非終結符,其他為終結符,稍后在詞法分析中針對此具體說明。
三、 詞法分析
詞法分析是編譯的第一個階段,它的任務是從左向右逐個字符地對源程序進行掃描,產生一個個單詞序列,用於語法分析。執行詞法分析的程序稱為詞法分析程序或者掃描程序。
在詞法分析過程中,最關鍵的是對詞法記號的描述。一般情況下,編譯系統使用正則文法來描述詞法的規則,而對正則文法識別的工具就是有限自動機。解析正則文法的有限自動機有時候可能不夠簡潔,這樣就需要把不確定的有限自動機(NFA)轉化為確定的有限自動機(DFA)。通過有限自動機把詞法記號識別出來,就完成了詞法分析的工作。
詞法分析的主要目的就是從源文件中獲取合法的詞法記號,主要功能如下:
(1)掃描輸入文件,消除注釋、無效空格、TAB、回車符。
(2)識別標識符、關鍵字、常量、界符等,產生詞法記號。
(3)識別詞法錯誤(記號過長、意外字符等)。
詞法分析器一般包括掃描器和解析器兩部分,掃描器從文件中讀入字符,解析器將掃描出來的字符轉換為詞法記號。本系統定義的所有詞法記號如表3-1所示:
表 3-1 詞法記號
3.1 掃描器
掃描器從源文件按字節讀入字符數據,將一組字符放入緩沖區。當需要獲取字符的時候,從緩沖區中讀取,用這種方式可以提高字符讀取的效率,代碼如下。
{
if(chAtLine>=lineLen) // 超出索引,行讀完,>=防止出現強制讀取的bug
{
chAtLine= 0; // 字符,行,重新初始化
lineLen= 0;
lineNum++; // 行號增加
ch= ' ';
while(ch!= 10) // 檢測行行結束
{
if(fscanf(fin, " %c ",&ch)==EOF)
{
line[lineLen]= 0; // 文件結束
break;
}
line[lineLen]=ch; // 循環讀取一行的字符
lineLen++;
if(lineLen==maxLen) // 單行程序過長
{
// 不繼續讀就可以,不用報錯
break;
}
}
}
// 正常讀取
oldCh=ch;
ch=line[chAtLine];
chAtLine++;
if(ch== 0)
return - 1;
else
return 0;
}
掃描器算法流程如圖3-1所示,
圖 3-1 掃描器算法流程
從算法中可以看出,緩沖區對應line數組,每個調用getChar可以將一個字符讀入變量ch,oldCh記錄上一個字符,lineNum記錄了行號方便定位錯誤位置。
3.2 解析器
解析器從掃描器緩沖區不斷讀入字符。將字符與表示語言詞法規則的有限自動機匹配,若成功則產生詞法記號,否則報告詞法錯誤。
標識符的解析流程與有限自動機DFA映射關系如圖3-2所示,根據有限自動機結構,若讀入的字符改變了有限自動機的狀態,則提供條件分支判斷;若狀態不變,則提供循環程序結構;若遇到終結符則表示識別該詞法記號,停止該部分有限自動機的運行。繼續獲取字符,直到將所有的詞法記號識別完為止。
圖 3-2 標識符解析流程與DFA映射關系
標識符識別代碼如下:
{
while(ch== ' '||ch== 10||ch== 9) // 忽略空格,換行,TAB
{
getChar();
}
if(ch>= ' a '&&ch<= ' z '||ch>= ' A '&&ch<= ' Z '||ch== ' _ ') // _,字母開頭的_,字母,數字串:標識符(關鍵字)
{
int idCount= 0; // 為標識符的長度計數
int reallen= 0; // 實際標識符長度
int f; // getChar返回標記
// 取出標識符
do
{
reallen++;
if(idCount<idLen) // 標識符過長部分掠去
{
id[idCount]=ch;
idCount++;
}
f=getChar();
}
while(ch>= ' a '&&ch<= ' z '||ch>= ' A '&&ch<= ' Z '||ch== ' _ '||ch>= ' 0 '&&ch<= ' 9 ');
id[idCount]= 0; // 結尾
if(reallen>idLen) // 標識符過長
{
lexerror(id2long, 0);
}
checkReserved();
return f;
}
}
其他詞法記號的識別方式如下:
(1)關鍵字識別。和標識符相同,不過在識別完成后要查詢系統預留的關鍵字表,若查詢結果不為空則作為關鍵字處理。
(2)單行注釋識別。讀取//兩個字符,直到行結束(換行符\n)。
(3)多行注釋識別。讀取/*兩個字符后,直到出現*/結束,中間忽略所有字符。這里多行注釋識別簡化了,因為無法識別包含*的注釋段。
(4)數字識別。從讀入第一個數字字符開始直到非數字字符結束。
(5)串識別。從讀入雙引號開始,直到出現下一個分號為止,中間的所有符號都作為串的內容處理。
(6)字符識別。從讀入單引號開始,讀取下一個字符作為字符內容,再識別下一個符號是否匹配單引號,否則產生詞法錯誤。
(7)其他界符。單字符界符直接識別即可,雙字符界符需讀入連續兩個字節匹配后才認為識別成功。
通過以上的詞法記號識別算法可以識別當前自定義語言的所有詞法記號。
3.3 異常處理
在詞法分析時,若出現意外,則返回無效的詞法記號,然后繼續分析。詞法錯誤處理的原則是出現詞法錯誤不影響詞法分析的進行。返回無效詞法記號時稱為詞法分析出現意外(即異常,並不一定是錯誤)。總共有以下幾種情況:
(1)處理完注釋,注釋不能作為有效的詞法記號,雖然能正常識別。
(2)出現詞法錯誤。返回無效詞法記號,繼續詞法分析,識別后續正常的詞法記號。
(3)文件結束:文件結束后返回-1作為符號,此符號是無意義的記號,但是標識編譯的結束條件。
(4)意外字符:文件中出現預期以外的字符時當作異常處理。
(5)有限自動機異常終止。例如識別字符時,在單引號和一個字符后沒有出現另一個單引號,此時拋出異常。
由於詞法分析的這種錯誤處理機制,在進行語法分析時必然會讀取無效詞法記號,此時需要一個過濾器將無效字符過濾掉再進行語法分析。過濾器不是詞法分析器的必須結構,可以將其作為語法分析的預處理過程。所有的詞法錯誤如表4-2所示:
表 3-2 詞法錯誤

四、 語法分析
文法描述了程序語言的構造規則,語法分析就是通過對源程序掃描解析出來的詞法記號序列識別是否是文法定義的正確的句子。一般情況下語法分析分為兩種形式,一種是自頂向下的語法分析方法,另一種是自底向上的語法分析方法(具體內容參考編譯原理教材)。本系統采用最容易實現的LL(1)的遞歸下降子程序分析算法。
在一遍編譯器的結構中,語法分析是整個編譯器的核心部分,幾乎所有的模塊都依賴於語法分析模塊。主要功能如下:
(1)將過濾后詞法記號和文法規則進行匹配。
(2)識別語法模塊。
(3)出錯時能進行錯誤恢復。
(4)正常時更新符號表內容,並產生語義動作。
由於詞法分析產生的詞法記號有時候是異常符號,再進行正式語法分析之前,必須對這些符號進行過濾。
4.1 過濾器
除了過濾無效的詞法記號功能外,過濾器還有一個重要的作用是允許在語法分析器獲取詞法記號的時候暫停讀取符號一次。這種方法本質上違背了LL(1)分析算法的初衷,因為LL(1)只允許超前查看一個詞法記號。但是有了這種“回退一次”機制,LL(1)可以多向前查看一個字符作為預分析,然后再暫停一次,雖然只能暫停一次。LL(1)只能分析正常的語法,當語法出錯需要恢復的時候就無能為力了,本文的過濾器算法能夠實現錯誤修復功能。
過濾器的工作流程如圖4-1所示:
圖4-1 過濾器工作流程
對應代碼如下:
int nextToken()
{
if(wait== 1) // 處理BACK
{
wait= 0; // 還原
return 0;
}
int flag= 0;
while( 1)
{
flag=getSym();
if(sym== null||sym==excep) // 無效符號掠過
{
if(flag==- 1) // 文件結束
{
oldtoken=token;
token= null;
return - 1;
}
}
else // get effective symbol
{
oldtoken=token; // 上一個符號
token=sym; // 當前符號
return 0;
}
}
}
4.2 遞歸下降子程序
語法分析是編譯器的核心,而語法分析算法LL(1)則是語法分析器的核心。一般情況下隨意構造的文法有可能不滿足LL(1)的要求,因此需要對文法做出修改,使之滿足LL(1)的要求。編譯教材里給出兩種基本的修正方式:合並左公因子和消除左遞歸。構造出的滿足LL(1)文法上述已經給出,下邊需要將該文法轉化為語法分析程序。如圖4-2展示了一個while語句的識別子程序。
圖4-2 遞歸下降子程序與文法映射關系
可以看出,LL(1)文法和遞歸下降子程序映射關系很明確:將文法規則中的非終結符轉化為子程序定義或者調用,而終結符轉化為詞法記號的匹配。
可以證明,這種映射方式可以正確的識別LL(1)定義的語言。但是當源程序有錯誤的時候,這種直接識別方式會有很大的弊端,因此需要對其進行改進。
4.3 錯誤處理
當詞法記號不能被文法規則匹配的時候就會產生語法錯誤,否則就對正確的文法模塊產生語義動作。但是,出現語法錯誤時不能停止語法分析的進行,以保證能及時發現更多的語法錯誤。因此,更不能因為前邊的語法錯誤導致后邊“更多”正確的語法“出錯”。基於此,錯誤修復算法是語法分析的另一個重點和難點。
錯誤恢復原理的形式化定義為:
設y是已讀入的符號串,L(G)為定義的語言,T是超前記號,y∈L(G),yT!∈L(G)表示T的插入導致語句出錯,基於此有四種修復方式:
(1)修改y:不推薦使用該方式,因為和LL(1)分析過程沖突。
(2)在y和T之間插入記號v使得 yvT∈L(G)。
(3)修改T為 V,使得 yV∈L(G)。
(4)刪除T,測試T的下一個記號Z是否使得yZ∈L(G)否則重復以上步驟。能解決一部分語法錯誤,但是可能會忽略很多有用的詞法記號。
采用方法(2)、(3)能恢復兩大類型的語法錯誤:一種是符號丟失錯誤——對應(2),需要回退一個詞法記號(過濾器操作);一種是符號內容錯誤——對應(3),修改該詞法記號並跳過它一次;如圖4-3所示:
圖 4-3 基本錯誤恢復實例
對應代碼如下:
{
if(token==semicon) // 空聲明
{
return;
}
else if(token==rsv_extern) // 外部變量聲明
{
type();
nextToken();
if(!match(ident)) // 標識符不匹配,極有可能是沒有標識符,回退
{
synterror(identlost,- 1);
BACK
}
else // 聲明標識符成功
{
}
nextToken();
if(!match(semicon))
{
if(token==rsv_extern||token==rsv_void||token==rsv_int||token==rsv_char||token==rsv_string)
{
synterror(semiconlost,- 1); // 丟失分號
BACK
}
else
{
synterror(semiconwrong, 0);
}
}
}
else
{
type();
nextToken();
if(!match(ident)) // 標識符不匹配,極有可能是沒有標識符,回退
{
synterror(identlost,- 1);
BACK
}
else // 聲明標識符成功,還不能確定是變量還是函數,暫時記錄作為參數傳遞
{
dec_name+=id;
}
dectail(dec_type,dec_name);
}
}
由於目前還是沒有絕對很有效的的錯誤恢復算法,針對這個問題,本系統站在使用者的角度來考慮,采用對出現在通常情況下人為導致的較高概率的錯誤進行處理,從而可以取得數學期望上的最大效率恢復的可能。
由此總結錯誤修復的算法流程如圖4-4所示(圖中文法符號表示終結符或者非終結符):
超前讀入的詞法記號按照語法規則與欲得到的記號進行匹配,若成功則繼續分析,否則查看該記號是否是文法規則中在下一個文法符號的First集中,如果在則表示丟失欲得到的符號,否則就按照符號不匹配處理。
圖 4-4 錯誤恢復算法流程
本系統能識別的語法錯誤如表4-1所示:
表 4-1 語法錯誤
五、 符號表
符號表是編譯過程中保存程序信息的數據結構,它從語法分析模塊獲取所需的信息,為語義處理和代碼生成模塊服務。主要功能如下:
(1)保存變量、函數的信息記錄。
(2)開辟串空間,保存靜態字符串。
(3)管理局部變量的可見性。
(4)處理變量、函數的聲明和定義。
5.1 數據結構
符號表相關的數據結構如下:
1.變量記錄數據結構定義如下:
{
symbol type;
string name;
union
{
int intVal;
char charVal;
int voidVal;
int strValId;
};
int localAddr;
int externed;
};
(1)type:記錄變量的類型,值是枚舉類型symbol的rsv_int、rsv_char、rsv_void、rsv_string。
(2)name:記錄變量的名字。
(3)匿名聯合類型:記錄變量的初值,如果沒有初值初始化為0,最關鍵的是strValId字段,它標志着字符串類型變量的存儲位置。strValId為-2時表示字符串為全局定義的字符串,存儲在數據段中;strValId為-1時表示字符串是局部定義的字符串或者是臨時結果字符串,存儲在堆棧段中;strValId為大於0的正整數時表示常量字符串存儲在串空間的ID。
(4)localAddr:表示局部變量的棧中位置相對於ebp的偏移量,若localAddr為0表示改變量是全局變量。
(5)externded:表示變量是否是外部變量。
2
.函數記錄數據結構定義如下:
{
symbol type;
string name;
vector<symbol> *args;
vector<var_record*>*localvars;
int defined;
int flushed;
int hadret;
void addarg();
int hasname( string id_name);
void pushlocalvar();
int getCurAddr();
void flushargs();
void poplocalvars( int varnum);
int equal(fun_record&f);
var_record*create_tmpvar(symbol type, int, int);
};
函數記錄數據結構的字段說明如下:
(1)type:函數的返回類型,和變量記錄相同。
(2)name:函數名。
(3)args:指向參數類型鏈表的指針。
(4)localvars:指向局部變量記錄鏈表的指針。
(5)defined:指示函數是否定義。
(6)flushed:指示函數的參數緩存的信息是否寫入了符號表。
(7)hasret:指示函數在末尾是否有return語句。
(1)addarg():為函數頭聲明的時候將參數變量信息寫入緩沖區。
(2)hasname(string):測試在函數作用域內是否有參數指定名字的變量聲明,包含參數名字。
(3)pushlcoalvar():將局部變量的信息壓入局部變量鏈表,並寫入符號表。
(4)getCurAddr():取得當前分析代碼時刻堆棧指針相對於ebp的偏移。
(5)flushargs():將參數緩存的參數信息寫入符號表。
(6)poplocalvars(int&):從局部變量鏈表后邊彈出參數指定數目的變量信息,同時在符號表刪除變量信息。
(7)equal(fun_record&f):判斷參數指定的函數的聲明是否和本記錄的聲明合法匹配。
(8)create_tmpvar(symbol type,int hasVal,int &var_num):為常量類型創建一個臨時變量,參與表達式運算或者參數傳遞。
3
.符號表數據結構記錄所有的符號信息,包括變量和函數符號的信息,另外還增加了一定的擴展信息,數據結構定義如下:
{
hash_map< string, var_record*, string_hash> var_map;
hash_map< string, fun_record*, string_hash> fun_map;
vector< string*>stringTable;
vector<var_record*> real_args_list;
public:
int addstring();
string getstring( int index);
void addvar();
void addvar(var_record*v_r);
var_record * getVar( string name);
int hasname( string id_name);
void delvar( string var_name);
void addfun();
void addrealarg(var_record*arg, int& var_num);
var_record* genCall( string fname, int& var_num);
void over();
void clear();
};
符號表數據結構的字段說明如下:
(1)var_map:變量記錄哈希表。
(2)fun_map:函數記錄哈希表。
(3)stringTable:串空間。
(4)real_args_list:函數調用實參變量記錄鏈表。
(1)addstring():向串空間添加一個常量串,id從0自增。
(2)getstring():根據串的索引獲取串內容。
(3)addvar():向符號表添加一個變量記錄信息。
(4)getVar(string):根據變量名字獲取變量聲明的記錄信息。
(5)hasname(string):測試指定的名字是否和當前作用域的變量的符號名重復,函數名稱不需要測試。
(6)delvar(string):刪除指定名稱的變量。
(7)addfun():向函數記錄哈希表添加一條函數記錄,同時檢查函數的聲明和定義的合法性。
(8)addrealarg(var_record*arg,int& var_num):向實參列表中添加一個實參變量記錄。
(9)genCall():產生函數調用的代碼。
(10)over():產生數據段信息。
(11)clear():清空符號表信息。
4
.全局對象
var_record tvar
:記錄當前分析的變量的聲明定義信息。
fun_record tfun
:記錄當前分析的函數的聲明定義信息。
Table table:符號表引用對象。
5.2 局部變量作用域管理
局部變量作用域管理算法執行流程如圖5-1所示:
圖5-1 局部變量作用域管理流程
函數定義時,編譯器先將函數記錄信息插入符號表,再將局部變量的定義依次插入符號表,並且記錄函數內插入變量的個數,等到函數定義結束的時候將剛才插入的變量依次從符號表刪除,最后清除緩沖區的變量記錄,更新符號表。另外,在表達式解析的過程中會產生臨時的局部變量,對其也當作正常的局部變量進行處理即可。
根據上述的變量處理規則,可以實現變量作用域的正確管理。根據5-2這個實例可以更加清晰的看到這一點。由此可以得出結論:
(1)全局變量登記后不會退出符號表。
(2)局部變量記錄在域結束后退出符號表。
(3)臨時變量同局部變量,但不能被程序直接訪問。
(4)域會對其內部聲明的變量計數,以便結束時彈出其記錄。
(5)不同作用域的變量聲明必然不能相互訪問。
圖 5-2 變量作用域管理實例
六、 語義處理
(1)引用符號表內容,檢查語義的合法性。
(2)引導代碼生成例程。
所有的語義錯誤如表6-1所示:
圖 6-1 語義錯誤
6.1 變量、函數聲明的合法性
extern 關鍵字是對外部變量的聲明。extern聲明可以重復出現,以保證每個單獨的文件都能引用別的文件的全局變量,對extern變量可以只是聲明但不使用。
{
if(synerr!= 0) // 有語法錯誤,不處理
return;
if(var_map.find(tvar.name)==var_map.end()) // 不存在重復記錄
{
var_record * pvar= new var_record(tvar);
var_map[tvar.name]=pvar; // 插入tvar 信息到堆中
}
else // 存在記錄,看看是不是已經聲明的外部變量
{
var_record * pvar=var_map[tvar.name];
// 刷新變量記錄信息
delete var_map[tvar.name];
var_map[tvar.name]=pvar; // 插入tvar 信息到堆中
semerror(var_redef);
}
}
和變量聲明處理方式類似,函數定義的語義檢查做類似處理,不同的是函數檢查的時候還要注意參數的合法性匹配,而且對於函數聲明,需要函數定義進行替換聲明記錄,具體代碼如下。
void Table::addfun()
{
if(synerr!=0)//有語法錯誤,不處理
return;
if(fun_map.find(tfun.name)==fun_map.end())//不存在記錄
{
fun_record * pfun=new fun_record(tfun);
fun_map[tfun.name]=pfun;//插入tfun 信息到堆中
//函數定義生成代碼
if(pfun->defined==1)
{
tfun.flushargs();
genFunhead();
}
}
else//函數聲明過了
{
fun_record * pfun=fun_map[tfun.name];//取得之前聲明的函數信息
//驗證函數聲明
if(pfun->equal(tfun))
{
//參數匹配,正常聲明
if(tfun.defined==1)//添加函數定義
{
if(pfun->defined==1)//已經定義了
{
//重復定義錯誤,覆蓋原來的定義,防止后邊的邏輯錯誤
semerror(fun_redef);
//函數形式完全相同,不需要更改函數記錄,刷新參數即可
tfun.flushargs();
}
else
{
//正式的函數定義
pfun->defined=1;//標記函數定義
tfun.flushargs()
//函數定義生成函數頭代碼
genFunhead();
}
}
return ;
}
else
{
//插入新的定義聲明
fun_record * pfun=new fun_record(tfun);
delete fun_map[tfun.name];//刪除舊的函數記錄
fun_map[tfun.name]=pfun;//插入tfun 信息到堆中
//參數聲明不一致++
if(tfun.defined==1)//定義和聲明不一致
{
semerror(fun_def_err);
tfun.flushargs();
}
else//多次聲明不一致
{
semerror(fun_dec_err);
}
}
}
}
6.2 break、continue語句的位置
根據語法規則,break和continue語句只能出現在循環體內部,然而語法定義中把這兩種語句作為正常語句處理,所以需要在語義處理中對他們的位置進行合法性檢查。
在出現循環語句的時候,為該循環設置一個唯一的標識ID,將ID的引用傳遞給循環體的復合語句模塊,即使出現循環嵌套,復合語句的也總能獲得最內層的循環的ID。在復合語句中,若出現break或者continue語句時,檢測該ID是否為0。若ID為0,說明沒有循環語句為復合語句傳遞參數,報告語義錯誤;否則,接收的ID即循環體的ID,表示break或者continue語句合法,由於循環體生成代碼時的標號名稱為“@whileID”或者“@whileID_exit”,所以,此時也為break語句和continue語句提供了跳轉地址的信息。
6.3 return語句返回值類型
根據語法規則,return語句可以出現在函數體的任何位置,在檢測到return語句時,產生函數退出的代碼。但是,在函數體內部可能會出現多層的復合語句,而在函數的第一級作用域內沒有return語句,從而導致函數生成的代碼沒有退出語句。
所以,為了保證程序的正常執行,必須在出現return語句的同時,檢測作用域的級別,若為1則正常,否則就是內部復合語句的return,此時函數記錄的hasret字段不能置為1。同時,還要return語句返回值和函數定義的類型匹配,本系統要求它們嚴格匹配,不進行默認的轉換。
另外,return語句生成的代碼中會強制恢復堆棧指針,因此不會導致程序堆棧空間崩潰。
6.4 函數調用語句實參列表的合法性
{
var_record*pRec=NULL;
if(errorNum!= 0) // 有錯誤,不處理
return NULL;
if(fun_map.find(fname)!=fun_map.end()) // 有函數聲明,就可以調用
{
fun_record*pfun=fun_map[fname];
// 匹配函數的參數
// 實參列表是共用的,因此需要動態維護
if(real_args_list.size()>=pfun->args->size()) // 實參個數足夠時
{
int l=real_args_list.size();
int m=pfun->args->size();
for( int i=l- 1,j=m- 1;j>= 0;i--,j--)
{
if(real_args_list[i]->type!=(*(pfun->args))[j])
{
semerror(real_args_err);
break;
}
else // 匹配
{
}
}
// 產生函數返回代碼
if(pfun->type!=rsv_void) // 非void函數在函數返回時將eax的數據放到臨時變量
{ pRec=tfun.create_tmpvar(pfun->type, 0,var_num); // 創建臨時變量
if(pfun->type==rsv_string) // 返回的是臨時string,必須拷貝
{
var_record empstr;
string empname= "";
empstr.init(rsv_string,empname);
pRec=genExp(&empstr,addi,pRec,var_num);
}
}
// 清除實際參數列表
while(m--)
real_args_list.pop_back();
}
else
{
semerror(real_args_err);
}
}
else
{
semerror(fun_undec);
}
return pRec;
}
6.5 賦值語句的類型轉換
七、 代碼生成
代碼生成的主要功能如下:
(1)根據相應的語義動作產生代碼
(2)結合運行時存儲實現對應語義的翻譯
7.1 表達式
本系統的表達式規則全是雙目運算,所以表達式處理的原則是根據兩個操作數的類型和操作符計算出結果臨時變量的類型,然后將結果的引用返回,供包含表達式的語句使用。
在表達式的計算中要考慮類型轉換的問題:
(1)void類型不參加任何運算。
(2)任意非void類型和string類型的+(連接)運算結果都是string類型,而且string類型只能參加+運算,其它運算都是非法的。
(3)在表達式計算中,char類型默認轉換為int類型參與運算。
(4)int類型可以參與所有的運算。
1.變量訪問規則
基本類型變量存儲形式簡單,全局變量在數據段,根據數據段生成規則,可以用變量名直接尋址([@var_name]);局部變量在堆棧段,根據變量記錄字段的localAddr可以得到變量地址相對於ebp指針的偏移,所以尋址為基址尋址[ebp+localAddr],當然輸出的時候要注意localAddr的符號。
string類型因為使用了輔助數據棧,訪問方式較復雜。輔助數據棧是用來專門存儲局部字符串內容而專門構建的。因為字符串長度無法在編譯的時候進行跟蹤,將臨時字符串的內容存儲在系統棧中將導致在字符串內容進棧之后變量無法確定自己的地址,即相對於ebp的偏移量。所以將字符串內容存儲在輔助數據棧里,而其地址作為雙字存儲在系統棧中。標准編譯器一般對復雜數據類型會專門開辟堆空間進行存儲,但是由於本編譯器復雜數據類型只有string類型,相對簡單,所以就不用堆而用棧存儲。
全局string變量是固定在數據段中長度為255字節的區域,通過變量名@str_name可以訪問該區域的首地址,通過@str_name_len可以獲得串的長度。
對於字符串常量,則是根據它在文字池中的ID來訪問的,@str_ID獲得首地址,@str_ID_len獲得長度。
局部string被倒序地壓入輔助數據棧中,通過[ebp+localAddr]得到的是string內容在輔助數據棧的基址,而且該地址指向內存區域的內容為string的長度,以該內容減去長度的值為基址,按照遞增的方式對內存訪問string長度個區域就能獲得string的內容。局部string的存儲訪問原理如圖7-1所示:
圖 7-1 string類型變量訪問規則
2.四則運算
若表達式形式為:oprand1 + oprand2,且是基本類型的運算,那么,通過變量的訪問規則可以獲得oprand1和oprand2的內容,分別存放在eax和ebx中,然后使用add eax,ebx指令將表達式計算出來,最后將eax的內容寫入臨時變量的內容中。其它的四則運算則類似,只是把運算指令分別修改為sub、imul、idiv。
3.關系運算
與四則運算類似,除了在eax,ebx存儲操作數的內容外,還要使用cmp eax,ebx指令進行比較,然后還需要根據運算符的含義使用恰當的jcc跳轉命令,而跳轉分支執行的語句是對eax進行寫1或者寫0操作。最后再把eax值寫入臨時變量中作為關系表達式結果。
4.字符串連接
如果操作數中出現了string類型,本系統限定string只能參與連接運算,運算結果會同時使用堆棧和輔助數據棧,為了方便臨時結果字符串壓入輔助數據棧,先把oprand2的內容壓入輔助數據棧,再把oprand1的內容壓入輔助數據棧。
圖7-2 string連接運算
在把oprand2內容壓入輔助數據棧之前需要先壓入一個字節的數據,數據內容為oprand2的長度,它是用來存儲結果字符串的長度的。長度被壓入后需要將其內存地址寫入到結果臨時變量在系統棧的內存中去,以用來訪問該結果。因此在壓入oprand1的時候需要先讀取長度,和oprand1的長度相加后再寫回,最后在壓入oprand1的內容。這樣結果字符串就能正確地被訪問了。字符串連接方式可以參照圖7-2。
另外需要注意的是字符串連接的操作數類型和存儲方式可能不盡相同,所以對操作數的訪問要遵循變量的訪問規則。如果操作數不是字符串類型,那么就需要對其默認轉換。對數字要通過除10取余的方式將數字位倒序壓入輔助數據棧,對字符則是把其看作一個長度的字符串常量進行連接即可。
7.2 賦值語句
賦值語句會對變量類型檢查,首先,void類型不能參與賦值運算;其次,要對賦值變量的類型默認轉換為賦值對象的類型。
翻譯賦值語句時,編譯器先訪問賦值對象的類型,如果賦值對象是全局string類型,則先把賦值表達式的內容轉換為臨時字符串,再把字符串的內容拷貝到全局string對應的數據段中,修改其長度。如果被賦值對象是局部string類型,則直接把臨時字符串的地址替換為局部string的地址。
如果賦值對象是基本非void類型,則把賦值變量的內容寫入到賦值對象地址對應的內存。
7.3 循環、分支語句
編譯到循環語句時,系統會為循環語句設置一個唯一的標識ID,然后根據該ID生成循環開始標簽(形如@while_ID)。繼而記錄循環開始前堆棧指針,再對循環條件表達式進行翻譯,為表達式結果產生比較跳轉指令,為0則跳轉到循環結束位置。接着對循環體的復合語句的代碼翻譯,然后生成跳轉到循環開始標簽的指令。最后恢復運行時堆棧狀態,生成循環退出標簽(形如@while_ID_exit)。若在循環體內遇到break語句,編譯器根據循環ID生成跳轉到循環結束標簽的指令,若遇到continue語句,編譯器會生成跳轉到循環開始標簽的指令。當然,在跳轉之前,要根據循環開始記錄的堆棧指針恢復堆棧狀態。
編譯遇到分支語句時,編譯器先保存if開始前的棧指針,然后對條件表達式的內容翻譯,產生為0 跳轉到else的指令。然后對if的復合語句翻譯,恢復棧指針,生成跳轉到else結束位置的指令。接着編譯器先生成else開始標簽,恢復if因為表達式計算修改的棧指針,再生成else復合語句指令,恢復棧指針,生成else結束標簽。
針對循環、分支代碼輔助棧的變化情況,參照圖7-3。
圖 7-3 循環分支語句運行時存儲規則
7.4 函數定義、return語句
函數定義的代碼分為函數頭部和函數尾部,所有函數定義的翻譯都需要生成進棧代碼和出棧代碼,即函數頭部和函數尾部。
函數頭部代碼在Intel指令集中可以用enter指令代替,它的功能和指令組push ebp 、mov ebp,esp 等價。函數尾部代碼也可以用指令leave代替,它和指令組mov esp,ebp pop ebp 等價,最后還要有ret指令讓函數返回。之所以這么做就是防止對push,pop指令的誤操作導致函數棧的崩潰,只要ebp不被修改,函數總能正確地返回。
另外,由於添加了輔助數據棧的因素,編譯器還要額外的為這個棧進行恢復操作,以和系統棧同步。所以在編譯器默認數據中有兩個32bit的變量保存着輔助數據棧的“esp”、“ebp”。在函數頭部和尾部的操作與系統棧類似。
依照gcc的代碼生成規則,return語句會把返回值保存在eax寄存器中。對於基本類型,只需要將變量的值mov到eax即可。但是對於string變量還要做一步處理,由於全局string和局部string存儲結構的差別,在返回字符串類型之前,要把全局string的內容壓入輔助數據棧,按照局部string類型返回。但是這么做必須在函數調用的時候把字符串及時拷貝出來,因為return返回后函數的棧指針會發生變化,數據有可能被刷新。
除了把返回值寫入eax,return語句還需要把函數的尾部代碼加上以保證函數能正確返回。
7.6 函數調用
函數調用翻譯步驟如下:
(1)生成實參的表達式計算指令。
(2)生成實參進棧代碼。
(3)使用call指令產生函數調用。
(4)恢復參數進棧之前的棧指針。
(5)若函數返回值是string類型,需要拷貝string的內容。
實參列表保存在符號表的鏈表對象中,在調用函數之前,需要倒序遍歷實參列表,訪問實參臨時變量內容,將內容壓入系統棧中,並對棧指針字節的變化計數。產生調用指令后,需要恢復棧指針,把esp加上剛才的計數值就能恢復棧的狀態,另外還要根據實參列表的個數彈出實際參數記錄,保證實參列表的動態平衡。
7.7 輸入、輸出
本系統沒有系統庫的支持,所以I/O的代碼需要自己來實現。系統調用Linux的int 0x80中斷轉到系統調用例程,根據傳遞的系統調用號執行輸入輸出。
對於輸入語句,系統先調用Linux的3號系統調用把輸入的字符串拷貝到臨時緩沖區中,然后根據輸入對象的類型將合法的數據拷貝到輸入對象的內存中。如果輸入對象是string類型,編譯器就把輸入緩沖區的內容按照賦值語句的規則拷貝到輸入對象;如果輸入對象是基本類型,編譯器就把緩沖區的數據轉換為基本類型,再把值拷貝到輸入對象。
對於輸出語句,系統先把表達式的結果強制轉換為string類型,然后將該臨時string通過調用Linux的4號系統調用進行標准輸出。
7.8 數據段
數據段的信息全部在符號表中,所以符號表是數據段翻譯的關鍵。
符號表的變量記錄哈希表保存着所有定義的全局變量,通過遍歷變量記錄哈希表把變量信息寫入數據段。例如變量說明:int a ; 寫入數據段格式為 @var_a dd 0\n 。其中??是編譯器為變量名加的前綴,由於變量是int類型,需要四個字節存儲,所以使用dd定義。另外編譯器沒有對變量的初始化和變量定義嚴加區分,所以,所有全局變量一律初始化為0。對於全局string變量,寫入數據段需要特殊處理。例如變量聲明:string g;生成數據段格式為:
@str_g_len db 0
全局字符串除聲明了
255字節的存儲空間外還生成了輔助變量存儲實際字符串的長度。
串空間保存了所有字符串常量,用它可以生成文字池。標准編譯器會把字符串常量保存在字符串表中,段名.strtab。本系統為了使段結構統一,將字符串常量輸出到數據段中,例如串“a\nb”,它在串空間的ID假如為3,生成格式如下:
@str_3_len equ 3
針對字符串常量的特殊字符,在生成的時候不能直接輸出,必須將特殊字符的ASCLL碼寫入目標文件以使得匯編器能正常識別特殊字符。
對於外部變量,本系統自定義了一種規則:同樣生成數據段對應的記錄,不過初始值需要改為1,以通知匯編器這是一個外部變量。
7.9 公共模塊
該編譯器將程序公共的模塊抽取出來單獨生成一個匯編文件common.s,供其他的匯編文件使用。該文件數據段.data包括系統必須的存儲結構,如輸入緩沖區和輔助數據段。
輸入緩沖區輸出格式為:
@buffer_len dd 0
輔助數據棧信息輸出格式為:
@s_ebp dd 0
另外,文件輸出了.bss段,該段包含了輔助數據棧空間,使用.bss段,這樣做可以節省不少磁盤空間,其格式為:
@s_base:
公共模塊的.text段有點類似C語言的crt,不過功能很簡單,就是保留一些函數和調用主函數main。保留的函數有@ str_2long用於提示字符串過長,@procBuf用於處理輸入緩沖區。主函數調用格式為:
_start:
call main
mov ebx, 0
mov eax, 1
int 128
至此,代碼生成工作的主要內容闡述完畢。
八、 編譯實例
這里使用一個漢諾塔的程序測試一下編譯器的效果,其源代碼main.c如下:
int main()
{
int n;
out<< " 輸入盤子個數: ";
in>>n;
hanoi( " A ", " B ", " C ",n);
return 0;
}
void hanoi( string a, string b, string c, int n)
{
if(n== 0)
{
return;
} else{}
hanoi(a,c,b,n- 1);
out<< " Move "+n+ " :\t[ "+a+ " --> "+c+ " ]\n ";
hanoi(b,a,c,n- 1);
}
使用本編譯器編譯,編譯命令為./cit main.c hanoi -s。運行效果如下:
命令格式如下:
圖8-1 編譯器命令
編譯過程如下:
圖8-2 編譯過程
執行一下,這里提前看看自己的生成的可執行文件(匯編過程以后會介紹):
圖8-3 運行效果
如果需要查看具體的編譯信息,只需要打開對應的編譯開關即可。
例如詞法分析信息:
圖8-3 詞法分析
語法分析信息:
圖8-4 語法分析
語義處理信息:
圖8-5 語義處理
符號表信息:
圖8-6 符號表
代碼生成信息:
圖8-7 代碼生成
生成的匯編文件為common.s和main.s代碼如下:
common.s文件:
@str2long:
mov edx,@str_2long_data_len
mov ecx,@str_2long_data
mov ebx, 1
mov eax, 4
int 128
mov ebx, 0
mov eax, 1
int 128
ret
@procBuf:
mov esi,@buffer
mov edi, 0
mov ecx, 0
mov eax, 0
mov ebx, 10
@cal_buf_len:
mov cl,[esi+edi]
cmp ecx, 10
je @cal_buf_len_exit
inc edi
imul ebx
add eax,ecx
sub eax, 48
jmp @cal_buf_len
@cal_buf_len_exit:
mov ecx,edi
mov [@buffer_len],cl
mov bl,[esi]
ret
global _start
_start:
call main
mov ebx, 0
mov eax, 1
int 128
section .data
@str_2long_data db " 字符串長度溢出! ", 10, 13
@str_2long_data_len equ 26
@buffer times 255 db 0
@buffer_len db 0
@s_esp dd @s_base
@s_ebp dd 0
section .bss
@s_stack times 65536 db 0
@s_base:
main.s文件:

main:
push ebp
mov ebp,esp
mov ebx,[@s_esp]
mov [@s_esp],esp
mov esp,ebx
mov ebx,[@s_ebp]
push ebx
mov [@s_ebp],esp
mov ebx,[@s_esp]
mov [@s_esp],esp
mov esp,ebx
; 函數頭
push 0
push 1
; 為@tmp_string_1產生輸出代碼
push - 1
; ----------生成常量string@tmp_string_1的代碼----------
mov eax,[@s_esp]
mov [@s_esp],esp
mov esp,eax
mov eax,@str_1_len
…………
hanoi:
push ebp
mov ebp,esp
mov ebx,[@s_esp]
mov [@s_esp],esp
mov esp,ebx
mov ebx,[@s_ebp]
push ebx
mov [@s_ebp],esp
mov ebx,[@s_esp]
mov [@s_esp],esp
mov esp,ebx
; 函數頭
push 0
push 0
mov eax,[ebp+ 20]
mov ebx,[ebp- 4]
cmp eax,ebx
je @lab_base_cmp_28
mov eax, 0
jmp @lab_base_cmp_exit_29
@lab_base_cmp_28:
mov eax, 1
@lab_base_cmp_exit_29:
mov [ebp- 8],eax
mov eax,[ebp- 8]
cmp eax, 0
je @if_1_middle
mov ebx,[@s_ebp]
mov [@s_esp],ebx
mov ebx,[@s_esp]
mov [@s_esp],esp
mov esp,ebx
pop ebx
mov [@s_ebp],ebx
mov ebx,[@s_esp]
mov [@s_esp],esp
mov esp,ebx
mov esp,ebp
pop ebp
ret
mov esp,ebp
jmp @if_1_end
@if_1_middle:
mov esp,ebp
mov esp,ebp
@if_1_end:
push - 1
; ----------生成動態stringa的代碼----------
mov eax,[@s_esp]
mov [@s_esp],esp
mov esp,eax
mov ebx,[ebp+ 8]
mov eax, 0
…………
; 函數尾
mov ebx,[@s_ebp]
mov [@s_esp],ebx
mov ebx,[@s_esp]
mov [@s_esp],esp
mov esp,ebx
pop ebx
mov [@s_ebp],ebx
mov ebx,[@s_esp]
mov [@s_esp],esp
mov esp,ebx
mov esp,ebp
pop ebp
ret
section .data
@str_1 db " 輸入盤子個數: "
@str_1_len equ 21
@str_2 db " A "
@str_2_len equ 1
@str_3 db " B "
@str_3_len equ 1
@str_4 db " C "
@str_4_len equ 1
@str_5 db " Move "
@str_5_len equ 5
@str_6 db " : ", 9, " [ "
@str_6_len equ 3
@str_7 db " --> "
@str_7_len equ 5
@str_8 db " ] ", 10
@str_8_len equ 2
section .bss
九、 總結
通過以上的敘述,比較詳細的介紹了一個編譯器的實現流程和具體所牽涉的細節,相信對想了解編譯器內部結構的人有所幫助。不過,由於本編譯器的結構是面向之前所介紹的靜態鏈接器的,因此生成的匯編代碼屬於自定義范疇,因此不會和gcc等主流軟件兼容,那么如何測試生成代碼的正確性呢?后邊就准備介紹如何自己構造一個匯編器,將這些匯編代碼轉換為二進制文件,使用靜態鏈接器鏈接為可執行文件后,執行一下便能知道結果是否正確了!