查看原文
其他

iOS15 动态链接 fixup chain 原理详解

仲凯宁 字节跳动终端技术 2023-03-17

技术干货哪里找?

👆 点击上方蓝字关注我们!


Fixup chain 是 Apple 在 iOS15 系统上所应用的一种新的动态链接技术,它的首次露面是在 Apple WWDC21 大会上所推出的 Xcode13 中,这个新特性藏在 Release Note 中被一笔带过:
All programs and dylibs built with a deployment target of macOS 12 or iOS 15 or later now use the chained fixups format. This uses different load commands and LINKEDIT data, and won’t run or load on older OS versions. (49851380)

下面这张图简要描述了使用 fixup chain 进行动态链接之后,静态链接器所生成 Mach-O 文件的结构

如果开发者使用了 fixup chain 动态链接方案,那么生成的 Mach-O 文件则会通过一种类似于链表的方式来储存动态链接过程中所需要的 rebase & bind 信息,相比较于 iOS15 之前使用压缩字节流的动态链接方案,fixup chain 可以为开发者发布的 APP 带来以下两个优势:
  • 更紧凑的存储信息格式所带来的更小的二进制产物

  • 更好的空间局部性所带来的更快的应用启动时间

(注:空间局部性,指一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内。由于缓存等机制的存在,计算机访问临近区域的地址时将会拥有更高效率,因此有着良好局部性的程序将会比局部性差的程序运行速度更快。) 

背景

在介绍 fixup chain 之前,需要先介绍一下什么是动态链接过程,对动态链接比较了解的同学可以跳过这一段。
当我们在应用程序中使用动态库(dylib,framework 等)中的函数,类等内容时,静态链接生成的二进制产物中会包含这些符号,并且标记为“unbound symbol”,在将 APP 加载到内存中启动时,操作系统会读取这些动态库并将它们加载到内存中,同时,动态链接器会找到我们引用的属于动态库的符号被加载到内存中的位置,并将二进制产物中的这些 unbound symbol 所对应的地址修正为内存中这些符号的实际地址;这就是动态链接的大概过程,并且整个过程在加载程序期间或者运行期间完成。而在 iOS 系统上,用来完成动态链接过程的动态链接器就叫做 dyld。 

动态链接过程中会涉及到两类符号:lazy symbol 和 non-lazy symbol,这两种符号的区别时它们的地址被修正为内存中这些符号真实地址的时机;对于 lazy symbol 而言,这些符号的加载是在第一次调用它们的时候,由 dyld 来找到外部动态库中这些符号的正确地址然后进行调用;对于 non-lazy symbol 而言,这些符号在二进制程序被加载进内存时就被 dyld 定位到符号地址并记录下来,在调用时可以直接调用。

dyld 是苹果 MacOS / iOS 系统上的动态链接器,负责在程序装载进内存时 / 程序运行时,将主二进制中引用外部符号的地址指向加载到内存中的外部动态库中对应符号所定义的位置,并对内部的一些数据指针所存储的地址的值进行修正;这个过程有两个核心阶段,rebase 和 binding。 

Rebase 阶段

Rebase 的过程与一种叫做 ASLR 的特性密不可分,ASLR(Address Space Layout Randomization,地址空间随机化)是一种操作系统用来抵御缓冲区溢出攻击的内存保护机制,苹果从iOS 4.3之后引入了这个特性。ASLR 的本质实际上非常简单,当 Mach-O 文件载入虚拟内存的时候,ASLR 会随机向加载到内存的 Mach-O 文件向后移动一段距离(被称为 slide),使得起始地址不从 0x0000 开始,而是从一个随机值,例如 0x5000 开始(也就是 slide=0x5000),并且后面的函数地址都会增加 0x5000;这使得攻击者通过缓冲区溢出来执行任意函数的过程变得非常困难,因为 ASLR 所移动的大小是随机的,因此攻击者无法得知进行移动之后他所希望执行的函数地址。总的来说,ASLR 增加了攻击者预测目的地址的难度,防止攻击者直接定位代码位置,阻止溢出攻击。

Rebase 表示 dyld 根据 ASLR 产生的 slide 来修正二进制程序中对应地址的过程;例如程序中引用的某个符号的地址为 0x10018,而由于 ASLR,程序载入的初始地址从 0x10000 变成了 0x15000(移动了 5000 字节),因此要将这个 0x10018 的地址通过 rebase 修正为 0x15018。

未开启 ASLR:

开启 ASLR: 

Binding 阶段

