打造基于Clang LibTooling的iOS自动打点系统CLAS(二)

1. 配置LLVM和Clang

在这篇文章里,我们会基于上一篇所述的方案进行展开,详细讲解如何从0开始创建一个基于Clang LibTooling的编译器前端工具。在开始之前,我们假设你已经基本了解何为抽象语法树AST,我们后面的所有内容都是基于对AST的解析完成的。如果不了解AST,请移步官方文档Introduction to the Clang AST补全基础知识,或者这篇中文文章

此外我们还需要下载并配置好LLVM和Clang的源码环境。LLVM和Clang的源码都可从llvm.org上面下载,官方提供的代码库http://llvm.org/git/llvm以及http://llvm.org/git/clang速度很慢,当然github上面的官方镜像https://github.com/llvm-mirror/llvm也并不比官方代码库好到哪里去,你可以自己尝试看哪个更快即可。

因为我们设计的CLAS是基于iOS系统的,我们需要使用与XCode 8.x所使用的Clang尽可能相近的版本来创建CLAS系统。苹果开源网站上目前所能下载到的最新的Clang源码版本800.0.42.1是跟随XCode 8.2.1一起发布的,距离现在也已经有了半年多时间。编写这篇文章的时候XCode 8.3.3所使用的Clang版本是802.0.42,版本号可以通过下面的命令获取查看:

> clang --version
Apple LLVM version 8.1.0 (clang-802.0.42)
Target: x86_64-apple-darwin16.7.0
Thread model: posix
InstalledDir: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin

80x版本的Clang,都是基于代码库release_39这个分支的,苹果只是在release_39的基础上进行bug修复,和release_39分支的代码相差不大,所以

> git clone -b release_39 http://llvm.org/git/llvm llvm
> cd llvm/tools
> git clone -b release_39 http://llvm.org/git/clang clang

clone完成后,进入llvm源码根目录,并创建llvm_build目录并进入:

> cd llvm && mkdir llvm_build && cd llvm_build

LLVM使用了CMake作为Make工具,它是一个跨平台的类似Cocoapods的系统,可以生成Unix Makefile,Ninja以及XCode,Visual Studio,KDE在内的项目文件。我们这里演示使用XCode,如需生成其他类型的项目文件,请查询CMake帮助文件。

cmake -G "XCode" ..

等待cmake完成,正常安装XCode的情况下不会报错,首次运行Cmake因为没有缓存时间会比较长,再次运行就会快很多。完成后便可以在llvm_build目录下见到熟悉的LLVM.xcodeproj的文件了,双击打开即可。因为LLVM和Clang模块多代码量巨大,所以打开工程的时候会很卡。第一次打开工程XCode会提示是否自动创建Schemes,你可以选择自动创建,这样会生成上百个Schemes。因为我们的目标是基于Clang的LibTooling的,并不关心LLVM的组件,所以我们选择稍后手动创建Scheme方便管理。到了这里,LLVM和Clang的基本配置已经完成,接下来我们关注如何使用LibTooling开发我们的CLAS系统。

2. LibTooling vs. Libclang?

在开始动手之前,我们应该先大致了解一下LibTooling。Clang的LibTooling是一个独立的库,它允许使用者很方便地搭建属于你自己的编译器前端工具。libclang是另外一个不错的选择,它提供给使用者基于C的稳定的编程接口,隔离了编译器底层的复杂设计,拥有更强的Clang版本兼容性,以及更好的多语言支持能力,对于大多数分析AST的场景来说,libclang是一个很好入手的选择。libTooling的优点与缺点一样明显,它基于C++接口,读起来晦涩难懂,但是提供给使用者远比libclang强大全面的AST解析和控制能力,同时由于它与Clang的内核过于接近导致它的版本兼容能力比libclang差得多,Clang的变动很容易影响到LibTooling。一般来说,如果你只需要语法分析或者做代码补全这类功能,libclang将是你避免掉坑的最佳的选择。我们之所以选择libTooling还有一个重要的原因是它提供了完整的参数解析方案,可以很方便的构建一个独立的命令行工具。这是libclang所不具备的能力。

官方对于如何进行选择的解释请看这里。有兴趣了解更多关于libclang,可以看官方doxygen文档以及这篇文章libclang: Thinking Beyond the Compiler

3. 创建CLAS工程

我们假设你已经按照1的指导完成了所有步骤。那么在开始这一节之前,我们需要编译项目内的clang和clangTooling这两个target,因为接下来我们创建CLAS需要这些依赖项。clang几乎依赖了所有llvm和clang的模块,所以耗时很长,我们只需要启动clang的编译就可以附带编译所有clang依赖的库,免去了我们一个一个地寻找并编译的麻烦,这也是为什么在1里我们并不推荐使用自动创建Schemes的原因,因为根本没有必要,有clang一个基本就够了。编译大概需要几十分钟时间,取决于你的电脑配置,同时大约消耗掉8~10G左右的磁盘空间。

完成后我们开始创建CLAS工程。因为需要基于LibTooling进行开发,我们选择了在Clang项目内添加一个Tools的方式来简化流程。当然你也可以单独创建一个工程,然后通过引用相应的头文件和库文件来使用LibTooling。但是就像上面一节所说的,LLVM和Clang模块多,依赖关系极其复杂,将工程的依赖项配置完整需要花费大量的时间和精力,不如直接在LLVM项目内开发来得方便,也适合后期调试,甚至可以直接步进LLVM和Clang的源码逐步加深对编译过程的理解。我们首先在llvm/tools/clang/tools目录下创建一个新的目录clang-autostats并进入目录:

> cd llvm/tools/clang/tools && mkdir clang-autostats
> cd clang-autostats

然后为我们的新工具加入第一个源文件ClangAutoStats.cpp,开始解析AST。CLAS的目标很直接,找到所有ObjC的Method定义,并在左大括号后面插入语句。我们第一次尝试,将从指定代码中找到并打印所有ObjC的Method名称。先从main函数写起,很简单只有几行代码:

#include "clang/Driver/Options.h"
#include "clang/Tooling/CommonOptionsParser.h"
#include "clang/Tooling/Tooling.h"
static cl::OptionCategory OptsCategory("ClangAutoStats");
int main(int argc, const char **argv) {
    CommonOptionsParser op(argc, argv, OptsCategory);
    ClangTool Tool(op.getCompilations(), op.getSourcePathList());
    int result = Tool.run(newFrontendActionFactory<ClangAutoStatsAction>().get());
    return result;
}

main的第一行代码创建了一个参数解析器op,用来处理工具的传入参数,创建所需的CompilationDatabase以及文件列表。OptionCategory指定了工具所处的分类,对我们的使用没有什么影响,随便定义一个即可。第二行创建了一个前端工具ClangTool,将参数解析的结果传入。第三行是我们完成任务的主要入口,ClangTool的run方法将使用我们指定的ASTFrontEndAction对输入文件进行遍历。我们接下来会讲解ASTFrontEndAction以及后面要提到的ASTConsumer以及RecursiveASTVisitor。

在开始下一节前,我们先将我们的clang-autostats工程加入到LLVM里面。进入llvm/tools/clang/tools目录,在CMakeLists.txt最后加入一行并保存:

cd llvm/tools/clang/tools
echo 'add_subdirectory(clang-autostats)' >> ./CMakeLists.txt

然后进入clang-autostats目录,创建CMakeLists.txt文件,粘贴下面的内容并保存退出:

set(LLVM_LINK_COMPONENTS
  Support
  )

add_clang_executable(ClangAutoStats
  ClangAutoStats.cpp
)

target_link_libraries(ClangAutoStats
    clangAST
    clangBasic
    clangDriver
    clangFormat
    clangLex
    clangParse
    clangSema
    clangFrontend
    clangTooling
    clangToolingCore
    clangRewrite
    clangRewriteFrontend
)

if(UNIX)
  set(CLANGXX_LINK_OR_COPY create_symlink)
else()
  set(CLANGXX_LINK_OR_COPY copy)
endif()

然后回到llvm_build目录,重新执行一遍cmake命令生成新的工程文件,打开LLVM.xcodeproj后会看到在Clang executables文件夹下会出现我们新创建的ClangAutoStats目录:

这个目录下面有很多官方提供的如何使用Clang的示例,有兴趣的同学可以学习研究。

4. 遍历AST

这一节我们会遇到Clang AST里面三个重要的基类,ASTFrontEndAction、ASTConsumer以及RecursiveASTVisito。

首先从ASTFrontEndAction开始,它继承自FrontEndAction这个抽象基类,很多其他类是从这个类继承出来的。例如PluginFrontEndAction、PreprocessorFrontendAction,CodeGenAction等。ASTFrontEndAction是用来为前端工具定义标准化的AST操作流程的。一个前端可以注册多个Action,然后在指定时刻轮询调用每一个Action的特定方法。这是一种抽象工厂的模式。借用一张图来描述这个问题:

图片来源请参阅:Clang之语法抽象语法树AST

我们继承ASTFrontEndAction并重写CreateASTConsumer方法。这个方法由ClangTool在run的时候通过CompilerInstance调用,创建并返回给前端一个ASTConsumer。

class ClangAutoStatsAction : public ASTFrontendAction {
public:
    virtual std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI, StringRef file) override {
        return llvm::make_unique<ClangAutoStatsASTConsumer>(&CI);
    }
};

Action有很多其他方法可以重写,每个方法代表一个指定事件发生时的回调,例如BeginSourceFileAction,ExecuteAction,EndSourceFileAction等,可根据需要重写指定方法。

接下来我们需要从ASTConsumer继承一个自己的Consumer,ClangAutoStatsASTConsumer:

class ClangAutoStatsASTConsumer : public ASTConsumer {
private:
    ClangAutoStatsVisitor Visitor;
    CompilerInstance *CI;
public:
    explicit ClangAutoStatsASTConsumer(CompilerInstance *aCI)
    : Visitor(&(aCI->getASTContext())), CI(aCI) {}
    
    void HandleTranslationUnit(ASTContext &context) override {
      TranslationUnitDecl *decl = context.getTranslationUnitDecl();
      Visitor.TraverseTranslationUnitDecl(decl);
    }
};

代码也很短,这个Consumer提供了一系列HandleXXX方法,供使用者根据关注的类别进行重写。有些教程里面重写了HandleTopLevelDecl,而我们的例子则重写了HandleTranslationUnit方法。HandleTopLevelDecl是在遍历到Decl(即声明或定义,例如函数、ObjC interface等)的时候立即回调,而HandleTranslationUnit则是在当前的TranslationUnit(即目标文件或源代码)的AST被完整解析出来后才会回调。TopLevel指的是在AST第一层的节点,对于OC代码来说,这一般是interface、implementation、全局变量等在代码最外层的声明或定义。在我们的场景里使用HandleTranslationUnit更为合适,HandleTopLevelDecl更适合在语法检查中使用,一旦遇到错误,这个方法返回false则立即中断解析并指出错误位置,避免产生冗余错误信息。

接下来到了最重要的RecursiveASTVisitor了。根据官方的注释,这是Clang用来以深度优先的方式遍历AST以及访问所有节点的工具类,支持前序遍历和后序遍历。它使用的是访问者模式。这个类依次做了3件事:

  1. 遍历(Traverse):遍历AST的每一个节点
  2. 回溯(WalkUp):在每一个节点上,从节点类型向上回溯直到节点的基类,然后再开始调用VisitXXX方法,父类型的Visit方法调用早于子类型的Visit方法调用。比如一个类型为NamespaceDecl的节点,调用Visit方法的顺序最终会是VisitDecl()->VisitNamedDecl()->VisitNamespaceDecl()。这种回溯机制保证了对于同一类型的节点被一起访问,防止不同类型节点的交替访问。
  3. 访问(Visit):对于每一个节点,如果用户重写了VisitXXX方法,则调用这个重写的Visit实现,否则使用基类默认的实现。

这3件事按照Traverse* > WalkUpFrom* > Visit*的顺序分层次执行,只能访问同一级的节点或者子节点,无法遍历到上层节点。比如一个函数定义:

- (void)func {
    ...
}

会按照如下顺序调用:

TraverseObjCMethodDecl
∟   WalkUpFromDecl
  ∟      VisitDecl
∟   VisitObjCMethodDecl
  ∟     TraverseCompoundStmt
    ∟       WalkUpFromStmt
      ∟         VisitStmt
      ∟     VisitCompoundStmt
        ∟       TraverseReturnStmt
                ...

