0%

iOS App启动过程

背景知识

进程

进程是操作系统中最重要的抽象,它是程序(指令和数据)运行的真正实例。它给每个应用提供了两个非常关键的抽象:一是逻辑控制流,二是私有地址空间。逻辑控制流通过称为上下文切换(context switching)的内核机制让每个程序都感觉自己在独占处理器。私有地址空间则是通过称为虚拟内存(virtual memory)的机制让每个程序都感觉自己在独占内存。这样的抽象使得具体的进程不需要操心处理器和内存的相关适宜,也保证了在不同情况下运行同样的程序能得到相同的结果。

XNU(X is Not Unix)

Core OS

iOS 系统架构分大致为四层,每个层级提供不同的服务。这里我们重点关注处于 Core OS 层的 Darwin 操作系统,它是 macOS 和 iOS 操作环境的操作系统部分,是类 Unix 操作系统。Darwin 的内核是 XNU,XNU 采用的是微内核 Mach 和 宏内核 BSD 的混合内核,具备微内核和宏内核的优点。
XNU

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 File type
Mach-O 文件的内部被划分成用大写字母表示的 Segment,每个 Segment 的空间大小为页的整数倍(为了方便虚拟内存的实现)。页的大小跟硬件有关,在 arm64 架构一页是 16KB,其余为 4KB。Segment 内部又被划分成小写字母表示的 Section,Section 的空间大小并不一定为页的整数倍,多个 Section 之间不会重叠。
Mach-O segment
在 __TEXT segment 中包含了 Mach header,header 中存储了文件类型、适用的 CPU 架构及加载指令数量等基本信息:

1
2
3
4
5
6
7
8
9
10
struct mach_header_64 {
uint32_t magic; // 64位还是32位
cpu_type_t cputype; // CPU 类型,比如 arm 或 X86
cpu_subtype_t cpusubtype; // CPU 子类型,比如 armv7
uint32_t filetype; // 文件类型
uint32_t ncmds; // load commands 的数量
uint32_t sizeofcmds; // load commands 大小
uint32_t flags; // 标签
uint32_t reserved; // 保留字段
};

紧跟在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
int __mac_execve(proc_t p, struct __mac_execve_args *uap, int32_t *retval)
{
// 为简化描述,省略部分代码。
...
struct uthread *uthread; // 线程
task_t new_task = NULL; // Mach Task
...
context.vc_thread = current_thread();
context.vc_ucred = kauth_cred_proc_ref(p);

// 分配大块内存,不用堆栈是因为 Mach-O 结构很大。
MALLOC(bufp, char *, (sizeof(*imgp) + sizeof(*vap) + sizeof(*origvap)), M_TEMP, M_WAITOK | M_ZERO);
imgp = (struct image_params *) bufp;

// 初始化开辟的 imgp 结构里的公共数据
...

uthread = get_bsdthread_info(current_thread());
if (uthread->uu_flag & UT_VFORK) {
imgp->ip_flags |= IMGPF_VFORK_EXEC;
in_vfexec = TRUE;
} else {// 程序如果是启动态,就需要创建新的 mach task 和 thread。这些指针将会在 image active 之后指向新的进程。
imgp->ip_flags |= IMGPF_EXEC;
// fork 进程
imgp->ip_new_thread = fork_create_child(current_task(),
NULL, p, FALSE, p->p_flag & P_LP64, TRUE);
// 异常处理
...

new_task = get_threadtask(imgp->ip_new_thread);
context.vc_thread = imgp->ip_new_thread;
}

// 这个函数主要是分配内存,检查权限,然后调用 load_machfile(),在从0x000000起始位置开始留出至少4G(64位)不可读写的空间用于捕捉NULL指针引用,根据 ASLR 随机一个地址开始加载 Mach-O 文件,读取 Mach-O header,根据解析后得到的 load command 信息,通过映射方式加载到内存中。还会使用 activate_exec_state() 函数处理解析加载 Mach-O 后的结构信息,设置执行 App 的入口点。
error = exec_activate_image(imgp);

if (imgp->ip_new_thread != NULL) {
new_task = get_threadtask(imgp->ip_new_thread);
}
...

kauth_cred_unref(&context.vc_ucred);

if (!error) {
task_bank_init(get_threadtask(imgp->ip_new_thread));
proc_transend(p, 0);

thread_affinity_exec(current_thread());

// 继承进程处理
if (!in_vfexec) {
proc_inherit_task_role(get_threadtask(imgp->ip_new_thread), current_task());
}

// 设置进程的主线程
thread_t main_thread = imgp->ip_new_thread;
task_set_main_thread_qos(new_task, main_thread);
}
...
}

设置完入口点后会通过load_dylinker()函数来解析加载 dyld,然后将入口点地址改成 dyld 的入口地址。这一步完后,内核部分就完成了 Mach-O 文件的加载。剩下的就是用户态层 dyld 加载 App 了。
load mach-o

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()函数,接下来:

  1. setContext(),使用 Mach-O header 中的信息检查并运行环境,创建链接上下文。
  2. 验证主执行文件与系统架构的兼容性成功后,实例化主执行文件的 ImageLoader。解析主执行文件的 header,获取需要加载的依赖库的列表。
  3. 找到每个 dylib 对其实例化一个 ImageLoader,打开文件读取开头部分,确保它是 Mach-O 文件。然后找到代码签名让内核校验签名。成功后对文件的每个 segment 调用mmap()。由于 dylib 可能会依赖其他 dylib,所以这个加载过程是递归的。一般应用会加载 100-400 个 dylib,但是大多数是系统库,这些库大部分已经被缓存在内存中了,所以加载速度很快。

load dylibs

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 消耗。
    rebasing

  • Binding:设置指向该 dylib 之外的指针,本质上是通过符号名称绑定的,这些符号信息保存在 __LINKEDIT segment 中,表示这个数据指针要指向某个符号。dyld 需要去符号表中找到该符号对应的实现,然后将指针值存储到 __DATA segment 中的指针中。
    binding

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 后成功加载到内存中。
notify objc runtime

Initializer

在完成所有的数据 fix-ups 后,就可以对需要初始化的数据进行初始化操作了。OC 的+load以及 C++ 的constructor方法都会在这个阶段执行。
initializers

完成上面这些工作后,dyld 会调用main()函数。其中调用UIApplicationMain(),我们的 App 就这样启动了。

注:本文中的 dyld 加载动态库的过程是基于 dyld2 描述的,iOS13 系统中已经升级为 dyld3,加载过程略有不同。

参考