在iOS項(xiàng)目中自動(dòng)生成函數(shù)調(diào)用關(guān)系圖(CallGraph)

文章所涉及代碼已托管至github: https://github.com/L-Zephyr/clang-mapper

在平時(shí)的開(kāi)發(fā)中經(jīng)常需要閱讀學(xué)習(xí)其他人的代碼,當(dāng)開(kāi)始閱讀一份自己完全不熟悉的代碼時(shí),通常會(huì)遇到一些麻煩,因?yàn)槲冶仨氁日业酱a邏輯的入口點(diǎn)并沿著邏輯鏈路將其梳理一遍,一份代碼文件通常會(huì)伴隨著許多的方法調(diào)用,這一個(gè)階段往往是比較痛苦的,因?yàn)槲冶仨毣ㄉ显S多時(shí)間來(lái)將這些方法之間的關(guān)系理清楚,這樣才能在我的大腦中生成一份邏輯關(guān)系圖。如果我們能自動(dòng)生成源碼中的方法調(diào)用圖(Call Graph),那樣一定會(huì)對(duì)源碼閱讀有很大的幫助。

我們需要一個(gè)能夠自動(dòng)生成源碼方法調(diào)用圖的工具,那么這個(gè)工具必須能夠理解并分析我們的代碼,而最能理解代碼的當(dāng)然就是編譯器了。我們編譯Objective-C的代碼所用的前端是Clang,Clang提供了一系列的工具來(lái)幫助我們分析源碼,我們可以基于Clang來(lái)構(gòu)建自己的工具。在這之前簡(jiǎn)單介紹一些相關(guān)概念:

抽象語(yǔ)法樹(shù)

抽象語(yǔ)法樹(shù)(Abstract Syntax Code, AST)是源代碼語(yǔ)法結(jié)構(gòu)的樹(shù)狀表示,其中的每一個(gè)節(jié)點(diǎn)都表示一個(gè)源碼中的結(jié)構(gòu),AST在編譯中扮演了一個(gè)十分重要的角色,Clang分析輸入的源碼并生成AST,之后根據(jù)AST生成LLVM IR(中間碼)。

我們可以使用Clang提供的工具clang-check來(lái)查看AST,創(chuàng)建一個(gè)代碼文件test.c

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

int main() {
    int result = square(2);
}

在終端執(zhí)行命令clang-check -ast-dump test.m,可以看到轉(zhuǎn)換后的AST結(jié)構(gòu):

|-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是一個(gè)庫(kù),提供了對(duì)AST的訪(fǎng)問(wèn)和修改的能力,LibTooling可以用來(lái)編寫(xiě)可獨(dú)立運(yùn)行的程序,如我們上面所使用的clang-check,LibTooling提供了一系列便捷的方法來(lái)訪(fǎng)問(wèn)語(yǔ)法樹(shù)。

Clang PluginLibTooling類(lèi)似,對(duì)AST有完全的控制權(quán),但是不同的是Clang Plugin是作為插件注入到編譯流程中的,并且可以嵌入xCode中。實(shí)際上使用LibTooling編寫(xiě)的獨(dú)立工具只需要經(jīng)過(guò)少許的改動(dòng)就可以變成Clang Plugin來(lái)使用。

訪(fǎng)問(wèn)抽象語(yǔ)法樹(shù)

要獲得函數(shù)之間的調(diào)用關(guān)系,我們必須分析AST,Clang提供了兩種方法:ASTMatchersRecursiveASTVisitor。

ASTMatchers

ASTMatchers提供了一系列的函數(shù),以DSL的方式編寫(xiě)匹配表達(dá)式來(lái)查找我們感興趣的節(jié)點(diǎn),并使用bind方法綁定到指定的名稱(chēng)上:

StatementMatcher matcher = callExpr(hasAncestor(functionDecl().bind("caller")), 
                                    callee(functionDecl().bind("callee")));

上面的表達(dá)式匹配了源碼中普通C函數(shù)的調(diào)用,并將調(diào)用者綁定到字符串"caller",被調(diào)用者綁定到字符串"callee",隨后在回調(diào)方法中可以通過(guò)名稱(chēng)caller和callee來(lái)獲取FunctionDecl類(lèi)型的對(duì)象:

class FindFuncCall : public MatchFinder::MatchCallback {
public :
    virtual void run(const MatchFinder::MatchResult &Result) {
        // 獲取調(diào)用者的函數(shù)定義
        if (const FunctionDecl *caller = Result.Nodes.getNodeAs<clang::FunctionDecl>("caller")) {
            caller->dump();
        }
        // 獲取被調(diào)用者的函數(shù)定義
        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);
    
    // 執(zhí)行Matcher
    CommonOptionsParser OptionsParser(argc, argv, MyToolCategory);
    ClangTool Tool(OptionsParser.getCompilations(), OptionsParser.getSourcePathList());
    Tool.run(newFrontendActionFactory(&finder).get());
    return 0;
}