Binding 表示 dyld 将二进制程序中引用的外部动态库符号的地址指向内存中加载好的这些动态库的地址;这个过程需要借助二进制程序(在苹果的操作系统上为 Mach-O 格式)的 __DATA segment 中 __la_symbol_ptr(lazy symbol)和 __nl_symbol_ptr(non-lazy symbol)两个 section;这两个 section 用来保存指向符号真实地址位置的指针,但第一次访问这个 section 中的符号时,dyld 还没有找到这些符号的真实地址,因此此时它们会指向 __TEXT segment 中 __stub_helper section 的位置,然后调用 dyld_stub_binder 寻找加载好的动态库中相应符号的地址,找到地址之后,dyld 会用这个地址覆盖 __la_symbol_ptr / __no_symbol_ptr 中原本指向 __stub_helper section 的内容,这样下一次访问这些符号时,可以直接跳转到符号的真实地址,无需再去通过 __stub_helper 定位符号地址。

其大概流程如下

第一次(符号 bind 至真实地址前): 

第二次(符号 bind 至真实地址后): 

压缩字节流方案

在 iOS15 之前,rebase 和 binding 操作都是通过一系列 rebase / binding info 来完成,其大概流程如下:
在 Mach-O 的 LC_DYLD_INFO_ONLY load command 中,会说明以下五种 info 的偏移量和大小:
  • Rebase info:用来进行 rebase 操作的一系列 opcode stream
  • Bind info:用来进行 bind 操作的一系列 opcode stream,用于 non-lazy symbol(__nl_symbol_ptr section 中的符号)
  • Lazy info:用来进行 bind 操作的一系列 opcode stream,用于 lazy symbol(__la_symbol_ptr section 中的符号)
  • Weak info:用来进行 bind 操作的一系列 opcode stream,与 bind info 的区别是这部分 info 用于 bind weak symbols,也就是说如果由于没找到这个符号而链接失败的话,不会直接触发 SIGTRAP 导致程序崩溃,而是返回一个 NULL 表示未找到这个符号,并且将后续使用这个符号时的处理流程交给开发者来解决;
  • Export info:用于 export 符号
所谓的 opcode stream 就是类似于汇编指令一般,是一系列由opcode和立即数组成的命令的合集。通过一行一行解释执行 stream 中的指令,dyld 就可以完成 rebase 和 binding 的过程。 

通过压缩字节流进行 rebase

这里列举了一部分用于进行 rebase 操作的 opcode;当 dyld 开始 rebase 流程时,设想你有下面所展示的这个表:

然后 dyld 根据 rebase opcode 指令进行操作(可以用 xcrun dyldinfo -opcodes CrashTest2 命令查看 Mach-O中的 rebase / bind opcode):

就可以逐行将上面的表填补起来,通过 xcrun dyldinfo -rebase CrashTest2 可以看到这些 rebase 信息

Rebase 的过程大概可以理解为,dyld 执行 rebase opcode 指令流,从而获取到表中 segment,section,address,type 这些字段的数据,并且并且根据这些信息进行 rebase 操作,rebase 得到的结果就是这个表中 value 字段的值,也就是加上 slide 之后的内存地址;然后 dyld 将这个值写入到 address 字段的虚拟内存地址所对应的 Mach-O 加载到内存中的真实地址的位置。

通过压缩字节流进行 bind

这里列举了一部分用于进行bind操作的opcode;当dyld开始bind流程时,设想你有下面所展示的这个表:

举例:

通过 xcrun dyldinfo -rebase CrashTest2 可以看到 Mach-O 的 bind 信息,并且我们发现上面的 bind 指令与下面的 bind 信息是可以对应上的:

Bind 的过程大概可以理解为,dyld 执行 bind opcode 指令流,从而获取到表中 segment,section,address,type,addend,dylib,symbol 这些字段的数据,并且根据这些信息进行 bind 操作,bind 得到的结果就是这个符号在加载到内存中的外部动态库中的地址;然后 dyld 将这个值写入到 address 字段的虚拟内存地址所对应的 Mach-O 加载到内存中的真实地址的位置。

压缩字节流存在的问题

