在iOS專案中自動生成函式呼叫關係圖(CallGraph)

L-Zephyr發表於2017-12-14

文章所涉及程式碼已託管至github: github.com/L-Zephyr/cl…

在平時的開發中經常需要閱讀學習其他人的程式碼,當開始閱讀一份自己完全不熟悉的程式碼時,通常會遇到一些麻煩,因為我必須要先找到程式碼邏輯的入口點並沿著邏輯鏈路將其梳理一遍,一份程式碼檔案通常會伴隨著許多的方法呼叫,這一個階段往往是比較痛苦的,因為我必須花上許多時間來將這些方法之間的關係理清楚,這樣才能在我的大腦中生成一份邏輯關係圖。如果我們能自動生成原始碼中的方法呼叫圖(Call Graph),那樣一定會對原始碼閱讀有很大的幫助。

我們需要一個能夠自動生成原始碼方法呼叫圖的工具,那麼這個工具必須能夠理解並分析我們的程式碼,而最能理解程式碼的當然就是編譯器了。我們編譯Objective-C的程式碼所用的前端是Clang,Clang提供了一系列的工具來幫助我們分析原始碼,我們可以基於Clang來構建自己的工具。在這之前簡單介紹一些相關概念:

抽象語法樹

抽象語法樹(Abstract Syntax Code, AST)是原始碼語法結構的樹狀表示,其中的每一個節點都表示一個原始碼中的結構,AST在編譯中扮演了一個十分重要的角色,Clang分析輸入的原始碼並生成AST,之後根據AST生成LLVM IR(中間碼)。

我們可以使用Clang提供的工具clang-check來檢視AST,建立一個程式碼檔案test.c

int square(int num) {
	return num * num;
}

int main() {
	int result = square(2);
}
複製程式碼

在終端執行命令clang-check -ast-dump test.m,可以看到轉換後的AST結構:

|-FunctionDecl 0x7fa933840e00 </Users/lzephyr/Desktop/test.c:1:1, line:3:1> line:1:5 used square 'int (int)'
| |-ParmVarDecl 0x7fa93302f720 <col:12, col:16> col:16 used num 'int'
| `-CompoundStmt 0x7fa933840fa0 <col:21, line:3:1>
|   `-ReturnStmt 0x7fa933840f88 <line:2:2, col:15>
|     `-BinaryOperator 0x7fa933840f60 <col:9, col:15> 'int' '*'
|       |-ImplicitCastExpr 0x7fa933840f30 <col:9> 'int' <LValueToRValue>
|       | `-DeclRefExpr 0x7fa933840ee0 <col:9> 'int' lvalue ParmVar 0x7fa93302f720 'num' 'int'
|       `-ImplicitCastExpr 0x7fa933840f48 <col:15> 'int' <LValueToRValue>
|         `-DeclRefExpr 0x7fa933840f08 <col:15> 'int' lvalue ParmVar 0x7fa93302f720 'num' 'int'
`-FunctionDecl 0x7fa933841010 <line:5:1, line:7:1> line:5:5 main 'int ()'
  `-CompoundStmt 0x7fa9338411f8 <col:12, line:7:1>
    `-DeclStmt 0x7fa9338411e0 <line:6:2, col:24>
      `-VarDecl 0x7fa9338410c0 <col:2, col:23> col:6 result 'int' cinit
        `-CallExpr 0x7fa9338411b0 <col:15, col:23> 'int'
          |-ImplicitCastExpr 0x7fa933841198 <col:15> 'int (*)(int)' <FunctionToPointerDecay>
          | `-DeclRefExpr 0x7fa933841120 <col:15> 'int (int)' Function 0x7fa933840e00 'square' 'int (int)'
          `-IntegerLiteral 0x7fa933841148 <col:22> 'int' 2
複製程式碼

###LibTooling和Clang Plugin LibTooling是一個庫,提供了對AST的訪問和修改的能力,LibTooling可以用來編寫可獨立執行的程式,如我們上面所使用的clang-checkLibTooling提供了一系列便捷的方法來訪問語法樹。

Clang PluginLibTooling類似,對AST有完全的控制權,但是不同的是Clang Plugin是作為外掛注入到編譯流程中的,並且可以嵌入xCode中。實際上使用LibTooling編寫的獨立工具只需要經過少許的改動就可以變成Clang Plugin來使用。

##訪問抽象語法樹 要獲得函式之間的呼叫關係,我們必須分析AST,Clang提供了兩種方法:ASTMatchersRecursiveASTVisitor

###ASTMatchers ASTMatchers提供了一系列的函式,以DSL的方式編寫匹配表示式來查詢我們感興趣的節點,並使用bind方法繫結到指定的名稱上:

StatementMatcher matcher = callExpr(hasAncestor(functionDecl().bind("caller")), 
                                    callee(functionDecl().bind("callee")));
複製程式碼

上面的表示式匹配了原始碼中普通C函式的呼叫,並將呼叫者繫結到字串"caller",被呼叫者繫結到字串"callee",隨後在回撥方法中可以通過名稱caller和callee來獲取FunctionDecl型別的物件:

class FindFuncCall : public MatchFinder::MatchCallback {
public :
    virtual void run(const MatchFinder::MatchResult &Result) {
        // 獲取呼叫者的函式定義
        if (const FunctionDecl *caller = Result.Nodes.getNodeAs<clang::FunctionDecl>("caller")) {
            caller->dump();
        }
        // 獲取被呼叫者的函式定義
        if (const FunctionDecl *callee = Result.Nodes.getNodeAs<clang::FunctionDecl>("callee")) {
            callee->dump();
        }
    }
};

int main(int argv, const char **argv) {
	StatementMatcher matcher = callExpr(hasAncestor(functionDecl().bind("caller")),
                                        callee(functionDecl().bind("callee")));
    MatchFinder finder;
    FindFuncCall callback;
    finder.addMatcher(matcher, &callback);
	
    // 執行Matcher
    CommonOptionsParser OptionsParser(argc, argv, MyToolCategory);
    ClangTool Tool(OptionsParser.getCompilations(), OptionsParser.getSourcePathList());
    Tool.run(newFrontendActionFactory(&finder).get());
    return 0;
}
複製程式碼

上述匹配表示式中的每一個函式(如callExpr)被稱為一個Matcher,所有的Matcher可以分為三類:

  • Node Matchers:匹配表示式的核心,用來匹配特定型別的所有節點,所有的匹配表示式都是由一個Node Matcher來開始的,並且只有在Node Matcher上可以呼叫bind方法。Node Mathcher可以包含任意數量的引數,在引數中傳入其他的Matcher來操縱匹配的節點,但是需要注意的是所有作為引數傳入的Matcher都會作用在同一個被匹配的節點上,如:
    DeclarationMatcher matcher = recordDecl(cxxRecordDecl().bind("class"),
    										hasName("MyClass"));
    複製程式碼
    該matcher的含義是查詢名字為“MyClass”的c++類,recordDecl是一個Node Matcher,匹配所有的class、struct和union的定義;hasName匹配名字為"MyClass"的節點;cxxRecordDecl匹配C++類定義的節點,並將其繫結到字串"class"上。
  • Narrowing Matchers:顧名思義,這種Matcher提供了條件判斷能力用來縮小匹配範圍,如第二個例子中的hasName就是一個Narrowing Matcher,只匹配名稱為"MyClass"的節點。
  • Traversal Matchers:以當前匹配的節點作為起點,用來限定匹配表示式查詢的範圍。如第一個例子中的hasAncestor,在當前節點的祖先節點中進行下一步的匹配。

###RecursiveASTVisitor RecursiveASTVisitor是Clang提供的另一種訪問AST的方式,使用起來很簡單,你需要定義三個類,分別繼承自ASTFrontendActionASTConsumerRecursiveASTVisitor
在自定義的MyFrontendAction中返回一個自定義的MyConsumer例項

class MyFrontendAction : public clang::ASTFrontendAction {
public:
    virtual std::unique_ptr<clang::ASTConsumer> CreateASTConsumer(
      clang::CompilerInstance &Compiler, llvm::StringRef InFile) {
      return std::unique_ptr<clang::ASTConsumer>(new MyConsumer);
    }
};
複製程式碼

在AST解析完畢後會呼叫MyConsumer的HandleTranslationUnit方法,TranslationUnitDecl是一個AST的根節點,ASTContext中儲存了AST相關的所有資訊,獲取TranslationUnitDecl並將其交給MyVisitor,我們主要的操作都在Visitor中完成

class MyConsumer : public clang::ASTConsumer {
public:
    virtual void HandleTranslationUnit(clang::ASTContext &Context) {
      Visitor.TraverseDecl(Context.getTranslationUnitDecl());
    }
private:
  	MyVisitor Visitor;
};
複製程式碼

在Visitor中訪問感興趣的節點只需要重寫該型別節點的Visit方法就行了,比如我想訪問程式碼中所有的C++類定義,只需要重寫VisitCXXRecordDecl方法,就可以訪問所有的的所有的C++類定義了

class MyVisitor : public RecursiveASTVisitor<FindNamedClassVisitor> {
public:
  	bool VisitCXXRecordDecl(CXXRecordDecl *decl) {
    	decl->dump();
    	return true; // 返回true繼續遍歷,false則直接停止
  	}
};
複製程式碼

之後在main函式中使用newFrontendActionFactory建立ToolAction就可以了:

Tool.run(newFrontendActionFactory<CallGraphAction>().get());
複製程式碼

##構建CallGraph工具 在Clang原始碼的Analysis資料夾中提供了一個名為CallGraph的類,參考這份原始碼的實現編寫了自己的CallGraph工具。其中核心部分主要為三個類:CallGraphCallGraphNodeCGBuilder

  • CallGraph:繼承自RecursiveASTVisitor,實現VisitFunctionDeclVisitObjCMethodDecl方法,遍歷所有的C函式和Objective-C方法:
    bool VisitObjCMethodDecl(ObjCMethodDecl *MD) {
        if (isInSystem(MD)) { // 忽略系統庫中的定義
            return true;
        }
    
        if (canBeCallerInGraph(MD)) {
            addRootNode(MD); // 新增一個Node到Roots
        }
        return true;
    }
    複製程式碼
    addRootNode中將其封裝成CallGraphNode物件並儲存在一個map型別的成員物件Roots中。隨後獲取函式體(CompoundStmt型別),將其傳遞給CGBuilder查詢在函式體中被呼叫的方法。
    void CallGraph::addRootNode(Decl *decl) {
      CallGraphNode *Node = getOrInsertNode(decl); // 將decl封裝成Node,並新增到Roots中
      
      // 初始化CGBuilder遍歷函式裡中所有的方法呼叫
      CGBuilder builder(this, Node, Context);
      if (Stmt *Body = decl->getBody())
          builder.Visit(Body);
    }
    複製程式碼
  • CallGraphNode:封裝了一個Decl型別的的例項(C函式或OC方法的定義),用來表示一個AST節點,所有被該函式所呼叫的其他函式會被新增到vector型別的成員變數CalledFunctions中。
    class CallGraphNode {
    private:
        // C函式或OC方法的定義
        Decl *decl;
        // 儲存所有被decl呼叫的Node
        SmallVector<CallGraphNode*, 5> CalledFunctions;
    ...
    複製程式碼
  • CGBuilder:繼承自StmtVisitor,初始化時獲取一個CallerNode,遍歷該CallerNode對應函式的函式體,查詢函式體中的方法呼叫:CallExprObjCMessageExprCallExpr表示普通的C函式呼叫,ObjCMessageExpr表示Objective-C方法呼叫。獲取被呼叫函式的定義並封裝成CallGraphNode型別,然後將其新增到CallerNode的CalledFunctions中。
    class CGBuilder : public StmtVisitor<CGBuilder> {
      CallGraph *G;
      CallGraphNode *CallerNode;
      ASTContext &Context;
    public:
      void VisitObjCMessageExpr(ObjCMessageExpr *ME) {
          // 從ObjCMessageExpr中獲取被呼叫方法的Decl
          Decl *decl = ...
          
          // 將decl封裝在CallGraphNode中並新增到CallerNode的CalledFunctions中
          addCalledDecl(decl); 
      }
    ...
    複製程式碼

目前只實現了一個基礎版本,支援C和Objecive-C,實現了最基本的功能,程式碼也比較簡單,之後會繼續優化並增加新的功能,所有程式碼已經託管到github上:https://github.com/L-Zephyr/clang-mapper

##使用

可以下載並自行編譯原始碼,或者直接使用release資料夾中預先編譯好的二進位制檔案clang-mapper(使用Clang5.0.0編譯),由於採用了Graphviz來生成呼叫圖,請確保在執行前已正確安裝了Graphviz

###編譯原始碼 關於如何編譯使用LibTooling編寫的工具,Clang官方文件中有詳細的說明

  1. 首先下載LLVM和Clang的原始碼。

  2. clang-mapper資料夾拷貝到llvm/tools/clang/tools/中。

  3. 編輯檔案llvm/tools/clang/tools/CMakeLists.txt,在最後加上一句add_clang_subdirectory(clang-mapper)

  4. 建議採用外部編譯,在包含llvm資料夾的目錄下建立build資料夾,在build目錄中編譯原始碼

    $ mkdir build
    $ cd build
    $ cmake -G 'Unix Makefiles' ../llvm
    $ make
    複製程式碼

    也可以按照文件中介紹的使用Ninja來編譯,編譯過程中會生成20多個G的中間檔案,編譯結束後在build/bin/中就能找到clang-mapper檔案了,將其拷貝到/usr/local/bin目錄下

###基本使用 傳入任意數量的檔案或是資料夾,clang-mapper會自動處理所有檔案並在當前執行命令的路徑下生成函式的呼叫圖,以程式碼檔案的命名做區分。如下,我們用clang-mapper分析大名鼎鼎的AFNetworking的核心程式碼。我不希望將分析生成的結果和原始碼檔案混在一起,所以我建立了一個資料夾CallGraph並在該目錄下呼叫

$ cd ./AFNetworking-master
$ mkdir CallGraph
$ cd ./CallGraph
$ clang-mapper ../AFNetworking --
複製程式碼

之後程式會自動分析../AFNetworking下的所有程式碼檔案,並在CallGraph目錄下生成對應的png檔案:

在iOS專案中自動生成函式呼叫關係圖(CallGraph)

在iOS專案中自動生成函式呼叫關係圖(CallGraph)

###命令列引數 clang-mapper提供了一些可選的命令列引數

  • -graph-only:只生成png檔案,不保留dot檔案,這個是預設選項
  • -dot-only:只生成dot檔案,不生成png檔案
  • -dot-graph:同時生成dot檔案和png檔案
  • -ignore-header:在iOS開發中標頭檔案通常只用來宣告,加上該選項可以忽略資料夾中的.h檔案

參考資料

  • https://clang.llvm.org/docs/LibASTMatchersTutorial.html
  • https://clang.llvm.org/docs/RAVFrontendAction.html
  • https://clang.llvm.org/docs/LibASTMatchersReference.html
  • https://clang.llvm.org/docs/IntroductionToTheClangAST.html

相關文章