首页 关于 微信公众号
欢迎关注我的微信公众号

iOS 应用的启动过程

应用入口

通常,说到一个 App 的入口,我们会想到 main() 函数,但其实在 main() 调用之前还有一段过程,我们称之为 pre-main

在 Xcode 新建一个项目,是通过 main() 来引导启动了一个 UIApplicationMain 方法来对 AppDelegate 进行回调。当断点在 main 函数里时,我们发现调用栈底多了一个 start 方法。这时的堆栈是:

* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
  * frame #0: 0x00000001025d27ec MyApp`main(argc=1, argv=0x000000016d8339f8) at main.m:13
    frame #1: 0x000000018212dfc0 libdyld.dylib`start + 4

进入 start 栈帧可以看到如下代码:

libdyld.dylib`start:
    0x18212dfbc <+0>: nop    
    0x18212dfc0 <+4>: bl     0x182176244               ; exit
    0x18212dfc4 <+8>: brk    #0x3

通过注释可以猜到这是进入程序退出步骤的代码,也就是 main 函数执行后最终的去处。但是能发现 start() 方法是 libdyld.dylib 动态库中的。

这是在 main() 中发现的全部信息,我们再换一种方式去进入这个 pre-main:+load 函数。

+load() 是 NSObject 的类方法,我们都知道很多早于 main() 执行的代码都可以在继承类的 +load() 里完成。通过在 AppDelegate 类添加一个 +load() 方法,断点查看调用栈:

(lldb) thread backtrace
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
  * frame #0: 0x00000001025d2884 MyApp`+[AppDelegate load](self=AppDelegate, _cmd="load") at AppDelegate.m:19
    frame #1: 0x00000001819929f0 libobjc.A.dylib`call_load_methods + 184
    frame #2: 0x0000000181993b58 libobjc.A.dylib`load_images + 76
    frame #3: 0x00000001027060c8 dyld`dyld::notifySingle(dyld_image_states, ImageLoader const*, ImageLoader::InitializerTimingList*) + 384
    frame #4: 0x000000010271612c dyld`ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 440
    frame #5: 0x00000001027151cc dyld`ImageLoader::processInitializers(ImageLoader::LinkContext const&, unsigned int, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 136
    frame #6: 0x0000000102715288 dyld`ImageLoader::runInitializers(ImageLoader::LinkContext const&, ImageLoader::InitializerTimingList&) + 84
    frame #7: 0x0000000102706498 dyld`dyld::initializeMainExecutable() + 220
    frame #8: 0x000000010270b0fc dyld`dyld::_main(macho_header const*, unsigned long, int, char const**, char const**, char const**, unsigned long*) + 6176
    frame #9: 0x000000010270521c dyld`_dyld_start + 68

可以发现此时还未执行到 main(),而且看起来调用函数也不少。不过暂且忽略具体内容,直接进入最底部的 _dyld_start() 栈帧,在最底下的 br 指令加一个断点,并执行到此处,显示如下汇编指令:

dyld`_dyld_start:
    0x1027051d8 <+0>:   mov    x28, sp
    0x1027051dc <+4>:   and    sp, x28, #0xfffffffffffffff0
    0x1027051e0 <+8>:   mov    x0, #0x0
...
    0x102705218 <+64>:  bl     0x102705260               ; dyldbootstrap::start(macho_header const*, int, char const**, long, macho_header const*, unsigned long*)
    0x10270521c <+68>:  mov    x16, x0
...
->  0x10270525c <+132>: br     x16

上述代码大致经历了以下步骤:

那 x16 寄存器存的地址是什么?我们通过 lldb 查看:

(lldb) register read x16
     x16 = 0x00000001025d27d4  MyApp`main at main.m:12

通过注释说明,这正是指向 main() 函数的地址,结合开头我们获取的 main 的调用栈地址 0x00000001026ea7ec,br 后就是进入了 main 函数的主体代码,相差的几个字节是压栈时移动栈底指针的汇编指令。

到这里就知道了 main() 的源头,但又产生更多的疑问,dyldbootstrap::start() 里面做了什么?是如果得到并返回 main() 函数地址的?调用栈里出现频繁的的 dyld/libdyld 是什么?而这个 _dyld_start 又看起来比 main() 更像入口一点。

另外,仔细观察上述汇编指令的地址,有些是 0x102 开头的连续地址,而有些是开头 0x181 的地址,就运行情况来说 0x102 开头对应的函数地址每次运行都会改变,0x181 的那些地址就是固定的,这里有什么原因和差异。

