前言

不深入涉及dyld源码的前提下,介绍一下动态链接的概念及原理,在iOS系统上的实现及应用。
一些需要提前了解的知识点:
静态链接

  • 最初始的应用程序只有一个文件,编译过程就是一个源文件到可执行文件的翻译。
    static_linker_1
  • 随着应用程序的复杂度和规模不断增加,程序源码被拆分到多个文件,此时需要对多个目标文件的编译产物进行整合,最终产出一个可执行文件,静态链接器就此出现。
    static_linker_2
  • 当一个应用程序的源文件增长到几千上万个时,每次改动后对所有源码进行编译变得非常耗时,此时静态库和ar工具应运而生。
    ar工具把多个.o产物打包成一个.a静态库,链接器再将.a静态库和.o文件链接成可执行文件,这样避免了重复编译工作。
    static_linker_3

ELF文件格式 / Mach-O文件格式 / DEX文件格式

  • 所有的mach-o文件都包含了一个或多个数据段(Segment),每一个数据段都包含了零个或者多个段落(Section),每个section包含了一些特定类型的代码或者数据,数据段准确的数量以及布局由加载命令和文件类型决定。
    mach-o

背景

静态链接存在的缺点:

  • 内存空间占用大,当多个目标文件需要链接相同的库时,会造成内存资源的浪费
  • 程序兼容性差,当其中一个链接的目标文件更新后,整个程序需要重新编译一遍,无法做到动态更新

基本思想

系统全局仅存在一份动态链接库文件,程序运行时再进行链接。
dyld-1

原理

装载时重定位(Rebasing)

静态链接通过重定位进行符号寻址,在静态链接器完成空间与地址分配后,便能确定所有目标符号的虚拟地址,对目标文件的外部符号进行重定位修正,这一步可以被称为链接时重定位。
但是静态链接的重定位方法并不适合动态链接库,动态链接库是在程序运行时才被加载至内存,当程序调用动态链接库文件代码段的绝对地址时,每个程序的重定位偏移量都会不同,没法做到多个程序共享同一份重定位后的动态链接库文件。

地址无关代码PIC (Position Independent Code)
前提:ELF文件格式

  • 一个模块前面是若干页可读代码段,后面是若干页读写数据段
  • 在一个模块内,任何一条指令与其访问的数据之间的相对位置是固定的
    我们期望动态链接库共享的指令在动态库装载时不需要因为装载地址的改变而改变,把那些需要重定位修改的部分抽离出来,与数据段放在一起。
    这样动态链接库指令部分保持不变,被多个程序共享,数据部分则在每个程序进程中存在一份副本。
  • 分类
    • 模块内部的函数调用
      • 处于同一个模块,相对地址固定,不需要重定位
    • 模块内部的数据访问
      • 指令中不能包含数据的绝对地址,只能进行相对寻址,当前指令加上一个偏移量即可访问
    • 模块外部的数据访问
      • 模块外部的数据访问需要等到装载时才能确定,处理思想是把与地址相关的部分放到数据段中
      • 在数据段中建立全局偏移表GOT(Global Offset Table),代码指令需要访问外部变量时,首先访问GOT中的对应项,在装载时重定位修改GOT的偏移量,找到变量的目标地址
      • 同模块内部数据访问一样,我们可以知道GOT与代码指令之间的相对位置固定
    • 模块外部的函数调用
      • 同样使用GOT,GOT中保存的是目标函数的地址
  • 总结
指令跳转、调用 数据访问
模块内部 相对跳转和调用 相对地址访问
模块外部 间接跳转和调用(GOT) 间接访问(GOT)
  • 还有一个问题
    • 编译器无法区分一个外部全局变量是否是模块外部调用还是内部调用
    • 默认按照模块外部全局变量进行调用
      • 即使用GOT

性能损耗

  • 原因
    • 对于全局和静态的数据访问即模块间的调用都需要进行复杂的GOT定位,间接调用
    • 动态链接的链接工作是在运行时完成的,即程序开始执行时,动态链接器都要进行一次链接工作
  • 优化
    • 延迟绑定
      • 将动态链接从程序启动时进行推迟到当函数第一次被调用时进行
      • PLT (Procedure Linkage Table)
  • 本质是一个方法跳转
  • 方法重定位后会将地址存入bar@plt
  • bar@plt最开始被初始化为push n,即执行下一条指令
  • _dl_runtime_resolve进行重定位,将方法真正地址写入bar@plt
    dyld

iOS动态链接过程

动态链接所需要的信息记录在这个LC_DYLD_INFO_ONLY这个load command中,包括了Rebase Info、Binding Info、Weak Binding Info、Lazy Binding Info和Export Info五部分内容。
dyld

Rebase

