本文为L_Ares个人写作,以任何形式转载请表明原文出处。
本文接上一节——iOS用到的LLVM(一)。请对LLVM和Clang不熟悉的同学们移步上一节,了解了基础的信息之后再阅读本节。
一、准备工作
步骤1 : 使用xcode新建一个空的macOS下的commond Line Tool命令行工具,下面称之为工程1。

注意 :
- 这里因为用的是命令行(
commond Line Tool),所以初创的情况下没有对其他的框架造成依赖。- 因为没有依赖,所以以下的命令都是不引入其他
iOS框架的(包括也没有引入Foundation框架)。- 如果想要引入其他的框架,那么就在
clang命令上添加框架的地址。下面是举例的一个命令,引入内容按照自己要使用的框架的情况进行修改即可。
clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator12.2.sdk(自己的SDK路径) -fmodules -fsyntax-only -Xclang -ast-dump main.m
步骤2 : 打开terminal终端,进入到刚创建的这个项目中main.m所在的文件夹下。
步骤3 : 在terminal终端中输入clang的查看详细编译步骤的指令。
clang -ccc-print-phases main.m
图片未必看的清楚,我把内容拷贝下来了,下面称之为内容1。
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
解释
- 如
input、preprocessor、compiler、backend、assembler、linker、bind-arch,这些东西表示的是编译中的操作名称。- 如
main.m、{0}.....{5},这些东西表示的是这一步操作中要读取的文件,也就是上一步操作的结果文件。- 如
objective-c、objective-c-cpp-output、ir、assembler、object、image等这些东西就是本步操作完成后,生成的文件,也就是上面2中说的上一步操作的结果文件。
对于这个工程,一共有0~6一共7个阶段。这就是
main.m这个文件从源码到机器语言的总的流程。下面开始按照流程来说。
二、源码的编译流程
2.1 编译总流程
命令 :
clang -ccc-print-phases main.m
编译总流程就是上面的内容1。
先阐述总流程0~6中都是什么 :
0: 输入文件 : "找到源文件"。
1: 预处理阶段 : 这个阶段处理了"宏的替换"和"头文件的导入"。
2: 编译阶段 : 进行"词法分析"、"语法分析","语义分析"。最重要的是要"
生成中间代码IR"。
3: 后端 : LLVM在这里会"通过一个一个的Pass去优化传入的IR",每个Pass做一些事情,最终生成汇编代码。
4: 生成汇编代码。
5: 链接 : "链接需要的动态库和静态库,生成可执行文件"。
6: 最后一步,"通过不同的架构,生成对应的可执行文件"。
这个步骤与之前经常提及的编译流程,0~6步分别对应着 :
源文件(0)-->预编译(1)-->编译(2)-->汇编(3,4)-->链接(5)-->生成可执行文件(6)
2.2 预处理阶段
在2.1的总流程中说过,预处理阶段要做的事情有两件 :
- 宏的替换
- 头文件的导入
举例
- 打开
工程1,定义一个宏#define JD_NUM 10。- 因为
Xcode自带的头文件引入#import <Foundation/Foundation.h>是导入Foundation框架,Foundation框架太大了而且现在我们不需要用,所以头文件引用就换成#import <stdio.h>。commond + s保存一下。- 打开
terminal终端,进入main.m所在的文件夹下。- 键入
clang指令查看预处理阶段的详细步骤。命令如下 (详细的Clang命令解释可以看上一节中的Clang常用指令)。- 操作图如下图2.2.0

Clang命令 :
clang -E main.m >> main2.m
解释 :
现在
main.m的文件夹下就会出现main2.m文件,它就是经过预处理阶段操作之后的结果。如下图2.2.1。

- 打开
main2.m文件,拉到文件的最后,找到main函数入口。结果如下图2.2.2