上述匹配表達(dá)式中的每一個(gè)函數(shù)(如callExpr)被稱(chēng)為一個(gè)Matcher,所有的Matcher可以分為三類(lèi):

  • Node Matchers:匹配表達(dá)式的核心,用來(lái)匹配特定類(lèi)型的所有節(jié)點(diǎn),所有的匹配表達(dá)式都是由一個(gè)Node Matcher來(lái)開(kāi)始的,并且只有在Node Matcher上可以調(diào)用bind方法。Node Mathcher可以包含任意數(shù)量的參數(shù),在參數(shù)中傳入其他的Matcher來(lái)操縱匹配的節(jié)點(diǎn),但是需要注意的是所有作為參數(shù)傳入的Matcher都會(huì)作用在同一個(gè)被匹配的節(jié)點(diǎn)上,如:
    DeclarationMatcher matcher = recordDecl(cxxRecordDecl().bind("class"),
                                          hasName("MyClass"));
    
    該matcher的含義是查找名字為“MyClass”的c++類(lèi),recordDecl是一個(gè)Node Matcher,匹配所有的class、struct和union的定義;hasName匹配名字為"MyClass"的節(jié)點(diǎn);cxxRecordDecl匹配C++類(lèi)定義的節(jié)點(diǎn),并將其綁定到字符串"class"上。
  • Narrowing Matchers:顧名思義,這種Matcher提供了條件判斷能力用來(lái)縮小匹配范圍,如第二個(gè)例子中的hasName就是一個(gè)Narrowing Matcher,只匹配名稱(chēng)為"MyClass"的節(jié)點(diǎn)。
  • Traversal Matchers:以當(dāng)前匹配的節(jié)點(diǎn)作為起點(diǎn),用來(lái)限定匹配表達(dá)式查找的范圍。如第一個(gè)例子中的hasAncestor,在當(dāng)前節(jié)點(diǎn)的祖先節(jié)點(diǎn)中進(jìn)行下一步的匹配。

RecursiveASTVisitor

RecursiveASTVisitor是Clang提供的另一種訪(fǎng)問(wèn)AST的方式,使用起來(lái)很簡(jiǎn)單,你需要定義三個(gè)類(lèi),分別繼承自ASTFrontendActionASTConsumerRecursiveASTVisitor。
在自定義的MyFrontendAction中返回一個(gè)自定義的MyConsumer實(shí)例

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);
    }
};

在A(yíng)ST解析完畢后會(huì)調(diào)用MyConsumer的HandleTranslationUnit方法,TranslationUnitDecl是一個(gè)AST的根節(jié)點(diǎn),ASTContext中保存了AST相關(guān)的所有信息,獲取TranslationUnitDecl并將其交給MyVisitor,我們主要的操作都在Visitor中完成

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

在Visitor中訪(fǎng)問(wèn)感興趣的節(jié)點(diǎn)只需要重寫(xiě)該類(lèi)型節(jié)點(diǎn)的Visit方法就行了,比如我想訪(fǎng)問(wèn)代碼中所有的C++類(lèi)定義,只需要重寫(xiě)VisitCXXRecordDecl方法,就可以訪(fǎng)問(wèn)所有的的所有的C++類(lèi)定義了

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

之后在main函數(shù)中使用newFrontendActionFactory創(chuàng)建ToolAction就可以了:

Tool.run(newFrontendActionFactory<CallGraphAction>().get());

構(gòu)建CallGraph工具

