戳我
戳我
文章目录
  1. 1 编译器
    1. 1.1 LLVM
    2. 1.2 Clang
      1. 1.2.1 预处理(preprocessor)
      2. 1.2.2 词法分析(lexical anaysis)
      3. 1.2.3 语法分析(semantic analysis)
      4. 1.2.4 静态分析(CodeGen)
      5. 1.2.5 生成汇编指令
      6. 1.2.6 汇编
      7. 1.2.7 链接
      8. 1.2.8 Mach-O文件
      9. 1.2.9 dyld动态链接
      10. 1.2.10 dSYM
  2. 2 Xcode编译
  3. 3 编译顺序
  4. 4 头文件
  5. 5 Clang Module
  6. 6 Swift编译
    1. 6.1 Swift调用OC
    2. 6.2 OC调用Swift
  7. 7 深入理解链接(Linker)

iOS编译原理

1 编译器

把一种编程语言(原始语言)转换为另一种编程语言(目标语言)的程序叫做编译器. 大多数编译器由两部分组成: 前端和后端.

  • 前端负责词法分析,语法分析,生成中间代码;
  • 后端以中间代码作为输入,进行行架构无关的代码优化,接着针对不同架构生成不同的机器码。

前后端依赖统一格式的中间代码(IR), 使得前后端可以独立的变化. 新增一门语言只需要修改前端, 而新增一个CPU架构只需要修改后端即可. Objective C/C/C++使用的编译器前端是clang, swift是swift, 后端都是LLVM.

1.1 LLVM

LLVM的核心库提供了现代化的source-target-independent优化器和支持诸多流行CPU架构的代码生成器. Clang 和 LLDB都是基于LLVM衍生的子项目.

1.2 Clang

Clang是C语言家族的编译器前端,诞生之初是为了替代GCC,提供更快的编译速度。一张图了解clang编译的大致流程:
Clang流程

大致看来, Clang可以分为一下几个步骤: 预处理 -> 词法分析 -> 语法分析 -> 静态分析 -> 生成中间代码和优化 -> 汇编 -> 链接

1.2.1 预处理(preprocessor)