应用的启动和加载

应用启动的大致流程

iOS 的 App 启动主要分为以下步骤:

MachO 文件介绍

在讲解各个阶段之前,先来了解一下 MachO 文件。Mach-O 格式是 iOS/OS X 系统的可执行程序和库的文件格式,主要包含 MachO header 和 load command 以及对应的多个段(Segment)和区(section)。

image

MachO header 描述了文件类型、兼容 CPU 类型以及文件标识等特性信息。

$ cd MyApp.app 
$ otool -v -h MyApp 
Mach header
      magic cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
MH_MAGIC_64   ARM64        ALL  0x00     EXECUTE    21       2624   NOUNDEFS DYLDLINK TWOLEVEL PIE

除了 header 和 load Command 部分,也就是程序自身的代码和数据都在 Segment 中保存,主要由 __TEXT__DATA 以及 __LINKEDIT Segment 以及其内的 sections 组成,几乎每个 MachO 文件都包含这些 Segment。可通过 size 命令查看 Segment/section 的偏移和大小信息:

$ size -m -l -x MyApp
Segment __PAGEZERO: 0x100000000 (vmaddr 0x0 fileoff 0)
Segment __TEXT: 0x8000 (vmaddr 0x100000000 fileoff 0)
	Section __text: 0x418 (addr 0x100006734 offset 26420)
	Section __stubs: 0x6c (addr 0x100006b4c offset 27468)
	Section __stub_helper: 0x84 (addr 0x100006bb8 offset 27576)
	Section __objc_methname: 0xa44 (addr 0x100006c3c offset 27708)
	Section __objc_classname: 0x3c (addr 0x100007680 offset 30336)
	Section __objc_methtype: 0x86d (addr 0x1000076bc offset 30396)
	Section __cstring: 0x7a (addr 0x100007f29 offset 32553)
	Section __unwind_info: 0x5c (addr 0x100007fa4 offset 32676)
	total 0x18cb
Segment __DATA: 0x4000 (vmaddr 0x100008000 fileoff 32768)
	Section __got: 0x10 (addr 0x100008000 offset 32768)
	Section __la_symbol_ptr: 0x48 (addr 0x100008010 offset 32784)
	Section __objc_classlist: 0x10 (addr 0x100008058 offset 32856)
	Section __objc_nlclslist: 0x8 (addr 0x100008068 offset 32872)
	Section __objc_protolist: 0x10 (addr 0x100008070 offset 32880)
	Section __objc_imageinfo: 0x8 (addr 0x100008080 offset 32896)
	Section __objc_const: 0xc20 (addr 0x100008088 offset 32904)
	Section __objc_selrefs: 0x18 (addr 0x100008ca8 offset 36008)
	Section __objc_classrefs: 0x8 (addr 0x100008cc0 offset 36032)
	Section __objc_superrefs: 0x8 (addr 0x100008cc8 offset 36040)
	Section __objc_ivar: 0x4 (addr 0x100008cd0 offset 36048)
	Section __objc_data: 0xa0 (addr 0x100008cd8 offset 36056)
	Section __data: 0xc0 (addr 0x100008d78 offset 36216)
	total 0xe34
Segment __LINKEDIT: 0x4000 (vmaddr 0x10000c000 fileoff 49152)
total 0x100010000

可以发现开头多了一个 __PAGEZERO 段,这是在虚拟内存预留的 empty 空间,64 位 CPU 预留空间为 4GB,即所有代码和数据都会被加载到 4GB 之后的地址。这段区域不可读写不可执行,这样内核可以识别空指针或指针截断的调用而抛出段异常,如 EXC_BAD_ACCESS 异常。

根据虚拟内存分页映射机制,相同的段(Segment)会映射到连续的页(Page)上,对应分页都会设置相应的权限,如 __TEXT 代码段所映射的页是只读可执行的(r-x), __DATA 段为可读写不可执行(rw-)。

Load Commands

Load Command 紧跟着 MachO header,顾名思义,它是 MachO 文件的加载指导信息。它描述了一个 MachO 文件是如何组成的,包含了各个 segment、section 和 string/symbol table 等数据的偏移和大小、对应的保护模式,以及依赖库列表、main() 的入口地址等基本信息。

通过 otool -l 可查看 MachO 文件的 Load commands,相对 size 指令会更详细,比如查看 __LINKEDIT 段内各块数据基本信息:

$ otool -l MyApp 
Load command 3
      cmd LC_SEGMENT_64
  cmdsize 72
  segname __LINKEDIT
   vmaddr 0x000000010000c000
   vmsize 0x0000000000004000
  fileoff 49152
 filesize 15920
  maxprot 0x00000001
 initprot 0x00000001
   nsects 0
    flags 0x0
Load command 4
            cmd LC_DYLD_INFO_ONLY
        cmdsize 48
     rebase_off 49152
    rebase_size 200
       bind_off 49352
      bind_size 264
  weak_bind_off 0
 weak_bind_size 0
  lazy_bind_off 49616
 lazy_bind_size 248
     export_off 49864
    export_size 168

从上面的这段信息里可以发现,__LINKEDITrebase_off 是一致的,所以从 LC_DYLD_INFO_ONLY 开始都是属于 __LINKEDIT 段的数据。LC_DYLD_INFO_ONLY 描述了 fix-up 阶段需要处理的数据所在 MachO 文件的偏移和大小。

Load command 11
       cmd LC_MAIN
   cmdsize 24
  entryoff 26580
 stacksize 0

上面这段信息可以看到,main() 函数的偏移也在其中。

Load command 13
          cmd LC_LOAD_DYLIB
      cmdsize 88
         name /System/Library/Frameworks/Foundation.framework/Foundation (offset 24)
   time stamp 2 Thu Jan  1 08:00:02 1970
      current version 1452.23.0
compatibility version 300.0.0
Load command 14
          cmd LC_LOAD_DYLIB
      cmdsize 56
         name /usr/lib/libobjc.A.dylib (offset 24)
   time stamp 2 Thu Jan  1 08:00:02 1970
      current version 228.0.0
compatibility version 1.0.0
Load command 15
          cmd LC_LOAD_DYLIB
      cmdsize 56
         name /usr/lib/libSystem.B.dylib (offset 24)
   time stamp 2 Thu Jan  1 08:00:02 1970
      current version 1252.50.4
compatibility version 1.0.0
Load command 16
          cmd LC_LOAD_DYLIB
      cmdsize 80
         name /System/Library/Frameworks/UIKit.framework/UIKit (offset 24)
   time stamp 2 Thu Jan  1 08:00:02 1970
      current version 3698.52.10
compatibility version 1.0.0

程序所引用的依赖库会在这里列出,每个依赖库都是一条 Load command,他们都属于 LC_LOAD_DYLIB command。另外还有字符串表、符号表以及签名等信息都在 Load command 中

Load command 7
          cmd LC_LOAD_DYLINKER
      cmdsize 32
         name /usr/lib/dyld (offset 12)

而内核就是通过 LC_LOAD_DYLINKER 命令来加载动态链接器 dyld。

__TEXT__DATA

__TEXT__DATA 就是我们熟悉的代码段以及数据段。__TEXT 段中除了存放在 __text section 的汇编代码外,还包含 ObjC 相关的类和方法等信息,如 __objc_methname__objc_classname

此外 __TEXT 段预置了 stubs(桩)section 和 stub helper section,由于 __text section 的映射页(page)是不可写,在加载过程中就无法对指针引用进行修正,所以预先埋下了一个桩由 __DATA 段的数据引用,而 __DATA 段可读可写,从而间接地完成了地址的修正,在 fix-up 阶段进行说明。

__LINKEDIT

rebase/binding info

__LINKEDIT 包含了动态链接相关的信息,上文通过 otool 命令获取的 Load command 4,即 LC_DYLD_INFO_ONLY 加载指令,就包含了 rebase、bind 和 export 等偏移信息,这些数据可在 <mach-o/loader.h>dyld_info_command 的结构体查看其注释说明:

struct dyld_info_command {
   uint32_t   cmd;      /* LC_DYLD_INFO or LC_DYLD_INFO_ONLY */
   uint32_t   cmdsize;      /* sizeof(struct dyld_info_command) */

    /*
     * Dyld rebases an image whenever dyld loads it at an address different from its preferred address.
     */
    uint32_t   rebase_off;  /* file offset to rebase info  */
    uint32_t   rebase_size; /* size of rebase info   */

    /*
     * Dyld binds an image during the loading process, if the image
     * requires any pointers to be initialized to symbols in other images.  
     */
    uint32_t   bind_off;    /* file offset to binding info   */
    uint32_t   bind_size;   /* size of binding info  */

