iOS进阶:编译
对于编译型语言来说,从源码到可执行文件需要经过编译、汇编和链接三个步骤。编译器接收源代码,输出目标代码(也就是汇编代码),汇编器接收汇编代码,输出由机器码组成的目标文件(二进制格式,.o 后缀),最后链接器将各个目标文件链接起来,执行重定位,最终生成可执行文件。
iOS的编译器LLVM
LLVM采用三相设计:
- 前端负责词法分析、语法分析、生成中间代码;
- 后端以中间代码作为输入,进行与架构无关的代码优化,接着针对不同架构生成不同的机器码。
- 前后端依赖统一格式的中间代码(IR),使得前后端可以独立的变化。新增一门语言只需要修改前端,而新增一个 CPU 架构只需要修改后端即可
Objective-C/C/C++ 使用的编译器前端是Clang
,swift 是swiftc
,后端都是 LLVM。
其实中间代码可以被省略,抽象语法树可以直接转化为目标代码(汇编代码)。然而,不同的 CPU 的汇编语法并不一致,因此一种比较高效的做法是先生成语言无关、CPU 也无关的中间代码,然后再生成对应各个 CPU 的汇编代码。
中间代码是编译器前端和后端的分界线。编译器前端负责把源码转换成中间代码,编译器后端负责把中间代码转换成汇编代码。
目标代码也可以叫做汇编代码。由于中间代码已经非常接近于实际的汇编代码,它几乎可以直接被转化。主要的工作量在于兼容各种 CPU 以及填写模板。在最终生成的汇编代码中,不仅有汇编命令,也有一些对文件的说明。
编译过程
在iOS中,从源码到生成可执行文件的过程:
- 预处理(Propressing):生成预处理文件——>
.i
文件 - 编译(Compilation):生成汇编文件——>
.s
文件 - 汇编(Assembly): 生成目标文件——>
.o
文件 - 链接(Linking):链接相关静态库生成可执行文件——Mach-O文件
编译器负责的部分:词法分析
、语法分析
、生成中间代码
和生成目标代码
预处理(预编译)
预编译过程主要处理源代码文件中以”#
”开头的预编译指令。比如#include、#define
等
预处理的实现有很多种:
- 有的编译器会在词法分析前先进行预处理,替换掉所有
#
开头的宏,比如Clang; - 有的编译器则是在词法分析的过程中进行预处理。当分析到
#
开头的单词时才进行替换,比如GCC。
源代码文件和相关的头文件被预编译器cpp
预编译成一个.i
文件。经过预编译后的文件(.i文件)
不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经插入到.i文件
中。
1 | // 预编译指令 |
预编译处理规则:
- 将所有的
#define
删除,并展开所有的宏定义 - 处理所有条件预编译指令,比如
#if,#ifdef,#elif,#else,#endif
- 处理
#include
预编译指令,将被包含的文件插入到该预编译指令的位置 - 删除所有的注释
//
和/**/
- 添加行号和文件名标识,比如
#2 “hello.c” 2。
- 保留所有的
#pragma
编译器指令
编译
编译过程就是把预处理完的文件进行一系列的:词法分析
、语法分析
、语义分析
及优化
后生成相应的汇编代码文件。
1 | // 编译指令 |
过程:
- 词法分析:源代码的字符序列分割成一个个
token
(关键字、标识符、字面量、特殊符号),比如把标识符放到符号表。 - 语法分析:生成抽象语法树 AST,此时运算符号的优先级确定了;有些符号具有多重含义也确定了,比如“*”是乘号还是对指针取内容;表达式不合法、括号不匹配等,都会报错。
- 语义分析(静态分析):分析类型声明和匹配问题。比如整型和字符串相加,肯定会报错。中间语言生成:CodeGen根据AST自顶向下遍历逐步翻译成 LLVM IR,并且对在编译期就可以确定的表达式进行优化,比如代码里t1=2+6,可以优化t1=8。(假如开启了bitcode)
- 目标代码生成与优化:根据中间语言生成依赖具体机器的汇编语言。并优化汇编语言。这个过程中,假如有变量且定义在同一个编译单元里,那给这个变量分配空间,确定变量的地址。假如变量或者函数不定义在这个编译单元,得链接时候,才能确定地址。
汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎对应一条机器令。
它没复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译。
1 | // 由汇编文件生成目标文件 |
汇编器会接收汇编代码,将它转换成二进制的机器码,生成目标文件(后缀是 .o
),机器码可以直接被 CPU 识别并执行。
最终的目标文件(机器码)也是分段的,主要有以下三个原因:
- 分段可以将数据和代码区分开。其中代码只读,数据可写,方便权限管理,避免指令被改写,提高安全性。
- 现代 CPU 一般有自己的数据缓存和指令缓存,区分存储有助于提高缓存命中率。
- 当多个进程同时运行时,他们的指令可以被共享,这样能节省内存。
对于一个目标文件来说,文件的最开头(也叫作 ELF 头)记录了目标文件的基本信息,程序入口地址,以及段表的位置,相当于是对文件的整体描述。段表记录了每个段的段名,长度,偏移量。比较常用的段有:
.strtab
段: 字符串长度不定,分开存放浪费空间(因为需要内存对齐),因此可以统一放到字符串表(也就是 .strtab 段)中进行管理。字符串之间用\\0
分割,所以凡是引用字符串的地方用一个数字就可以代表。.symtab
: 表示符号表。符号表统一管理所有符号,比如变量名,函数名。符号表可以理解为一个表格,每行都有符号名(数字)、符号类型和符号值(存储地址).rel
段: 它表示一系列重定位表。这个表主要在链接时用到。
链接
链接就是将目标文件(.o文件)与其中调用的外部函数所在的目标文件通过重定位关联起来。
链接过程主要包括地址和空间分配、符号决议和重定位。
1 | clang main.o -o main |
在一个目标文件中,不可能所有变量和函数都定义在文件内部。比如,main
函数中调用print
函数时,print
函数就是一个被调用的外部函数,此时就需要把 main.o
这个目标文件和包含了 print
函数实现的目标文件链接起来。函数调用对应到汇编其实是 jump
指令,后面写上被调用函数的地址,但在生成 main.o
的过程中, print()
函数的地址并不知道,所以只能先用 0
来代替,直到最后链接时,才会修改成真实的地址。
链接器就是靠着重定位表来知道哪些地方需要被重定位的。每个可能存在重定位的段都会有对应的重定位表。在链接阶段,链接器会根据重定位表中,需要重定位的内容,去别的目标文件中找到地址并进行重定位。
Swift编译流程差异
不同于OC使用Clang
作为编译器前端,Swift自定义了编译器前端Swiftc。
相比OC的编译过程,Swift新增了对SIL
(Swift Intermediate Language
)的处理。SIL
是Swift引入的新的高级中间语言,用以实现更高级别的优化。
Swift编译流程:
- Swift源码经过词法分析、语法分析、语义分析后生成
AST
。 SILGen
获取AST
后生成未优化、代码量巨大的Raw SIL
;再经过分析和优化后再生成更简洁的Canonical SIL
。- 最后
IRGen
再将Canonical SIL
转化为LLVM IR
交给优化器和后端处理。
iOS程序详细编译过程
- 写入辅助文件:将项目的文件结构对应表、将要执行的脚本、项目依赖库的文件结构对应表写成文件,方便后面使用;并且创建一个
.app
包,后面编译后的文件都会被放入包中; - 运行预设脚本:
Cocoapods
会预设一些脚本,当然你也可以自己预设一些脚本来运行。这些脚本都在Build Phases
中可以看到; - 编译文件:针对每一个文件进行编译,生成可执行文件
Mach-O
,这过程LLVM
的完整流程,前端、优化器、后端; - 链接文件:将项目中的多个可执行文件合并成一个文件;
- 拷贝资源文件:将项目中的资源文件拷贝到目标包;
- 编译、链接
storyboard
文件:将编译后的storyboard
文件链接成一个文件; - 编译
Asset
文件:我们的图片如果使用Assets.xcassets
来管理图片,那么这些图片将会被编译成机器码,除了icon
和launchImage
; - 运行
Cocoapods
脚本:将在编译项目之前已经编译好的依赖库和相关资源拷贝到包中。 - 将
Swift
标准库拷贝到包中 - 对包进行签名