那么这个通过压缩字节流的格式储存 rebase & bind 信息进行动态链接的过程有什么问题呢?
首先,要完成动态链接的 rebase和bind,就势必要进行两次遍历,一次用于遍历 rebase 过程所需的 opcode,一次用于遍历 bind 过程所需的 opcode;然而,如果在内存中 rebase 和 bind 过程所存储的压缩字节流位置有重叠的话,这样两次遍历的方式势必会失去空间局部性;即使 rebase 和 bind 有一部分信息放在同一个内存 page 中,动态链接器也不会在访问到这个 page 时一次性完成 rebase 和 bind 操作,而是当 rebase 过程结束,第一次遍历完结后,再进行第二次遍历访问到这个 page,而如果能一次性利用完同一个 page 上的 rebase & bind 信息,我们则可以利用空间局部性的原理,提升动态链接的效率:
举例而言,例如 rebase 过程中访问了加载到内存中的 Mach-O 的第5,6,12,14个 page,bind 过程中访问了加载到内存中的 Mach-O 的第7,12,14个 page,那么实际上当 rebase 过程中访问到重叠的第12,14个 page 时,由于 iOS 操作系统会对一段时间没有访问到的页进行压缩操作,相比于先 rebase 完,然后在 bind 过程中对这两个 page 进行解压缩再访问的方式,一次完成 rebase 和 bind 从而避免重复访问被压缩的这两个 page 的方式会更加高效。
其次,我们可以发现,在 Mach-O 文件中用于存放 rebase & bind 信息的压缩字节流,在动态链接完成之后,这部分信息便失去了作用;然而,这块空间仍然占据了二进制产物的很大一部分体积,如果能对这部分空间进行优化,对这部分空间进行重复利用而不是用完即弃的话,则可以达到减小 APP 的包大小效果。
因此,针对这两项痛点,fixup chain 方案则通过一种新的存放动态链接信息所需的 rebase & bind 信息的方式,取代了原有的压缩字节流方案,从而达到了优化动态链接速度以及减包的效果。

Fixup chain 方案

Fixup chain 是 iOS15 系统新引入的用来 rebase / bind 的新方案。对 iOS15 及以上的系列来说,rebase / binding 不再需要使用上文所述的 opcode stream 的方式来操作,而是以一个 fixup chains 来代替。

Fixup chain 可以看作是一个链表,其中每个节点存储了一条如何进行 rebase / bind 操作的信息,并且还带有指向下一个链表地址的信息;dyld 可以逐个节点遍历这条链表,对每个节点根据其提供的信息进行 rebase / bind,最终完成整个 rebase / bind过程。

Release / bind 操作的本质,实际上就是将一个地址修正为 Mach-O 加载到内存里之后的实际地址的值;如上面叙述的那样,对于 rebase 来说,就是将未计算 ASLR slide 的地址修正为加上 ASLR slide 偏移量之后的实际地址,并且将这个地址写入上文 rebase table 中 address 字段所指向的内存位置,而对于 bind 来说,就是将指向符号的 stub_helper 的地址修正为已经加载到内存中的动态库中对应符号的地址,并且将这个地址写入上文 bind table中 address 字段所指向的内存位置。
以 bind 操作为例,在 bind 之前,Mach-O 中__got section 是这样的:

而 bind 过程就是将这张图中 Data 一栏中诸如 0x00000000 的地址修正为符号的真实地址,当使用压缩字节流进行动态链接时,dyld 实际上是依赖着上文中通过保存在  __LINKEDIT section 的 binding info 部分的 binding opcode 中的信息来修正这个地址,而 fixup chain 则是将这些信息保存在链表的各个节点中,使得 dyld 在遍历这个链表的节点时得以获取到这些信息,并正确修正地址,这部分流程可以姑且简单理解为以下过程:

Fixup chain 结构综述

尽管 fixup chain 本质上可以简单理解为只是一个链表结构,但实际上在 Mach-O 文件中,fixup chain 的结构仍然具有一定的复杂性,这里以一个实际的 Mach-O 文件举例,fixup chain 在 Mach-O 中的结构如下图所示:

其中涉及到的各个结构体如下所示:

  • linkedit_data_command

位置在 Load command 部分中

用于表示 LC_DYLD_CHAINED_FIXUPS 这个 load command,结构体内部的 dataoff 字段指向 chained fixups header 结构体。

  • dyld_chained_fixups_header

位置在 __LINKEDIT 段中

用于表示 Mach-O 文件中 fixup chain 的头部信息,结构体内部的 starts offset,imports offset 和 symbols offset 字段指向 chain starts in image,chained import 数组和 symbol name 池。 

  • dyld_chained_starts_in_image

位置在 chained fixups header 后面

指向 chained starts in segment 的位置,Mach-O 有几个segment(用 seg count字段表示),chained starts in image 就包含几条 seg offset 用于表示 chained starts in segment 的偏移量(有的 segment 可能没有,这样的话 offset 就用 0 表示)。 

  • dyld_chained_starts_in_segment

位置在 chained starts in image 后面

用于表示一个 segment 内 fixup chain 的起始信息,dyld 可以从这个结构体开始,逐个遍历 fixup chain 的各个节点;其 page count 字段表示了这个 segment 中有几个 page 拥有 fixup chain,其 page start 字段指向这个 segment 各个包含 fixup chain 的 page 中第一个 fixup chain 节点的位置;对于 64 位系统而言,最常见的两种 fixup chain 节点是 chained ptr 64 bind 和 chained ptr 64 rebase。 

  • dyld_chained_import