    /*
     * Some uses of external symbols do not need to be bound immediately.
     */
    uint32_t   lazy_bind_off;   /* file offset to lazy binding info */
    uint32_t   lazy_bind_size;  /* size of lazy binding infs */

    /*
     * The symbols exported by a dylib are encoded in a trie. 
     */
    uint32_t   export_off;  /* file offset to lazy binding info */
    uint32_t   export_size; /* size of lazy binding infs */
};

由于 ASLR + PIE,使得程序在内存的加载地址是随机的,所以需要 dyld 在加载将内部地址进行修正。rebase 数据描述了哪些是对指向 MachO 内部的引用并将其修正,而 bind 数据描述哪些是指向外部的引用并进行修正。lazy_bind 部分的数据类似 lazy load,仅在第一次使用时才会绑定,不会在启动时进行,提高启动效率。

image

export info

export 数据描述了对外可见的符号,通过 objdump 命令可查看外部可见符号,当然 main 函数作为程序的入口是一定对外可见的:

$ objdump -exports-trie MyApp 

MyApp:	file format Mach-O arm64

Exports trie:
0x100000000  __mh_execute_header
0x1000067D4  _main
0x100008CD8  _OBJC_CLASS_$_ViewController
0x100008D50  _OBJC_CLASS_$_AppDelegate
0x100008D00  _OBJC_METACLASS_$_ViewController
0x100008D28  _OBJC_METACLASS_$_AppDelegate

export 用 trie 树结构来存储这些符号,以提高数据的查询效率和移动设备上的空间使用率。

symbol/string table

string table 记录了符号名、selector string 以及引用路径等字符串,他们拼接成一个长字符串,以 \0 作为间隔。symbol table 的每一个符号都存储了一个在 string table 的偏移值来从中获取符号名称,通过 <mach-o/nlist.h> 可查看符号表结构体:

/*
 * This is the symbol table entry structure for 64-bit architectures.
 */
struct nlist_64 {
    union {
        uint32_t  n_strx; /* index into the string table */
    } n_un;
    uint8_t n_type;        /* type flag, see below */
    uint8_t n_sect;        /* section number or NO_SECT */
    uint16_t n_desc;       /* see <mach-o/stab.h> */
    uint64_t n_value;      /* value of this symbol (or stab offset) */
};

n_um.n_strx 就是相对 string table 的偏移值,指向引用的字符串,其他字段都是对符号的描述如 section 的索引等,可在 <mach-o/stab.h> 查看。

内核初始化阶段

当用户点击 App 时,就开始了内核调用,内核主要进行了以下操作:

在这个过程中内核会产生 ASLR 随机数值,这个值用于加载的 MachO 起始地址在内存中的偏移,随机的地址可防止 MachO 代码扫描并被 hack,提升安全性。

除了是启动时间序的第一站,内核在 iOS 这个沙箱环境下的操控与管理是至关重要的,包括每个 App 所需的虚拟内存、I/O 以及安全管理等,这在后续 App 启动以及运行的阶段都会有内核的参与,包括 ASLR 以及虚拟内存的映射、写时覆盖等,我们先了解一下这些内核机制。

ASLR/PIE

通过 ASLR 虽然可随机化各内存区基地址,但无法将程序内的代码段和数据段随机化,如果绕过(bypass) ASLR 依然可进行篡改,就需要结合 PIE 共同使用。

$ otool -hv MyApp 
Mach header
      magic cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
MH_MAGIC_64   ARM64        ALL  0x00     EXECUTE    21       2624   NOUNDEFS DYLDLINK TWOLEVEL PIE

ASLR + PIE 技术增强了程序的安全性,使得依赖固定地址进行攻击的方法失效,但也增加了程序自身的复杂度,MachO 文件的 rebase 和 bind info 等部分以及启动时的 fix-ups 地址修正阶段就是配合它而产生的。

虚拟内存/分页

每个进程实际上是一个逻辑地址空间,并没有直接映射 RAM,是通过虚拟内存间接的完成 RAM 映射,使得程序运行在一片连续的内存,有更高的执行效率。

这个进程的逻辑地址空间由分页(page)组成,这便于内核管理,如对某个页设置权限、创建、释放和多进程共享。在代码段(__DATA)映射的页一般是只读可执行的,数据段(__DATA)的页则是可读写不可执行,通过这个机制也增强了程序的安全性。

