iOS App - 从编译到运行
在iOS开发中,app是被直接编译成机器码后在CPU上运行的,而不是使用解释器编译成字节码再运行。从app的编译到运行的过程中,要经过编译、链接、启动几个步骤。而在iOS中,编译阶段分为前端和后端,前端使用Apple开发的Clang,后端使用LLVM。
编译
编译过程主要有
- 预处理
- 词法分析
- 语法分析
- 静态分析
- 中间代码生成
- 汇编生成
- 链接生成可执行文件
预处理
在预处理的阶段中,编译器Clang首先预处理我们代码,做一些比如将宏替换到代码中、删除注释、处理预编译命令等工作
词法分析
在此阶段词法分析器读入预处理过的代码字节流,将其中的字符处理成有意义的词素序列,对于每个词素产生词法单元并标记位置,处理完成后进入下一步。这个过程主要是为了在下一步生成语法树做基础工作。
语法分析
这一步中使用在词法分析中生成的词法单元,抽象生成一个语法树(AST,Abstract syntax tree)。抽象语法树上的每个节点也标记了它在源代码的位置。抽象语法树的遍历比起源代码块很多,这一步主要是为了后面的静态分析。
静态分析 | 中间代码生成
将源代码转化为抽象语法树后,编译器就可以遍历整个树来做静态分析。**常见的类型检查、语法错误、方法未定义等都是在静态分析中发现并处理的,当然静态分析能做的事情还有非常多。**在静态分析结束后,编译器会生成IR。IR是整个编译链接系统的中间产物,是一种比较接近机器码的形式,但他与平台无关,通过IR可以生成多个平台的机器码。IR是在iOS编译系统中,前端Clang和后端LLVM的分界点。Clang的任务在生成IR
后结束,将IR
交付给LLVM后LLVM开始工作。
汇编生成
在获得到IR
后,LLVM可以根据优化策略对IR
进行一些优化,如尾递归优化、循环优化、全局变量优化。在优化完成后,LLVM会调用汇编生成器将IR转化成汇编代码。此时,生成产物就是.o
文件了(二进制文件)。
在生成二进制文件后,我们可以通过二进制重排的方式对我们的编译产物进行更进一步的优化,已达到缩小编译产物大小、优化启动速度等目的
链接
在将源代码编译成.o
文件后,就开始链接。链接其实就是一个打包的过程,将编译出的所有.o
文件和一些如dylib,.a,tbd文件链接起来,一起合并生成一个Mach-o文件。到这里,编译过程全部结束,可执行文件mach-o已生成。在链接前,符号是未跟内存地址、寄存器绑定的,尤其是一些被定义在其他模块的符号。而在链接阶段,链接器完成了上述工作,进行了除动态库符号外的符号绑定,同时将这些目标文件链接成一个可执行文件
Mach-o文件结构
- Header
- Header 包含该二进制文件的一般信息 字节顺序、架构类型、加载指令的数量等。 使得可以快速确认一些信息,比如当前文件用于32位还是64位,对应的处理器是什么、文件类型是什么
- Load Commands
- 是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。这一段紧跟Header,加载Mach-O文件时会使用这里的数据来确定内存的分布
- Data
- Data 通常是对象文件中最大的部分,包含Segement的具体数据,如静态C字符串,带参数/不带参数的OC方法,带参数/不带参数的C函数。当运行一个可执行文件时,虚拟内存 (virtual memory) 系统将 segment 映射到进程的地址空间上。
- Segment __PAGEZERO 规定进程地址空间的前多少空间不可读写
- Segment __TEXT 包含可执行的二进制代码
- Segment __DATA 包含了将被更改的数据
- Segment __LINKEDIT 包含了方法和变量的元数据,代码签名等信息。
静态链接
编译主要分为静态链接
和动态链接
。在编译器阶段进行的是静态链接,也就是在上文中提到的过程。这一阶段是将在前面生成的各种目标文件和各种库(or module in swift)链接起来,生成一个可执行文件mach-o。
运行
装载
一个程序从可执行文件到运行,基本都要经过装载和动态库链接两个阶段。由于在可执行文件生成前已经完成了静态库链接,所以在装载时所有的源代码和静态库已经完成了装载,而动态库链接则需要下文提到的动态链接来完成。
可执行文件,或者说程序
,是一个静态的概念,而进程是一个动态的概念。每个程序在运行起来后,他对应的进程都会拥有独立的地址空间,而这个地址空间是由计算机硬件(CPU的位数)决定的,当然,进程只是以为自己拥有计算机整个的地址空间,实际上他是与其他的进程共享计算机的内存(虚拟化)
装载,就是把硬盘上的可执行文件映射到虚拟内存上的过程。
装载的过程,也可以当作是进程建立的过程,一般来说有以下几个步骤。
- 创建一个独立的虚拟地址空间
- 读取可执行文件头,建立虚拟地址空间与可执行文件之间的映射关系。(将可执行文件中的相对地址与虚拟地址空间的地址进行绑定)
- 将CPU的指令寄存器设为可执行文件的入口地址,交与CPU启动运行
动态链接
静态链接是链接静态库,需要链接进Mach-o文件中,如果需要更新就需要重新编译一次,所以无法动态更新和加载。而动态链接是使用dyld
动态加载动态库,可以实现动态地加载和更新。并且其他的进程、框架链接的都是同一个动态库,节省了内存。
iOS中我们常用的一些如UIKit
、Foundation
等框架都是使用动态链接的,而为了节省内存,系统将这些库放在动态库共享缓存区(Dyld shared cache)
。
在mach-o
文件中,属于动态库的符号会被标记为未定义,但他们的名字与路径会被记录下来。在运行时dyld
会通过dlopen
与dlsym
导入动态库,并通过记录的路径找到对应的动态库,通过记录的名字找到对应的地址,进行符号与地址的绑定。
dlopen
会将动态库映射到进程的虚拟地址空间中,由于载入的动态库中可能也会存在未定义的符号,也就是说该动态库还依赖了其他的动态库,这时会触发更多的动态库被载入,但dlopen
可以决定是立刻载入这些依赖库还是延后载入。
dlopen
打开动态库后返回的是引用的指针,dlsym
的作用就是通过dlopen
返回的动态库指针和函数符号,得到函数的地址然后使用。
动态链接解决了静态链接内存占用过多
,只要有库修改就要重新编译打包
的缺点,但同时也引入了新的问题。
- 结构复杂,动态链接将重定位推迟到运行时进行。
- 引入了安全问题,这也是我们能够进行PLT HOOK的基础
- 性能问题
而提到动态库链接,在iOS领域就必须提到我们的dyld
。
dyld - Dynamic Link Editor
dyld
是苹果开发的动态链接器,是苹果系统的一个重要组成部分。它负责mach-o
文件的动态库链接和程序的启动。相关代码已开源
- 启动流程
启动工程,在_objc_init
处设置一个symbolic breakpoint
,Xcode会帮我们在main
方法执行前设置断点。进入lldb
后使用bt
命令,我们就可以看到_objc_init
方法前的调用栈。
可以看到,dyld
是最先被启动的。_dyld_start
后,首先调用的是dyldbootstrap
命名空间里的start
函数,dyld:bootstrap
意义为dyld进行自举工作。由于动态链接器本身也是一个共享对象,那么它自己也需要重定向工作。那么为了避免循环重定向的问题,动态链接器相对于其他的共享对象需要有一些特性。第一个就是它不可以依赖于其他的共享对象,第二个是它的重定向工作可以由自己完成。这种具有一定限制条件的启动代码称为自举(bootstrap)。
由于dyld比较复杂,在这里就先不详细展开,留待另一篇文章中细讲。启动的大体流程为
- dyld 开始将程序二进制文件初始化
- 交由 ImageLoader 读取image,其中包含了我们的类、方法等各种符号
- 由于 runtime 向 dyld 绑定了回调,当image 加载到内存后,dyld会通知runtime进行处理
- runtime接手后调用map_images做解析和处理,接下来load_images中调用call_load_methods方法,遍历所有加载进来的Class,按继承层级依次调用Class的 +load 方法和其 Category 的 +load 方法
所以动态链接器的工作流程为
- 动态链接器自举 (动态链接器的地址在可执行文件的
.interp
段) -> - 装载共享对象(在这个步骤合并生成全局符号表)->
- 重定位(遍历可执行文件和每个共享对象的重定位表将GOT/PLT中需要重定位的位置进行修正)->
- 初始化(执行共享对象.init段中的代码,进程的.init段由程序初始化代码执行)->
- 将控制权交还给程序的入口
写在最后
在写这篇的过程中系统地学习了一下app从编译到运行的过程。在编译阶段,静态链接
和动态链接
这种编译原理相关的知识很重要,有时间可以读一下编译原理那本书。运行阶段,dyld
在main函数执行前做了非常多工作,其实现也很复杂,待仔细学习后再写一篇聚焦于dyld
的笔记。
链接:https://juejin.cn/post/7030435738944536607
来源:稀土掘金