位置在 chained starts in segment 后面

用于表示 Mach-O 文件引入的外部符号信息;每个 chained import 结构体中表示通过动态链接引入的一个符号,其 lib ordinal 字段表示了这个符号来自于哪个动态库(以 LC_LOAD_DYLIB load command 的索引形式表示),name offset 字段表示了这个符号的名称(通过去 symbol name 池中对应偏移量的位置找到以null结尾的字符串形式表示的符号名)。 

  • dyld_chained_ptr_64_rebase / dyld_chained_ptr_64_bind

位置在不同的 segment 中
每个chained ptr 64 bind 或 chained ptr 64 rebase 结构体都包含了如何让 dyld 处理这个节点(也就是进行 rebase 或 bind 操作)的必要信息,并且其 next 字段指向了 fixup chain 的下一个节点;在 dyld 处理完当前节点之后,可以通过 next 指向的位置找到下一个节点,逐个对 fixup chain 的节点进行 rebase / bind,直到到达 fixup chain 的终点。
下面就通过对一个实际的 Mach-O 文件进行解析,来展示 fixup chain 各个结构体之间的关系以及它们所表示的信息。

Load command

首先,如果一个 Mach-O 采用了 fixup chain,那么在 load command 中一定会有一个 LC_DYLD_CHAINED_FIXUPS 的 load command,其结构体如下

我们可以通过 objdump -p CrashTest2 得到这个 Mach-O 的全部 Load command,并且在里面我们可以找到

实际上这个 payload 就在 __LINKEDIT 段的起始处:

我们知道了 LC_DYLD_CHAINED_FIXUPS 的 payload 就在 0x100014000 位置。

dyld_chained_fixups_header

Load command 中指示的 payload 实际上是一个 chained fixups header 结构体: 
我们找到 0x0000000100014000 地址,这里的内存为:

因此这个 chained fixups header 应该是:

  • starts offset=32 表示 chained starts in image 结构体在相对 chain_data 起始地址+32字节的位置;chain data 就是 linkedit data command 里面 data offset 的地址,也就是 chained fixups header 这个结构体自己的起始地址。由于 chained fixups header 大小正好32字节(算上内存对齐),所以 chained starts in image 其实就在它后面。

  • imports offset =104 表示 chained import 结构体数组就在相对于这个结构体起始地址 104 字节的位置,这 104 字节除去 chained fixups header 自己所占的 32 字节之外,还包含了一个 chained starts in image 结构体(大小 24 字节),以及两个 chained starts in segment 结构体(每个大小 24 字节,共 48 字节);imports count 表示一共有135个 chained import 结构体。

  • symbols offset 表示 symbol name 池在相对于这个结构体起始地址 644 字节的位置;由于一个 chained import 结构体的大小是 4 字节,104+4*135=644,因此 symbol name 池就在包含了135个 chained import 的结构体数组后面;symbols_format=0 表示 symbol name 没被压缩;这个 symbol name 池以字符串形式保存了动态链接需要的符号的名字。 

dyld_chained_import 结构体数组

我们先看需要 import 的这些符号的信息,根据 dyld 源码:
chained fixups header 中指定了 imports_format = 1,因此我们使用的是 chained import 格式: 

我们找出第一个 chained import 的内存地址,也就是 chained fixups header 地址加上 import offset:0x100014000+104=0x100014068

  • lib ordinal=0x00001111=15,表示这个符号是来自于第 15 个 LC_LOAD_DYLIB 命令引入的动态库
  • weak import=0,表示这个符号不是 weak symbol
  • name offset=0b1=1,表示符号相对于 symbol pool 起始地址的偏移量是 1,我们找到 symbol name 池偏移量为 1 的位置,发现这个符号名为 _$s10ObjectiveC22_convertBoolToObjCBoolyAA0eF0VSbF
因此这个结构体内容如下 

依次类推,我们可以得到全部 135 个这样的结构体。

生成 targetAddrs 数组

获得全部 135 个 chained import 结构体后,dyld 会在 MachOAnalyzer :: 

forEachChainedFixupTarget 函数中生成一个包含 binding 过程需要全部动态库信息的 bindTargets 数组,其大概流程如下: 

大概流程就是 dyld 遍历每一个 chained import 结构体,通过 name offset 去 symbol name 池中获取它们的符号名,然后通过 lib ordinal 获取它们来自于通过哪个 Load command 加载的动态库,以及这些符号是否是 weak symbol;在获取了这些信息之后,dyld 将它们传入一个 callback 回调函数进行下一步处理;这个回调函数的定义如下: 