Address space layout randomization (ASLR) is a computer security technique involved in preventing exploitation of memory corruption vulnerabilities. In order to prevent an attacker from reliably jumping to, for example, a particular exploited function in memory, ASLR randomly arranges the address space positions of key data areas of a process, including the base of the executable and the positions of the stack, heap and libraries.

地址空间布局随机化(ASLR)是一种计算机安全技术,可防止利用内存进行漏洞攻击。为了防止攻击者可以依靠地址跳转到内存中的某个特定程序段利用功能,ASLR会随机排列进程的关键数据区域的地址,包括可执行文件、堆栈和库的地址。
对内部指针进行调整,Mach-O文件映射到内存中时,ASLR会进行一个随机偏移,所以需要对内部指针进行调整。

Bind

对预留的外部指针进行绑定。

Non-lazy binding

在程序启动时,dyld便通过重定位确定好需动态链接符号的地址。
在__DATA.__got段中存储所有non-lazy symbol pointers。

Lazy binding

当符号首次被调用时,dyld才进行符号绑定操作。
Dyld Lazy binding过程详解
dyld
dyld
总结一下流程:

  1. 首次调用TEXT.__stub段方法,跳转到对应的DATA.__la_symbol_ptr 方法
  2. DATA.__la_symbol_ptr 中方法最开始指向到 TEXT.__stub_helper
  3. TEXT.__stub_helper跳转到libdyld.dylib文件__TEXT段的dyld_stub_binder方法
  4. dyld内部调用到_dyld_func_lookup方法动态链接到对应方法,并修改对应的__la_symbol_ptr段
  5. 再次调用,由TEXT.__stub段跳转至DATA.__la_symbol_ptr,直接调用对应方法

CHAIEND FIXUPS

上述Rebase & Bind的过程分析在很多博客中都有过解析,但是我们此时再新建一个工程尝试复刻时,会发现DATA段中的__la_symbol_ptr消失了,这是怎么一回事呢?
因为目前新建工程时默认的Minimum Deployments为iOS15.0,苹果使用了CHAINED_FIXUPS取代了之前Rebase & Bind机制。苹果在今年的WWDC session中宣称这项优化实际在iOS13.4及之后的版本便已支持。测试来看仅在Minimum Deployments>=15.0时,Mach-O文件格式会发生变化。
使用CHAINED_FIXUPS的Mach-O会在load command中使用LC_DYLD_CHAINED_FIXUPS和LC_DYLD_EXPORTS_TRIE代替LC_DYLD_INFO_ONLY。

dyld
回顾Rebase & Bind过程,不免发现它存在着几个缺点:

  1. Rebase和Bind操作对Mach-O文件进行了两次遍历,可能对某些段进行了重复访问,iOS系统会对较长时间没有用到的page进行压缩处理,两遍遍历导致了额外的page压缩与解压缩操作,增加耗时的同时没有利用到空间局部性;
  2. 用于存放Rebase和Bind信息的LINKEDIT段在动态链接完成后失去了作用,但仍占用了Mach-O文件的体积,如果能对这部分空间重复利用的话可以减少包体积。

Chained Fixups实际上采用链表的方式来将rebase和bind的信息整合到一起,需要进行Rebase/Bind的符号不再被记录到__LINKEDIT中,而是以链表的形式记录到__DATA 段中,__LINKEDIT段仅记录需要修复的segment表头。
dyld
dyld

不使用Chained Fixups:
dyld
dyld
使用Chined Fixups:
dyld

对比可以发现,使用Chained Fixups的Mach-O文件DATA段不再保存__la_symbol_ptr,全部使用__got段存储需动态链接的符号,且不再使用空指针占位,而是保存了链表信息,实际是存放了64位大小的 ChainedFixupPointerOnDisk union(可以是 dyld_chained_ptr_64_rebase,dyld_chained_ptr_64_bind,或者其它类型的fixup chain节点),而 dyld 则会利用这部分信息进行 rebase&bind。
总体来说,Chained fixup方案中,dyld不需要再去一边读取LINKEDIT段信息一遍做rebase&bind操作,而是直接沿着链表逐个读取节点,这样dyld即可在一次遍历中完成rebase及bind操作,减少了不必要的page压缩及解压缩操作,利用了空间局限性,减少了耗时。

Page-in Linking

今年的WWDC,苹果又公布了dyld的一项新功能,Page-in Linking。和过去 dyld 在启动时一次性统一修正所有的动态链接库符号不同,dyld现在可以在 page-in 的时候去自动修正 DATA 段内的 page。一直以来,内核都可以通过 mmap() 来访问 page,但是现在,如果它是一个 DATA page,内核在访问的同时还会顺便对其进行 fixup,这种机制减少了脏内存和启动时间,这也意味着 DATA_CONST 段的page是干净的,它们可以像 TEXT 页面一样被暂时移出内存和重新创建,从而减少内存压力。但 page-in linking 只适用于用 chained fixups 构建的二进制文件。这是因为在 chained fixups 中,大部分修复信息将被编码在磁盘上的 DATA 段中,这意味着在 page-in 时内核可以使用这些信息。
有一点需要注意的是,dyld 只会在应用启动时使用这一机制。任何后来被 dlopen() 打开的 dylibs 都不会进行 page-in linking。在这种情况下,dyld 会采用传统的方法,在调用 dlopen 时应用 fixup。
dyld

动态链接在APP中的应用

iOS系统层面实际上运用了大量的动态链接库,可以在App的Mach-O文件的Load Commands中查看所有LC_LOAD_DYLIB动态库。
dyld
但是对于APP来说,只能引入App内签名的动态库和系统签名的动态库,并不能如愿的引入第三方发布的动态链接库,原因在于dyld内部禁止加载非App签名的image文件,尝试加载会触发crashIfInvalidCodeSignature的crash。

https://opensource.apple.com/source/dyld/dyld-239.3/src/ImageLoaderMachO.cpp
https://opensource.apple.com/source/xnu/xnu-2422.1.72/osfmk/vm/vm_fault.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int ImageLoaderMachO::crashIfInvalidCodeSignature()​
{​
// Now that segments are mapped in, try reading from first executable segment.​
// If code signing is enabled the kernel will validate the code signature​
// when paging in, and kill the process if invalid.​
for(unsigned int i=0; i < fSegmentsCount; ++i) {​
if ( (segFileOffset(i) == 0) && (segFileSize(i) != 0) ) {​
// return read value to ensure compiler does not optimize away load​
int* p = (int*)segActualLoadAddress(i);​
return \*p;​
}​
}​
return 0;​
}

即使在代码层面想办法绕过这个crash,在提审阶段,也会因为动态库签名问题遭到拒审。

应该如何引入动态库?
苹果提供Embed这种方式来让项目引入动态库,其含义是不链接进项目的可执行文件,但是会被打包进ipa包,这样实际丧失了动态链接本身的空间优化和动态更新的优点。
dyld
但动态链接真的就没有作用了吗?并不是这样。
随着大型程序的发展,需要链接的库会越来越多,业务研发同学可能会只专注于几个库的开发,此时将会面临代码修改后,编译链接时长增长、lldb调试卡顿等一系列问题,降低研发效能。
而将业务库作为动态链接库进行开发,编译时不再需要每次都重新构建一整个庞大的主二进制文件,而只需要重新构建各自业务对应的小动态库,研发复杂度大大降低,链接、调试和传输慢等一些列问题也就“迎刃而解”了。
但是增加动态库也会带来诸多其他问题,比如增加启动耗时。
业界普遍采用动态库懒加载方案,在程序启动时候默认不加载这些动态库,在启动结束后通过子线程预加载或者进入对应业务场景时候再按需加载对应的动态库。
总之,动态库的使用需要进行斟酌和取舍,研发效率和使用体验总需要进行平衡,没有银弹。

动态链接在Hook中的应用

接触过Objc开发的同学对fishhook应该都不陌生,Method swizzle只能针对oc方法生效,对于c方法调用,需要使用fishhook,且fishhook只能针对跨模块方法Hook,为什么这样呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
* A structure representing a particular intended rebinding from a symbol
* name to its replacement
*/
struct fbr_rebinding {
const char *name; // 需要hook的方法名称
void *replacement; // 替换方法的地址
void **replaced; // 原始方法的地址
};

/*
* For each rebinding in rebindings, rebinds references to external, indirect
* symbols with the specified name to instead point at replacement for each
* image in the calling process as well as for all future images that are loaded
* by the process. If rebind_functions is called more than once, the symbols to
* rebind are added to the existing list of rebindings, and if a given symbol
* is rebound more than once, the later rebinding will take precedence.
*/
FISHHOOK_VISIBILITY
int fbr_rebind_symbols(struct fbr_rebinding rebindings[], size_t rebindings_nel);

fishhook实际上就是对动态链接的符号进行hook,之前我们说过,模块间的方法调用需要通过间接跳转,相关跳转方法最终存在DATA段的__got和__la_symbol_ptr中,通过修改这一部分即可达到Hook效果,而模块内的方法调用是固定在TEXT段中的,因此无法进行修改,这也就解释了为什么fishhook只能针对跨模块方法进行Hook。

Question

  • 动态链接的优点和缺点
  • 地址无关代码、延迟绑定的含义
  • Mach-O动态链接过程
  • iOS开发中使用动态链接库的意义
  • Fishhook原理

参考文档

《程序员的自我修养——链接、装载与库》
Link fast: Improve build and launch times
Frameworks: embed or not embed that’s the question
Dynamic Library Programming Topics
iOS15 动态链接 fixup chain 原理详解