內容摘要 本文將以 C 語言爲例,介紹 gcc 在接受一個 .c文件的輸入以後,其前端是如何進行處理並獲得一箇中間表示並轉交給後端處理。而後,在瞭解了 gcc[1] 的工做流程後,介紹一下做者嘗試在 gcc內部的 RTL 表示層中 hack gcc 的過程,與你們分享一些經驗,但願能給對有興趣研究和開發 gcc 的讀者有所幫助。html
1. GCC 簡介
編譯器的工做是將源代碼(一般使用高級語言編寫)翻譯成目標代碼(一般是低級的目標代碼或者機器語言),在現代編譯器的實現中,這個工做通常是分爲兩個階段來實現的:前端
第一階段,編譯器的前端接受輸入的源代碼,通過詞法、語法和語義分析等等獲得源程序的某種中間表示方式。linux
第二階段,編譯器的後端將前端處理生成的中間表示方式進行一些優化,並最終生成在目標機器上可運行的代碼。程序員
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.parse_file)(set_yydebug); |
這裏的 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所在的目錄,運行命令:
$ gdb cc1 $ break main $ run -dr /PATH/test.c |
這樣就是以-dr爲編譯參數運行gcc來編譯test.c文件了,而且在main函數的入口處設置了一個斷點,-dr做爲編譯參數就是要求在RTL表示生成之後將其dump到一個以.rtl結尾的文件中去。接下來在rest_of_compilation以前再設置一個斷點,並用continue命令運行到該斷點,用backtrace命令查看此時函數棧幀的狀況:
$ break 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生成有關的函數調用,特別引發咱們注意的又是一個鉤子函數:
(*lang_hooks.rtl_expand.stmt) (DECL_SAVED_TREE (fndecl)); |
這行代碼的註釋說這個鉤子函數用來生成一個被編譯函數的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的編譯過程當中,有三次比較重要的轉換:
RTL是gcc內部使用的中間表示語言,爲了對其有一個直觀點的印象,咱們能夠把它dump出來看一看。使用
$ gcc -dr test.c |
就能夠獲得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語句的功能:
int modify_rtl() { rtx insn; rtx seq; //emit my_function_begin at the beginnig of each function start_sequence(); emit_libarary_call(gen_rtx(SYMBOL_REF, Pmode, my_function_begin), 0, VOIDmode, 0); seq = get_insns(); end_sequence(); for(insn = get_insns(); ; insn = NEXT_INSN(insn)) if((GET_CODE(insn) == NOTE) && (NOTE_LINE_NUMBER(insn) == NOTE_INSN_FUNCTION_BEGIN)) break; emit_insn_after(seq, insn); … } |
這段代碼中所使用數據結構、函數的具體功能和用法,屬於十分細節的內容,無須在這裏描述清楚,請讀者參考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,使用以下命令編譯:
$ gcc mylib.c -c -fPIC $ gcc mylib.o -shared -o libmylib.so |
把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的時候碰到困難,歡迎與我探討。