这个回调函数通过调用 resolve 函数可以解析出这个符号在加载好的动态库中的真实地址,并且将这个地址推入 targetAddrs(一个数组),等待在 binding 阶段使用。

dyld_chained_starts_in_image

回到 chained fixups header 这个结构体上来

刚刚我们知道了 dyld 会怎么处理 imports offset 指向的 imports 表,现在回到 starts offset 上;starts offset 会指向一个 chained starts in image 结构体,这个结构体的地址在 chained fixups header+starts offset 偏移量的位置: 
我们到 chain data 起始地址+ 32字节(starts offset)的位置
(0x100014000+32=0x100014020),这里的内存:
  • seg count 就是 seg_info_offset 数组长度
  • seg_info_offset 数组中的每一个entry表示了每个 chained starts in segment 相对于 chained starts in image (也就是这个结构体自己)的起始地址偏移量;chained starts in segment 描述了每一个 segment 中 fixup chain 的信息
最终的 chained starts in image 结构体内容如下: 

这个 Macho-O 文件一共有五个 segment,每个 segment 中的 fixup chain 都通过一个 chained starts in segment 结构体来表示它们的相关信息:
  • __PAGEZERO: 0 (这个段中没有 fixup chain)
  • __TEXT: 0(这个段中没有 fixup chain)
  • __DATA_CONST: 24(dyld_chained_starts_in_image 结构体就是24字节,所以就是紧跟在这个 dyld_chained_starts_in_image 的后面)
  • __DATA: 48(dyld_chained_starts_in_segment 结构体大小也是24字节,所以就是跟在上一个 dyld_chained_starts_in_segment 的后面)
  • __LINKEDIT: 0(这个段中没有 fixup chain) 

dyld_chained_starts_in_segment

chained starts in image 后面紧跟的就是 chained starts in segment 池,也就是一个包含了 chained starts in segment 结构体的数组;这个结构体的定义如下: 

由于 __PAGEZERO 和 __TEXT 的偏移量为0,就是这两个 segment 里面没有 dyld fixup chain
所以第一个 chained starts in segment 表示的是 __DATA_CONST 段里面的 fixup chain;其位置在chained starts in image 起始地址+ 24(0x100014020+24=0x100014038),这部分内存为: 
其代表的结构体内容为:

也就是说这个结构体

  • 大小是 24 字节
  • page 大小 0x4000
  • DYLD_CHAINED_PTR 类型为 DYLD_CHAINED_PTR_64_OFFSET
  • segment_offset 表示这个页所在的segment的起始地址的偏移量(就是 __DATA_CONST 段的 VM 地址)
  • max_valid_pointer 给 32 位 OS 用的,64 位 OS 先不管
  • page array 中只有一个 entry,也就是这个段只有一个页;每个 entry 表示本段中每个页中第一个 fixup chain 的偏移量(没有的话这个值为 DYLD_CHAINED_PTR_START_NONE=0xFFFF)
  • 这唯一的一个页中第一个 fixup chain 的偏移量为 0
第二个 chained starts in segment 表示的是 __DATA 段里面的fixup chain;其位置在chained starts in image 起始地址 + 48(0x100014020+48=0x100014050),这部分内存为: 

其代表的结构体内容为:

也就是说这个结构体:

  • 大小是 24 字节

  • page 大小 0x1000

  • DYLD_CHAINED_PTR 类型为 DYLD_CHAINED_PTR_64_OFFSET

  • segment_offset 表示这个页所在的segment的起始地址的偏移量(就是 __DATA 段的 VM 地址)

  • max_valid_pointer 给 32 位 OS 用的,64 位 OS 先不管

  • page array 中只有一个 entry,也就是这个段只有一个页;每个 entry 表示本段中每个页中第一个 fixup chain 的偏移量(没有的话这个值为 DYLD_CHAINED_PTR_START_NONE=0xFFFF)

  • 这唯一的一个页中第一个 fixup chain 的偏移量为 24 

__DATA_CONST 段

我们先看 __DATA_CONST 段的,根据 __DATA_CONST 段的 LC_SEGMENT_64 load command 得到这个段的起始地址 0x000000010000c000,加上刚刚我们获取到的 __DATA_CONST 段的 dyld_chained_starts_in_segment 中的偏移量 0,得到 0x000000010000c000(发现正好是在 __got section 中)。

由于 64 位中 bind 对应的 bit 为 1,所以我们可以知道这是个 bind 节点;根据 chained ptr 64 bind 结构体的定义: 

