1. 程式人生 > >分析函式呼叫關係圖(call graph)的幾種方法

分析函式呼叫關係圖(call graph)的幾種方法

繪製函式呼叫關係圖對理解大型程式大有幫助。我想大家都有過一邊讀原始碼(並在頭腦中維護一個呼叫棧),一邊在紙上畫函式呼叫關係,然後整理成圖的經歷。如果運氣好一點,藉助偵錯程式的單步跟蹤功能和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.phphttp://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。具體方法在《

》中有詳細介紹,這裡不再贅述。

從適應能力上看,原始碼分析法是最強的,即便原始碼中有語法錯,標頭檔案不全也沒關係,它照樣能分析個八九不離十。而基於編譯器的分析法對原始碼的要求要高一些,至少能編譯通過(gcc 引數 -c)——能產生object file,不一定要連結得到可執行檔案。這至少要求原始碼沒有語法錯,其中呼叫的函式不一定有定義(definition),但要有宣告(declaration),也就是說標頭檔案要齊全。當然,真的不全也沒關係,自己放幾個函式宣告在前面就能糊弄編譯器:) 至於動態分析,要求最高——程式需得執行起來。如果你要分析的是作業系統中某一部分,比如記憶體管理或網路協議棧,那麼這裡提到的兩種動態分析法恐怕都不適用了。

我發現前面列舉的所有免費工具幾乎都和GCC、GNU Binutils脫不了干係。這裡在把它們整理一下,用Graphviz繪成圖: