前言
我们有很多维度可以将计算机语言进行分类,其中以编译/执行方式为维度,可以将计算机语言分为:
-
编译型语言
- C++/Objective-C/Swift/Kotlin等
- 编译型语言需要在运行之前就将代码全部编译好,最终运行的文件是编译后的可执行文件
- 👍 执行效率较高
- 👎 调试周期长
我们将编译型语言所使用的编译方式称为 AOT (Ahead of time) 预先编译 它们的执行过程如下:
graph LR A(源代码) --> B(预处理器) --> C(编译器) --> D(汇编) --> E(目标代码) --> F(链接器) --> G(可执行文件) -
直译式语言(脚本语言)
- JavaScript/Python等
- 不需要经过编译,在执行时通过一个中间的解释器将代码解释为CPU 可以执行的代码
- 👍 编写调试方便
- 👎 执行效率低
我们将直译式语言所使用的编译方式称为 JIT (Just in time)即时编译 它们的执行过程如下:
graph LR A(源代码) --->|输入| B(解释器) --->|输出| C(结果)
1 iOS编译工具发展史
Apple早年从GCC切换到LLVM的时候,开始用的是基于GCC库写的一套LLVM前端,但由于Apple对代码优化的要求更高,而GCC官方又迟迟不肯对针对性的更新,所以衍生出GCC的一套分支LLVM-GCC,由Apple自己维护,导致Apple使用的GCC版本远低于官方版本,最后由于GCC的开源协议改变,让Apple彻底抛弃GCC,在 Xcode 5 中将 GCC 彻底抛弃,替换为了Clang。
相比于GCC,Clang具有如下优点:
- 编译速度快:在某些平台上,Clang的编译速度显著的快过GCC
- 占用内存小:Clang生成的AST所占用的内存是GCC的五分之一左右
- 模块化设计:Clang采用基于库的模块化设计,易于 IDE 集成及其他用途的重用
- 诊断信息可读性强:在编译过程中,Clang 创建并保留了大量详细的元数据 (metadata),有利于调试和错误报告
- 设计清晰简单,容易理解,易于扩展增强
我们以 Xcode 为例,Clang 编译 Objective-C 代码的速度是 Xcode 5 版本前使用的 GCC 的3倍,其生成的 AST 所耗用掉的内存仅仅是 GCC 的五分之一左右。
2 编译器介绍
Chris Lattner (克里斯·拉特纳) 在2000年开发了一个叫作 Low Level Virtual Machine 的编译器开发工具套件,后来涉及范围越来越大,可以用于常规编译器,JIT 编译器,汇编器,调试器,静态分析工具等一系列跟编程语言相关的工作,于是就把简称 LLVM 这个简称作为了正式的名字。Chris Lattner 后来又开发了 Clang ,使得 LLVM 直接挑战 GCC 的地位。 2005年加入苹果,将苹果使用的 GCC 全面转为 LLVM。 2010年开始主导开发 Swift 语言。
2.1 传统编译器架构
传统编译器架构(如GCC)将前端,优化器,后端耦合在一起,优化难度大,对多架构兼容的也不太友好,需要做大量重复的工作。
graph LR
A(源代码) --> B(前端 -- 优化器 -- 后端) --> E(机器码)
2.2 iOS的编译器架构(LLVM)
在说编译之前,先说明几个概念:
-
LLVM:Low Level Virtual Machine,由 Chris Lattner(Swift 作者) 用于 Objective-C 和 Swift 的编译,后来加上了很多功能可用于常规编译器、JIT 编译器、调试器、静态分析工具等。总结来说,LLVM 是工具链技术与一个模块化和可重用的编译器的集合。
-
Clang:是 LLVM 的子项目,可以对 C、C++和 Objective-C 进行快速编译,编译速度比 GCC 快 3 倍。Clang 可以认为是 Objective-C 的编译前端,LLVM 是编译后端,前端调用后端接口完成任务。Swift有编译前端 SIL optimizer,编译后端同样用的是 LLVM。
-
AST:抽象语法树,按照层级关系排列。
-
IR:中间语言,具有与语言无关的特性,整体结构为 Module(一个文件)--Function--Basic Block--Instruction(指令)。
-
编译器:编译器用于把代码编译成机器码,机器码可以直接在 CPU 上面运行。好处是运行效率高,坏处是调试周期长,需要重新编译一次(OC 改完代码需要重新运行)。
-
解释器:解释器会在运行时解释执行代码,会一段一段的把代码翻译成目标代码,然后执行目标代码。好处是具有动态性,调试及时(类似 Flutter、Playground),坏处是执行效率低。平时在调试代码的时候,使用解释器会提供效率。
LLVM 包含了编译器前端、优化器和后端三大模块。其中 Swift 除了在编译器前端和 Objective-C 稍有不同,其他模块都差不多。 1、前端面向源码,将源码转化为同样的LLVM Intermediate Representation (LLVM IR), 2、优化器则针对LLVM IR进行一系列优化,如:无用代码消除,内存优化,甚至是代码混淆等等。 3、后端则将IR转化为对应的机器码。
- 编译器前端
编译器前端的任务是解析源代码 。它会进行: 预处理 ,词法分析 , 语法分析 , 语义分析 ,检查源代码是否存在错误 ,然后构建 抽象语法树(Abstract Syntax Tree, AST),静态分析 。 LLVM 的前端还会生成 中间代码(intermediate representation,IR) 。
- 优化器
优化器负责进行 各种优化 ,改善代码的运行时间 ,例如消除冗余计算等。
- 后端
将代码映射到 目标指令集 ,生成机器语言 ,并且进行机器相关的代码优化 。
graph LR
A(Clang C/C++/Objective-C)
B(Swiftlang/Swiftc Swift)
A -->|LLVM IR| C
B -->|LLVM IR| C
C(LLVM Optimizer)
-->|LLVM IR| D(LLVM Backend)
从两种架构的设计可以看得出来,LLVM最大的优势就在于三端分离,所以如果我们想编写一门独立的语言,只需要编写相应的前端就可以兼容各大终端设备。如果以后多了一种终端设备,我们也只需要编写一次后端,就可以兼容各大语言。
2.2.1 LLVM IR
LLVM IR (Intermediate Representation)直译过来是“中间描述”,它是整个编译过程中生成的区别于源码和机器码的一种中间代码。IR 提供了独立于任何特定机器架构的源语,因此它是 LLVM 优化和进行代码生成的关键,也是 LLVM 有别于其他编译器的最大特点。LLVM 的核心功能都是围绕的 IR 建立的,它是 LLVM 编译过程中前端的输出,后端的输入。
在这一点上 IR 和 JVM 的 Java bytecode 很像,两者都是用于表述计算的模型,但两者所处的抽象层次不同。Java bytecode 更高层(更抽象),包含了大量类 Java 的面向对象语言的操作。LLVM IR 则更底层(更接近机器)。IR 的存在意味着它可以作为多种语言的后端,这样 LLVM 就能够提供和语言无关的优化,同时还能够方便的针对多种 CPU 代码生成。
为什么需要 IR ???
- 编译器的架构分为前端、优化器和后端。传统编译器(如 CGG )的前端和后端没有完全分离,耦合在了一起,因而如果要支持一门新的语言或硬件平台,需要做大量的工作。而 LLVM 和传统编译器最大的不同点在于,前端输入的任何语言,在经过编译器前端处理后,生成的中间码都是 IR 格式的。
- 这样做的优点是如果需要支持一种新的编程语言,那么我们只需要实现一种新的前端。如果我们需要支持一种新的硬件设备,那我们只需要实现一个新的后端。而优化阶段因为是针对了统一的 LLVM IR ,所以它是一个通用的阶段,不论是支持新的编程语言,还是支持新的硬件设备,这里都不需要对优化阶段做修改。所以从这里可以看出 LLVM IR 的作用。
LLVM IR 的三种格式:
- 内存中的编译中间语言
- 硬盘上存储的可读中间格式(以 .ll 结尾)
- 硬盘上存储的二进制中间语言(以 .bc 结尾)【开启Bitcode编译】
这三种中间格式是完全等价的。
Bitcode
iOS 开发的小伙伴可能对 IR 不是很了解,但我相信你一定听说过 Bitcode 。Bitcode 说白了其实就是我们前面提到的 LLVM IR 三种格式中的第三种,即存储在磁盘上的二进制文件(以 .bc 结尾)。
之所以要把 Bitcode 拿出来单独说,是因为 Apple 单独对 Bitcode 进行了额外的优化。从 Xcode 7 开始,Apple 支持在提交 App 编译产物的同时提交 App 的 Bitcode (非强制),并且之后对提交了 Bitcode 的 App 都单独进行了云端编译打包。也就是说,即便在提交时已经将本地编译好的 ipa 提交到 App Store,Apple 最终还是会使用 Bitcode 在云端再次打包,并且最终用户下载到手机上的版本也是由 Apple 在云端编译出来的版本,而非开发人员在本地编译的版本。
为什么需要 Bitcode
Apple 之所以这么做,一是因为 Apple 可以在云端编译过程中做一些额外的针对性优化工作,而这些额外的优化是本地环境所无法实现的。二是 Apple 可以为安装 App 的目标设备进行二进制优化,减少安装包的下载大小。
比如我们在本地编译生成的 ipa 是同时包含了多个CPU架构的(armv7 ,arm64 ),对于 iPhone X 而言 armv7 的架构文件就是无用文件。而 Apple 可以在云端为不同的设备编译出对应 CPU 架构的 ipa ,这样 iPhone X 下载到的 App 就只包含了所需的 arm64 架构文件。
更为黑科技的是,由于 Bitcode 是无关设备架构的,它可以被转化为任何被支持的 CPU 架构,包括现在还没被发明的 CPU 架构。以后如果苹果新出了一款新手机并且 CPU 也是全新设计的,在苹果后台服务器一样可以从这个 App 的 Bitcode 开始编译转化为新 CPU 上的可执行程序,可供新手机用户下载运行这个 App ,而无需开发人员重新在本地编译打包上传。
2.2.2 编译器前端之Clang
Clang是LLVM项目的一个子项目,基于LLVM架构的C/C++/Objective-C编译器前端(Swift的前端是Swiftlang/Swiftc)。 对于我们的开发人员来说,接触最多的就是我们的 Clang Clang 是 LLVM 项目中的一个子项目。它是基于 LLVM架构 的 轻量级编译器 ,诞生之初是为了替代 GCC ,提供更快的编译速度。它是负责编译 C、C++、Objecte-C 的编译器,它属于整个 LLVM 架构中的,编译器前端。对于开发者来说,研究 Clang 可以给我们带来很多好处。
Clang 的主要工作:
- 预处理: 比如把宏嵌入到对应的位置,头文件的导入,去除注释
- 词法分析: 这里会把代码切成一个个 Token,比如大小括号,等于号还有字符串等
- 语法分析: 验证语法是否正确
- 生成 AST : 将所有节点组成抽象语法树 AST
- 静态分析:分析代码是否存在问题,给出错误信息和修复方案
- 生成 LLVM IR: CodeGen 会负责将语法树自顶向下遍历逐步翻译成 LLVM IR
2.2.3 举例Objective-c代码探索编译流程
我们以下面的代码为例进行探索,文件名main.m,代码内容如下:
#import <Foundation/Foundation.h>
//计算总和函数
int addResult(int a, int b) {
return a + b;
}
#define ADD_FUNCTION(x, y) addResult(x, y)
int main(int argc, const char * argv[]) {
@autoreleasepool {
int numA = 1;
int b1 = 1;
int b2 = 2;
int numB = b1 + b2;
int result = ADD_FUNCTION(numA, numB);
NSLog(@"结果是:%d", result);
}
return 0;
}
2.2.3.1 编译阶段概览
通过下面的命令我们可以列出完整的编译阶段:
clang -ccc-print-phases main.m
可以看到编译源文件需要的几个不同的阶段
0: input, “main.m”, objective-c
1: preprocessor, {0}, objective-c-cpp-output
2: compiler, {1}, ir
3: backend, {2}, assembler
4: assembler, {3}, object
5: linker, {4}, image
6: bind-arch, “x86_64”, {5}, image
- 0:输入文件
- 找到源文件
- 1:预处理
- 这个过程处理包括宏的替换,头文件的导入,去除注释
- 2:编译
- 进行词法分析、语法分析、检测语法是否正确,最终生成IR
- 3:后端
- 这里LLVM会通过一个一个的Pass(可以理解为一个节点)去优化,每个Pass做一些事情,最终生成汇编代码
- 4:汇编
- 生成目标文件
- 5:链接
- 链接需要的动态库和静态库,生成相应的镜像可执行文件
- 6:绑定结构
- 根据不同的系统架构,生成对应的可执行文件
下面逐步讲解整个编译的过程
2.2.3.2 预处理
执行完后可以看到文件
clang -E main.m >> preprocessor.m
生成的preprocessor.m的文件内容如下:
# 1 "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/Foundation.framework/Headers/FoundationLegacySwiftCompatibility.h" 1 3
# 187 "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/Foundation.framework/Headers/Foundation.h" 2 3
# 2 "main.m" 2
int addResult(int a, int b) {
return a + b;
}
int main(int argc, const char * argv[]) {
@autoreleasepool {
int numA = 1;
int b1 = 1;
int b2 = 2;
int numB = b1 + b2;
int result = addResult(numA, numB);
NSLog(@"结果是:%d", result);
}
return 0;
}
通过对比,很明显的发现这里的宏定义被替换掉了,去掉了注视,导入了头文件。下面这些代码也会在这步处理。
- “#define”
- “#include”
- “#ifdef”
- 注释
- “#pragma”
2.2.3.3 编译阶段
词法分析
Clang 在进行词法分析时,将代码切分成 一个一个Token,比如大小括号、等于号和字符串等。上面打印的信息就可以看到每个 Token ,里面有它的类型、值和位置。Clang 定义的 Token 类型,可以简单分为以下 4 类:
- 关键字:语法中的关键字,比如 if、else、while、for 等;
- 标识符:变量名;
- 字面量:值、数字、字符串;
- 特殊符号:加减乘除等符号; 所有的 Token 类型可以查看这里。
clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m
语法分析
语法分析的任务是 验证语法是否正确 。在词法分析的基础上将单词序列组合成各类语法短语,如 “程序” , “语句” , “表达式” 等,然后将所有节点组成 抽象语法树(AbstractSyntaxTree,AST) 。语法分析其目的就是 对源程序进行分析判断,在结构上是否正确 。
clang -fmodules -fsyntax-only -Xclang -ast-dump main.m
其中 TranslationUnitDecl 是根节点,表示一个编译单元;Decl 表示一个声明;Expr 表示表达式;Literal 表示字面量,是一个特殊的 Expr;Stmt 表示语句。
确认无误后将所有节点组成抽象语法树 AST 。
静态分析
这个阶段我们重点说一个工具,就是 clang static analyzer。这是一个静态代码分析工具,可用于查找 C、C++和 Objective-C 程序中的 bug。clang static analyzer 包括 analyzer core 和 checker 两部分,所有的 checker 都是基于底层的 analyzer core,并且通过 analyzer core 提高的功能能够编写自己的 checker。 每执行一条语句,analyzer core 就会遍历所有 checker 中的回调函数,所以 checker 越多,语句执行速度越慢。可以通过命令行查看当前 Clang 版本下的 checker:
clang -cc1 -analyzer-checker-help
通过编译的这个过程,我们就可以做很多事情了,比如自定义检查规则、自动混淆代码甚至将代码转换成另一种语言等。
生产中间代码IR CodeGen 会负责将语法树自顶向下遍历逐步翻译成 LLVM IR,IR 是编译过程的前端的输出后端的输入
clang -S -fobjc-arc -emit-llvm main.m
2.2.3.4 LLVM Optimizer
编译优化
这里 LLVM 会去做些优化工作,在 Xcode 的编译设置里也可以设置优化级别-01,-02,-03,-0s,还可以写些自己的 Pass,官方有比较完整的 Pass 教程: Writing an LLVM Pass — LLVM 5 documentation 。 Pass 是 LLVM 优化工作的一个节点,一个节点做些事,一起加起来就构成了 LLVM 完整的优化和转化。
clang -Os -S -fobjc-arc -emit-llvm main.m -o main-optimize.ll
这里发现优化后的中间代码简介了不少,并且代码中冗余的计算逻辑直接生成了结果
LLVM 还提供了其他优化 Pass:
- 各种类,方法,成员变量等的结构体的生成,并将其放到对应的 Mach-O 的 section 中
- Non-Fragile ABI 合成 OBJC_IVAR_$_ 偏移值常量
- ObjCMessageExpr 翻译成相应版本的 objc_msgSend,super 翻译成 objc_msgSendSuper strong,weak,copy,atomic 合成@property 自动实现 setter 和 getter
- @synthesize 的处理
- 生成 block_layout 数据结构
- _block 和 __weak
- _block _invoke
- ARC 处理,插入 objc_storeStrong 和 objc_storeWeak 等 ARC 代码
- ObjCAutoreleasePoolStmt 转 objc_autorealeasePoolPush / Pop,自动添加 [super dealloc],给每个 ivar 的类合成 .cxx_destructor 方法自动释放类的成员变量。
BitCode
这是 xcode7 以后开启 bitcode 苹果会做进一步的优化,生成 bc 的中间代码。我们可以试试用优化后的 IR 代码生成 bc 代码。
clang -emit-llvm -c main-optimize.ll -o main-optimize.bc
这里的 bc 文件无法使用文本方式进行查看
2.2.3.5 LLVM Backend
生成汇编代码
可以通过的 .bc 或者 .ll 代码生成汇编代码
clang -S -fobjc-arc main-optimize.bc -o main-optimize.s
或
clang -S -fobjc-arc main.ll -o main.s
对比一下优化前后的汇编代码,发现优化后的指令少了很多
生成目标文件
目标文件的生成,是 汇编器 以汇编代码作为 输入 ,将 汇编代码 转换为 机器代码 ,最后输出 目标文件(object-file) 。
clang -fmodules -c main.s -o main.o
xcrun nm -nm main.o
查看符号输出:
(undefined) external _NSLog
(undefined) external ___CFConstantStringClassReference
(undefined) external _objc_autoreleasePoolPop
(undefined) external _objc_autoreleasePoolPush
0000000000000000 (__TEXT,__text) external _addResult
0000000000000020 (__TEXT,__text) external _main
000000000000008e (__TEXT,__ustring) non-external l_.str
-
_NSLog 是一个是 undefined external 的
-
undefined
- 表示在当前文件暂时找不到符号 _NSLog
-
external
- 表示这个符号是外部可以访问的
-
有兴趣可以移步 通过符号表找到符号 有介绍查找 _NSLog 的过程
链接、生成可执行文件
链接器把编译产生的 .o文件 和 (dylib、.a、framework)文件 ,生成一个 mach-o文件(可执行文件)。
clang -framework Foundation main.o -o main
xcrun nm -nm main
查看符号输出:
(undefined) external _NSLog (from Foundation)
(undefined) external ___CFConstantStringClassReference (from CoreFoundation)
(undefined) external _objc_autoreleasePoolPop (from libobjc)
(undefined) external _objc_autoreleasePoolPush (from libobjc)
(undefined) external dyld_stub_binder (from libSystem)
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100003ed0 (__TEXT,__text) external _addResult
0000000100003ef0 (__TEXT,__text) external _main
0000000100008018 (__DATA,__data) non-external __dyld_private
这里的符号就已经和对应的框架对应起来了,如: _NSLog (from Foundation) 。而且 mach-o 文件中的偏移地址也有了。
正常的iOS工程编译完成之后会生成一些文件,我们主要介绍一下其中三种:
-
进制内容 Link Map File Xcode在生成可执行文件的时候默认情况下不生成该文件,需要开发者手动设置Target --> Build Setting --> Write Link Map File为YES。Link Map FIle 文件内容包含三个部分:
- Object file:.m 文件编译后的 .o 文件和需要连接的 .a 文件,包括文件编号和文件路径;
- Section:描述每个 Section 在可执行文件中的位置和大小;
- Symbol:Symbol 对 Section 进行了再划分,描述了所以的 method、ivar、string,以及它们对应的 address、size 和 file number 信息;
如果你想要使用二进制重排提升 APP 的启动速度,那么你就会用到 Link Map File 了,相关的文章可以看这篇:iOS 优化篇 - 启动优化之Clang插桩实现二进制重排。
-
dSYM 文件
dSYM 文件里面存储了函数地址映射的信息,所以调用栈的地址就可以通过 dSYM 映射表获得具体的函数信息。它通常可以用来做 crash 的文件符号化,将 crash 时候保存的调用栈信息转化为相应的函数。这就是为什么友盟或者 bugly,都需要你上传 dSYM 文件的原因。
-
Mach-O
看下面介绍。
2.2.3.6 Mach-O文件
Mach-O是Mach Object文件格式的缩写,是mac以及iOS上可执行文件的格式。是一种用于记录编译后的可执行文件、对象代码、共享库、动态加载代码和内存转储的文件格式。Mach-O 里面的 _CodeSignature 包含了程序代码的签名,这个签名就是为了保证里面的文件不能被直接更改。不过如果你有用过企业版重签名的功能,就会发现其实还是有些东西可以改的,只不过改完之后要重新生成签名文件。
Mach-O 文件里面主要包含三个部分:
- Mach-O Header:包含字节顺序、魔数、CPU 类型、加载指令的数量等;
- Load Command:包括区域的位置、符号表、动态符号表等。每个加载指令包含一个元信息,比如指令类型、名称以及在二进制中的位置等;
- Data:内容最多的部分,包含了代码、数据。比如符号表、动态符号表等;
Mach-O文件格式
- 目标文件.o
- 库文件
- .a
- .dylib
- framework
- 可执行文件
- dyld
- .dsym
实际开发中,MatchO文件有很多不同的类型,可以通过在Xcode上指定。 Targets → Build Settings → Linking → Mach-O Type
通用二进制文件
- 苹果公司提出的一种程序代码,能同时适用多种架构的二进制文件。
- 同一个程序包中同时为多种架构提供最理想的性能。
- 因为需要储存多种代码,通常比单一平台二进制的程序要大。
- 由于执行中只调用一部分代码,运行起来也不需要额外的内存。
在Xcode编译可以指定生成哪些架构的Match-O文件,同时也可以添加其他架构 Targets → Build Settings → Architectures → Architectures
设备的CPU架构(指令集)
-
模拟器:
- 4s-5: i386
- 5s-6s Plus: x86_64
- Xcode 12之后模拟器支持arm64
-
真机(iOS设备):
- armv6: iPhone、iPhone 2、iPhone 3G、iPod Touch(第一代)、iPod Touch(第二代)
- armv7: iPhone 3Gs、iPhone 4、iPhone 4s、iPad、iPad 2
- armv7s: iPhone 5、iPhone 5c
- arm64: iPhone 5s之后机型
2.2.3.7 实际应用
深入了解 LLVM 和 Clang ,以及他们提供的开发工具,我们能够实现很多有意思的功能。比如通过 Libclang、libTooling ,我们可以实现语法树分析、语言转化(例如将 Objective-C 转换为 Swift )。我们还可以开发自己的 Clang 插件,对我们的代码做个性化的检查。我们还可以通过写 Pass 实现自定义的代码优化、代码混淆。我们甚至可以开发自己的新语言,你需要做的仅仅是写一个编译器前端,将你的代码转换成 LLVM IR 。
2.2.3.7 Clang Plugin自定义插件
通过自己写个插件,可以将这个插件动态的加载到编译器中,对编译进行控制。
开始创建插件之前先对要实现的功能做一个简单的介绍: 自定义插件想要实现的功能是当检测到NSString、NSArray、NSDictionary类型的属性使用的修饰属性不为copy时,发出警告。
- 需要本地安装cmake,然后下载llvm
> mkdir llvm_all && cd llvm_all
> git clone https://github.com/llvm/llvm-project.git
目前我们只需要关注其中两个文件clang和llvm分别是clang的源码和llvm的源码
-
修改llvm-project/clang/tools下的CMakeLists.txt文件,在最下面新增add_clang_subdirectory(hkplugin)
-
在llvm-project/clang/tools/目录下新建插件目录hkplugin:
>在tools里新建一个hkplugin文件夹,由于clang都是用C++编写的,自然我们就需要新建C++的文件hkplugin.cpp,又因为我们是用cmake编译,所以CMakeLists文件是少不了的。
// 通过终端在XJPlugin目录下创建这两个文件
touch hkplugin.cpp
touch hkplugin.txt
我们把注意力放在add_clang_executable这里,这里表示将JRVisitor.cpp的源文件编译成目标文件JRVisitor,并且这个目标文件是可执行的。如此,我们即可在编写插件代码的时候动态的调试我们的程序。
// CMakeLists.txt文件中添加如下代码
set(LLVM_LINK_COMPONENTS support)
add_clang_executable(hkplugin
hkplugin.cpp
)
target_link_libraries(hkplugin
PRIVATE
clangTooling
libclang
)
调试的时候可以设置插件schme的 Run->Arguments,在Arguments Passed On Launch 下一次添加如下六行参数:
/Users/xmly/Desktop/llvm_all/Person.m //测试文件
-extra-arg
-isysroot
-extra-arg
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator15.0.sdk //SDK的路径必须是你自己的路径
--
如果我们将add_clang_executable修改为add_llvm_loadable_module或者add_llvm_library,则最后生成的目标文件为.dylib。这是我们最后需要的插件的格式。
add_llvm_loadable_module(hkplugin
hkplugin.cpp
)
或者
add_llvm_library( hkplugin MODULE BUILDTREE_ONLY
hkplugin.cpp
)
person.h文件如下,用于调试:
#import <Foundation/Foundation.h>
@interface person : NSObject
@property (nonatomic,strong)NSString *MyName;
@end
person.m文件如下,用于调试:
#import "Person.h"
@implementation person
void foo() {
}
- (void)MyTestFunc {
foo();
}
@end
hkplugin.cpp源码如下:
#include <iostream>
#include "clang/AST/AST.h"
#include "clang/AST/DeclObjC.h"
#include "clang/AST/ASTConsumer.h"
#include "clang/ASTMatchers/ASTMatchers.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/ASTMatchers/ASTMatchFinder.h"
#include "clang/Frontend/FrontendPluginRegistry.h"
// 声明使用命名空间
using namespace clang;
using namespace std;
using namespace llvm;
using namespace clang::ast_matchers;
// 插件命名空间
namespace XJPlugin {
// 第三步:扫描完毕回调
// 4、自定义回调类,继承自MatchCallback
class XJMatchCallback : public MatchFinder::MatchCallback {
private:
// CI传递路径:XJASTAction类中的CreateASTConsumer方法参数 -> XJASTConsumer的构造函数 -> XJMatchCallback的私有属性,通过构造函数从XJASTConsumer构造函数中获取
CompilerInstance &CI;
// 判断是否是自己的文件
bool isUserSourceCode(const string fileName) {
// 文件名不为空
if (fileName.empty()) return false;
// 非Xcode中的代码都认为是用户的
if (0 == fileName.find("/Applications/Xcode.app/")) return false;
return true;
}
// 判断是否应该用copy修饰
bool isShouldUseCopy(const string typeStr) {
// 判断类型是否是 NSString / NSArray / NSDictionary
if (typeStr.find("NSString") != string::npos ||
typeStr.find("NSArray") != string::npos ||
typeStr.find("NSDictionary") != string::npos) {
return true;
}
return false;
}
public:
// 构造方法
XJMatchCallback(CompilerInstance &CI):CI(CI) {}
// 重载run方法
void run(const MatchFinder::MatchResult &Result) override {
// 通过Result获取节点对象,根据节点id("objcPropertyDecl")获取(此id需要与XJASTConsumer构造方法中bind的id一致)
const ObjCPropertyDecl *propertyDecl = Result.Nodes.getNodeAs<ObjCPropertyDecl>("objcPropertyDecl");
// 获取文件名称(包含路径)
string fileName = CI.getSourceManager().getFilename(propertyDecl->getSourceRange().getBegin()).str();
// 如果节点有值 && 是用户文件
if (propertyDecl && isUserSourceCode(fileName)) {
// 获取节点的类型,并转成字符串
string typeStr = propertyDecl->getType().getAsString();
// 节点的描述信息
ObjCPropertyAttribute::Kind attrKind = propertyDecl->getPropertyAttributes();
// 应该使用copy,但是没有使用copy
if (isShouldUseCopy(typeStr) && !(attrKind & ObjCPropertyAttribute::kind_copy)) {
// 通过CI获取诊断引擎
DiagnosticsEngine &diag = CI.getDiagnostics();
// Report 报告
/**
错误位置:getLocation 节点位置
错误:getCustomDiagID(等级,提示)
*/
diag.Report(propertyDecl->getLocation(), diag.getCustomDiagID(DiagnosticsEngine::Warning, "%0 - 这个属性推荐使用copy修饰!!"))<< typeStr;
}
}
}
};
// 第二步:扫描配置完毕
// 3、自定义XJASTConsumer,继承自抽象类 ASTConsumer,用于监听AST节点的信息 -- 过滤器
class XJASTConsumer : public ASTConsumer {
private:
// AST 节点查找器(过滤器)
MatchFinder matcher;
// 回调对象
XJMatchCallback callback;
public:
// 构造方法中创建MatchFinder对象
XJASTConsumer(CompilerInstance &CI):callback(CI) { // 构造即将CI传递给callback
// 添加一个MatchFinder,每个objcPropertyDecl节点绑定一个objcPropertyDecl标识(去匹配objcPropertyDecl节点)
// 回调callback,其实是在CJLMatchCallback里面重写run方法(真正回调的是回调run方法)
matcher.addMatcher(objcPropertyDecl().bind("objcPropertyDecl"), &callback);
}
// 重载两个方法 HandleTopLevelDecl 和 HandleTranslationUnit
// 解析完毕一个顶级的声明就回调一次(顶级节点,即全局变量,属性,函数等)
bool HandleTopLevelDecl(DeclGroupRef D) override {
//cout<<"正在解析..."<<endl;
return true;
}
// 当整个文件都解析完毕后回调
void HandleTranslationUnit(ASTContext &Ctx) override {
//cout<<"文件解析完毕!!!"<<endl;
// 将文件解析完毕后的上下文context(即AST语法树) 给 matcher
matcher.matchAST(Ctx);
}
};
//2、自定义XJASTAction类,继承PluginASTAction,即自定义AST语法树行为
class XJASTAction : public PluginASTAction {
public:
// 重载ParseArgs 和 CreateASTConsumer方法
bool ParseArgs(const CompilerInstance &CI, const std::vector<std::string> &arg) override {
/**
解析给定的插件命令行参数
- param CI 编译器实例,用于报告诊断。
- return 如果解析成功,则为true;否则,插件将被销毁,并且不执行任何操作。该插件负责使用CompilerInstance的Diagnostic对象报告错误。
*/
return true;
}
// 返回自定义的XJASTConsumer对象,抽象类ASTConsumer的子类
unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI, StringRef InFile) override {
/**
传递CI
CI用于:
- 判断文件是否是用户的
- 抛出警告
*/
return unique_ptr<XJASTConsumer>(new XJASTConsumer(CI));
}
};
}
// 第一步(1):注册插件,并自定义XJASTAction类
// 1、注册插件
// X 变量名,可随便写,也可以写自己有意思的名称
// XJPlugin 插件名称,️很重要,这个是对外的名称
// this is XJPlugin 插件备注
// 生成正式dylib插件的时候需要打开注释
//static FrontendPluginRegistry::Add<XJPlugin::XJASTAction> X("XJPlugin", "this is XJPlugin");
// 加入以下代码,生成可执行文件用于调试,生成dylib需要将一下代码注释
#include "clang/Tooling/CommonOptionsParser.h"
#include "clang/Tooling/Tooling.h"
#include "clang/Frontend/FrontendActions.h"
#include "clang/AST/RecursiveASTVisitor.h"
#include "clang/Frontend/CompilerInstance.h"
using namespace clang::tooling;
using namespace llvm;
using namespace clang;
// Apply a custom category to all command-line options so that they are the
// only ones displayed.
static llvm::cl::OptionCategory MyToolCategory("my-tool options");
// CommonOptionsParser declares HelpMessage with a description of the common
// command-line options related to the compilation database and input files.
// It's nice to have this help message in all tools.
static cl::extrahelp CommonHelp(CommonOptionsParser::HelpMessage);
// A help message for this specific tool can be added afterwards.
static cl::extrahelp MoreHelp("\nMore help text...\n");
int main(int argc, const char **argv) {
CommonOptionsParser OptionsParser(argc, argv, MyToolCategory);
// OptionsParser.getSourcePathList()获取要解析的源文件,目前是test.cpp
const std::vector<std::string> vec = OptionsParser.getSourcePathList();
for (size_t i = 0; i < vec.size(); ++i) {
string str = vec[i];
printf("filepath:%s\n",str.c_str());
}
ClangTool Tool(OptionsParser.getCompilations(),
OptionsParser.getSourcePathList());
return Tool.run(newFrontendActionFactory<XJPlugin::XJASTAction>().get());
}
- 编译llvm
mkdir llvm_xcode && cd llvm_xcode
cmake -S ../llvm-project/llvm -B build -G Xcode -DLLVM_ENABLE_PROJECTS="clang"
⚠️注意:不要选择Automatically Create Schemes,选择Manually Manage Schemes。 否则会引入一些不必要的scheme,拖累Xcode速度。 原则:使用哪个scheme,就引入哪个。
- 自定义插件需要添加clang和clangTooling
- 开始运行clang和clangTooling,第一次运行时需要进行编译,往后再运行
- 进入llvm_xcode目录打开LLVM.xcodeproj,然后添加lang和clangTooling,hkplugin的scheme,并依次进行编译
原理主要分为三步:
-
【第一步】注册插件,并自定义XJASTAction类
-
自定义XJASTAction类(继承自抽象类PluginASTAction),重载两个函数ParseArgs和CreateASTConsumer,在CreateASTConsumer中创建XJASTConsumer类对象,并将编译器实例CI传递过去。CI主要用于以下两个方面
- 判断文件是否是用户的
- 抛出警告
-
通过FrontendPluginRegistry注册插件,需要关联插件名与自定义的XJASTAction类。
-
-
【第二步】扫描配置完毕
-
自定义XJASTConsumer类(继承自ASTConsumer),声明节点查找器MatchFinder matcher和回调对象XJMatchCallback callback。 实现构造函数,创建MatchFinder对象,并将CI传递给回调对象callback。 重载两个方法
- HandleTopLevelDecl:解析完毕一个顶级的声明就回调一次
- HandleTranslationUnit:当整个文件都解析完毕后回调,将文件解析完毕后的上下文context(即AST语法树)给matcher。
-
-
【第三步】扫描完毕的回调
-
自定义回调类XJMatchCallback(继承自MatchCallback),声明私有变量CI,用于接收ASTConsumer类传递过来的CI。
-
重写run方法
-
1、通过Result根据节点id获取节点对象(此id需要与XJASTConsumer构造方法中bind的id一致)。
-
2、判断节点有值并且是用户文件
-
3、获取属性节点的描述信息
-
4、获取属性节点的类型,并转成字符串
-
5、判断属性是否需要用copy但是没有用copy
-
6、通过CI获取诊断引擎
-
7、通过诊断引擎报告错误
-
通过终端测试插件:
/Users/xmly/Desktop/llvm_all/llvm-project/build/Debug/bin/clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator15.0.sdk -Xclang -load -Xclang /Users/xmly/Desktop/llvm_all/llvm-project/build/Debug/lib/hkplugin.dylib -Xclang -add-plugin -Xclang XJPlugin -c ~/Desktop/test/test/AppDelegate.m
2.2.3.8 Xcode集成插件
此插件只作为研究clang之用,实际开发的项目中最好不要继承,因为会影响Xcode编译速度。此插件集成是针对项目,不是针对整个Xcode,测试项目可以放心集成。
加载插件
打开测试项目,在target -> Build Settings -> Other C Flags添加如下内容:
-Xclang -load /Users/xmly/Desktop/llvm_all/llvm-project/build/Debug/lib/hkplugin.dylib -Xclang -add-plugin -Xclang XJPlugin
设置编译器
由于clang插件需要使用对应的版本去加载,如果版本不一致会导致编译失败
-
在Build Settings栏目中新增两项用户定义的设置,分别是CC和CXX
- CC 对应的是自己编译的clang的绝对路径
- /Users/xmly/Desktop/llvm_all/llvm-project/build/Debug/bin/clang
- CXX 对应的是自己编译的clang++的绝对路径
- /Users/xmly/Desktop/llvm_all/llvm-project/build/Debug/bin/clang++
- CC 对应的是自己编译的clang的绝对路径
-
接下来在Build Settings中搜索index,将Enable Index-Wihle-Building Functiona lity的Default改为NO
-
最后,重新编译测试项目,会出现我们想要的效果。
2.2.4 Swift 编译器
和 Clang 一样,Swift 编译器主要负责对 Swift 源代码进行静态分析和纠错,并转换为 LLVM IR 。他是 Swift 编译的前端模块。不过和 Clang 不同,Swift 编译器会多出 SIL optimizer ,它会先将 Swift 文件转换成中间代码 SIL ,然后再根据 SIL 生成 IR 。是不是觉得很复杂,Swift 编译器会在编译其间生成两种不同的中间代码,这是为什么呢?下面会有详细的解释。
Swift 编译器的主要工作:
- 解析:解析器负责生成没有任何语义或类型信息的抽象语法树( AST ),并针对输入源的语法问题发出警告或错误
- 词法分析:获取解析的 AST 并将其转换为格式良好,完全类型检查的AST形式,为源代码中的词法问题发出警告或错误
- Clang 导入器:导入 Clang 模块并将它们导出的 C 或 Objective-C API 映射到相应的 Swift API
- SIL 生成:将经过类型检查的 AST 降级为 SIL
- SIL 规范化:执行额外的数据流诊断(例如使用未初始化的变量)
- SIL 优化:为程序执行额外的高级 Swift 特定优化,包括自动引用计数优化,虚拟化和通用专业化
- LLVM IR 生成:将 SIL 降级到 LLVM IR
为什么要增加 SIL 层
Swift 中间语言( SWIFT Integration Layer )是一种高级的,特定于 Swift 的中间语言,适用于进一步分析和优化 Swift 代码。SIL 属于 High-Level IR,其相对于LLVM IR 的抽象层级更高,而且是特定于 Swift 语言的。 由于源码和 LLVM IR 之间存在着非常大的抽象鸿沟,IR 不适用对源码进行分析和检查。因此 Clang 使用了 Analysis 通过 CFG (控制流图)来对代码进行分析和检查。但是 CFG 本身不够精准,且不在主流程上(会和 IR 生成过程并行执行),因此 CFG 和 IR 生成中会出现部分重复分析,做无用功。 而在 Swift 的编译过程中,SIL 会在生成 LLVM IR 之前做好所有的分析和规范化,并在 IRGen 的帮助下降级到 LLVM IR ,避免了部分重复任务,也使得整个编译流程更加统一。
而且因为 Swift 在编译时就完成了方法绑定直接通过地址调用属于强类型语言,方法调用不再是像 Objective-C 那样的消息转发,这样编译就可以获得更多的信息用在后面的后端优化上。因此我们可以在 SIL 上对 Swift 做针对性的优化,而这些优化是 LLVM IR 所无法实现的。
这些优化包括:
- 临界拆分:不支持任意的基础 block 参数通过终端进行临界拆分
- 泛型优化:分析泛型函数的特定调用,并生成新的特定版本的函数.然后将泛型的特定用法全部重写为对应的特定函数的直接调用 witness和虚函数表的去虚拟化优化:通过给定类型去查找关联的类的虚函数表或者类型的 witness 表,并将虚函数调用替换为调用函数映射
- 内联优化:对于transparent函数进行内联
- 内存提升:将 alloc_box 结构优化为 alloc_stack 引用计数优化
- 高级领域特定优化:对基础的 Swift 类型容器(类似 Array 或 String )实现了高级优化
通过分析和检查的安全 SIL 会被 IRGen 转换成 LLVM IR,并进一步接受 LLVM 的优化。
Swift 编译流
Swift 编译流和 Clang 一样都是编译前端,和 Clang 一样代码会被解析成语法数 AST,接下来会比 Clang 多一步,通过 SILGen 生成 SIL 这一次方便做些 Swift 特定的优化,SIL 会被传递给 IR 生成阶段生成 LLVM IR,最后由 LLVM 解决余下事情。看到这里大家肯定会好奇 swift 是如何与 C 和 OC 交互的比如系统底层的模块,这里就要提提 swift 的模块映射了(Module map),它调用 Clang 的模块,将其传入 Clang importer 中生成 AST 来分析使得 Swift 能够和 C/OC 进行交互。
下面通过一个例子看详细了解下 Swift 编译流吧。先创建一个 toy.swift
print(“hi!”)
swiftc toy.swift
生成检查 AST
swiftc -dump-ast toy.swift
可以还原之前函数名
swiftc -emit-silgen toy.swift | xcrun swift-demangle
llvm ir 和汇编的生成
swiftc -emit-ir toy.swift
swiftc -emit-assembly toy.swift
生成可执行的脚本
xcrun -sdk macosx swiftc toy.swift -o toy