当加载程序或数据时,实际上它并没有在内存中,内核仅仅将虚拟内存的某些页(page)与磁盘上的文件进行了映射,直到有读取操作进来时,产生 page fault 内核异常,内核才去进行实际磁盘读取将所需数据加载到内存,这种方式提高了程序效率,相当于 lazy load。

当某个进程需要修改共享的页时,就发生了 COW(copy-on-write),新复制的页由此进程专属。共享的页只有在修改时才会触发 COW,实现了内存利用的最小化。

动态链接阶段

dyld 在被内核加载启动后开始执行 __dyld_start(),然后执行引导函数 dyldbootstrap::start() 到 dyld 的入口 dyld::_main(),这里就是我们开头在 +load 断点看到的调用栈底部的方法了。

dyld 的职责就是将加载的 MachO 处理为可用状态,有以下阶段:

Load dylibs -> Rebase -> Bind -> ObjC -> Initializers

这个过程也包括了其众多依赖库(MachO)文件的加载。

Load libs

通过在 Load command 中解析 LC_LOAD_DYLIB 加载指令获取依赖库列表后,dyld 主要进行以下几步操作:

上述流程主要在递归地加载依赖库,根据 Apple 的描述,大部分 App 所加载的库在 100 到 400 个。不过 iOS 系统库已经被特殊优化过,如提前加入共享缓存,提前做好地址修正等。

加载完依赖库后,dyld 就开始进行可执行程序 MachO 内部引用地址修正了。

Fix-ups

因为 ASLR 的关系,内核在初始化过程中会生成一个随机数作为程序的偏移(slide),然后启动并传给 dyld,而 dyld 也是 MachO 文件,拿到 slide 随机值首先修正(rebase)自身内部的引用地址,然后执行 dyld::_main() 方法。

Fix-up 阶段主要进行 rebasing 和 binding,包括 weak_bind 以及 lazy_bind,他们在 __LINKEDIT 段内数据流的编码协议基本相同,都是以操作数(opcode)、立即数(immediate)以及 uleb128/sleb128 编码的偏移值组成。

dyld 通过有限状态机(FSA)对数据流进行遍历,直到遇到 0x00 结束。

rebasing

在进行 rebasing 之前,内核只是将 MachO 数据映射到虚拟内存,还未加载到内存。当 rebasing 阶段开始在 __DATA 段进行读取时,发现没有数据,产生了 page fault 内核异常,这个时候内核才会从磁盘将相应的页(page)读到内存继续进行 rebasing,所以 rebasing 阶段除了计算地址修正,IO 操作也在这一步完成。

rebasing 的协议和操作相对简单,都是找到地址后给其值加上偏移(slide)即可。

rebase 协议:通过 byte & 0xF0 得到 opcode,byte & 0x0F 得到 immediate(立即数),根据操作数(opcode)进行分支处理:

0x00    REBASE_OPCODE_DONE
rebasing 结束标志

0x10    REBASE_OPCODE_SET_TYPE_IMM
立即数(immediate)设置为 type,分为以下类型:
REBASE_TYPE_POINTER 1
REBASE_TYPE_TEXT_ABSOLUTE32 2
REBASE_TYPE_TEXT_PCREL32 3

0x20    REBASE_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB
立即数(immediate)设置为当前上下文的指向 segment 索引,从而计算出当前 segment 首地址 segmentStartAddress
当前 byte 后的数据为 ULEB128 字节流的值,解码为相对 segmentStartAddress 的偏移,从而计算出操作地址 address

0x30    REBASE_OPCODE_ADD_ADDR_ULEB
操作地址 address 向后移动 ULEB128 数据对应的值,即 address += read_uleb128(p, end);

0x40    REBASE_OPCODE_ADD_ADDR_IMM_SCALED
操作地址 address 向后移动立即数(immediate)倍数的指针宽度,即 address += immediate*sizeof(uintptr_t);

0x50    REBASE_OPCODE_DO_REBASE_IMM_TIMES
将立即数(immediate)作为操作(循环)次数,依次将当前操作地址 address 对应的值进行 rebasing,即,将内部的值加上 slide 偏移
每次循环后操作地址 address 向后移动指针宽度的字节,进入下一个需要 rebase 的地址

0x60    REBASE_OPCODE_DO_REBASE_ULEB_TIMES
与上一个 0x50 值相似,唯一不同点就是立即数的值替换为 ULEB128 的值进行循环操作,这意为着需要 rebase 的地址超过了 4 位数能表示的最大值,即超过 16(0x0F)个.

