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
2
// 预编译指令
clang -E main.m -o main.i

预编译处理规则:

  • 将所有的#define删除,并展开所有的宏定义
  • 处理所有条件预编译指令,比如#if,#ifdef,#elif,#else,#endif
  • 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置
  • 删除所有的注释///**/
  • 添加行号和文件名标识,比如#2 “hello.c” 2。
  • 保留所有的#pragma编译器指令

编译

编译过程就是把预处理完的文件进行一系列的:词法分析语法分析语义分析优化后生成相应的汇编代码文件。

1
2
// 编译指令
clang -S main.i -o main.s

过程:

  1. 词法分析:源代码的字符序列分割成一个个token(关键字、标识符、字面量、特殊符号),比如把标识符放到符号表。
  2. 语法分析:生成抽象语法树 AST,此时运算符号的优先级确定了;有些符号具有多重含义也确定了,比如“*”是乘号还是对指针取内容;表达式不合法、括号不匹配等,都会报错。
  3. 语义分析(静态分析):分析类型声明和匹配问题。比如整型和字符串相加,肯定会报错。中间语言生成:CodeGen根据AST自顶向下遍历逐步翻译成 LLVM IR,并且对在编译期就可以确定的表达式进行优化,比如代码里t1=2+6,可以优化t1=8。(假如开启了bitcode)
  4. 目标代码生成与优化:根据中间语言生成依赖具体机器的汇编语言。并优化汇编语言。这个过程中,假如有变量且定义在同一个编译单元里,那给这个变量分配空间,确定变量的地址。假如变量或者函数不定义在这个编译单元,得链接时候,才能确定地址。

汇编

汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎对应一条机器令。

它没复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译。

1
2
// 由汇编文件生成目标文件
clang -c main.s -o main.o

汇编器会接收汇编代码,将它转换成二进制的机器码,生成目标文件(后缀是 .o),机器码可以直接被 CPU 识别并执行。

最终的目标文件(机器码)也是分段的,主要有以下三个原因:

  1. 分段可以将数据和代码区分开。其中代码只读,数据可写,方便权限管理,避免指令被改写,提高安全性。
  2. 现代 CPU 一般有自己的数据缓存和指令缓存,区分存储有助于提高缓存命中率。
  3. 当多个进程同时运行时,他们的指令可以被共享,这样能节省内存。

对于一个目标文件来说,文件的最开头(也叫作 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编译流程:

  1. Swift源码经过词法分析、语法分析、语义分析后生成AST
  2. SILGen获取AST后生成未优化、代码量巨大的Raw SIL;再经过分析和优化后再生成更简洁的Canonical SIL
  3. 最后IRGen再将Canonical SIL转化为LLVM IR交给优化器和后端处理。

iOS程序详细编译过程

  1. 写入辅助文件:将项目的文件结构对应表、将要执行的脚本、项目依赖库的文件结构对应表写成文件,方便后面使用;并且创建一个 .app 包,后面编译后的文件都会被放入包中;
  2. 运行预设脚本:Cocoapods 会预设一些脚本,当然你也可以自己预设一些脚本来运行。这些脚本都在 Build Phases中可以看到;
  3. 编译文件:针对每一个文件进行编译,生成可执行文件 Mach-O,这过程 LLVM 的完整流程,前端、优化器、后端;
  4. 链接文件:将项目中的多个可执行文件合并成一个文件;
  5. 拷贝资源文件:将项目中的资源文件拷贝到目标包;
  6. 编译、链接 storyboard 文件:将编译后的 storyboard 文件链接成一个文件;
  7. 编译 Asset 文件:我们的图片如果使用 Assets.xcassets 来管理图片,那么这些图片将会被编译成机器码,除了 iconlaunchImage
  8. 运行 Cocoapods 脚本:将在编译项目之前已经编译好的依赖库和相关资源拷贝到包中。
  9. Swift 标准库拷贝到包中
  10. 对包进行签名