新一篇:?解讀VC++編程中的文件操作API和CFile類?|?舊一篇:?Effective Item21 盡可能使用const
內容摘要 本文將以 C 語言為例,介紹 gcc 在接受一個 .c文件的輸入之后,其前端是如何進行處理并得到一個中間表示并轉交給后端處理。然后,在了解了 gcc[1] 的工作流程后,介紹一下作者嘗試在 gcc內部的 RTL 表示層中 hack gcc 的過程,與大家分享一些經驗,希望能給對有興趣研究和開發 gcc 的讀者有所幫助。
1. GCC 簡介
編譯器的工作是將源代碼(通常使用高級語言編寫)翻譯成目標代碼(通常是低級的目標代碼或者機器語言),在現代編譯器的實現中,這個工作一般是分為兩個階段來實現的:
第一階段,編譯器的前端接受輸入的源代碼,經過詞法、語法和語義分析等等得到源程序的某種中間表示方式。
第二階段,編譯器的后端將前端處理生成的中間表示方式進行一些優化,并最終生成在目標機器上可運行的代碼。
GCC(GNU Compiler Collection) 是在 UNIX 以及類 UNIX 平臺上廣泛使用的編譯器集合,它能夠支持多種語言前端,包括 C, C++, Objective-C, Ada, Fortran, Java 和 treelang 等。
GCC 設計中有兩個重要的目標,其中一個是在構建支持不同硬件平臺的編譯器時,它的代碼能夠最大程度的被復用,所以 GCC必須要做到一定程度的硬件無關性;另一個是要生成高質量的可執行代碼,這就需要對代碼進行集中的優化。為了實現這兩個目標,GCC內部使用了一種硬件平臺無關的語言,它能對實際的體系結構做一種抽象,這個中間語言就是 RTL(Register TransferLanguage)。
雖然關于 GCC 的研究和開發工作側重于 GCC 后端代碼優化方面,但本文中我們關注的目標是在 GCC 的編譯過程中前端是如何工作的。
把 GCC 的前端獨立出來研究目的在于,在設計新的編譯器的時候,我們僅僅需要關注如何設計新編譯器的前端,而將代碼優化和目標代碼的生成留給 GCC 后端去完成,避免了后端設計的重復性勞動。
本文將以 C 語言為例,介紹 gcc[2]在接受一個 .c 文件的輸入之后,其前端是如何進行處理并得到一個中間表示并轉交給后端處理。然后,在了解了 gcc的工作流程后,介紹一下作者嘗試在 gcc 內部的RTL表示層中 hack gcc 的過程,與大家分享一些經驗,希望能給對有興趣研究和開發gcc 的讀者有所幫助。
2. gcc 的工作流程
gcc 是一個驅動程序,它接受并解釋命令行參數,根據對命令行參數分析的結果決定下一步動作,gcc 提供了多種選項以達到控制 gcc 編譯過程的目的,我們可以在 GCC 的手冊中查找這些編譯選項的詳細信息。
gcc 的使用是比較簡單的,但是要深入到其內部去了解編譯流程,情況就比較復雜了。面對龐大的[3] gcc,我們只能選擇感興趣的部分來分析。但我們無法獲得關于 gcc 編譯流程的詳盡文檔[4],這主要是由于 gcc 本身過于繁雜,而且它處于不斷的變化當中,所以我們只有通過其它途徑來了解 gcc。有兩個比較好的方法:一是閱讀source,對感興趣的函數可以跟蹤過去看一看,閱讀代碼看起來可怕,但其實代碼中會有很多注釋說明它的功能,使得我們的閱讀變得更簡單一些,這種方法便于從整體上把握 gcc;另外一個是 debug gcc,就是使用調試器來跟蹤 gcc 的編譯過程,這樣可以看清 gcc編譯的實際流程,也可以追蹤我們感興趣的細節部分。我們先從大處著眼,從 source 中看看 gcc一些比較重要的函數以及它們之間的調用關系,然后在 hack gcc 的時候,對 gcc 進行 debug來追蹤我們關心的細節,并且可以通過調試來發現和修改 patch 中的錯誤。
在開始閱讀 gcc 的代碼之前,推薦您閱讀一下 GCC internals 中 passes and files of the compiler 一章——如果您以前沒有看過的話,這段內容會幫助您對 gcc 的結構建立一個大概的映像。
好了,我們以 gcc 中的函數為單位,希望能夠盡量詳細地描述 gcc 中自頂向下的函數調用關系。在 gcc源碼目錄中,很容易就發現了一個文件 main.c,應該是 gcc 的入口了,這個main.c 文件中只有一個函數 main,而這個 main函數中也只有一條語句,調用了一下toplev_main 函數。之所以單獨用一個 main 函數來調用toplev_main,是為了讓不同的語言前端可以方便設計不同的 main 函數。
toplev_main 函數是在 toplev.c 文件中定義的,從名字中就可以看出這個文件應該是用來控制 gcc 最頂層的編譯流程的,在程序開始的注釋中也說明了它是用來處理命令行參數、打開文件、以合適的順序調用各個分析程序[5]并記錄它們各自所用的處理時間。toplev_main 首先對 gcc做了一下初始化,主要是設置環境變量和診斷信息等等,然后就開始解析命令行參數,我們對這些并不感興趣,重要的是接下來調用了 do_compile函數,這個函數看從名字看就是做編譯工作的,而在此之后 toplev_main 函數就返回了。
do_compile 函數也是在 tolev.c中定義的,它調用了一些函數來做進一步的初始化,比如對編譯過程中計時器的初始化、針對特定程序設計語言的初始化以及對后端的初始化等等,同時它還對toplev_main 函數中解析的命令行參數做了進一步處理。在完成了上述工作后,調用了 compile_file()函數,這個函數應該是用來進行真正的編譯工作了。
compile_file 函數還是在 toplev.c 中定義的,這里提一下 compile_file 函數和上面的do_compile函數,它們是參數和返回類型都為 void 的函數,在編譯的時候需要的各種參數包括編譯的文件名、編譯參數以及 gcc內部使用的一些鉤子函數等等都是采用全局變量來表示的,當然,這些全局變量在前面各種初始化函數中都已經被適當地初始化了。接著說compile_file 函數,它又做了一些我們并不太關心的初始化工作,之后,它終于調用了一個鉤子函數來分析(parse)整個輸入文件了:
|
這里的 lang_hooks 是一個全局變量,不同語言的前端對此賦以不同的值,以便調用各自特有的分析程序,關于 lang_hooks結構的定義和初始化等等可以參見源碼中的 langhooks.h、langhooks.c 和 langhooks-def.h等文件,這里就不詳細追究了。對于 C 語言來說,這條語句相當于調用了 c-opts.c 中的 c_common_parse_file 函數。
c_common_parse_file中調用了c-parse.c中的c_parse_file函數,在此函數中又調用了同樣位于c-parse.c中的yyparse函數。有必要介紹一下c-parse.c文件,它是由GNU bison[6] 從c-parse.y中得到的一個語法解析器。c-parse.y則是一個YACC文件,它使用BNF(Backus Naur Form)來描述了某種程序設計語言的語法。[7]
|
至此,我們對gcc中主要的函數調用關系還是相當清楚的,從main函數層層深入,進入了c-parse.c中的yyparse函數。前面提到過c-parse.c文件是由GNUbison對c-parse.y這個YACC文件作用后自動生成的,這導致這段代碼閱讀起來比較困難,因為bison生成的c-parse.c文件中有很多條goto語句以及超過500個case的switch語句,如此多的選擇和跳轉語句無疑給追蹤gcc的函數調用帶來了極大的困難,我們不可能再繼續下去了。
再回過頭去看看前面那些代碼和注釋以及一些文檔,注意到多次提到過一個函數――rest_of_compilation,這似乎是一個很重要的函數,我們可以過去看看。
在toplev.c中我們找到了這個函數,注釋中說明它的作用是:在對程序中頂層的函數定義或者變量的定義處理以后,接著對這些函數或者變量進行編譯并輸出相應的匯編代碼,在此函數返回后,gcc內部使用的tree結構就消亡了。看來這個函數的功能比較復雜,它已經把源程序對應的匯編代碼生成了,并且把對應的tree結構占用的空間已經釋放了,而我們所感興趣的部分是gcc編譯過程中內部使用RTL表示的情況,這部分處理應該是在rest_of_compilation這個函數返回之前做的。
前面我們從main函數跟蹤到了yyparse函數,這里又發現了一個很重要的rest_of_compilation函數,但中間這段過程gcc做了些什么我們還不清楚,也許我們所關心的有關RTL的處理就在其中。
現在我們只有對gcc進行調試才能確切的看清進入yyparse后函數調用的情況了,這里介紹一下調試gcc的方法:
對gcc進行調試,其實是對編譯gcc源代碼所得到的cc1程序調試,進入到cc1所在的目錄,運行命令:
|
這樣就是以-dr為編譯參數運行gcc來編譯test.c文件了,并且在main函數的入口處設置了一個斷點,-dr作為編譯參數就是要求在RTL表示生成以后將其dump到一個以.rtl結尾的文件中去。接下來在rest_of_compilation之前再設置一個斷點,并用continue命令運行到該斷點,用backtrace命令查看此時函數棧幀的情況:
|
下表1給出了使用gdb調試時顯示出的從main到rest_of_compilation的函數調用情況:
調用順序 | 函數名字 | 所在文件名 |
#1 #2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15 | main toplev_main do_compile compile_file c_common_parse_file c_parse_file yyparse finish_function cgraph_finalize_function cgraph_assemble_pending_functions cgraph_expand_function c_expand_body c_expand_body_1 tree_rest_of_compilation rest_of_compilation | main.c toplev.c toplev.c toplev.c c-opts.c c-parse.y c-parse.y c-decl.c cgraphunit.c cgraphunit.c cgraphunit.c c-decl.c c-decl.c tree-optimize.c toplev.c |
表1. 部分函數調用棧幀列表
調試的結果證實我們前面的分析是正確的,從main函數到yyparse函數的調用順序與我們閱讀代碼時所分析得到的結果是吻合的。現在我們得到了gcc編譯時從yypare到rest_of_compilation之間的一系列函數調用,這些都是值得關注的目標,讓我們返回到源碼中去看看這些函數的功能。
時刻記得我們的目標:對于gcc如何生成tree結構我們并不關心,也不關心gcc是如何由中間表示層RTL生成匯編代碼的,我們感興趣的是RTL表示是如何生成的,并希望在RTL表示層做一些修改,以達到我們的目的。為了省去一些篇幅,本文中略去了對那些我們不太關心的函數的分析,直接跳轉到RTL生成和處理相關的部分。
終于,在tree-optimize.c中的tree_rest_of_compilation中,我們發現了一系列看起來是與RTL生成有關的函數調用,特別引起我們注意的又是一個鉤子函數:
|
這行代碼的注釋說這個鉤子函數用來生成一個被編譯函數的RTL表示,接下來還調用了幾個函數來進行RTL生成階段的最后處理(包括調用gcc編譯時內部使用的垃圾收集函數),然后就調用了rest_of_compilation了。前面已經提到了,rest_of_compilation的作用是對RTL表示做優化并且生成匯編代碼輸出,至此我們可以做出這樣的推斷:在tree_rest_of_compilation調用了一系列生成RTL表示的函數之后,到調用rest_of_compilation之前,gcc的內部保存了一個原始的、未優化的RTL中間表示。如果我們希望對函數的RTL表示做一些修改,在這里插入代碼做改動應該是一個不錯的選擇。
到這里,我們所關心的gcc編譯流程基本已經結束了,也搞清了RTL表示在什么地方生成的,我們應該有一定的信心在RTL表示層上對gcc進行hack了。
3. RTL簡介
我們的目標是在RTL表示層上hack gcc,所以有必要對RTL做一些介紹。在gcc internals中有專門的一章描述RTL,如果對RTL沒有任何了解,那么它很值得您一看;同時,在理解和插入RTL語句的時候,這份文檔也可以作為比較詳盡的手冊來參照。
在gcc的編譯過程中,有三次比較重要的轉換:
- 待編譯的源代碼―<gcc抽象語法樹表示。
- gcc抽象語法樹―<RTL表示。
- RTL表示-<匯編代碼輸出。
RTL是gcc內部使用的中間表示語言,為了對其有一個直觀點的印象,我們可以把它dump出來看一看。使用
|
就可以得到test.c的RTL表示,文件名一般為test.c.00.rtl。
RTL的設計據說是從LISP語言得到了靈感,所以我們dump出來的.rtl文件看起來也像是一個LISP程序,每條RTL語句都是用來描述需要輸出的指令的,可以對照我們dump出的.rtl文件以及上面提到的文檔來深入學習RTL。但我們的要求不僅如此,我們需要插入自己的RTL語句來hackcc,必須閱讀gcc源代碼提供的RTL操作的接口,這個過程比較繁瑣而且沒有文檔可以參考,唯一有幫助的就是已有的在RTL表示層上對gcc做的補丁,以吸取其他gcc hackers的經驗,作者在嘗試自己的補丁時曾經參考過StackGuard[8] 的代碼,另外可以在gcc的maillist上看到有些hacker提供的patch,這些已有的工作對于gcc hacker newbie來說是很有裨益的。
僅僅這么多文字來介紹RTL還遠遠不夠,但是如果希望把RTL描述得十分清楚,那應該由另外一篇文章來完成了,本文就不再詳述了。
4. Let's hack gcc!
下面進入hackgcc的實戰階段了,先說一下我的目的:我希望使用修改過的gcc編譯程序的時候,能夠在每個函數的開始和結束的地方插入一個函數調用語句,也就是說,在每個函數的第一條指令之前,由編譯器強制插入一個函數調用,在函數最后一條指令結束之后,也要插入一個函數調用。下面用兩段C語言代碼來表達這個補丁的效果:
int foo() { ??? first statement; ??? … ??? … ??? … ??? last statement; } | int foo() { ??? my_function_begin; ??? first statement; ??? … ??? last statement; ??? my_function_end; } |
左邊一列是程序員正常編寫的普通函數,我希望使用修改過的gcc編譯該函數后,能夠得到相當于編譯右邊這段函數的結果,就是對程序員透明地在每個函數的第一條語句之前和最后一條語句之后自動插入兩個函數調用:my_function_begin和my_function_end。當然,這兩個函數具體實現什么功能可以由程序員來編寫,最簡單的實現可以僅僅在標準輸出上分別打印一句話表示該函數確實被調用了即可。
gcc中生成抽象語法樹表示和RTL表示都是以一個完整的函數定義或者toplevel的聲明為單位的,這也就意味著在tree_rest_of_compilation這個函數調用了一系列用于生成RTL表示的函數之后,我們所得到的只是當前正在被編譯的函數的RTL表示,而并不是整個源程序的RTL表示,這正好方便我們以函數為單位來進行修改。
我們在tree_rest_of_compilation函數中調用rest_of_compilation之前插入一條語句,調用一個新函數modify_rtl來對gcc生成的RTL表示做一些處理。函數modify_rtl的定義放在function.c文件中,這是因為gcc在生成RTL表示時需要的相關函數大部分都定義在這個文件中,我們的補丁也可以看作是gcc生成RTL表示的一部分工作,所以把modify_rtl放到這個文件中定義是最合適的。
接下來工作的關鍵就集中到如何定義modify_rtl函數了。現在我們得到了當前編譯函數的RTL表示,我們可以對這個RTL單元進行掃描,找到合適的位置分別調用my_function_begin和my_function_end函數即可。函數的RTL表示是一個雙向連接的鏈表結構,其中每個節點稱為一個insn[9] ,有的insn可能表示一條真實的匯編指令,有的則表示jump指令跳轉的標簽或者其它各種聲明信息。為了簡便起見,這里直接給出一個常用的gcc所提供的訪問insn的宏和函數列表,并給出它們的功能:
宏(函數)名 | 功能 |
INSN_UID(insn) | 獲取該insn的id |
PREV_INSN(insn) | 獲取insn鏈表中該insn的前一個insn |
NEXT_INSN(insn) | 獲取insn鏈表中該insn的后一個insn |
GET_CODE(insn) | 獲取該insn的code |
NOTE_LINE_NUMBER(insn) | 如果insn的code是NOTE,則返回該insn對應源代碼的行號,否則返回一個負數 |
Get_insns() | 獲取當前函數RTL表示的第一個insn |
Get_last_insn() | 返回當前函數RTL表示的最后一個insn |
表2. 部分gcc提供的insn操作接口列表
一個函數完整的、未被優化的RTL表示中會有兩個noteinsn表示函數的開始和結束,gcc定義了兩個全局變量NOTE_INSN_FUNCTION_BEGIN和NOTE_INSN_FUNCTION_END來表示這兩個note insn的行數。這樣我們就可以掃描當前RTL單元,當碰到這兩個noteinsn的時候,就可以插入相應的函數調用語句了。
gcc提供了emit_library_call函數來插入一個函數調用,這個函數返回的是一個表示函數調用的RTL表達式,并默認地把這個RTL表達式插入到當前RTL單元的最后一個insn之后。所以如果直接調用emit_library_call,就會把函數調用語句插入到RTL單元最后一個insn之后,而不是我們所希望的函數開始和結束的地方,我們可以使用start_sequence和end_sequence函數,它們產生一個相對獨立的sequence并把函數調用語句保存到一個RTL表達式中以備后用。
我們已經找到插入函數調用的點,并且也生成了表示函數調用的RTL語句,現在就可以使用gcc提供的emit_insn_before和emit_insn_after函數來插入RTL語句了。
到這里,modify_rtl函數的實現基本已經成型了,下面這段示例代碼就可以完成在每個函數的開始處插入RTL語句的功能:
|
這段代碼中所使用數據結構、函數的具體功能和用法,屬于十分細節的內容,無須在這里描述清楚,請讀者參考gcc源代碼。
對于在函數結束的地方插入my_function_end函數同樣如此,我們可以用get_last_insn得到RTL單元的最后一個insn,然后使用PREV_INSN(insn)開始向前掃描,遇到行號為NOTE_INSN_FUNCTION_END的noteinsn時,用emit_insn_before把相應的函數調用RTL表達式插入到這個insn之前即可。
現在這個patch的基本功能已經完成了,我們還可以再做一些工作使得它功能更強大和實用一些,比如加入一個編譯選項(比如-finsert-function)來指定是否啟用這個patch的,當編譯的命令行參數中沒有提供這個編譯選項時,我們所作的補丁就不起作用。關于如何增加編譯選項,我們可以參考opts.c中的decode-options函數,在此就不詳細分析了。
在modify_rtl中調用current_function_name函數可以得到當前正在被編譯的函數名,我們可以把這些函數名寫到一個文件中去,這樣可以記錄我們對哪些函數做了修改;還可以實現一個過濾器,在啟用了patch的情況下,對于指定的函數,我們還可以將其過濾掉,不對其做處理,這些功能也是很容易實現的。
我們還可以再實現一些功能,比如在掃描RTL的時候,如果發現一條call_insn,可以把這條call指令所調用的函數名記錄下來,這樣我們甚至可以得到一個程序運行時刻的動態的函數調用關系圖,這就可以描繪程序的實際運行軌跡。
最后,還需要把my_function_begin和my_function_end兩個函數實現一下,可以把它們的功能擴展一下,不是僅僅輸出一條語句到標準輸出,而是記錄一些信息到文件中,這樣就可以得到一個以函數為粒度的運行時刻日志,甚至可以使這兩個函數與linux內核聯系起來,做一些特殊的檢查工作等等,這樣就使得我們的patch有一些實用性了。這兩個函數我們可以在mylib.c中實現,編譯成一個sharedobject,使用如下命令編譯:
|
把libmylib.so放到/usr/lib目錄下,那么在編譯的時候只需加上-lmylib參數就可以使用這個shared object中的函數了。
剩下的工作就是進行調試和測試了,當我們解決了各種問題,使這個修改過的編譯器能夠完美的運行起來的時候,也許我們就能體會到gcc hacker的那種成就感和喜悅之情了。
5. 經驗總結
先說一下我自己嘗試的結果,我是基于gcc version3.4.0工作的,給gcc加入了一個編譯選項以選擇是否啟用添加的補丁,可以在每個函數的開始和結束的時候插入函數調用,也可以在函數調用之前和返回之后插入函數調用,實現了一個過濾器,可以忽略一些函數不對其做處理,并且可以在運行時將一些信息記錄到文件中去留待分析。這個補丁的功能基本上就是這些了,實現方法可能和本文中的方法有所不同,文中描述的方法是較早的時候我采用的方法,現在則進行了一些改動,這里就不詳加介紹了。我已經成功的使用“我的”gcc編譯了emacs和lynx等實用軟件,運行正常,補丁功能也正常,可以說是取得了一個小小的成功。但是我沒有空間可以上載我的補丁,有興趣的讀者可以通過e-mail向我索取。
最后談談我的經驗:
在理解gcc的編譯流程以及試圖找到做補丁的思路的時候,需要多閱讀文檔,包括學習已有的工作是怎么做的。不要貿然嘗試,不要奢望可以憑運氣達成目的,盡量找到最合適的實現方法,在確立了一個基本思路之后,可以在gcc的maillist上咨詢一下,看看有沒有人提供更好的思路,在確信自己思路的可行性之后再開始具體的工作。
在做具體實現的時候,肯定會遇到各種各樣的問題,比如在編譯自己修改過的gcc時會出錯,或者用patch過的gcc編譯程序時出錯,或者是編譯通過運行時刻出錯等等,這時候需要耐心地檢查代碼和進行debug,盡量自己解決問題,不要把一些特別細節地問題拿到maillist上討論。我記得在maillist上曾經有人嚴厲地告誡我:“you won't go very far if you ask a question eachtime you get anerror”,自己debug才是解決問題的最好方法,當然如果實在不明白的問題必須拿到maillist上去討論,這時候要盡量詳細的描述自己的目的和問題,才能夠得到有效的幫助。
好了,這就是我自己學習和嘗試hack gcc的工作過程,希望我的一些經驗能夠給您幫助,如果對本文中的觀點有疑問或者在學習gcc的時候碰到困難,歡迎與我探討。
參考資料
- GCC internals.http://gcc.gnu.org/onlinedocs/gccint/,這是得到公認的除了gcc源碼以外,學習gcc最好的材料,雖然比較難讀,但它的權威性和全面性是毋庸置疑的,GCC的手冊和info也是很好的參考資料。
- GCC Front end HOWTO, 通過自己設計一個GCC前端來幫助GCC hacker newbie來學習和了解GCC,http://www.icewalkers.com/Linux/Howto/GCC-Frontend-HOWTO.html#toc1,適合新手閱讀,作者還提供了相關程序的軟件包。
- StackGuard,http://www.immunix.org/stackguard.html,可以作為一個在RTL表示層hack gcc的實例來學習。
王逸,2002年秋季入學的南京大學計算機科學與技術系碩士研究生,在分布式系統國家重點實驗室學習。對于軟件安全、linux系統和無線網絡有興趣,目前工作主要集中在緩沖區溢出攻擊的防范上。您可以通過cnnjuwy@hotmail.com與我聯系,也可以在南京大學小百合bbs上( http://bbs.nju.edu.cn/或者 http://lilybbs.net/)與LinuxLover賬號聯系。