0x70    REBASE_OPCODE_DO_REBASE_ADD_ADDR_ULEB
根据上下文数据执行 rebase 操作
随后操作地址 address 向后移动,偏移值为 ULEB128 加一个指针宽度的值,即 address += read_uleb128(p, end) + sizeof(uintptr_t);

0x80    REBASE_OPCODE_DO_REBASE_ULEB_TIMES_SKIPPING_ULEB
连续读取两个 ULEB128 值,依次为循环次数 count 和跳过的字节数 skip
执行循环,根据之前得出的上下文数据执行 rebasing
操作地址 address 向后移动 skip 加指针宽度的偏移量,即 address += skip + sizeof(uintptr_t);

通过以上协议得到操作地址 address、和 type 地址类型,再加上内核提供的 slide 偏移就可以完成对内部地址引用的修正(rebasing)了。

binding

binding 相对 rebasing 较复杂一些,它多了查找依赖库的部分,不过总体协议是相似的。包含 non-lazy binding、lazy binding 和 weak binding。在 ObjC 中,类继承关系以及 protocol 等是 non-lazy 的,启动时就需要开始绑定,而在函数里的调用外部函数等等都是 lazy binding,在第一次调用时才会进行绑定。

binding 协议:和 rebasing 相同,通过 byte & 0xF0 得到 opcode,byte & 0x0F 得到 immediate(立即数),根据操作数(opcode)进行分支处理:

0x00    BIND_OPCODE_DONE    
binding 结束标志

0x10     BIND_OPCODE_SET_DYLIB_ORDINAL_IMM
立即数(immediate)设置为依赖库索引 Ordinal,即 Load command 中的 LC_LOAD_DYLIB 按顺序排列的库,

0x20    BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB
将随后的 ULEB128 值设置为依赖库索引 Ordinal

0x30    BIND_OPCODE_SET_DYLIB_SPECIAL_IMM
根据立即数计算索引 Ordinal
0x0为self,0xf(-1)为main executable,0xe(-2)为flat lookup。

0x40    BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM
从 byte 后获取以 \0 结尾的符号名字符串
立即数作为符号的标志(flag)

0x50    BIND_OPCODE_SET_TYPE_IMM
立即数(immediate)设置为 type,分为以下类型:
BIND_TYPE_POINTER 1
BIND_TYPE_TEXT_ABSOLUTE32 2
BIND_TYPE_TEXT_PCREL32 3

0x60    BIND_OPCODE_SET_ADDEND_SLEB
设置上下文的加数(addend)为随后的 SLEB128 值

0x70    BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB
立即数(immediate)设置为当前上下文的 segment 索引,从而计算出当前 segment 首地址 segmentStartAddress
将随后的 ULEB128 字节流的值作为 segmentStartAddress 的偏移,从而计算出操作地址 address

0x80    BIND_OPCODE_ADD_ADDR_ULEB
操作地址 address 向后移动 ULEB128 数据对应的值,即 address += read_uleb128(p, end);

0x90    BIND_OPCODE_DO_BIND 
利用之前计算的上下文数据执行 binding
操作地址 address 向后移动一个指针宽度

0xA0    BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB
利用之前计算的上下文数据执行 binding
操作地址 address 向后移动 ULEB128 的值加一个指针宽度

0xB0    BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB
利用之前计算的上下文数据执行 binding
操作地址 address 向后移动立即数倍数的指针宽度(immediate*sizeof(intptr_t))再加一个指针宽度

0xC0    BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB
连续读取两个 ULEB128 值,先后为循环次数 count 和跳过的字节数 skip
执行循环,根据上下文数据执行 binding 操作
操作地址 address 向后移动 skip 加指针宽度的偏移量,即 address += skip + sizeof(uintptr_t);

每次 binding 是在 rebasing 之后进行的,他们交替进行,每个 MachO 镜像加载完成后需要将内部的地址引用都修正为 slide 之后的正确地址,然后执行 binding 来修改外部引用地址。

ObjC Setup

经过了 MachO 程序和依赖库的加载以及地址修正之后,dyld 所做的大部分事情已经完成了。  

在这一阶段,dyld 开始对主程序的依赖库进行初始化工作,而初始化的执行部分会回调到依赖库内部执行,如 ObjC 的运行时环境所在的 libobjc.A.dylib 以及 libdispatch.dylib 等。

ObjC Setup 的过程,主要是对 ObjC 数据进行关联注册:

以上步骤由 dyld 启动 libSystem.dylib 统一对基础库进行调用执行,这里面就包含了 libobjc 的 Runtime,同时 Runtime 会在 dyld 绑定回调,当 dyld 处理完相关数据后就会调用 ObjC Runtime 执行 Setup 工作。

Initializer

从这一步就开始接近上(业务)层:

Main Entry

到了最后,dyld 回到 Load command,找到 LC_MAIN

Load command 11
       cmd LC_MAIN
   cmdsize 24
  entryoff 26580
 stacksize 0

拿到 entryoff 再加上 MachO 在内存的加载首地址(首地址就是内核传来的 slide 偏移)就得到了 main() 的入口地址,从而进入我们显式的程序逻辑。

优化分析与小结

通过上文已经了解了 pre-main 的各个阶段主要在于加载动态库、fix-up 和 ObjC Setup 以及初始化调用,对这几部分进行性能分析首先需要进行统计其时长,然后找到瓶颈再做针对性的优化,而 Xcode 内的 Instrument 工具所监控的是 main 之后的阶段,所以 dyld 提供配置环境变量来统计 pre-main 阶段的耗时。

在 Xcode - Edit Scheme - Environment Variable 中添加 DYLD_PRINT_STATISTICS,Value 为 1,启动 App 后就会在控制台打印出相关统计信息:

Total pre-main time: 106.95 milliseconds (100.0%)
         dylib loading time:  31.06 milliseconds (29.0%)
        rebase/binding time:  20.60 milliseconds (19.2%)
            ObjC setup time:  10.75 milliseconds (10.0%)
           initializer time:  44.36 milliseconds (41.4%)
           slowest intializers :
             libSystem.B.dylib :  10.08 milliseconds (9.4%)
   libBacktraceRecording.dylib :   6.62 milliseconds (6.1%)
    libMainThreadChecker.dylib :  21.91 milliseconds (20.4%)

dyld 将这四个阶段的具体执行时间都进行了统计,而且 dyld 已经排除了因为监控的指令插入以及调试中传输数据的耗时,以使得结果更加准确。

上面的输出结果是同一个空项目 MyApp 的执行时间,一般来说启动耗时(pre-main)在 400ms 内比较合适。

由于实际的 App 组成结构复杂,包含依赖静态库、动态库、不同数量的 Class,每个 Class 属性、方法、协议以及 category 数量不一,和一些必要的构造函数,所以下面我们对这几个阶段进行一些单维度的分析以观察其影响。

Load lib

上面的 dylib loading time 较少是自身只引用了基础的系统框架,我们添加 50 个 lib 到项目中,查看统计时间:

Total pre-main time: 520.19 milliseconds (100.0%)
         dylib loading time: 342.41 milliseconds (65.8%)
        rebase/binding time:  42.02 milliseconds (8.0%)
            ObjC setup time:  39.24 milliseconds (7.5%)
           initializer time:  96.35 milliseconds (18.5%)
           slowest intializers :
             libSystem.B.dylib :  13.56 milliseconds (2.6%)
    libMainThreadChecker.dylib :  26.07 milliseconds (5.0%)
                MyLaunchingApp :  45.92 milliseconds (8.8%)

dylib loading time 达到了 300 ms,我们再回顾一下 Load lib 阶段的处理流程:

如果是 50 个库,那么每一个都要进行缓存查找、路径查找、校验、实例化,如果这 50 个依赖库又有各自不同的依赖库,又需要不断重复进行上述操作,增加了上述方法的重复调用和 CPU 的上下文切换时间。所以最好的方式是将依赖库合并,在编译链接阶段就将他们统一到一个 lib 中,下面是合并为一个 lib 的统计时长:

Total pre-main time: 269.61 milliseconds (100.0%)
         dylib loading time:  94.76 milliseconds (35.1%)
        rebase/binding time:  22.97 milliseconds (8.5%)
            ObjC setup time:  58.69 milliseconds (21.7%)
           initializer time:  93.03 milliseconds (34.5%)
           slowest intializers :
             libSystem.B.dylib :  14.91 milliseconds (5.5%)
   libBacktraceRecording.dylib :   6.71 milliseconds (2.4%)
    libMainThreadChecker.dylib :  23.71 milliseconds (8.7%)
               combine_dynamic :  55.35 milliseconds (20.5%)

