繪製函數呼叫歷程圖對理解大型程式大有協助。我想大家都有過一邊讀源碼(並在頭腦中維護一個調用棧),一邊在紙上畫函數調用關係,然後整理成圖的經曆。如果運氣好一點,藉助調試器的單步跟蹤功能和call stack視窗,能節約一些腦力。不過如果要分析的是指令碼語言的代碼,那多半隻好老老實實用第一種方法了。如果在讀代碼之前,手邊就有一份調用圖,豈不妙哉?下面舉出我知道的幾種免費的分析C/C++函數調用關係的工具。
函數呼叫歷程圖(call graph)是圖(graph),而且是有向圖,多半還是無環圖(無圈圖)——如果代碼中沒有直接或間接的遞迴的話。Graphviz是專門繪製有向圖和無向圖的工具,所以很多call graph分析工具都以它為後端(back end)。那麼前端呢?就看各家各顯神通了。
調用圖的分析分析大致可分為“靜態”和“動態”兩種,所謂靜態分析是指在不運行待分析的程式的前提下進行分析,那麼動態分析自然就是記錄程式實際運行時的函數調用情況了。
靜態分析又有兩種方法,一是分析源碼,二是分析編譯後的目標檔案。
分析源碼獲得的調用圖的品質取決於分析工具對程式設計語言的理解程度,比如能不能找出正確的C++重載函數。Doxygen是源碼文檔化工具,也能繪製調用圖,它似乎是自己分析源碼獲得函數調用關係的。GNU cflow也是類似的工具,不過它似乎偏重分析流程圖(flowchart)。
對程式設計語言的理解程度最好的當然是編譯器了,所以有人想出給編譯器打補丁,讓它在編譯時間順便記錄函數調用關係。CodeViz(其靈感來自Martin Devera (Devik) 的工具)就屬於此類,它(1.0.9版)給GCC 3.4.1打了個補丁。另外一個工具egypt的思路更巧妙,不用大動幹戈地給編譯器打補丁,而是讓編譯器自己dump出調用關係,然後分析分析,交給Graphviz去繪圖。不過也有人另起爐灶,自己寫個C語言編譯器(ncc),專門分析調用圖,勇氣可嘉。不如要是對C++語言也這麼幹,成本不免太高了。分析C++的調用圖,還是藉助編譯器比較實在。
分析目標檔案聽起來挺高深,其實不然,反組譯碼的工作交給binutils的objdump去做,只要分析一下反組譯碼出來的文字檔就行了。下面是Cygwin下objdump -d a.exe的部分結果:
00401050 <_main>:
401050: 55 push %ebp
401051: 89 e5 mov %esp,%ebp
401053: 83 ec 18 sub $0x18,%esp
......
40107a: c7 44 24 04 00 20 40 movl $0x402000,0x4(%esp)
401081: 00
401082: c7 04 24 02 20 40 00 movl $0x402002,(%esp)
401089: e8 f2 00 00 00 call 401180 <_fopen>
從中可以看出,main()調用了fopen()。CodeViz帶有分析目標檔案的功能。
動態分析是在程式運行時記錄函數的調用,然後整理成調用圖。與靜態分析相比,它能獲得更多的資訊,比如函數調用的先後順序和次數;不過也有一定的缺點,比如程式中語句的某些分支可能沒有執行到,這些分支中調用的函數自然就沒有記錄下來。
動態分析也有兩種方法,一是藉助gprof的call graph功能(參數-q),二是利用GCC的 -finstrument-functions 參數。
gprof產生的輸出如下:
index % time self children called name
0.00 0.00 4/4 foo [4]
[3] 0.0 0.00 0.00 4 bar [3]
-----------------------------------------------
0.00 0.00 1/2 init [5]
0.00 0.00 1/2 main [45]
[4] 0.0 0.00 0.00 2 foo [4]
0.00 0.00 4/4 bar [3]
-----------------------------------------------
0.00 0.00 1/1 main [45]
[5] 0.0 0.00 0.00 1 init [5]
0.00 0.00 1/2 foo [4]
-----------------------------------------------
從中可以看出,bar()被foo()調用了4次,foo()被init()和main()各調用了一次,init()被main()調用了一次。用Perl指令碼分析gprof的輸出,產生Graphviz的dot輸入,就能繪製call graph了。這樣的指令碼不止一個人寫過:http://www.graphviz.org/Resources.php,http://www.ioplex.com/~miallen/。
GCC的-finstrument-functions 參數的作用是在程式中加入hook,讓它在每次進入和退出函數的時候分別調用下面這兩個函數:
void __cyg_profile_func_enter( void *func_address, void *call_site )
__attribute__ ((no_instrument_function));
void __cyg_profile_func_exit ( void *func_address, void *call_site )
__attribute__ ((no_instrument_function));
當然,這兩個函數本身不能被鉤住(使用no_instrument_function這個__attribute__),不然就反反覆複萬世不竭了:) 這裡獲得的是函數地址,需要用binutils中的addr2line這個小工具轉換為函數名,如果是C++函數,還要用c++filt進行name demangle。具體方法在《
用Graphviz 可視化函數調用》中有詳細介紹,這裡不再贅述。
從適應能力上看,源碼分析法是最強的,即便源碼中有文法錯,標頭檔不全也沒關係,它照樣能分析個八九不離十。而基於編譯器的分析法對源碼的要求要高一些,至少能編譯通過(gcc 參數 -c)——能產生object file,不一定要連結得到可執行檔。這至少要求源碼沒有文法錯,其中調用的函數不一定有定義(definition),但要有聲明(declaration),也就是說標頭檔要齊全。當然,真的不全也沒關係,自己放幾個函式宣告在前面就能糊弄編譯器:) 至於動態分析,要求最高——程式需得運行起來。如果你要分析的是作業系統中某一部分,比如記憶體管理或網路通訊協定棧,那麼這裡提到的兩種動態分析法恐怕都不適用了。
我發現前面列舉的所有免費工具幾乎都和GCC、GNU Binutils脫不了干係。這裡在把它們整理一下,用Graphviz繪成圖: