本文为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
是一个单一架构的文件不是胖二进制文件。