dylib loading time 从 340ms 缩减到了 100ms 以内,所以简单的缩减合并 lib 是最合适的方法,但在较大的 App 由多团队协同开发时,模块化又是提高开发效率、降低维护成本较好的方式,所以这里就需要架构上进行折中,或统一的出口以使得依赖库数量降低到最少。

rebasing/binding

这个阶段的目的是指针引用的修正,包括内部指针修正和外部引用的绑定。经过测试,在一个 Class 中分别加入 20000+ 个 property、selector、protocol 以及 category,耗时都没有太大影响:

Total pre-main time: 190.91 milliseconds (100.0%)
         dylib loading time:  36.16 milliseconds (18.9%)
        rebase/binding time:  20.05 milliseconds (10.5%)
            ObjC setup time:  76.93 milliseconds (40.2%)
           initializer time:  57.49 milliseconds (30.1%)
           slowest intializers :
             libSystem.B.dylib :  13.23 milliseconds (6.9%)
   libBacktraceRecording.dylib :   7.76 milliseconds (4.0%)
    libMainThreadChecker.dylib :  29.32 milliseconds (15.3%)

但如果 Class 本身数量非常多,对性能的影响就比较大了。这是添加了 20000 个 Class 的情况,实际上由于都是程序内的 Class,所以大部分时间都在 进行磁盘 IO 和 rebasing 操作,而继承类查找父类指针进行了 binding。

Total pre-main time: 555.75 milliseconds (100.0%)
         dylib loading time:  44.64 milliseconds (8.0%)
        rebase/binding time: 173.40 milliseconds (31.2%)
            ObjC setup time: 265.53 milliseconds (47.7%)
           initializer time:  71.90 milliseconds (12.9%)
           slowest intializers :
             libSystem.B.dylib :  11.56 milliseconds (2.0%)
   libBacktraceRecording.dylib :  14.94 milliseconds (2.6%)
    libMainThreadChecker.dylib :  24.09 milliseconds (4.3%)
  libViewDebuggerSupport.dylib :  12.45 milliseconds (2.2%)

与 Load lib 相似,在开发时我们建议较小的 Class,按子模块分类各司其职,方便模块划分及维护。不过数量众多的 Class 也会导致性能瓶颈,或者说这样有比较大的优化空间。这个问题同样需要做 trade off,在众多的模块和性能之间进行平衡。

ObjC Setup

过上面的图我们也可以发现 Class 的数量对 ObjC setup 阶段的影响是最大的,回顾 dyld 阶段的介绍得知 ObjC Runtime 需要维护一张全局唯一的类名表来对类进行映射,然后对 selector 和 category 等进行关联,所以类名的长度同样会产生影响,做确定唯一性的时候都在进行字符串比较,字符串越长,计算耗时就越多。

另外在 __DATA 段的 ObjC section 中会存储所有 class、selector 和 category,他们都包含 metadata,如果这些数据量较大也会在增加 fix-up 的计算。

Initializer

从前文介绍可知在这个阶段主要进行各依赖库的初始化调用以及每个 Class 的 +load 方法和 C/C++ 构造器和隐式初始化的调用。通过上文 dyld 的统计可以到看 Initializer 的耗时:

           initializer time:  71.90 milliseconds (12.9%)
           slowest intializers :
             libSystem.B.dylib :  11.56 milliseconds (2.0%)
   libBacktraceRecording.dylib :  14.94 milliseconds (2.6%)
    libMainThreadChecker.dylib :  24.09 milliseconds (4.3%)
  libViewDebuggerSupport.dylib :  12.45 milliseconds (2.2%)

如果是通过 +load 和初始化构造器执行的时间,也可以从中发现,我们在 +load 方法里添加一千万次的空循环,运行时间如下:

+load()
start loop
finish loop
Total pre-main time: 170.65 milliseconds (100.0%)
            ...
           initializer time:  96.97 milliseconds (56.8%)
           slowest intializers :
             libSystem.B.dylib :  10.82 milliseconds (6.3%)
            ...
                MyLaunchingApp :  98.10 milliseconds (57.4%)

可以看到 slowest intializers 多了 MyLaunchingApp,这就是程序自身在 initializer 阶段的耗时,所以尽量避免在 +load 以及构造器中执行操作也是提升性能的一环。

界面绘制阶段

界面绘制阶段包括了这些流程:进入 main() -> UIApplicationMain -> 初始化回调 -> 显示 UI。

这是我们比较熟悉的,这里就不再细讲,直接给出一些建议:

参考

Blog

Opinion

Project