问 : typedef是不是预处理阶段进行的处理?
其实这里通过对
#define和typedef本身的概念了解就知道是不一样的,typedef本身是存储类关键字,本质上并不属于宏或头文件。预处理阶段并不会对关键字做解释。
简单验证一下 :
- 在
工程1中加入typedef int JD_USE_INT,将int类型创建别名为JD_USE_INT。以后工程1改叫工程2。commons + s保存工程2的代码。- 依然使用
clang -E main.m >> main2.m指令,得到main2.m。- 打开
main2.m直接找到文本最后的main函数入口。- 操作图如下图2.2.3
- 结果图如下图2.2.4。


2.3 编译阶段
编译阶段的主要任务有3个 :
- 词法分析 : 将预处理阶段传过来的源码的字符序列一个一个的读入源程序,然后根据构词规则转换成单词序列(
Token)。- 语法分析 : 在词法分析的基础上,将单词序列组合成各类语法短句。例如 : 程序、语句、表达式等。然后将所有的语句节点抽象出来,生成
抽象语法树(AST),再检查源程序的结构是否符合语法规则。- 生成中间代码
IR: 完成上述步骤以后,代码生成器会将抽象语法树(AST)自上而下的遍历,逐步将其转换成LLVM IR。
举例
1. 词法分析
terminal终端cd进入新的工程2的main.m所在文件夹下。- 输入以下
clang指令,查看词法分析。- 源代码图为上图2.2.3,
clang结果图为下图2.3.0- 这里注意,
空格也算一个位置。
clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m

2. 语法分析
terminal终端cd进入工程2的main.m所在文件夹下。- 输入以下
clang指令,查看语法分析。- 结果如下图2.3.1
clang -fmodules -fsyntax-only -Xclang -ast-dump main.m

FunctionDecl: 方法节点
<line:7:1, line:13:1>:
方法节点的代码范围是第7行第1个字符到第13行第1个字符。line:7:5 main 'int (int, const char **)':
从第7行第5个字符的位置开始,是main方法的位置,第一个int表示main方法的返回值类型,(int, const char **)表示main方法的参数类型。
ParmVarDecl: 参数节点
<col:10, col:14>:
参数节点因为与main方法在同一行,所以不再说明是第7行。
直接说明第一个参数的位置是第10个字符开始,到第14个字符为止。argc 'int':
参数名称是argc,参数类型是int。- 上述是第一个参数的解释,下面的第二个参数相同,不再赘述。
CompoundStmt: 围栏,也可以说是范围,代表的就是main方法的{ }函数块区域。
ObjCAutoreleasePoolStmt: 自动释放池。
VarDecl: 变量节点。内容比较简单,可以自行理解一下。
CallExpr: 调用函数,这一行后面的int代表这个函数的返回值的类型是int。这里借助一下图片,如下图2.3.2。

BinaryOperator: 函数的第二个参数,这叫字节运算符。这一行表示这个字节运算符是加法运算。表明函数的第二个参数是一个加法运算的结果。再看下图2.3.3

ReturnStmt: 返回节点。IntegerLiteral: 整型。- 语法分析阶段会对代码中的错误进行提示。例如将
工程1的代码去掉一个;,重新运行语法分析的clang指令,结果如下图2.3.4,明显提示少了一个;,在第10行的第42个字符处。

3. 生成中间代码LLVM IR
IR的基本语法
| 语法 | 释义 |
|---|---|
| ; | 注释 |
| @ | 全局标识 |
| % | 局部标识 |
| alloca | 开辟空间 |
| align | 内存对齐 |
| i32 | 32bit,4字节 |
| store | 写入内存 |
| load | 载入内存 |
| call | 调用函数 |
| ret | 返回 |
操作
- 修改
工程2的代码,多添加一个函数,方便把所有的IR代码的语法都了解一遍。新的工程命名为工程3。工程3代码如下 :
#import <stdio.h>
int sumFunc(int a, int b) {
return a + b + 3;
}
int main(int argc, const char * argv[]) {
int c = sumFunc(1, 2);
printf("%d",c);
return 0;
}
commond + S保存工程3的代码。terminal终端cd进入工程3所在文件夹下。- 输入以下
clang指令,生成IR文件。clang指令执行完成后,会在main.m文件所在的文件夹下生成main.ll文件。- 生成
main.ll的结果如下图2.3.5。
clang -S -fobjc-arc -emit-llvm main.m

- 可以利用
Sublime Text打开main.ll文件,并将Sunlime Text软件右下角的Plain Text改成Objective-C的格式。结果如下图2.3.6。

2.4 优化器
我们知道了Clang是LLVM的前端,Clang做了2.2预处理阶段和2.3编译阶段的事情,那么从哪里开始算是LLVM的后端?
优化器
(Optimizer)和代码生成器(CodeGenerator)都可以算作LLVM后端。
- 后端的作用 :
(1). 优化。
将2.3编译阶段最后生成的LLVM IR代码传入一个一个的Pass进行IR优化,每个Pass都会对传入的IR进行本Pass要做的优化。
(2). 生成汇编代码。
完成所有所需Pass优化的IR将会变成汇编代码。- 什么是
Pass?
(1). 首先,Pass是节点。是LLVM优化过程中的优化逻辑所在之处。
(2). 其次,Pass是属于LLVM的后端(Backend)的。
(3). 最后,LLVM的优化是以节点(Pass)来完成的,是一个节点一个节点去完成的,所有节点一起合作之后,才完成了LLVM的优化的转化。
例如 : 有的节点是负责运算之后将冗余的代码减去的,有的节点则是负责跳转之后再减去冗余代码的。- 什么是
bitCode?
(1). 苹果在xcode7之后可以开启bitCode,在iOS中,我们说bitCode是苹果对LLVM在编译阶段生成的IR的一种特殊形式,本质上bitCode也是IR,也是中间代码,它以二进制形式存在,苹果推出bitCode就是一种官方的优化方式。
(2). 在经过bitCode的优化之后,IR代码文件会转化成.bc文件格式的中间代码。
举例
很明显,通过2.3编译阶段生成的IR在阅读理解上是很冗余的,短短的几行简单的代码都变得很长,所以LLVM中存在对IR代码进行一些适当的优化,当然这个优化在xcode上面是可选择的。还是选择以工程3为基本,如图2.4.0。

xcode是带有对IR代码是否进行优化的可视化界面的,一般情况下,Debug模式下默认都是没有开启代码优化,而Release模式下,则开启了优化。
4.1 LLVM的优化级别
| 级别 | 释义 |
|---|---|
| O0 | None,不进行IR优化 |
| O1 | Fast |
| O2 | Faster |
| O3 | Fastest |
| Os | Fastest , Smallest |
| Ofast | 比Os还要更近一步的优化 |
| Oz | 让IR代码体积最小的优化 |
注释 : 级别的中的
O是英文字母,不是数字0。
4.2 利用命令行对IR进行优化的举例
还是利用工程3,我们就不直接利用xcode的优化了,为了看到优化的IR代码,利用终端的命令行对IR代码进行优化。
- 利用
终端,进入到工程3的main.m所在文件夹下。- 在
终端中输入以下clang命令
clang -Os -S -fobjc-arc -emit-llvm main.m
- 依然利用
Sublime Text打开main.ll文件,调整成OC的语法格式。
- 结果如下图2.4.1所示。

4.3 bitCode的生成
还是利用
工程3。
- 利用
终端进入工程3的main.m所在的文件夹下。- 在
终端中输入以下clang命令,先生成IR的main.ll文件。
clang -S -fobjc-arc -emit-llvm main.m
- 再在
终端中输入以下clang命令,利用main.ll文件生成main.bc文件。
clang -emit-llvm -c main.ll -o main.bc
- 生成的结果如下图2.4.2所示。

2.5 汇编
2.5.1 直接生成汇编
直接利用上面
图2.4.2中的3个文件。
.m格式的源文件转化为汇编代码,利用下述命令。
clang -S -fobjc-arc main.m -o main.s
.ll格式的IR代码文件转化为汇编代码,利用下述命令。
clang -S -fobjc-arc main.ll -o main1.s
.bc格式的bitCode优化后的文件转化为汇编代码,利用下述命令。
clang -S -fobjc-arc main.bc -o main2.s
结果如下图2.5.0和2.5.1所示


2.5.2 生成汇编可进行优化
生成汇编进行的优化是对机器语言的优化。
我们已经知道,源码变成汇编的过程要经过 : 源码 --> IR --> bitcode --> 汇编,其实除了在源码 --> IR的时候可以进行优化,在生成汇编的时候,系统还是会进行一步优化,我们在上一节的传统优化器的设计中说过后端/代码生成器也有优化能力。
还是利用
工程3的源码。并且优化的级别统一选定为最高级别Os,其他的级别自行更换尝试。
- 源码直接生成汇编的优化
clang -Os -S -fobjc-arc main.m -o main3.s
对比main.m未经过优化和经过优化分别生成的汇编main.s和main3.s :

IR生成汇编的优化
clang -Os -S -fobjc-arc main.ll -o main4.s

bc生成汇编的优化
clang -Os -S -fobjc-arc main.bc -o main5.s

因为我们的源码只有最简单的11行,所以优化的效果不会有那么的大,但也可以看得出来优化的效果还是很好的。
但是!!!这里我们正常的情况下是不可以手动的进行调节的。
对比
IR的优化来看,IR的优化我们可以在xcode中就可以进行配置,就是上面的图2.4.0,而生成汇编的时候进行的优化,我们没有办法人工的干预。
2.6 生成目标文件和生成可执行文件(链接)
以下所有的操作都是以工程3为基础的。
2.6.1 生成目标文件
目标文件的生成是汇编器以
汇编代码作为输入,将汇编代码转换成机器代码,最后输出目标文件(object file)。
常用命令是 :
clang -fmodules -c main.s -o main.o
命令结果 :

查看目标文件main.o的符号的命令 :
xcrun nm -nm main.o
命令结果 :

undefined: 表示在当前文件,暂时找不到某个符号,比如在上图2.6.1中就是说找不到_printf这个符号,也就是找不到printf这个方法。
external: 表示这个符号是外部可以访问的。比如上图的2.6.1中找不到的_printf这个符号是可以在外部访问的到的,也就是说printf这个方法不是本文件的方法,但是是可以经过外部的文件找得到的方法。
2.6.2 生成可执行文件(链接)
我们知道,可执行文件的生成就是由很多的.o文件来完成的。这些.o文件要集合在一起需要要存在一些的联系,而这个联系就是由链接(linker)来做到的。
连接器把编译产生的.o文件和.dylib或.a文件生成一个mach-o文件。
用下述命令生成可执行文件 :
clang main.o -o main
生成可执行文件的结果 :

链接之后,我们再查看可执行文件的符号,对比目标文件来看。
查看可执行文件的符号的命令 :
xcrun nm -nm main
结果图 :

从图3.6.3中可以看到,虽然undefined标识是依然存在的,但是后面的括号中已经告诉我们_printf符号是来自于libSystem的。
那为什么要有这个from libSystem呢?
因为当这个可执行文件
main要被执行的时候,main内部有一个符号_printf是来自于外部,当要调用这个_printf的时候,dyld会在加载的时候进行绑定,而如何绑定呢?就会根据符号提供的位置,也就是(from libSystem)来确定_printf符号是来自于libSystem的,这时iOS的操作系统中的libSystem动态库就会把_printf的地址告诉dyld,然后进行符号的绑定。所以说,这个符号是在运行的时候动态绑定的。这也是为什么
fishhook可以去hook一些外部函数的原因。
当main这个可执行文件生成之后,我们就可以直接执行这个main,命令行如下 :
./main
结果如下图 :

也可以查看一下main的基本信息,比如它的格式、版本信息、运行所需的系统要求等,命令行 :
file main
结果如下图 :

可以看到main的文件格式是Mach-O,是64位的x86架构下可运行的,也就是说main是一个单一架构的文件不是胖二进制文件。