iOS 编译原理与应用

5,629 阅读13分钟

引言

在Xcode中,当我们按下command + B进行build操作后发生了那些事情,这是一个将代码编译的过程。Xcode现在使用的编译器是LLVM,Xcode 早期使用的是GCC编译器,由于一些历史原因,从Xcode5开始正式过渡到使用LLVM编译器。下文将着重介绍LLVM。

编译原理

LLVM简介

  • LLVM项目是模块化、可重用的编译器以及工具链技术的集合。

  • 美国计算机协会(ACM)将其 2012 年软件系统奖颁给了LLVM,之前曾获得此奖项的软件和技术包括:Java、Apache、Mosaic、the World Wide Web、SmallTalk、UNIX、Eclipse等等。

  • LLVM项目的发展起源于2000年伊利诺伊大学厄巴纳-香槟分校维克拉姆·艾夫(Vikram Adve)与克里斯·拉特纳(Chris Lattner)的研究,他们想要为所有静态及动态语言创造出动态的编译技术。LLVM是以BSD授权来发展的开源软件。2005年,苹果电脑雇用了克里斯·拉特纳及他的团队为苹果电脑开发应用程序系统,LLVM为现今Mac OS X及iOS开发工具的一部分。

  • LLVM的命名最早源自于底层虚拟机(Low Level Virtual Machine)的首字母缩写,由于这个项目的范围并不局限于创建一个虚拟机,这个缩写导致了广泛的疑惑。官方描述如下:The name “LLVM” itself is not an acronym;it is the full name of the project。LLVM这个名称并不是首字母缩略词,它是项目的全名。

  • LLVM开始成长之后,成为众多编译工具及低级工具技术的统称,使得这个名字变得更不贴切,开发者因而决定放弃这个缩写的意涵,现今LLVM已单纯成为一个品牌,适用于LLVM下的所有项目,包含LLVM中介码(LLVM IR)、LLVM除错工具、LLVM C++标准库等。

  • 目前NDK/Xcode均采用LLVM作为默认的编译器。

传统的编译器架构

  • Frontend:前端,对源码做词法分析、语法分析、语义分析、生成中间代码
  • Optimizer:优化器,用于中间代码优化
  • Backend:后端,用于生成机器码

LLVM架构

  • 前端将各种类型的源代码编译为中间代码,也就是bitcode,在LLVM体系内,不同的语言有不同的编译器前端,常见的如clang负责 c/c++/oc的编译,flang负责fortran的编译,swiftc负责swift的编译等等。

  • 不同的前后端使用统一的中间代码LLVM Intermediate Representation(LLVM IR)。

  • 优化阶段是一个通用的阶段,针对的是统一的LLVM IR,无论是新的编程语言,还是支持新的硬件设备,都不需要对优化阶段做修改,具体是对bitcode进行各种类型的优化,将bitcode代码进行一些逻辑的转换,使得代码效率更高,体积更小,比如DeadStrip/SimplifyCFG。

  • 后端,也叫CodeGenerator,负责把优化后的bitcode编译为指定目标架构的机器码,比如 X86Backend负责把bitcode编译为x86指令集的机器码。

  • GCC相比之下,前后端耦合在了一起。所以,GCC支持一门新的语言,或是为了支持一个新的平台,就变得异常困难。

  • LLVM现在被作为实现各种静态和运行时编译语言通用基础架构(GCC 家族、Java、.Net、Python、Ruby、Scheme、Haskell、D等)。

  • LLVM体系中,不同语言源代码将会被转化为统一的bitcode格式,三个模块相互独立,可以充分复用。比如,如果开发一门新的语言,只要制造一个该语言的前端,将源码编译为bitcode,优化和后端不用管。同理,如果新的芯片架构问世,只需基于LLVM重新编写一套目标平台的后端即可。

Clang

  • LLVM项目的一个子项目。

  • 基于LLVM架构的C/C++/Objective-C/Objective-C++编译器前端。

  • 相比于 GCC,Clang具有如下优点:

  • 编译速度快:在某些平台上,Clang的编译速度显著的快过GCC(Debug 模式下编译 OC 速度比 GCC 快 3 倍);

  • 占用内存小:Clang生成的AST所占用的内存是GCC的五分之一左右;

  • 模块化设计:Clang采用基于库的模块化设计,易于IDE集成及其他用途的重用;

  • 诊断信息可读性强:在编译过程中,Clang创建并保留了大量详细的元数据(metadata),有利于调试和错误报告;

  • 设计清晰简单,容易理解,易于扩展增强。

客观的说GCC也有很多优点:例如支持多平台,基于C无需 C++编译器即可编译。这个优点到苹果那里反而成了缺点,苹果需要的是快。

Clang与LLVM

  • 广义的LLVM:整个LLVM架构
  • 狭义的LLVM:LLVM后端(代码优化、目标代码生成等)

OC源文件的编译过程

  • 命令行查看编译的过程:
clang -ccc-print-phases main.m

  • 查看preprocessor(预处理)的结果:
clang -E main.m -F /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks 

1. 词法分析

词法分析,生成Token

int sum(int a,int b){
    int c = a + b;
    return c;
}
clang -fmodules -E -Xclang -dump-tokens main.m

这个命令的作用是,显示每个Token的类型、值,以及位置。参考该链接,可以看到Clang定义的所有Token类型。 可以分为下面这4类:

  • 关键字:语法中的关键字,比如 if、else、while、for 等;
  • 标识符:变量名;
  • 字面量:值、数字、字符串;
  • 特殊符号:加减乘除等符号。

2. 语法分析

利用上面输出的Token先按照语法组合成语义,生成类似VarDecl这样的节点,然后将这些节点按照层级关系构成抽象语法树(AST)。

  • 语法分析,生成语法树(AST,Abstract Syntax Tree)
clang -fmodules -fsyntax-only -Xclang -ast-dump main.m

TranslationUnitDecl是根节点,表示一个编译单元;Decl表示一个声明;Expr表示的是表达式;Literal表示字面量,是一个特殊的Expr;Stmt表示陈述。

除此之外,Clang还有众多种类的节点类型。Clang里,节点主要分成Type类型、Decl声明、Stmt陈述这三种,其他的都是这三种的派生。通过扩展这三类节点,就能够将无限的代码形态用有限的形式来表现出来。

3. LLVM IR

LLVM IR有3种表示形式,但本质上是等价的。

  • text:便于阅读的文本格式,类似于汇编语言,拓展名 .ll
clang -S -emit-llvm main.m
  • memory:内存格式
  • bitcode:二进制格式,拓展名 .bc
clang -c -emit-llvm main.m

4. IR基本语法

.ll 文件部分内容,如下:

  • 注释以分号 ; 开头
  • 全局标识符以@开头,局部标示符以%开头
  • alloca,在当前函数栈帧中分配内存
  • i32 ,32bit,4 个字节
  • align,内存对齐
  • store,写入数据
  • load,读取数据

应用与实践

基于LLVM 、Clang可以做很多实践,如下:

  • LibClang、LibTooling、Clang Plugin

官方参考:

clang.llvm.org/docs/Toolin…

应用:语法树分析、语言转换等

  • OCLint、Clang静态分析器(Clang Static Analyzer)

  • Clang插件开发

官方参考:

clang.llvm.org/docs/ClangP…

clang.llvm.org/docs/Extern…

clang.llvm.org/docs/RAVFro…

应用:代码检查(命名规范、代码规范)等

  • Pass开发

官方参考:

llvm.org/docs/Writin…

应用:中间代码优化、代码混淆等

  • 开发新的编程语言

llvm-tutorial-cn.readthedocs.io/en/latest/i…

kaleidoscope-llvm-tutorial-zh-cn.readthedocs.io/zh_CN/lates…

编写及运行Clang插件

Clang使用模块化设计,可以将自身功能以库的方式来供上层应用来调用。比如,编码规范检查、IDE 中的语法高亮、语法检查等上层应用,都是使用Clang库的接口开发出来的。Clang有三个接口库可以供上层应用调用,分别是LibClang、Clang Plugin、LibTooling。

LibClang为了兼容更多Clang版本,相比Clang少了很多功能;Clang Plugin和LibTooling具备Clang 的全量能力。Clang Plugin编写代码的方式,和LibTooling几乎一样,不同的是Clang Plugin还能够控制编译过程,可以加warning或者直接中断编译提示错误。另外,编写好的LibTooling能够非常方便地转成Clang Plugin。 因此,Clang Plugin在功能上是最全的。

1. 源码下载

下载 LLVM Project

 git clone https://github.com/llvm/llvm-project.git

上图中,clang目录就是类C语言编译器的代码目录;llvm目录的代码包含两部分,一部分是对源码进行平台 无关优化的优化器代码,另一部分是生成平台相关汇编代码的生成器代码;lldb目录里是调试器的代码;lld里是链接器代码。

2. 源码编译

macOS属于类UNIX平台,因此既可以生成Makefile文件来编译,也可以生成Xcode工程来编译。

进入llvm-project文件目录,生成Makefile文件:

  • 在llvm同级目录下新建一个llvm_make目录
  • 在llvm_make中利用CMake进行编译
cmake -DLLVM_ENABLE_PROJECTS=clang -G "Unix Makefiles" ../llvm

生成Xcode工程,可以使用如下命令

  • 安装CMake工具
  • 在llvm同级目录下新建一个llvm_xcode目录
  • 在llvm_xcode中利用CMake进行编译
cmake -G Xcode -DLLVM_ENABLE_PROJECTS=clang ../llvm

想要更多地了解CMake的语法和功能,你可以查看官方文档。

执行cmake命令时,你可能会遇到下面的提示:

-- The C compiler identification is unknown -- The CXX compiler identification is unknown CMake Error at CMakeLists.txt:39 (project):

No CMAKE_C_COMPILER could be found. 

CMake Error at CMakeLists.txt:39 (project):

No CMAKE_CXX_COMPILER could be found.

这表明cmake没有找到代码编译器的命令行工具。分两种情况处理:

  • 如果没有安装Xcode Commandline Tools的话,可以执行如下命令安装:
xcode-select --install
  • 如果你已经安装了Xcode Commandline Tools的话,直接reset即可
sudo xcode-select --reset

生成Xcode工程后,打开生成的LLVM.xcodeproj文件,选择Automatically Create Schemes。

生成Xcode项目后再利用Xcode进行编译,但是速度很慢

3. 插件目录

  • 在clang/tools源码目录下新建一个插件目录,比如叫做mskj_plugin,添加MSKJPlugin.cpp文件和 CMakeLists.txt文件。其中,CMake编译需要通过CMakeLists.txt文件来指导编译,cpp是源文件。

  • 在clang/tools目录下的CMakeList.txt文件当中最后一行加入:
add_clang_subdirectory(mskj-plugin)
  • 使用如下代码编写clang/tools/mskj-plugin/CMakeLists.txt文件,来定制编译流程:
add_llvm_library(MSKJPlugin MODULE MSKJPlugin.cpp PLUGIN_TOOL clang)

MSKJPlugin是插件名,MSKJPlugin.cpp是源代码文件,这段代码是指,要将Clang插件代码集成到LLVM的Xcode工程中,并作为一个模块进行编写调试。添加了Clang插件的目录和文件后,再次用cmake命令生成Xcode工程,里面就能够集成MSKJPlugin.cpp文件。

4. 编写插件源码

① 编写PluginASTAction代码

由于Clang插件是没有main函数的,入口是PluginASTAction的ParseArgs函数。所以,编写Clang插件还要实现ParseArgs来处理入口参数。代码如下所示:

 class MSKJASTAction: public PluginASTAction {
    public:
        unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &ci, StringRef iFile) {
            return unique_ptr<MSKJASTConsumer> (new MSKJASTConsumer(ci));
        }
        
        bool ParseArgs(const CompilerInstance &ci, const vector<string> &args) {
            return true;
        }
    };

② 编写ASTConsumer

FrontActions是编写Clang插件的入口,也是一个接口,是基于ASTFrontendAction的抽象基类。FrontActions为接下来基于AST操作的函数提供了一个入口和工作环境。

通过这个接口,你可以编写在编译过程中自定义的操作,具体方式是:通过ASTFrontendAction在 AST上自定义操作,重载CreateASTConsumer函数返回你自己的Consumer,以获取AST上的 ASTConsumer单元。ASTConsumer可以提供很多入口,是一个可以访问AST的抽象基类,可以重载 HandleTopLevelDecl()和 HandleTranslationUnit()两个函数,以接收访问AST时的回调。其中,HandleTopLevelDecl()函数是在访问到全局变量、函数定义这样最上层声明时进行回调,HandleTranslationUnit()函数会在接收每个节点访问时的回调。

class MSKJASTConsumer: public ASTConsumer {
    private:
        MatchFinder matcher;
        MSKJHandler handler;
        
    public:
        MSKJASTConsumer(CompilerInstance &ci) :handler(ci) {
            matcher.addMatcher(objcInterfaceDecl().bind("ObjCInterfaceDecl"), &handler);
        }
        
        void HandleTranslationUnit(ASTContext &context) {
            matcher.matchAST(context);
        }    
};

③ 处理节点

 class MSKJHandler : public MatchFinder::MatchCallback {
    private:
        CompilerInstance &ci;
     
    public:
        MSKJHandler(CompilerInstance &ci) :ci(ci) {}
        
        void run(const MatchFinder::MatchResult &Result) {
            if (const ObjCInterfaceDecl *decl = Result.Nodes.getNodeAs<ObjCInterfaceDecl>("ObjCInterfaceDecl")) {
                size_t pos = decl->getName().find('_');
                if (pos != StringRef::npos) {
                    DiagnosticsEngine &D = ci.getDiagnostics();
                    SourceLocation loc = decl->getLocation().getLocWithOffset(pos);
                    D.Report(loc, D.getCustomDiagID(DiagnosticsEngine::Error, "MSKJ:类名中不能带有下划线"));
                }
            }
        }
    };

5. 注册Clang插件

在Clang插件源码中编写注册代码。编译器会在编译过程中从动态库加载Clang插件。使用FrontendPluginRegistry::Add<>在库中注册插件。注册Clang插件的代码如下:

static FrontendPluginRegistry::Add<MSKJPlugin::MSKJASTAction> X("MSKJPlugin", "The MSKJPlugin is my first clang-plugin.");

在Clang插件代码的最下面,定义的MSKJPlugin字符串是命令行字符串,供以后调用时使用,The MSKJPlugin is my first clang-plugin是对Clang插件的描述。

6. 使用clang 插件

利用CMake命令重新生成Xcode工程,可在Loadable modules下看到MSKJPlugin:

选择MSKJPlugin这个target进行编译,编译完会生成一个动态库文件。

LLVM官方有一个完整可用的Clang插件示例,可以帮我们打印出最上层函数的名字。

通过学习这个插件示例,看看如何使用Clang插件。

使用Clang插件可以通过-load命令行选项加载包含插件注册表的动态库,-load命令行会加载已经注册了的所有Clang插件。使用-plugin选项选择要运行的Clang插件。Clang插件的其他参数通过-plugin-arg-来传递。

cc1进程类似一种预处理,这种预处理会发生在编译之前。cc1和Clang driver是两个单独的实体,cc1负责前端预处理,Clang driver则主要负责管理编译任务调度,每个编译任务都会接受cc1前端预处理的参数,然后进行调整。

有两个方法可以让-load 和-plugin等选项到Clang的cc1进程中:

  • 直接使用-cc1选项,缺点是要在命令行上指定完整的系统路径配置;
  • 使用-Xclang来为cc1进程添加这些选项。-Xclang参数只运行预处理器,直接将后面参数传递给cc1进程,而不影响clang driver的工作。

下面是一个编译Clang插件,然后使用-Xclang加载使用Clang插件的例子:

$ export BD=/path/to/build/directory 
$ (cd $BD && make PrintFunctionNames ) 
$ clang++ -D_GNU_SOURCE -D_DEBUG -D__STDC_CONSTANT_MACROS \
          -D__STDC_FORMAT_MACROS -D__STDC_LIMIT_MACROS -D_GNU_SOURCE \ 
          -I$BD/tools/clang/include -Itools/clang/include -I$BD/include -Iinclude \                                        tools/clang/tools/clang-check/ClangCheck.cpp -fsyntax-only \
          -Xclang -load -Xclang $BD/lib/PrintFunctionNames.so -Xclang \
          -plugin -Xclang print-fns

上面命令中,先设置构建的路径,再通过make命令进行编译生成PrintFunctionNames.so,最后使用clang命令配合-Xclang参数加载使用Clang插件。

你也可以直接使用-cc1参数,但是就需要按照下面的方式来指定完整的文件路径:

$ clang -cc1 -load ../../Debug+Asserts/lib/libPrintFunctionNames.dylib -plugin print-fns some-input-file.c

7. 更多

实现更复杂的插件功能,可以利用clang的API对语法树进行相应的分析与处理。

关于AST的资料:

clang.llvm.org/doxygen/nam…

clang.llvm.org/doxygen/cla…

clang.llvm.org/doxygen/cla…

Clang插件本身的编写和使用并不复杂,关键是如何更好地应用到工作中,通过Clang插件不光能够检查代 码规范,还能够进行无用代码分析、自动埋点打桩、线下测试分析、方法名混淆等。

结语

理解iOS的编译原理,有利于我们更加深层次的理解程序,让我们从底层的角度去看待问题和思考问题的解决方案。

作者简介

范冲冲,民生科技有限公司 用户体验技术部 移动金融开发平台开发工程师

Thanks!