其内存内容为:

  • 前 24 位 ordinal 表示这个符号是通过 targetAddrs 数组索引为 0 的元素引入的

  • addend=0b0=0

  • reserved 全是 0

  • next 是 0x2=2,表示 fixup chain 的下一个节点在哪(本节点地址 + stride*next,stride 为 4 字节)

  • bind 为 1,表示这是个 bind 节点(chained ptr 64 bind)

因此这里存放的结构体为: 

这个 pointer 所在内存位置存放的就是 chained ptr 64 bind 结构体的内存内容,在 dyld 进行 bind 之后,这个 pointer 所在的地址存放的内容将被替换为符号的真实地址,这个地址可以用 64 位表示,也就是说正好可以放在 chained ptr 64 bind 这个结构体所在的这部分空间内。
同时 next=2,dyld 可以找到 fixup chain 的下一个节点,也就是 0x000000010000c000(当前节点地址)+4(stride)*2(next)=0x10000c008: 

  • 前 24 位 ordinal 表示这个符号是通过 targetAddrs 数组索引为 1 的元素引入的

  • addend=0b0=0

  • reserved 全是 0

  • next 是 0x2=2,表示 fixup chain 的下一个节点在哪(本节点地址 + stride*next,stride 为 4 字节)

  • bind 为 1,表示这是个 bind 节点(chained ptr 64 bind) 

    我们再看通过 otool -chained_fixups CrashTest2 命令 dump 出来的 dyld chained import 数组;这个数组就是上文所述通过紧跟在 chained fixups header 后面的 chained import 结构体数组得到的 targetAddrs 数组内容。 
    __DATA_CONST 段的 fixup chain 第一个节点表示的符号是由 dyld chained import[0] 引入的,也就是来自于 libswiftObjectiveC 这个动态库,符号名 为_$s10ObjectiveC22_convertBoolToObjCBoolyAA0eF0VSbF。
    __DATA_CONST 段的 fixup chain 第一个节点表示的符号是由 dyld chained import[1] 引入的,也就是来自于 libswiftUIKit 这个动态库,符号名 为_$s5UIKit17UIApplicationMainys5Int32VAD_SpySpys4Int8VGGSgSSSgAJtF。
    我们再看 otool -dyld_info CrashTest2 命令dump 出的 bind 信息中的前两个符号: 

    跟我们 dyld chained pointer 包含的信息完全匹配。

    其中 pointer 字段存储的就是 chained ptr 64 bind 结构体的内存内容,在 dyld 进行 bind 操作之后,这个 64 位的 pointer 将会被替换为符号的真实地址, chained ptr 64 bind 原本存储的信息由于 bind 已经完成,这段信息已经发挥完了自己的作用,因此可以直接被覆写为符号的真实地址,原有信息可以直接抛弃。 

    __DATA段

    __DATA 段的,__DATA_CONST 段起始地址 0x0000000100010000,加上我们之前获取到的 __DATA 段的 chained starts in segment 中的偏移量 24,得到 0x100010018,其内存内容为: 

    由于 64 位中 bind 对应的 bit 为 0,所以我们可以知道这是个 rebase 节点;根据 chained ptr 64 rebase 结构体的定义:

    • 前 36 位表示 target 在运行期的偏移量位置,也就是 44064

    • high8=0b0=0

    • reserved 是 0

    • next 是 0xe=14

    • bind 为 0,表示这是个 rebase 节点(chained ptr 64 rebase)

    我们得到这个结构体的内容: 

    我们通过 next=14 找出这个 fixup chain 的下一个节点,也就是 0x100010018(当前节点地址)+4(stride)*14=0x100010050:

    我们再通过 otool -dyld_info CrashTest2 命令 dump 出 Mach-O 的 __DATA 段的 rebase 信息: 

    跟我们 dyld chained pointer 包含的信息完全匹配。

    其中 pointer 字段存储的就是 chained ptr 64 rebase 结构体的内存内容,在 dyld 进行 rebase 操作之后,这个 64 位的 pointer 将会被替换为内存中的真实地址, chained ptr 64 rebase 原本存储的信息由于 rebase 已经完成,这段信息已经发挥完了自己的作用,因此可以直接被覆写为符号的真实地址,原有信息可以直接抛弃。

    总体结构

    使用 fixup chain 的 Mach-O 二进制文件的总体结构如下图所示: 


    通过 fixup chain 进行 rebase / bind

    dyld 实际上是用 ChainedFixupPointerOnDisk 这个 union 来存放 fixup chain 的节点:也就是这 64 位内存既可以表示 rebase 节点,也可以用来表示 bind 节点;在 dyld 完成 rebase / bind 之后,还可以被解释为uint64_traw64 来表示 rebase / bind 后获取到的真实地址;也就是说,在 rebase / bind 前后,这 64 位内存是以不同方式被解释为不同的类型,同一块内存得到了重复利用:
    dyld 中涉及到 fixup 的函数调用关系如下:
    • fixupAllChainedFixups

    整个 fixup 流程的入口,用来处理 Mach-O 中的所有 fixup chain;基本上就是通过 block 定义了一个闭包,然后调用forEachFixupInAllChains;这个闭包会一路传递下去,直到在 walkChain 函数中作为处理 chain rebase / bind 操作的 handler 被使用。

    • forEachFixupInAllChains

    forEachFixupInAllChains 会逐个遍历 Mach-O 中的每个 segment,并且调用 forEachFixupInSegmentChains 对每个 segment 中的 fixup chain 进行处理。

    • forEachFixupInSegmentChains

    用来处理每个 segment 中的 fixup chain;forEachFixupInAllChains会逐个遍历这个 segment 中包含 fixup chain page(chained starts in segment 结构体中的 page count 和 page offset 字段描述了这些 page 的信息),并且对每个 page,调用 walkChain 对其 fixup chain 进行处理。

    • walkChain

    沿着 fixup chain 一路进行 rebase / bind 操作,直到到达 fixup chain 末尾;walkChain 会对每个节点使用从 fixupAllChainedFixups 一路传递过来的 block,这个 block 会根据每个节点的的 bind 字段来判断当前节点是 rebase 还是 bind 节点,并且进行相应的 rebase / bind 操作。

    这个 block 中的关键流程如下: 

    值得注意的是,在测试的时候我发现这个 targetAddr 数组里保存的引入符号的顺序与 bind 过程中需要 bind 的未定义符号的顺序是一致的,也就是说当 dyld 去 bind 第一个符号时,这个符号也是 targetAddr 中的第一个符号(ordinal=0);当 dyld 去 bind 第二个符号时,第二个符号也是 targetAddr 中的第二个符号(ordinal=1);也就是说随着 dyld 完成 bind 过程,dyld 会顺序访问 targetAddr 数组。这个现象是静态链接器(ld64)进行的一个优化手段,链接器通过保证 bind 的符号与这里 import 的符号顺序相同,使得 dyld 在访问 targetAddr 时会进行顺序访问,而这个过程是拥有很好的空间局部性的,对 bind 过程的速度会有一定提升效果。 

    Fixup chain 的优势

    减小 App 包大小

    dyld 加载 Mach-O 文件的 rebase / bind 过程,实质上就是“从一个地方获取到 dyld 进行 rebase / bind 操作必要的信息” 和 “dyld 通过这些信息获取到符号的正确地址并将地址写入到某个位置”两部分过程。那么我们对比一下 fixup chain 和原来使用 opcode 和立即数组成的指令流的方案:

    • 指令流

    指令流方案中,“从一个地方获取到 dyld 进行 rebase / bind 操作必要的信息”的过程,就是 dyld 访问 rebase info / binding info 的过程;而“dyld 通过这些信息获取到符号的正确地址并进行写入”就是将 rebase table / binding table 中的 pointer 字段指向的地址替换为对应符号真实地址的过程。

    通过 otool -dyld_opcodes CrashTest2 可以看到 Mach-O 文件中用于 rebase / bind 操作的指令流: 

    dyld 进行 rebase 的过程就是通过执行这一系列 rebase opcodes,将 Mach-O 中未添加 slide 偏移量的地址修正为 ASLR 之后加上 slide 偏移量的实际地址,而 bind 的过程就是通过执行这一系列 bind opcodes,将 Mach-O 中引入外部符号的地址修正为加载到内存中的动态库对应符号的地址。

    • Fixup chain

    Fixup chain 方案中,Mach-O 里不再存在所谓的 rebase opcodes 和 bind opcodes,这两部分用于指示 dyld 如何进行 rebase 和 bind 的信息在 fixup chain 方案中被存放在 dyld 将要在 rebase / bind 过程中写入的 64 位地址的内存区域,可以通过 otool -dyld_opcodes CrashTest2 验证这一点:在使用了 fixup chain 的 Mach-O 里面,otool 是 dump 不出来给 dyld 使用的 opcode 的。

    我们可以通过 MachOView 对比两种方案生成的 Mach-O 的 __DATA_CONST 段的 __got  section 部分,

    不使用 fixup chain: 

    可以看到当不使用 fixup chain 时,Data 字段的值都是 0(空指针),在 dyld 完成 bind 之后这个值才会被替换为真实地址,也就是说在 dyld 进行 bind 之前,这 64 位的空间实际上是被浪费了,并没有提供任何有效的信息;而 fixup chain 则利用了这部分信息,

    使用 fixup chain:

    (fixup chain方案废除了 lazy symbol,全部符号都变成 non-lazy symbol,全部在加载二进制时进行 bind ,所以这里相比用于不使用 fixup chain 部署于旧版本 iOS 系统的 Mach-O 二进制文件多了很多符号)

    可以看到相比不使用 fixup chain 的 Mach-O 文件,Data 字段已经不再是空指针,而是存放了64位大小的 ChainedFixupPointerOnDisk union(可以是 chained ptr 64 rebase,chained ptr 64 bind,或者其它类型的fixup chain节点),而 dyld 则会利用这部分信息进行 rebase / bind。

    通过使用 fixup chain 的方式,用于rebase / bind 的信息可以被一种更加紧凑的方式编码进 Mach-O中,从而减小 Mach-O 文件的大小;根据 https://www.emergetools.com/blog/posts/iOS15LaunchTime 一文中作者的测试结果来看,使用 fixup chain 之后,大约能够节省将近 50% 的用于动态链接的信息所使用的空间大小。

    更快的 rebase 和 bind 过程

    Fixup chain 方案除了可以带来更紧凑的 Mach-O 二进制产物以外,还可以优化动态链接过程的速度。

    当一个 Mach-O 开始被加载进内存时,iOS 系统并不会在第一时间加载整个 Mach-O,而是等到操作系统需要访问这个 Mach-O 的某个 page 时,才会触发 page fault,通过操作系统将要访问的这个 page 加载到内存中来。对于较长时间没有用到的 page,iOS 会对它们进行压缩处理,作为额外的优化手段。

    在动态链接过程中,无论是 rebase 还是 bind,都会触发 dyld 改写某个地址,因此包含这个地址的 page 此时一定会被加载进内存中。无论 dyld 是采用 fixup chain 还是 opcode stream 进行动态链接,要改写的地址数量都是一样多的,因此这个过程中触发的 page fault 次数也不会相差很多。尽管如此,iOS 压缩 page 的这个优化却决定了两个方案速度上的差异。对于 opcode stream 的方式来说,dyld 会遍历一遍 rebase opcodes,然后遍历一遍 bind opcodes,并且两次遍历会访问到的 page 是存在重叠的;那么,当 rebase 过程结束后,这些 page 将会被 iOS 进行压缩处理,但是由于 binding 过程还会访问这些重叠的 page,操作系统此时会对这些 page 进行解压处理;而 fixup chain 方案则避免了这种重复“压缩页-解压页”的情况,由于 fixup chain 是遍历“每个 segment 中含有 fixup chain 的 page”,并且这一遍遍历是同时适用于 rebase 和 bind 两种 fixup chain 的,因此,遍历完每个 page 的 fixup chain 后,dyld 在这一遍操作中就完成了当前 page 的全部 rebase 和 bind 操作,并且不需要在后续流程中回到这个 page 进行访问了;在后续流程中即使 iOS 压缩了这个 page 也不会有影响,因为 dyld 是不会再访问到这个 page 使得操作系统进行解压的,避免了无谓的反复“压缩页-解压页”操作对 rebase / bind 流程的这个优化,使得 App 可以更快的在 iOS15 系统上启动。

    总的来说,在 fixup chain 方案中,dyld 是不需要去一边读取 rebase / bind opcodes,一边进行 rebase / bind 操作的,而是直接沿着每个 page 中的 fixup chain 逐个读取节点,每读取一个节点就根据节点储存的信息,完成当前节点的 rebase / bind 操作,然后就地将节点内容覆盖为 rebase / bind 得到的新的 64 位地址;这个操作带来的提升包括
    1. 无需额外在 Mach-O 的 dyld info 部分存储 rebase info 和 bind info,而是重复利用了 dyld 写入的 64 位地址空间用来存储这部分信息,实现了空间的重复利用,能够使 Mach-O 二进制变得更加紧凑,减小了包大小。

    2. 使用 fixup chain 的 dyld 可以一遍完成 rebase / bind 操作,避免了在前后不同时间反复访问同一个内存页,避免了操作系统对这个 page 进行多余的压缩-解压操作,提升了整个动态链接过程的效率。 




    🔥 火山引擎 APMPlus 应用性能监控是火山引擎应用开发套件 MARS 下的性能监控产品。我们通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,助力企业提升异常问题排查与解决的效率。

    目前我们面向中小企业特别推出「APMPlus 应用性能监控企业助力行动」,为中小企业提供应用性能监控免费资源包。现在申请,有机会获得60天免费性能监控服务,最高可享6000万条事件量。


    👇 点击阅读原文,立即申请

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存