背景知识
进程
进程是操作系统中最重要的抽象,它是程序(指令和数据)运行的真正实例。它给每个应用提供了两个非常关键的抽象:一是逻辑控制流,二是私有地址空间。逻辑控制流通过称为上下文切换(context switching)的内核机制让每个程序都感觉自己在独占处理器。私有地址空间则是通过称为虚拟内存(virtual memory)的机制让每个程序都感觉自己在独占内存。这样的抽象使得具体的进程不需要操心处理器和内存的相关适宜,也保证了在不同情况下运行同样的程序能得到相同的结果。
XNU(X is Not Unix)
iOS 系统架构分大致为四层,每个层级提供不同的服务。这里我们重点关注处于 Core OS 层的 Darwin 操作系统,它是 macOS 和 iOS 操作环境的操作系统部分,是类 Unix 操作系统。Darwin 的内核是 XNU,XNU 采用的是微内核 Mach 和 宏内核 BSD 的混合内核,具备微内核和宏内核的优点。
Mach 是为解决早期 Unix 中“一切皆文件”的抽象机制的不足(不方便进行进程间通信(IPC))而出现的替代。它负责操作系统最基本的工作,包括进程和线程抽象、处理器调度、进程间通信、消息机制、虚拟内存管理、内存保护等。但是因为内核态/用户态的上下文切换会额外消耗时间,同时内核与服务进程之间的消息传递也会降低运行效率,苹果深度定制了 BSD 宏内核,使其和 Mach 混合使用。BSD 是对 Mach 封装,提供了 POSIX 应用程序接口(Portable Operating System Interface of UNIX)及进程管理、安全、网络、驱动、内存、文件系统(HFS+)、网络文件系统(NFS)、虚拟文件系统(VFS)等功能。
进程对应到 Mach 是 Mach Task,Mach Task 可以看做是线程执行环境的抽象,包含虚拟地址空间、IPC 空间、处理器资源、调度控制、线程容器。进程在 BSD 里是由 BSD Process 处理,BSD Process 扩展了 Mach Task,增加了进程 ID、信号信息等。
线程对应到 Mach 是 Mach Thread,是 Mach 里的最小执行单位。Mach Thread 有自己的状态,包括机器状态、线程栈、调度优先级(有 128 个,数字越大表示优先级越高)、调度策略、内核 Port、异常 Port。线程对应到 BSD 中为POSIX 兼容的线程模型 pthread。
Mach 的模块包括进程和线程都是对象,对象之间不能直接调用,只能通过 Mach Msg 进行通信,也就是 mach_msg() 函数。在用户态可以通过 mach_msg_trap() 函数触发陷阱来处理异常消息切换到内核态,BSD 在异常消息机制的基础上建立了信号处理机制,用户态产生的信号会先被 Mach 转换成异常,BSD 将异常再转换成信号。
Mach-O
Mach-O is a bunch of file types.iOS 的可执行文件及动态库、静态库等都是 Mach-O 格式的。
Mach-O 文件的内部被划分成用大写字母表示的 Segment,每个 Segment 的空间大小为页的整数倍(为了方便虚拟内存的实现)。页的大小跟硬件有关,在 arm64 架构一页是 16KB,其余为 4KB。Segment 内部又被划分成小写字母表示的 Section,Section 的空间大小并不一定为页的整数倍,多个 Section 之间不会重叠。
在 __TEXT segment 中包含了 Mach header,header 中存储了文件类型、适用的 CPU 架构及加载指令数量等基本信息:
1 | struct mach_header_64 { |
紧跟在 header 之后的是多条 Load Command,其中提供了内核与 dyld 加载 Mach-O 文件时所需要的辅助信息。
在 __LINKEDIT segment 中存储了诸如函数名称和全局变量的名称及地址等信息的元数据,还保存了每个页的 hash 用于载入时确保文件不被篡改。
XNU 加载主程序(内核态)
在传统的 Unix 系统中,fork()
是唯一用来创建新进程的方法,该方法将复刻一个当前进程的完整结构,包括二进制代码。所以负责启动其他 App 的进程为了能跑其他人的程序,还需要配合 exec()
方法,把 fork 出来的进程的 image 覆盖成新 App 的。
XNU 加载主程序时,会fork()
进程,并对进程进行一些基本设置,比如为进程分配虚拟内存、为进程创建主线程、将 Mach-O 可执行文件映射(map 而不是 read)进内存等。相关的代码我们可以看苹果的开源文件:kern_exec.
1 | int __mac_execve(proc_t p, struct __mac_execve_args *uap, int32_t *retval) |
设置完入口点后会通过load_dylinker()
函数来解析加载 dyld,然后将入口点地址改成 dyld 的入口地址。这一步完后,内核部分就完成了 Mach-O 文件的加载。剩下的就是用户态层 dyld 加载 App 了。
Dyld 加载动态库(用户态)
Dyld 也是开源的,在dyldStartup.s中 Dyld 的入口函数__dyld_start
中,设置好一些寄存器参数后,调用dyldbootstrap::start()
方法,其中调用了dyld::_main()
,该方法会加载相关的动态库,完成后返回 APPmain()
函数地址,然后调用main()
函数就进入我们熟悉的程序入口了。
Load dylibs
dyldbootstrap::start()
方法会根 ASLR(Address Space Layout Randomization)计算主执行文件偏移后的地址与 Mach-O header 指针,将其作为参数传递给dyld::_main()
函数,接下来:
setContext()
,使用 Mach-O header 中的信息检查并运行环境,创建链接上下文。- 验证主执行文件与系统架构的兼容性成功后,实例化主执行文件的 ImageLoader。解析主执行文件的 header,获取需要加载的依赖库的列表。
- 找到每个 dylib 对其实例化一个 ImageLoader,打开文件读取开头部分,确保它是 Mach-O 文件。然后找到代码签名让内核校验签名。成功后对文件的每个 segment 调用
mmap()
。由于 dylib 可能会依赖其他 dylib,所以这个加载过程是递归的。一般应用会加载 100-400 个 dylib,但是大多数是系统库,这些库大部分已经被缓存在内存中了,所以加载速度很快。
Fix-ups
加载完所有的 dylibs 后,还处于各自独立的状态,需要通过 Fix-ups 将它们绑定在一起。由于代码签名的存在,我们不能直接修改指令,那么如何在一个 dylib 中调用另一个 dylib 的方法呢?这里采用了叫做 dynamic PIC(Position independent Code)的技术,它会在 __DATA segment 中创建一个指针,指向要调用的方法或变量。所以 Fix-ups 都是在修正 __DATA segment 中的指针和数据,将符号绑定在地址上。留意保存在 __DATA segment 中的 __la_symbol_ptr section 中的符号并不是在启动时绑定的,而是在程序运行时首次访问时才绑定的。
Rebasing and Binding
Fix-ups 分为两种类型:
Rebasing:在 dylib 内部调整指针,由于 ASLR 的使用,dylib 会被整体偏移到某个地址,所以需要遍历所有的指针并添加一个偏移值。这里可能会导致 page fault 和 COW,从而会导致I/O问题,但是好在是序列化的,内核会预先读取,减少I/O 消耗。
Binding:设置指向该 dylib 之外的指针,本质上是通过符号名称绑定的,这些符号信息保存在 __LINKEDIT segment 中,表示这个数据指针要指向某个符号。dyld 需要去符号表中找到该符号对应的实现,然后将指针值存储到 __DATA segment 中的指针中。
Notify Objc Runtime
这部分内容我在之前的博客:Runtime 源码笔记:对象与类 中类的加载过程一节中有描述到,在 Runtime 的入口函数_objc_init
中有注册 dyld 相关事件的回调函数:
1 | _dyld_objc_notify_register(&map_images, load_images, unmap_image); |
OC 基于 Runtime 机制可以用类的名字来实例化一个类的对象,Runtime 维护了一张映射类名与类的全局表,当 ImageLoader 加载完 dylib 后,dyld 会调用回调函数map_images
对其进行解析和处理,将可执行文件中和动态库所有的符号(Class,Protocol,Selector,IMP,…)都按格式 fix-up 后成功加载到内存中。
Initializer
在完成所有的数据 fix-ups 后,就可以对需要初始化的数据进行初始化操作了。OC 的+load
以及 C++ 的constructor
方法都会在这个阶段执行。
完成上面这些工作后,dyld 会调用main()
函数。其中调用UIApplicationMain()
,我们的 App 就这样启动了。
注:本文中的 dyld 加载动态库的过程是基于 dyld2 描述的,iOS13 系统中已经升级为 dyld3,加载过程略有不同。