讲了这么多,对于CLAS来说,我们只需要重写感兴趣的VisitObjCImplementationDecl方法即可。你可能会问,为什么我们关注的是OC方法,却重写了@implementation的Visit方法呢?因为我们在CLAS的需求中,遍历方法的时候,需要根据当前的类名生成一个唯一的TAG标识(就是前一篇文章所说的唯一名字)。如果重写了VisitObjCMethodDecl方法,会增加我们实现这个功能的复杂度,单独记录每个类的类名,并不方便。我们在这个初级的例子里,重写VisitObjCImplementationDecl方法遍历所有的顶层子节点,如果这个子节点是ObjCMethodDecl类型的,并且有函数体Body(没有Body的方法是声明,而不是定义,只会出现在@interface而不是@implementation里面),就打印出方法名:

class ClangAutoStatsVisitor
: public RecursiveASTVisitor<ClangAutoStatsVisitor> {
public:
    explicit ClangAutoStatsVisitor(ASTContext *Ctx) {}
    
    bool VisitObjCImplementationDecl(ObjCImplementationDecl *ID) {
        for (auto D : ID->decls()) {
            if (ObjCMethodDecl *MD = dyn_cast<ObjCMethodDecl>(D)) {
                handleObjcMethDecl(MD);
            }
        }
        return true;
    }
    
    bool handleObjcMethDecl(ObjCMethodDecl *MD) {
        if (!MD->hasBody()) return true;
        errs() << MD->getNameAsString() << "\n";
        return true;
    }
};

好了,现在一个初具雏形的基于libTooling的工具诞生了,虽然它仅仅只能打印OC的方法名称。我们还需要一个测试的HelloViewController.m文件:

#import <UIKit/UIKit.h>

@interface HelloViewController : UIViewController
- (void)sayHi;
@end

@implementation HelloViewController
- (void)sayHi {
  NSLog(@"Hello world!");
}
@end

让我们在XCode里编译一下ClangAutoStats,你需要创建一个基于ClangAutoStats的Scheme:

直接在XCode里运行ClangAutoStats什么也不会发生,因为我们要传入需要分析的源文件路径以及编译选项等参数,编辑你刚刚创建的ClangAutoStats的Scheme,切换到Arguments,将下面的参数一行一行地加入进去:

/Users/test/workspace/HelloViewController.m
--
-mios-simulator-version-min=8.0
-isysroot
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk
-isystem
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/8.1.0/include
-I/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include/c++/v1
-I/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/usr/include
-F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks

如果你的XCode安装在非标准路径,请自行修改。添加完成后应该是这个样子的:

好了,运行之后你会看到XCode的console中打印出了sayHi。那个参数列表里奇怪违和的"--"到底是个什么呢?我们查阅了一下官方文档发现这个参数是被这么轻描淡写一句带过的:

the two dashes after we specify the source file. The additional options for the compiler are passed after the dashes rather than loading them from a compilation database - there just aren’t any options needed right now.

大概意思是说,在“--”后面的参数,是传递给CI的Compilation DataBase的,而不是这个命令行工具本身的。比如我们的HelloViewController.m,因为有#import <UIKit/UIKit.h>这么一条语句,以及继承了UIViewController,那么语法分析器(Sema)读到这里的时候就需要知道UIViewController的定义是从哪里来的,换句话说就是它需要找到定义UIViewController的地方。怎么找呢?通过指定的-I、-F这些参数指定的目录来寻找。“--”后面的参数,可以理解为如果你要编译HelloViewController.m需要什么参数,那么这个后面就要传递什么参数给我们的 CLAS,否则就会看到Console里打出找不到xxx定义或者xxx.h文件的错误。当然因为一般的编译指令,会有"-c"参数指定源文件,但是“--”后面并不需要,因为我们在“--”前面就指定了。“--”这种传参的方式还有另外一种方法,使用-extra-arg="xxxx"的方式指定编译参数,这样就不需要“--”了:

-extra-arg="-Ixxxxxx"
-extra-arg="-Fxxxxxx"
-extra-arg="-isysroot xxxxxx"

“--”有一个更正式的名字叫做Fixed Compilation Database,详细内容请参见Compilation databases for Clang-based tools

这一章我们搭建了CLAS的基本结构,写出了我们第一个使用Clang LibTooling的编译前端工具,虽然它现在还只能打印出OC的方法名称,而且还不能处理特殊情况,例如Category,但是它是我们最初的原型,只要理解了如何使用Visitor我们可以在此基础上扩展出很多有趣的功能。

待续

下面一章我们会完善Visitor的功能,使用Rewriter进行源码转换(Source-Source Transformation)。敬请期待...

推荐阅读更多精彩内容