iOS MachO
一、前言
1.1 程序和进程
广义上的程序就是一个静态的可执行文件,是由一个已经编译好的指令和数据集合的一个文件。就像通过 Xcode 编译好的 Mach-O 文件。而进程则是一个动态的概念,是程序的运行时的一个过程。
1.2 虚拟内存
每个进程内部都是使用的逻辑地址空间,这个逻辑地址与物理 RAM 之间存在着映射关系,这个映射是以 page 为单位的。这种映射关系不一定是 1 对 1 的,有可能某个逻辑地址不对应任何的物理 RAM,也可以多个逻辑地址共同对应一个物理 RAM 地址。虚拟内存主要有以下几个用处:
- 当某个逻辑地址没有对应的物理内存地址的时候,内核会中断当前线程以执行对应的策略;
- 多个逻辑地址映射到同一个物理内存地址的时候,可以允许不同的进程共享同一块物理内存;
- 允许在读取文件的时候不用读取整个文件直接到内存,而是通过调用 mmap 将文件的某个部分读取到进程的某段逻辑地址,这样当你第一次读取文件的某个部分的时候,因为它还没有与真实的物理地址对应,也就是上面的第一种情况,此时系统会仅仅读取该部分,也就是 page 大小的内容到内存中,实现了读取文件的懒加载
每个进程运行的时候都有自己独立的虚拟地址空间,这个空间的大小是由计算机的硬件决定的,比如在 32 位硬件平台上,它的寻址空间大小是 232 - 1,64 位的寻址空间为 264 - 1。
1.3 以 page 为单位的操作
基于上面的特性,任何 Mach-O 镜像的 TEXT segment 部分都可以被映射到不同的进程内,被懒加载读取,而且以 page 为单位被多个进程共享。
而对于 DATA 部分,因为它是可读写的,因此有对应的 Copy-On-Write 策略,当某进程在去读取 DATA 部分而另外一个进程需要修改的时候,内核会将需要被写入的 page 大小部分拷贝到另外一个物理内存地址中,然后将该线程内它的虚拟地址映射到新的内存地址。此时系统成有 dirty 和 clean 两份 page,被 copy 出来的那份为 dirty page,同时脏数据页还包含了进程相关的信息,这部分数据页是比较耗性能的。
最后,权限的设置也是以 page 为单位的,可以对单独的 page 数据设置可读或可写等权限。
1.4 安全性
- ASLR:物理内存的分配是随机的,镜像被夹在到随机的内存地址当中;
- 代码签名:为了保证在运行的时候保证文件内容的正确性,需要对文件进行签名,但是因为文件内存的物理地址是随机的而且是以 page 为单位的,为了快速地判断文件内容是否被修改过,需要对每个 page 签名,所有这些签名信息被存储在 LINKEDIT。
一、Mack-O 格式
Mack-O 包含如下几种格式的文件:
- Executable:应用以及 extension 的可执行二进制文件
- Dylib:动态链接库,如其他平台上的 DSO 和 DLL
- Bundle:不能被链接,只能在运行时使用 dlopen 加载
- Image:镜像,可以用来表示任何一种类型的可执行文件,如 Executable、Dylib 和 Bundle
- Framework:苹果特有的一种可执行文件,里面包含了 Dylib 以及相关的头文件和资源
1.1 Mack-O 镜像文件
- 镜像文件被划分为 segments,所有的 segments 名字都是大写的;
- 每个 segment 具有一个或多个 page size 大小,page size 大小由硬件决定,如 arm64 是 16kb,其他的是 4kb;
- 几乎每一个镜像文件都包含的 segment 有 TEXT、DATA 和 LINKEDIT;
- TEXT 处于文件的开头,它包含了 Mack Header(指定文件的目标体系结构,如 PPC、PPC64、IA-32 或 x86-64),被执行的机器指令以及一些只读常量,如 c 字符串;
- DATA 部分是可读写的,包含了全局变量以及静态变量;
LINKEDIT 是原数据部分,如函数的名字和地址。
Mack-O 通用文件
针对多个硬件平台的Mack-O文件的合体
文件头部为Fat Header segment,里面记录了所有架构以及他们的TEXT segement在文件内的偏移量
这个Fat Header只有一个 page 的大小
Mack-O 通用文件内容
这里所有的segment的大小都需要是page大小的整数倍,主要原因与接下来要将的虚拟内存有关
虚拟内存
每个进程内部都是使用的逻辑地址空间,这个逻辑地址与物理RAM之间存在着映射关系,这个映射是以page为单位的。这种映射关系不一定是1对1的,有可能某个逻辑地址不对应任何的物理RAM,也可以多个逻辑地址共同对应一个物理RAM地址。虚拟内存主要有以下几个用处:
当某个逻辑地址没有对应的物理内存地址的时候,内核会中断当前线程以执行对应的策略
多个逻辑地址映射到同一个物理内存地址的时候,可以允许不同的进程共享同一块物理内存
允许在读取文件的时候不用读取整个文件直接到内存,而是通过调用mmap将文件的某个部分读取到我这个进程的某段逻辑地址,这样当你第一次读取文件的某个部分的时候,因为它还没有与真实的物理地址对应,也就是上面的第一种情况,此时系统会紧紧读取该部分,也就是page大小的内容到内存中,实现了读取文件的懒加载
以page为单位的操作
基于上面的特性,任何Mach-O镜像的TEXT segment部分都可以被映射到不同的进程内,被懒加载读取,而且以page为单位被多个进程共享。
而对于DATA部分,因为它是可读写的,因此有对应的Copy-On-Write策略,当某进程在去读取DATA部分而另外一个进程需要修改的时候,内核会将需要被写入的page大小部分拷贝到另外一个物理内存地址中,然后将该线程内它的虚拟地址映射到新的内存地址。此时系统成有dirty和clean两份page,被copy出来的那份为dirty page,同时脏数据页还包含了进程相关的信息,这部分数据页是比较耗性能的
最后,权限的设置也是以page为单位的,可以对单独的page数据设置可读或可写等权限
安全性
ASLR:物理内存的分配是随机的,镜像被夹在到随机的内存地址当中
代码签名:为了保证在运行的时候保证文件内容的正确性,需要对文件进行签名,但是因为文件内存的物理地址是随机的而且是以page为单位的,为了快速地判断文件内容是否被修改过,需要对每个page签名,所有这些签名信息被存储在LINKEDIT
Mack-O 镜像的加载
首先在加载一个dyld的时候,我们将它进行虚拟地址映射而不是直接读取到物理内存中,文件的开头映射到虚拟内存的起始位置
系统首先读取文件的Mach Header,发现没有对应的物理内存,此时开始上文提到的懒加载,将一个page的数据读取到物理内存中,并做好与虚拟内存的映射
然后系统用同样的方式继续往后读取Mach Header,当发现某些信息需要从LINKEDIT中获取的时候,就开始用同样的方式读取LINKEDIT中的部分
但是在进程中,LINKEDIT会告诉dyld,你需要对DATA部分做一些修正才能让dyld正确运行,于是又开始用同样的懒加载方式读取了DATA部分的数据库到内存中
但是此时进程是会修改DATA部分的数据的,因此被修改的部分被标记为脏数据页
此时如果有另外一个进程使用此dyld的时候,TEXT和LINKEDIT部分是不会被重新读取的,可以直接使用内存中已有的内容,对于DATA部分的数据,如果内存中有对应的脏数据页,则重新读取,如果没有那么就服用内存中现有的数据,如果同一段数据都被两个进程修改了,那么此时内存中会有两个对应的脏数据页
镜像文件的加载
exec() to main()
exec()
exec是一个系统调用。
当内核启动一个应用的时候,会随机地在无用的内存地址内找个地方加载你的应用。
加载应用的起始点到内存的开头被标记为不可读取、不可写、不可执行
当需要使用到动态库的时候,由dyld来加载动态库,因此此时系统也将Dyld夹在到内存中的一个随机的地址,有dyld来结束剩下的加载,它的主要职责就是加载所有使用到的动态库,并使它们准备好运行
动态库加载
dyld的运行主要有以下几个步骤:
首先,dyld映射所有以来到的dylibs,通过从主运行程序中的头部中可以读取到所有依赖的dylibs
查找到所有的dylibs
打开并开始读取每个Mack-O文件
验证Mack-O文件,并将验证信息注册到内核当中
这样就可以对每个page块调用mmap()
如此依赖应用的所有动态库也被加载到内存中,一般情况下一个应用会加载1-400个dylibs,当然大部分是系统的动态库,在加载的时候系统已经做过优化
但是此时所有的动态库都是各自为政的,它们之间存在着依赖关系,所以我们还需要将它们串联起来,也就是修正引用的地址。这里就涉及到一个问题,因为有代码签名的原因,我们不能直接修改指令,那么我们又该如何修正动态库之间的互相调用呢?
现代的code-gen代码生成器是一种动态的PIC(Position Independent Code),位置独立表示代码可以被加载到任意的地址,动态表示指令不是直接被指向的,也就是说当需要调用其他库中的指令的时候,code-gen会在DATA块中创建一个指针,这个指针指向了真实的指令,然后本库中的调用加载这个被创建的指针并跳转到真正被调用的指令去。
dyld在这阶段的主要任务就是修正这些指针和数据。这里分为两种情况:rebasing和binding。
rebasing是修正指向本镜像内部的指针;在iOS4.3前会把dylib加载到指定地址,所有指针和数据对于代码来说都是固定的,dyld 就无需做rebase/binding了,iOS4.3后引入了 ASLR ,dylib会被加载到随机地址,这个随机的地址跟代码和数据指向的旧地址会有偏差,dyld 需要修正这个偏差,做法就是将 dylib 内部的指针地址都加上这个偏移量。然后就是重复不断地对LINK 段中需要 rebase 的指针加上这个偏移量。这可能会产生 I/O 瓶颈,但因为 rebase 的顺序是按地址排列的,所以从内核的角度来看这是个有次序的任务,它会预先读入数据,减少 I/O 消耗
binding是修正指向镜像外部的指针。这部分指针实际上是通过字符串来表示的,在运行的时候dyld需要找到这个符号所表示的真实实现在哪里,这里就需要通过查找符号表,因此会有大量的计算。这一步所需要的io很少,因为link段中的数据在之前的rebasing阶段已经被读取过了。
地址修正
修正信息
接下来的步骤就是Objc运行时初始化。
Objc在DATA段中有大量对应的数据信息,这部分在rebasing和binding阶段基本上已经完成
因为Objc是一种动态语言,因此需要注册类名与类相关信息的一张注册表
对在其他dylib中定义的category,也需要通过rebasing和binding来修正扩展方法的地址
保证selector的唯一性
到这里我们基本完成了DATA段中静态数据的修正,接下来就是对一些动态数据的修正:
C++被静态初始化的对象方法,如用attribute((constructor))修饰
Objc对应的load方法(官方不推荐使用load方法,而推荐使用initialize,如果有自定义load方法的话,那么将会在这个时刻执行)
按照引用层级,所有上面的方法从底向上调用执行