在Clang源碼的Analysis文件夾中提供了一個(gè)名為CallGraph的類(lèi),參考這份源碼的實(shí)現(xiàn)編寫(xiě)了自己的CallGraph工具。其中核心部分主要為三個(gè)類(lèi):CallGraph、CallGraphNodeCGBuilder

  • CallGraph:繼承自RecursiveASTVisitor,實(shí)現(xiàn)VisitFunctionDeclVisitObjCMethodDecl方法,遍歷所有的C函數(shù)和Objective-C方法:
    bool VisitObjCMethodDecl(ObjCMethodDecl *MD) {
        if (isInSystem(MD)) { // 忽略系統(tǒng)庫(kù)中的定義
            return true;
        }
    
        if (canBeCallerInGraph(MD)) {
            addRootNode(MD); // 添加一個(gè)Node到Roots
        }
        return true;
    }
    
    addRootNode中將其封裝成CallGraphNode對(duì)象并保存在一個(gè)map類(lèi)型的成員對(duì)象Roots中。隨后獲取函數(shù)體(CompoundStmt類(lèi)型),將其傳遞給CGBuilder查找在函數(shù)體中被調(diào)用的方法。
    void CallGraph::addRootNode(Decl *decl) {
      CallGraphNode *Node = getOrInsertNode(decl); // 將decl封裝成Node,并添加到Roots中
      
      // 初始化CGBuilder遍歷函數(shù)里中所有的方法調(diào)用
      CGBuilder builder(this, Node, Context);
      if (Stmt *Body = decl->getBody())
          builder.Visit(Body);
    }
    
  • CallGraphNode:封裝了一個(gè)Decl類(lèi)型的的實(shí)例(C函數(shù)或OC方法的定義),用來(lái)表示一個(gè)AST節(jié)點(diǎn),所有被該函數(shù)所調(diào)用的其他函數(shù)會(huì)被添加到vector類(lèi)型的成員變量CalledFunctions中。
    class CallGraphNode {
    private:
        // C函數(shù)或OC方法的定義
        Decl *decl;
        // 保存所有被decl調(diào)用的Node
        SmallVector<CallGraphNode*, 5> CalledFunctions;
    ...
    
  • CGBuilder:繼承自StmtVisitor,初始化時(shí)獲取一個(gè)CallerNode,遍歷該CallerNode對(duì)應(yīng)函數(shù)的函數(shù)體,查找函數(shù)體中的方法調(diào)用:CallExprObjCMessageExprCallExpr表示普通的C函數(shù)調(diào)用,ObjCMessageExpr表示Objective-C方法調(diào)用。獲取被調(diào)用函數(shù)的定義并封裝成CallGraphNode類(lèi)型,然后將其添加到CallerNode的CalledFunctions中。
    class CGBuilder : public StmtVisitor<CGBuilder> {
      CallGraph *G;
      CallGraphNode *CallerNode;
      ASTContext &Context;
    public:
      void VisitObjCMessageExpr(ObjCMessageExpr *ME) {
          // 從ObjCMessageExpr中獲取被調(diào)用方法的Decl
          Decl *decl = ...
          
          // 將decl封裝在CallGraphNode中并添加到CallerNode的CalledFunctions中
          addCalledDecl(decl); 
      }
    ...
    

目前只實(shí)現(xiàn)了一個(gè)基礎(chǔ)版本,支持C和Objecive-C,實(shí)現(xiàn)了最基本的功能,代碼也比較簡(jiǎn)單,之后會(huì)繼續(xù)優(yōu)化并增加新的功能,所有代碼已經(jīng)托管到github上:https://github.com/L-Zephyr/clang-mapper

使用

可以下載并自行編譯源碼,或者直接使用release文件夾中預(yù)先編譯好的二進(jìn)制文件clang-mapper(使用Clang5.0.0編譯),由于采用了Graphviz來(lái)生成調(diào)用圖,請(qǐng)確保在運(yùn)行前已正確安裝了Graphviz

編譯源碼

關(guān)于如何編譯使用LibTooling編寫(xiě)的工具,Clang官方文檔中有詳細(xì)的說(shuō)明

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

  2. clang-mapper文件夾拷貝到llvm/tools/clang/tools/中。

  3. 編輯文件llvm/tools/clang/tools/CMakeLists.txt,在最后加上一句add_clang_subdirectory(clang-mapper)

  4. 建議采用外部編譯,在包含llvm文件夾的目錄下創(chuàng)建build文件夾,在build目錄中編譯源碼

    $ mkdir build
    $ cd build
    $ cmake -G 'Unix Makefiles' ../llvm
    $ make
    

    也可以按照文檔中介紹的使用Ninja來(lái)編譯,編譯過(guò)程中會(huì)生成20多個(gè)G的中間文件,編譯結(jié)束后在build/bin/中就能找到clang-mapper文件了,將其拷貝到/usr/local/bin目錄下

基本使用

傳入任意數(shù)量的文件或是文件夾,clang-mapper會(huì)自動(dòng)處理所有文件并在當(dāng)前執(zhí)行命令的路徑下生成函數(shù)的調(diào)用圖,以代碼文件的命名做區(qū)分。如下,我們用clang-mapper分析大名鼎鼎的AFNetworking的核心代碼。我不希望將分析生成的結(jié)果和源碼文件混在一起,所以我創(chuàng)建了一個(gè)文件夾CallGraph并在該目錄下調(diào)用

$ cd ./AFNetworking-master
$ mkdir CallGraph
$ cd ./CallGraph
$ clang-mapper ../AFNetworking --

之后程序會(huì)自動(dòng)分析../AFNetworking下的所有代碼文件,并在CallGraph目錄下生成對(duì)應(yīng)的png文件:

每個(gè)代碼文件的調(diào)用關(guān)系圖
AFHTTPSessionManager的調(diào)用關(guān)系圖(部分)

命令行參數(shù)

clang-mapper提供了一些可選的命令行參數(shù)

  • -graph-only:只生成png文件,不保留dot文件,這個(gè)是默認(rèn)選項(xiàng)
  • -dot-only:只生成dot文件,不生成png文件
  • -dot-graph:同時(shí)生成dot文件和png文件
  • -ignore-header:在iOS開(kāi)發(fā)中頭文件通常只用來(lái)聲明,加上該選項(xiàng)可以忽略文件夾中的.h文件

參考資料

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀(guān)點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容