本文读者:
- 遭遇应用启动速度慢问题的朋友
- 希望保持应用启动速度快的朋友
- 对操作系统知识感兴趣的朋友
内容概览
理论部分
- Mach-O 相关知识
- 虚拟内存相关知识
- Mach-O 映像加载过程
- 从 exec() 到 main()
实践部分
- 如何度量
- 优化启动时间
内容分为两大部分,本文只讲解理论部分
Mach-O 相关知识
Mach-O 术语
文件类型:
- 可执行文件,应用程序主要的二进制文件
- Dylib,动态库(也叫做 DSO 或 DLL)
- Bundle,不可以被链接的 Dylib,只可以进行
dlopen()
,比如:插件
映像:一个可执行文件 或者 dylib 或者 bundle。
框架:带有目录的 dylib ,其目录中包含资源和头文件。
Mach-O Image File
- 文件被分割为段(
segment
),并采用大写命名 - 所有段的大小都是页大小的整数倍(arm64 架构为16KB,其他架构是4KB)
- 组是段内的子范围,并采用小写命名
- 常见的段:
- __TEXT,包含头文件、代码和只读常量(比如C语言字符串常量)
- __DATA,包含所有读写内容:全局变量、静态变量等
- __LINKEDIT,包含如何加载程序的元数据(方法名和地址等信息)
Mach-O Universal Files
Fat Header
- 占用一页的大小
- 列出所有支持的架构和其对应的偏移量
你可能比较好奇,为什么段的大小是页的整数倍?
为什么 Fat Header 要占用一页的大小,而这其实会浪费很多空间?
事实上,这种基于页的处理方式与虚拟内存有关。
虚拟内存相关知识
什么是虚拟内存(Virtual Memory)?
- 一个中间层
- 映射每个进程的地址到物理内存
- 特性:
- 页面错误
- 同一个RAM页面可以用于不同的进程
- 由文件提供支持的页
- mmap
- 懒加载
- 写时复制技术
- 脏页和干净页(Dirty vs. Clean)
- 权限控制
计算机科学领域有一句名言: 任何问题都可以通过增加一个中间层来解决。
对于虚拟内存而言,它所解决的问题是:如何为进程管理所有的物理内存?
有了虚拟内存之后:
- 物理内存不需要和逻辑地址一一对应
- 逻辑地址甚至可以没有物理内存与之对应
- 多个逻辑地址可以对应相同的物理内存
这就为我们提供了很多机会。
那么,我们可以利用虚拟内存做什么呢?
首先,如果你有一个逻辑地址尚未映射到任何物理内存,当你在进程中访问这个逻辑地址时,就会有页面错误
产生。
另外,如果你有两个具有不同逻辑地址的进程,并且他们影射到了同样的物理地址,那么他们就可以共享同样的 RAM。
还有一个有趣的特性就是基于文件的映射
。你可以利用虚拟内存系统将文件的切片映射到你的进程的某个地址范围中 ,而不必将整个文件从磁盘读取到RAM。这可以让你对大文件进行懒加载。
现在,结合 Mach-O 和虚拟内存的知识,我们可以知道 动态库(dylib)或者映像(image)中的 __TEXT
段可以被映射到不同的进程中。它可以被懒加载,还可以在不同的进程中共享。
虚拟内存与 __DATA 段又有什么关系呢?
__DATA
段是可以读写的。针对写操作,有一个写时复制技术(Copy-On-Write)
。
__DATA
段的数据在不同的进程中被共享。进程可以读取全局变量,但是当进程需要对 __DATA
段进行写入操作时,而这个操作会触发操作系统内核操作。内核会将该内存页拷贝一份到物理内存上,然后重定向内存映射到这个新的内存地址。然后,这个进程就自己拥有了该页的拷贝。
现在,这个拷贝就是脏页
。
脏页含有进程中的具体信息,而干净页可以在需要的时候被系统内核再生成。
所以,脏页比干净页贵重得多。
而权限控制基于对页的控制,你可以将页标记为可读、可写、可执行或三者的组合。
Mach-O 映像加载过程
假设我们有一个 dylib 文件。
我们将其映射到内存中,而不是读取到内存中。它将占用8页内存。
注意观察 ZeroFill,这解释了为什么全局变量的初始化值为0。
在第一次访问这些 ZeroFill 的页时,虚拟内存会将这一页的值置为0。
当动态加载器dyld(dynamic loader
)在当前进程的内存中查找 Mach-O 头文件的时候会发生页面错误,而系统内核知道这是文件映射,所以内核会去将这个文件的第一页读取到物理内存中并设置映射到这块内存。
现在,动态加载器就可以读到 Mach-O 头文件了。而这时 Mach-O 头文件需要 __LINKEDIT
段中的一些信息,所以动态加载器又会去进程中去寻找,因此又会发生页面错误。
在内核将 __LINKEDIT
段读取到内存页之后,动态加载器可以正常使用 __LINKEDIT
段。
__LINKEDIT
中指明了需要将 __DATA
页的某些信息修补后才能正常运行这个动态库。
而这个时候,动态链接器需要修改 __DATA
页的内容,写时复制操作就会因此触发。
这一页也因此变成了脏页,所以现在有两个干净页和一个脏页。
接下来,如果另一个进程也需要加载这个动态库,会发生什么事情呢?
另一个进程也会重复相同的步骤。
首先,查找 Mach-O 头文件。内核会告知动态链接器,该文件已经加载到内存中,所以只需要重定向映射即可完成这一步操作,不需要进行 IO 操作。对于
__LINKEDIT
,情况也是类似的。
在读取
__DATA
页时,内核会去物理内存中寻找干净的 __DATA
页。如果有就重用,如果没有就重新读取。
由于
__LINKEDIT
页只用于动态加载器的操作过程,所以在这个过程结束后,系统内核可以回收这部分物理内存。
这里涉及到安全问题的两个重点:
- 地址空间布局随机化(
ASLR - address space layout randomization
),使读取的物理内存地址随机化 - 代码签名(
Code Signing
),基于页对 Mach-O 文件进行签名,在按页读取代码的过程中 - 进行校验以防止代码被篡改
以上是学习后续内容的基础。
从 exec() 到 main()
什么是 exec?
exec 是一个系统调用。
执行 exec()
,内核会将你的程序映射到新的内存空间,而且起始地址是随机的。
同时,对于当前进程而言,从起始内存地址到0的低内存地址区域被标记为不可用(不可读、不可写、不可执行)。
对于32位的系统,这个区域至少为 4KB;对于64位的系统,这个区域至少为 4GB。
而且,这将捕获任何 NULL
指针引用错误和任何指针截断错误。
在最开始的时候,内核需要做的工作就只是:映射程序到内存,然后设置程序计数器(PC)
并开始运行程序。
但是,后来发明了共享库。
加载共享库的工作由谁来负责呢?
人们意识到这个工作会变得很复杂,所以不打算让操作系统内核来做这个工作。
于是,人们创造了一个辅助程序:动态加载器,在苹果生态中被叫作 Dynamic Loader(dyld)
。
在其他的类 Unix 系统中,被叫作 ld.so
。
现在,当内核完成程序的进程映射之后,它就会将另一个叫作 dyld 的 Mach-O 文件映射到这个进程中的一个随机地址。
然后设置程序计数器到 dyld 并让 dyld 来完成进程的启动。
现在,dyld 在进程中运行。它的任务就是加载所有你依赖的 dylib,然后做好所有准备工作并开始运行程序。
让我们来了解一下 dyld 的工作步骤:
这是一系列的步骤,看起来像时间轴一样。
Load dylibs
Load dylibs 阶段的主要步骤:
- 解析 dylib 依赖列表
- 找到对应的 Mach-O 文件
- 打开和读取文件的开始部分
- 检验 Mach-O 文件
- 注册代码签名到系统内核
- 对 dylib 的每个段调用
mmap()
首先,dyld 需要映射所有依赖的 dylib。那么,什么是依赖的 dylib?
可执行程序(main executable
)的头文件中列出的所有依赖的库就是依赖的 dylib。
内核已经完成了该头文件的映射工作,dyld只需要读取并解析即可获取依赖的 dylib。
在加载依赖 dylib 时,dyld 需要加载 dylib 中的每一个文件开始部分。
然后对文件进行检验,检验的内容包括:
- 验证文件是否为 Mach-O 文件
- 查找代码签名并注册到系统内核
接下来,在 dylib 的每个段(segment) 中实际调用
mmap()
。
这个过程看起来还是比较简单。
不过,dylib 之间可能会有相互依赖关系。
比如:应用依赖于 A.dylib 和 B.dylib,然后 B.dylib 又依赖于 C.dylib,然后 ...... 。
所以,这里就需要递归加载这些依赖,直到所有的依赖被加载完毕。
所以,每个进程会有很多的 dylibs 需要加载,一个进程可能需要加载
100-400
个 dylibs。
不过,大多数都是系统中的 dylib,而系统已经对这些 dylibs 做了很多优化工作,所以加载速度非常快。
Fix-ups
我们目前只是完成了 dylib 的单独加载,我们还需要将它们绑定起来。
这个过程叫作修复。
由于代码已经签名,所以我们不可以修改代码指令。
在不修改代码指令的情况下,如何让一个 dylib 可以调用另一个 dylib 呢?
还是一样的解决方案:导入中间层
!
我们所使用的代码生成工具叫作:动态PIC (Position Independent Code
)。
这可以使代码被动态加载到某些地址,而这些地址是被间接寻址的。
当你调用一个函数的时候,代码生成工具会在 DATA 段中生成一个指针,这个指针会指向你想调用的代码。
代码会加载这个指针,然后跳转到这个指针。
所以,所有的 dyld 都会进行修复指针和数据的操作。
这里主要有两种修复操作,一个是变基,一个是绑定。
当指针指向的是应用的映像(image
)内部,则需要进行变基;
当指针指向的是应用的映像(image
)外部,则需要进行绑定。
这两种修复操作的方式有所区别,现在让我们来了解一下:
你可以使用以下指令输出任何二进制程序的修复信息。
Rebase
以前,dylib 会被加载到预定的内存地址。而现在,因为 ASLR
的存在 dylib 会被加载到随机的内存地址。
所以,这个操作会使实际的地址偏移。这就意味着,dylib 中的指针和数据还在旧地址处。
为了修复这个问题,我们需要计算偏移量,然后把这个偏移量加到 dylib 中的指针和数据的旧地址上。
因此,变基就是对所有的指针进行内存地址偏移操作。
所以,核心的操作就是读取指针,修改偏移量,然后写入。
但是,这些指针在哪里呢?
这些指针所在位置被编码并存储在 __LINKEDIT
段中,而进行变基操作需要读取所有的 __DATA
页。
当我们修改这些指针时,这就会导致写时复制(Copy-On-Write
)操作。
因此,变基操作会是一个IO密集型操作
。
不过,我们采取了顺序读入。因为读入操作对于内核来说是顺序进行的,所以内核可以预加载内容,这可以减少IO的耗时。
Bind
绑定操作主要针对的是指向你的 dylib 之外的指针,比如系统中的 dylib。
实际上,绑定是通过名称来完成的。这些指针实际上只是字符串。
如果 __LINKEDIT 段中存储了 malloc,也就意味着数据指针需要指向 malloc。
所以,dyld 需要在运行时通过查找符号表来找到符号对应的实现部分,这是一个CPU密集型操作。
一旦找到对应的实现,它的值就会被存储到数据指针中。
这个过程几乎没有IO操作,因为变基操作已经完成了大部分的IO操作。
ObjC
这些指针已经通过变基、绑定操作完成了修复。
但是,还有一些额外的操作是 ObjC 运行时(
runtime
)需要完成的。
首先,ObjC 是一个动态语言,你可以通过类名字符串实例化一个类。
这就意味着,ObjC 的运行时需要维护一个包含所有类映射的全局表。
所以,定义了的类都需要注册到这个表中。
对于 C++,你可能听说过“脆弱的基类问题(Fragile base class problem)”。
然而,ObjC 不存在这个问题。因为,在加载时,修复操作会动态修改成员变量的地址偏移量。
接下来,你可以在 ObjC 中定义分类(category
)来改写类中的方法。
有时候,这些方法不在你的 dylib 中。所以,这些方法的修复操作就需要在现在这个阶段进行。
最后,ObjC 的 selector
需要具有唯一性,所以还需要对 selector
做唯一化处理。
Initializers
现在,我们完成了静态的数据修复,接下来还要完成动态的数据修复。
这里主要有几个步骤:
- 为静态分配的对象生成初始化方法
- 执行 ObjC 的
+load
方法,这个方法已经被废弃,推荐使用+initialize
方法 - 自底向上地初始化 dylib,然后所有的初始化方法就可以调用依赖的 dylib
- 在所有的初始化方法调用完成后,dyld 调用可执行程序的
main()
方法
总结
dyld 是一个辅助程序,它的主要工作:
- 加载可执行程序所依赖的所有的 dylibs
- 修复 DATA 页中的指针
- 执行所有的初始化方法并调用
main()
函数
后续内容待更新,敬请期待~
参考内容:
Optimizing App Startup Time
转载请注明出处,谢谢~