预处理会进行如下操作:

  • 头文件引入, 递归将头文件引用替换为头文件中的实际内容, 所以尽量减少头文件中的#import, 使用@class替代, 把#import放到.m文件中.
  • 宏替换, 在源码中使用的宏定义会被替换为对应#define的内容, 不要在需要预处理的代码中加入太多的内联代码逻辑
  • 注释处理, 在预处理的时候, 注释被删除
  • 条件编译, (#if, #else, #endif)

1.2.2 词法分析(lexical anaysis)

这一步把源文件中的代码转化为特殊的标记流. 词法分析器读入源文件的字符流, 将他们组织称有意义的词素(lexeme)序列,对于每个词素,此法分析器产生词法单元(token)作为输出.

源码被分割成一个一个的字符和单词, 在行尾Loc中都标记出了源码所在的对应源文件和具体行数, 方便在报错时定位问题. 类似于下面:

int 'int'     [StartOfLine]    Loc=<main.m:14:1>
identifier 'main'     [LeadingSpace]    Loc=<main.m:14:5>
l_paren '('        Loc=<main.m:14:9>
int 'int'        Loc=<main.m:14:10>
identifier 'argc'     [LeadingSpace]    Loc=<main.m:14:14>
comma ','        Loc=<main.m:14:18>
char 'char'     [LeadingSpace]    Loc=<main.m:14:20>
star '*'     [LeadingSpace]    Loc=<main.m:14:25>

1.2.3 语法分析(semantic analysis)

词法分析的Token流会被解析成一颗抽象语法树(abstract syntax tree - AST). 在这里面每一节点也都标记了其在源码中的位置.

有了抽象语法树,clang就可以对这个树进行分析,找出代码中的错误。比如类型不匹配,亦或Objective C中向target发送了一个未实现的消息.

AST是开发者编写clang插件主要交互的数据结构,clang也提供很多API去读取AST.Introduction to the Clang AST

1.2.4 静态分析(CodeGen)

把源码转化为抽象语法树之后,编译器就可以对这个树进行分析处理。静态分析会对代码进行错误检查,如出现方法被调用但是未定义、定义但是未使用的变量等,以此提高代码质量. 也可以使用 Xcode 自带的静态分析工具(Product -> Analyze).

常见的操作有:

  • 当在代码中使用 ARC 时,编译器在编译期间,会做许多的类型检查. 最常见的是检查程序是否发送正确的消息给正确的对象,是否在正确的值上调用了正常函数。如果你给一个单纯的 NSObject* 对象发送了一个 hello 消息,那么 clang 就会报错,同样,给属性设置一个与其自身类型不相符的对象,编译器会给出一个可能使用不正确的警告.

    一般会把类型分为两类:动态的和静态的。动态的在运行时做检查,静态的在编译时做检查。以往,编写代码时可以向任意对象发送任何消息,在运行时,才会检查对象是否能够响应这些消息。由于只是在运行时做此类检查,所以叫做动态类型。

    至于静态类型,是在编译时做检查。当在代码中使用 ARC 时,编译器在编译期间,会做许多的类型检查:因为编译器需要知道哪个对象该如何使用。

  • 检查是否有定义了,但是从未使用过的变量
  • 检查在 你的初始化方法中中调用 self 之前, 是否已经调用 [self initWith…] 或 [super init] 了

此处遍历语法树,最终生成LLVM IR代码。LLVM IR是前端的输出,后端的输入. Objective C代码在这一步会进行runtime的桥接:property合成,ARC处理等

LLVM 会去做些优化工作, 在 Xcode 的编译设置里也可以设置优化级别-01,-03,-0s,还可以写些自己的 Pass.

如果开启了 Bitcode 苹果会做进一步的优化. 虽然Bitcode仅仅只是一个中间码不能在任何平台上运行, 但是它可以转化为任何被支持的CPU架构, 包括现在还没被发明的CPU架构. iOS Apps中Enable Bitcode 为可选项, WatchOS和tvOS, Bitcode必须开启. 如果你的App支持Bitcode, App Bundle(项目中所有的target)中的所有的 Apps 和 frameworks 都需要支持Bitcode.

1.2.5 生成汇编指令

LLVM对IR进行优化后,会对代码进行编译优化例如针对全局变量优化、循环优化、尾递归优化等, 然后会针对不同架构生成不同的目标代码,最后以汇编代码的格式输出.

1.2.6 汇编

在这一阶段,汇编器将上一步生成的可读的汇编代码转化为机器代码。最终产物就是 以 .o 结尾的目标文件。使用Xcode构建的程序会在DerivedData目录中找到这个文件

Tips:什么是符号(Symbols)? 符号就是指向一段代码或者数据的名称。还有一种叫做WeakSymols,也就是并不一定会存在的符号,需要在运行时决定。比如iOS 12特有的API,在iOS11上就没有.

1.2.7 链接

目标文件(.o)和引用的库(dylib,a,tbd)链接起来, 最终生成可执行文件(mach-o), 链接器解决了目标文件和库之间的链接.

这时可执行文件的符号表信息已经有了, 会在运行时动态绑定.

1.2.8 Mach-O文件

Mach-O是OS X中二进制文件的原生可执行格式,是传送代码的首选格式。可执行格式决定了二进制文件中的代码和数据读入内存的顺序。代码和数据的顺序会影响内存使用和分页活动,从而直接影响程序的性能.

Mach-O是记录编译后的可执行文件,对象代码,共享库,动态加载代码和内存转储的文件格式。不同于 xml 这样的文件,它只是二进制字节流,里面有不同的包含元信息的数据块,比如字节顺序,cpu 类型,块大小等。文件内容是不可以修改的,因为在 .app 目录中有个 _CodeSignature 的目录,里面包含了程序代码的签名,这个签名的作用就是保证签名后 .app 里的文件,包括资源文件,Mach-O 文件都不能够更改.

Mach-O 文件包含三个区域:

  • Mach-O Header: 包含字节顺序,magic,cpu 类型,加载指令的数量等.
  • Load Commands: 包含很多内容的表,包括区域的位置,符号表,动态符号表等。每个加载指令包含一个元信息,比如指令类型,名称,在二进制中的位置等.
  • Data: 最大的部分,包含了代码,数据,比如符号表,动态符号表等.

Mach-O 文件格式探索
趣探 Mach-O:加载过程

1.2.9 dyld动态链接

生成可执行文件后就是在启动时进行动态链接了, 进行符号和地址的绑定. 首先会加载所依赖的 dylibs,修正地址偏移,因为 iOS 会用 ASLR 来做地址偏移避免攻击,确定 Non-Lazy Pointer 地址进行符号地址绑定,加载所有类,最后执行 load 方法和 clang attribute 的 constructor 修饰函数.

1.2.10 dSYM

在每次编译后都会生成一个 dSYM 文件,程序在执行中通过地址来调用方法函数,而 dSYM 文件里存储了函数地址映射,这样调用栈里的地址可以通过 dSYM 这个映射表能够获得具体函数的位置。一般都会用来处理 crash 时获取到的调用栈 .crash 文件将其符号化

当release的版本 crash的时候,会有一个日志文件,包含出错的内存地址, 使用symbolicatecrash工具能够把日志和dSYM文件转换成可以阅读的log信息,也就是将内存地址,转换成程序里的函数或变量和所属于的 文件名.

这里是一篇通过dsym来解析crash文件的教程.

2 Xcode编译

按下Command+B, 在XCode的Report Navigator模块中, 可以找到编译的详细日志:

  • 创建Product.app的文件夹
  • 把Entitlements.plist写入到DerivedData里,处理打包的时候需要的信息(比如application-identifier). Entitlements.plist保存了App需要使用的特殊权限,比如iCloud,远程通知,Siri等
  • 创建一些辅助文件,比如各种.hmap,这是headermap文件,具体作用下文会讲解。
  • 执行CocoaPods的编译前脚本:检查Manifest.lock文件。
  • 编译.m文件,生成.o文件。
  • 链接动态库,o文件,生成一个mach o格式的可执行文件。
  • 编译assets,编译storyboard,链接storyboard
  • 拷贝动态库Logger.framework,并且对其签名
  • 执行CocoaPods编译后脚本:拷贝CocoaPods Target生成的Framework
  • 对Demo.App签名,并验证(validate)
  • 生成Product.app

3 编译顺序

XCode是根据下面的依赖关系, 尽可能的利用多核性能, 多Target并发编译:

  • Target Dependencies - 显式声明的依赖关系
  • Linked Frameworks and Libraries - 隐式声明的依赖关系
  • Build Phase - 定义了编译一个Target的每一步

XCode会对每一个Task生成一个哈希值, 只有哈希值改变的时候才会重新编译. 所以是增量编译速度很快,

4 头文件

头文件对于编译器来说就是一个promise. 头文件里的声明, 编译会认为有对应实现, 在链接的时候再解决具体实现的位置. 当只有声明,没有实现的时候,链接器就会报错。

Objective C的方法要到运行时才会报错,因为Objective C是一门动态语言,编译器无法确定对应的方法名(SEL)在运行时到底有没有实现(IMP).

日常开发中,两种常见的头文件引入方式:

#include "CustomClass.h" //自定义
#include <Foundation/Foundation.h> //系统或者内部framework

这里有个文件类型叫做heademap, headermap是帮助编译器找到头文件的辅助文件: 存储这头文件到其物理路径的映射关系.

  • clang发现#import “TestView.h”的时候, 先在headermap(Demo-generated-files.hmap,Demo-project-headers.hmap)里查找, 如果headermap文件找不到,接着在own target的framework里找
  • 系统的头文件查找的时候也是优先headermap,headermap查找不到会查找own target framework,最后查找SDK目录
  • 以#import <Foundation/Foundation.h>为例,在SDK目录查找时首先查找framework是否存在, 如果framework存在,再在headers目录里查找头文件是否存在

#import””与#import<>的区别

5 Clang Module

传统的#include/#import都是文本语义: 预处理器在处理的时候会把这一行替换成对应头文件的文本, 这样就导致:

  • 大量的预处理消耗。假如有N个头文件,每个头文件又#include了M个头文件,那么整个预处理的消耗是N*M。
  • 文件导入后,宏定义容易出现问题。因为是文本导入,并且按照include依次替换,当一个头文件定义了#define std hello_world,而第另一个头文件刚好又是C++标准库,那么include顺序不同,可能会导致所有的std都会被替换。
  • 边界不明显。拿到一组.a和.h文件,很难确定.h是属于哪个.a的,需要以什么样的顺序导入才能正确编译

clang module不再使用文本模型, 而是采用更高效的语义模型。clang module提供了一种新的导入方式:@import,module会被作为一个独立的模块编译,并且产生独立的缓存,从而大幅度提高预处理效率,这样时间消耗从M*N变成了M+N.

XCode创建的Target是Framework的时候,默认define module会设置为YES,从而支持module,当然像Foundation等系统的framwork同样支持module.

#import <Foundation/NSString.h>的时候,编译器会检查NSString.h是否在一个module里,如果是的话,这一行会被替换成@import Foundation.

modulemap文件描述了一组头文件如何转换为一个module. swift是可以直接import一个clang module的,比如你有一些C库,需要在Swift中使用,就可以用modulemap的方式.

6 Swift编译

编译一个Swift头文件,需要解析module中所有的Swift文件,找到对应的声明. 这也就是swift没有头文件又是怎么找到声明的原因.

6.1 Swift调用OC

Swift的编译器内部使用了clang,所以swift可以直接使用clang module,从而支持直接import Objective C编写的framework. swift编译器会从objective c头文件里查找符号,头文件的来源分为两大类:

  • Bridging-Header.h中暴露给swfit的头文件
  • framework中公开的头文件,根据编写的语言不同,可能从modulemap或者umbrella header查找

XCode提供了宏定义NS_SWIFT_NAME来让开发者定义Objective C => Swift的符号映射,可以通过Related Items -> Generate Interface来查看转换后的结果

6.2 OC调用Swift

xcode会以module为单位,为swift自动生成头文件,供Objective C引用,通常这个文件命名为ProductName-Swift.h

swift提供了关键词@objc来把类型暴露给Objective C和Objective C Runtime.

7 深入理解链接(Linker)

链接器会把编译器编译生成的多个文件,链接成一个可执行文件。链接并不会产生新的代码,只是在现有代码的基础上做移动和补丁.

链接器的输入可能是以下几种文件:

  • object file(.o),单个源文件的编辑结果,包含了由符号表示的代码和数据。
  • 动态库(.dylib),mach o类型的可执行文件,链接的时候只会绑定符号,动态库会被拷贝到app里,运行时加载
  • 静态库(.a),由ar命令打包的一组.o文件,链接的时候会把具体的代码拷贝到最后的mach-o
  • tbd,只包含符号的库文件

以打印hello world为例, 在.o文件中字符串”hello world\n”作为一个符号(l_.str)被引用,汇编代码读取的时候按照l_.str所在的页加上偏移量的方式读取,然后调用printf符号。到这一步,CPU还不知道怎么执行,因为还有两个问题没解决:

  • l_.str在可执行文件的哪个位置?
  • printf函数来自哪里

但是链接之后的mach o文件中确可以正常执行, 是因为:

  • 链接后,不再是以页+偏移量的方式读取字符串,而是直接读虚拟内存中的地址,解决了l_.str的位置问题。
  • 链接后,不再是调用符号_printf,而是在DATA段上创建了一个函数指针_printf$ptr,初始值为0x0(null),代码直接调用这个函数指针。启动的时候,dyld会把DATA段上的指针进行动态绑定,绑定到具体虚拟内存中的_printf地址

Mach-O有一个区域叫做LINKEDIT,这个区域用来存储启动的时dyld需要动态修复的一些数据: 比如刚刚提到的printf在内存中的地址.


参考资料:
1.深入浅出iOS编译
2.iOS编译过程
3.深入理解iOS App的启动过程
4.命令行工具解析Crash文件,dSYM文件进行符号化
5.Mach-O 文件格式探索
6.趣探 Mach-O:加载过程
7.探秘 Mach-O 文件
8.iOS编译过程
9.编译器
10.Mach-O可执行文件
11.iOS编译过程的原理和应用