Mach-O探索
前言
我们都知道在iOS应用程序中的可执行文件的格式是Mach-O
,那么Mach-O
到底存储了哪些数据,又是怎么工作的呢?下面我们来探索一下。
1.Mach-O简介
维基百科对于Mach-O
的描述:
Mach-O
为Mach Object
文件格式的缩写,它是一种用于可执行文件,目标代码,动态库,内核转储的文件格式。作为a.out
格式的替代,Mach-O
提供了更强的扩展性,并提升了符号表中信息的访问速度。
Mach-O
曾经为大部分基于Mach
核心的操作系统所使用。NeXTSTEP
,Darwin
和Mac OS X
等系统使用这种格式作为其原生可执行文件,库和目标代码的格式。而同样使用GNU Mach
作为其微内核的GNU Hurd
系统则使用ELF
而非Mach-O
作为其标准的二进制文件格式。
- Header 包含了 Mach-O 文件的基本信息,如 CPU 架构,文件类型,加载指令数量等
- Load Commands 是跟在 Header 后面的加载命令区,包含文件的组织架构和在虚拟内存中的布局方式,在调用的时候知道如何设置和加载二进制数据
- Data 包含 Load Commands 中需要的各个 Segment 的数据。
绝大多数 Mach-O 文件包括以下三种 Segment:
- __TEXT: 代码段,包括头文件、代码和常量。只读不可修改。
- __DATA:数据段,包括全局变量, 静态变量等。可读可写。
- __LINKEDIT: 如何加载程序, 包含了方法和变量的元数据(位置,偏移量),以及代码签名等信息。只读不可修改。
以下内容参考自:WWDC 2016 Optimizing App Startup Time
1.1 Mach-O 的几种类型
Executable
类型
Executable
主程序的二进制文件,就是我们iOS应用程序显示包内容的MachO文件
可以通过Products->xxx.app->Show in Finder->显示包内容 查看
Dylib
类型
Dylib
动态库,在其他平台上也叫DSO
或者DLL
。
Bundle
类型
Bundle
不能被连接的Dylib
,只能在Runtime
运行时通过dlopen
函数来加载它,它可以在macOS
上用于插件。
Image
类型
Image
是一种可执行的二进制文件或者包,包含了上述三种文件类型
Framework
类型
Framework
其实也是一种dylib
, 它周围有一个特殊的目录结构来保存该dylib
所需的文件。
那么这些都有什么区别和应用呢,请参考我的另一篇文章iOS开发中『库』的区别应用
1.2 Mach-O结构分析
1.2.1 segment 段
Mach-O 文件是由 segment
段组成的,分别是TEXT段、DATA段、LINKEDIT段
- 段的名称为大写格式
- 所有段都是
page size
的倍数 - arm64上段的大小为16K
- 其他架构均为4K
此处实际上是指的虚拟内存的一页
1.2.2 section
在segment
段内部还有许多section
节,section
的名称为小写。
But sections are really just a subrange of a segment, they don't have any of the constraints of being page size, but they are non-overlapping.
但是sections
实际上只是segment
段的子范围,它们没有页面大小的限制,也不会重叠在一起。
通过MachOView也可以看出上述的结构:
1.2.3 常见的 segment 与作用
-
__TEXT
: 代码段,包括头文件、代码和常量以及mach header。 read-only(只读的)
-
__DATA
:数据段,包括全局变量、静态变量,是可读可写的。
-
__LINKEDIT
: 如何加载程序,包括了方法和变量的元数据(位置、偏移量),以及代码签名等信息。只读不可修改。
1.2.4 Mach-O Universal Files
因为有时候我们需要构建多种架构的Mach-O
文件,这个时候的做法是通过Mach-O Universal Files
来实现的,Xcode会重新生成不同架构的二进制文件,然后合并到一起,简称Fat(胖)二进制文件。它通过header
来记录不同架构在文件中的偏移量,segment
占多个分页,header
占用一页空间,那么header
占用一页是不是浪费了很多空间?答案是肯定的,那么为什么还要占用一页空间呢?所有东西都基于页面的好处是什么呢?下面我们通过虚拟内存来解释它。
1.3 virtual memory 虚拟内存
PS: 软件工程格言
every problem can be solved by adding a level of indirection.
每个问题都可以通过添加中间层来解决
所以说虚拟内存是通过中间层间接寻址的一种技术
虚拟内存解决是管理所有进程使用物理内存的问题。通过添加间接层来让每个进程使用逻辑地址空间,它可以映射到RAM上的某个物理页面上,这种映射不是一对一的,逻辑地址也有可能映射不到RAM上,也有可能有多个逻辑地址映射到同一个物理RAM上。
virtual memory 应用:
- 一个逻辑地址不映射任何物理RAM时,进程要访问该地址时时会触发
page fault
页面错误,内核将停止该线程,并试图找出解决方案,或者通过CPU调度去物理磁盘读取缺失的内容,或者其他处理 - 多个逻辑地址映射到同一物理RAM时,两个进程共享一样比特的RAM,通常就是我们说的共享缓存技术,比如说我们的多个APP同时访问
UIKit
- 另一个就是文件的映射,不用把整个文件读入RAM,而是可以调用
mmap()
函数告诉虚拟内存系统,我想把这部分文件映射到进程里的这段地址,为什么要这样做呢?不用读取整个文件,通过设置该映射第一次访问这些不同的地址时,如果已经在内存里读过,每次访问未访问过的地址时,都会触发page fault
,内核会处理该page fault
,时间文件的懒加载 - 通过以上的介绍我们可以知道任意一个
dylib
或者image
的TEXT段都可以映射到多个进程中,并且可以实现懒加载,也可以实现进程间共享。 - 那么DATA段呢?有一个策略叫写入时复制,这个和APP的文件系统的克隆很相似,写入时复制所做的是它积极地在所有进程里共享DATA页面,只要进程只读有共享内容的全局变量,但是一旦有进程想要写入其DATA页面,写入时复制就是内核会把该页面进行复制,放入另一个物理RAM并重定向映射,所以该进程有了该页面的副本,这把我们带入了脏和干净页面,该副本被认为是脏页面。脏页面是指含有进程特定信息,干净页面是指内核可以按照需要重新建立页面,比如重新读取磁盘,所以脏页面比干净页面要昂贵许多。
- 页面的权限界限,这指的是可以标记一个页面可读可写可执行,或者它们的任何组合。
1.4 virtual memory & Mach-O 之间的映射
首先我们拥有一个Dylib文件,我们还没有把他读取到物理内存中,只是先进行了映射。这时候静态链接器会把所有值为0的全局变量都移动到了尾端。
当我们第一次访问的时候,虚拟内存会触发page fault
,这个时候内核意识到它被映射到了一个文件,这个时候内核会读取这个文件将它放入物理RAM设置其映射。
当我们还需要读取其他页面的时候,比如读取LINKEDIT
和DATA
的时候也是同样的流程。
但是当我们要在DATA段写入一些内容的时候,就会触发写入时复制,这个时候DATA这个页面就变为脏页面了,这个时候我们只有一个脏页面和两个干净的页面,如果一开始就加载全部,可能就都是脏页面了。
此时如果另一个进程也要加载该Dylib,就可以复用RAM1和RAM2,内核只是简单的把映射重定向,不需要任何IO操作,如果DATA页面那个RAM3没有变成脏页面也可以直接复用,如果变成脏页面内核会查看RAM3的副本是否在内存中,如果还在就可以治截止使用,如果不在就会重新读取。
这就实现了不同进程共享这些Dylib,当进程都不需要使用某一段时比如LINKEDIT
,在别的进程需要RAM时,就会将其释放。
1.5 安全如何影响DYLD
1.5.1 ASLR
ASLR
(Address Space Layout Randomization) 地址空间布局随机化,镜像会在随机的地址上加载。内存偏移量还需要计算ASLR
的位置。
1.5.1 Code Signing
在Xcode中 Code Signing
是指对整个文件运行一个加密哈希算法,然后对文件进行一个签名。为了在运行时进行验证,整个文件都必须要重新读取,所以在编译阶段,在每个Mach-O文件的每一个页面都进行自己的加密哈希算法,所有哈希都存储在LINKEDIT
里,这使得你的每个未被修改的页面,在被读取的过程中都能得到及时验证。
1.6 exec()
Exec is a system call. When you trap into the kernel, you basically say I want to replace this process with this new program.
exec 是一个系统调用,当你进入内核,我想把这个进程换成这个新程序,内核会抹去整个地址,映射指定的可执行程序,
ASLR
把它映射到一个随机地址,下一步是从该随机地址回溯到0地址把整个区域标记为不可访问,就是不可读,不可写,不可执行,该区域在32位处理器下至少4KB大小,64位处理器下至少4GB大小,这样就可以捕获任何空指针引用,捕获任何指针截断。
2. dyld
2.1 dyld 简介
Unix 诞生初期一切都很简单,我只需映射一个程序,把指针引用指向它,开始运行即可,后来人们有发明了共享缓存库,那么谁来加载Dylibs
呢?这是件很复杂的事情,人们意识到不能让内核来做这件事,所以帮助程序就诞生了在Unix平台人们叫它LD.SO
,在iOS上他被叫做DYLD
。
当内核完成进程的映射,它现在映射到另一个Mach-O
文件,调用Dyld
进入该进程到另一个随机地址,把指针引用指向Dyld
,让Dyld
完成进程的启动,Dyld
的工作是加载所有依赖的Dylib
,让它们全部准备好,开始运行。
2.2 dyld加载Mach-O流程
2.2.1 加载主流程(时间轴)
2.2.2 Load dylibs
-
Dyld
首先要根据内核映射好的主可执行文件的头文件,该头文件里有一个所有依赖的库的列表,根据这个列表映射所有Dylib
。 - 找到所有
Dylib
后,确定它是一个MachO
文件后,通过代码签名对他进行验证并注册到内核。 - 然后它可以在该
Dylib
里的每一段调用mmap()
,将其读入内存。 -
Dyld
还会对每个Dylib
进行递归加载,因为每个不同的Dylib
还有可能依赖Dylib
(已加载的或者未加载的),直到全部加载完毕。 - 其实我们需要加载的
Dylib
有很多,大约有100
到400
个,但是大部分都是OS Dylib
,这里系统为我们做了足够多的优化,以确保加载速度非常非常的快。
2.2.3 Fix-Ups
现在Dylibs
都已经加载完毕了,但是它们都是彼此独立的,我们下一条把它们绑定在一起。这就是Fix-Ups(修复)。
由于代码签名的存在,我们无法修改指令,那么dylib
该如何调用另一dylib
呢?这个时候我们使用code-gen,即动态PIC(Position Independent Code) 地址无关代码,代码可以加载到该地址,并且是动态的,也就是说地址间接的被分配,为了一个调用另一个,code-gen实际上在DATA段新建了一个指向被调用者的指针,任何加载该指针并跳转过去。
所以所有的dyld
都在修复指针和数据,修复有两种,一种是重设基址,另一种是绑定。重设基址是指如果有一个指针指向Image内,需要作出所有修改;绑定是指 如果指针指向Image范围外,也需要进行不同的修复。
dyldinfo还有很多选项参数,我们可以在任何二进制文件上运行,就可以看到所有的修复。
过去你可以为每一个dylib
指定首选加载地址,该首选加载地址是一个静态指针和dyld
一起合作,比如若把它加载到该首选加载地址,所有指针和数据本应该是内部编码的,都是正确的,那么dyld
就不用做任何修复。现在有了ASLR
,dylib
被加载到随机地址上,它偏移到了其他的地址,也就是说所有的指针和数据都依然指向旧地址,所以为了修复它们,我们需要计算偏移值,并且对每一个内部指针都添加该偏移值,所以重设基址就是指遍历所有内部数据指针,然后为它们添加一个偏移值。概念非常简单,就是读取一个指针,添加偏移值,在写入新值。那么这些数据指针都在哪里呢?这些指针都在LINKEDIT
段里存储着。此时所有的映射都已经结束,当我们开始重设基址的时候实际上所有DATA页面上都产生了错误,然后对页面进行修改,触发写入时复制,所有的重设基址有时会非常昂贵,由于这些都需要I/O操作,但是有一个技巧,就是按顺序操作,从内核的角度来看,它认为数据错误顺序按照产生,当它如此认为时,内核会进行预读,这样就会降低很多I/O成本
2.2.4 Binding
绑定是针对那些指向dylib范围外的指针而言的,这些指针通过名称就是绑定,实际就是字符串,本例中LINKEDIT段里的malloc,也就是说该数据指针需要指向malloc,所以在运行时dyld需要找到实现该符号的位置,这需要很多的计算,遍历查找符号表,一旦找到就把值存到该数据指针里,计算复杂度比重设基址高的多。
2.2.5 Notify ObjC Runtime
- Objc有很多DATA结构,DATA结构类,也就是指向方法的指针,以及高光指针,几乎都已经被修复,通过重设基址或者绑定。
- 但是在Objc运行时还需要一些额外的操作,首先Objc是一门动态语言,可以把一个类用名称实例化,即Objc运行时需要维护一张表,包含所有名称及其映射的类,每次加载的名称都将定义一个类,名称需要登记在一个全局表格里。
- 在C++中你可能听说过脆弱的基类问题,但是在Objc中就不存在该问题,因为我们做的其中一种修复就是,在加载时动态改变所有ivar的偏移量。
- 在Objc里可以定义Categories,有时候它们在另一个dylib里,此时那些方法修复必须已经完成。
- Objc基于选择器是唯一的,所以我们需要唯一的选择器
2.2.6 Initializers
So 我们现在完成了所有所有静态描述的DATA的修复,现在是进行动态DATA修复的时机。
- 在C++中有一个叫做Initializers的初始化器,可以指定你想要的任何表达式,在这里我们可以通过运行初始化器来完成那些抽象表达式的初始化。
- 在Objc有一种方法叫
+load
方法,但是现在+load
方法已经不在建议使用(建议使用+initialize
),如果使用了它现在将开始运行 - 顶端是主可执行文件,所有的dylibs依照这张大图,必须要运行初始化器从下往上运行,原因是当初始化器运行时可能会调用一些dylib,你需要确保那些dylib已经准备好被调用。从下往上一直到类,可以很安全的调用依赖的内容
- 但所有初始化器完成时,我们实际已经最终调用的主Dylib程序
dyld是一个帮助程序:
- 可以加载所有的依赖库
- 修复所有DATA页面
- 运行初始化器,跳转到主函数
2.3 dyld2 && dyld3
详见WWDC2017 - 413 - App Startup Time: Past, Present, and Future
在iOS 13之前,所有APP都是通过dyld2来启动的,主要过程如下:
- 解析
MachO
的Header
和Load Commands
,找到其依赖的库,并递归找到所有依赖的库 - 加载
MachO
文件 - 进行符号查找
- 绑定和重设基址
- 运行初始化程序
dyld3被分为了三个组件:
- 一个进程外的
MachO
解析器- 预先处理了所有可能影响启动速度的
Search Path
、@rpaths
和环境变量 - 开始分析
MachO
的Header
和依赖,并完成了所有符号查找的工作 - 最后将这些结果创建成了一个启动包
- 这是一个普通的
daemon
进程,可以使用通常的测试架构
- 预先处理了所有可能影响启动速度的
- 一个进程内的引擎,用来运行启动闭包
- 这部分在进程中处理
- 验证启动闭包的安全性,然后映射到
dylib
中,在跳转到main函数 - 不需要解析
Mach-O
的Header
和依赖,也不需要符号查找
- 一个启动闭包缓存服务
- 系统APP的启动闭包被构建在一个
Shared Cache
中,我们甚至不需要打开一个单独的文件 - 对于第三方的APP,我们会在APP安装或者升级的时候构建这个启动闭包
- 在iOS、tvOS、watchOS中,这一切都是APP启动之前完成的,在macOS上,由于有Side Load App,进程内引擎会在首次启动的时候启动一个daemon进程,之后就可以启动闭包了。
- 系统APP的启动闭包被构建在一个