跟着 WWDC 一起探秘符号解析的魔法

Objective-C 的世界中,对对象的方法调用都会被转为消息发送,通过 self_cmd,在 method_list 中寻找对应的函数指针,最终触发函数调用。对于我们自己代码中的类,这很好理解,Runtime 会维护一个巨大的列表,存储着我们的类的信息。但对于系统 framework 中的类型呢?难道 Runtime 会在启动时预先将所有系统类也加载进来吗?更广义一点说,诸如 printf 这样的方法又是如何找到函数指针的呢?

剧透在前

我们都知道,在 .m 中编写代码时,我们只需要引入 .h 头文件,就可以使用对应文件中定义的类与方法,而不需要关心对应实现。这是因为,在静态阶段,我们并不真正执行任何二进制代码,只是生成指令而已,因此,我们只需要知道我们希望调用的函数的 符号,而不需要知道地址。而这些符号,将在运行时被转化为实际的内存地址,以供调用。

事实上,由于大量系统库以及 ASLR 的存在,我们也不可能在静态阶段就得到所有实际代码的执行地址。

macOSiOS 系统中,可执行文件的格式为 Mach-OMach-O 内部一般由三个部分组成:

其中 __TEXT 段为存放应用代码以及常量的地方,为只读属性。__DATA 段存放全局变量、静态变量等数据,为读写属性。__LINKEDIT 存放着加载该二进制时的一些元数据。这张截图自 2016 年的 Optimizing App Startup Time,对以上三个段的解释简短清晰,但也留下了一些问题:在我们自己编写的代码中,夹杂了对系统库的调用,这些调用在编译后会变成符号,但我们的代码最终是放在只读的 __TEXT 段的,系统要怎么把符号转成地址呢?另外 __LINKEDIT 又到底存放了哪些信息呢?

从汇编说起

要弄清楚这些问题,就要先看看我们的代码经过编译,究竟会变成什么样,考虑如下代码:

1
2
3
4
5
6
@implementation ViewController
- (void)viewDidLoad {
NSDictionary *obj = [NSDictionary new];
NSLog(@"%@",obj);
}
@end

对应的汇编在精简后大概是这样(所有 .开头的均不是实际汇编代码):

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
"-[ViewController viewDidLoad]":        ; @"\01-[ViewController viewDidLoad]"
Lfunc_begin0:
sub sp, sp, #48 ; =48
stp x29, x30, [sp, #32] ; 8-byte Folded Spill
add x29, sp, #32 ; =32

adrp x8, l_OBJC_SELECTOR_REFERENCES_@PAGE
add x8, x8, l_OBJC_SELECTOR_REFERENCES_@PAGEOFF
adrp x9, l_OBJC_CLASSLIST_REFERENCES_$_@PAGE
add x9, x9, l_OBJC_CLASSLIST_REFERENCES_$_@PAGEOFF
stur x0, [x29, #-8]
str x1, [sp, #16]
Ltmp0:
ldr x9, [x9]
ldr x1, [x8]
mov x0, x9
bl _objc_msgSend
str x0, [sp, #8]
ldr x8, [sp, #8]
mov x9, sp
str x8, [x9]
adrp x0, l__unnamed_cfstring_@PAGE
add x0, x0, l__unnamed_cfstring_@PAGEOFF
bl _NSLog
add x9, sp, #8 ; =8
mov x0, x9
mov x1, x8
bl _objc_storeStrong
ldp x29, x30, [sp, #32] ; 8-byte Folded Reload
add sp, sp, #48 ; =48
ret

如果你不熟悉汇编,也没太大关系,其中大部分代码都是在往“正确”的寄存器里面塞参数,保证函数调用的正确性,我们关心的是这几句:

1
2
3
4
5
6
7
8
// selector 以及 class 的符号
adrp x8, l_OBJC_SELECTOR_REFERENCES_@PAGE
add x8, x8, l_OBJC_SELECTOR_REFERENCES_@PAGEOFF
adrp x9, l_OBJC_CLASSLIST_REFERENCES_$_@PAGE
add x9, x9, l_OBJC_CLASSLIST_REFERENCES_$_@PAGEOFF
// 常量 string 的符号
adrp x0, l__unnamed_cfstring_@PAGE
add x0, x0, l__unnamed_cfstring_@PAGEOFF

你可以在 ARM Information Center 搜到 adrp 的详细文档,简单地说它就是用来取某个“符号”的地址的

限于篇幅,我们先只关注其中 NSDictionary 相关的,在汇编代码中搜索 l_OBJC_CLASSLIST_REFERENCES_,最终可以发现他落在这样一个地方:

1
2
3
4
.section	__DATA,__objc_classrefs,regular,no_dead_strip
.p2align 3 ; @"OBJC_CLASSLIST_REFERENCES_$_"
l_OBJC_CLASSLIST_REFERENCES_$_:
.quad _OBJC_CLASS_$_NSDictionary

可以看到,这个符号实际是在 __DATA 段中的,也就是说,我们在 __TEXT 中的符号,实际指向的是 __DATA 段里的东西

挖掘 Mach-O

到这一步,我们就需要借助一些工具,来继续看看 __DATA 段又发生了什么事情,我个人使用 MachOView 来查看文件内部大体布局,用 Hopper 来查看具体内存地址是什么。

根据上面汇编代码给出的信息,我们很容易就可以在 __objc_classrefs 中找到我们的指针。

我们利用 offset,在 Hopper 中跳到对应地址,将会看到另一条非常简单的汇编语句:

Hopper 提供非常强大的跳转功能,双击图中黄色高亮的符号,又可以跳到该符号定义的地方:

如果你稍稍向上滑动一下,会发现这段代码对应在 __DATA 段的 External Symbols Segment 中,而除了我们的 _OBJC_CLASS_$_NSDictionary,还有诸如 UIResponderNSObject 以及 NSLog 等等系统符号。就目前来看,符号最终指向了 0x00,也就是 null,这也是符合预期的,正如前文提到的,这些系统库在 runtime 时的地址是我们编译阶段无法确定的,需要在装载可执行文件时动态确定,也就是说,系统一定会在某个时机,将 0x00 改为实际地址。

那么这一步发生在什么时候呢?

截图同样来自 Optimizing App Startup Time,在系统动态加载 Mach-O 文件的时候,会经过 Rebase 以及 Bind 两个阶段,其中 Rebase 是将内部指针进行固定数值的偏移(slide),而 Bind 则正是用于将外部符号转为实际指针的步骤。在 2018 年的 Behind the Scenes of the Xcode Build Process session 中也提到了这一个步骤:

可以看到,整个流程 1-2-3-4 均和我们上面的分析相符,而上图中的最后一个步骤正是 Bind__LINKEDIT 中的信息就是建立应用内符号到系统函数符号的映射。这时候我们打开 MachOView 找到对应 Bind 阶段的信息,可以发现 _OBJC_CLASS_$_NSDictionary 的确是在这一步完成绑定的。

至此,符号解析的流程就走通了,__TEXT 中的代码段指向 __DATA 中的符号,在装载二进制时,系统会根据 __LINKEDIT 中的信息,再将 __DATA 中的符号和实际系统函数地址建立映射。

Lazy Binding

细心的你一定还发现,__DATA 段中还有一个 __la_symbol_ptr,而上面的截图中也存在 Lazy Binding Info 的字样。这是因为,并不是所有符号都是在启动时进行解析绑定的,出于性能考虑,一部分符号将会在首次调用时进行绑定。那么绑定的过程是如何的呢?

为了方便调试,我们稍微修改一下代码,让 app 启动时直接进入 lazybind 流程。

1
2
3
4
//main.m
int main(int argc, char * argv[]) {
printf("hi, pritf");
}

重新编译后,用 Hopper 重新打开你的 Mach-O,记得不要勾选 Resolve Lazy Bindings。

用寻找 NSDictionary 符号相似的方法,我们可以很快在 Hopper 中定位到如下位置:

若继续跟踪代码,我们会来到一个似乎看不出什么端倪的地方:

这段代码实际属于 __stub_helper,不确定是否是我所使用的 Hopper 版本问题,同样的段落在 MachOView 中查看,可以看到正确的代码:

这里,我们将 0x100006c38 地址处的内容,存入了 w16,回到 Hopper,可以看到该处的值为 0x46 (具体值因 lazy pointer 数量而异)。随后我们将调用 0x100006bf4 处的函数:

此处逻辑是将 0x100008008 指针传递给 x17,随后调用 dyld_stub_binder 方法。

该方法是 dyld 内部的方法(源码在这),作用就是将 __la_symbol_ptr 当前指向 __stub_helpers 段的指针绑定到真实的函数地址上。结合 dyld 源码以及 Xcode 的 Always Show Assembly 选项,我们得以了解这个绑定的大概流程:

我们传入的首参,也就是之前放置于 x17 处的指针,实际上是用于让 dyld 创建 ImageLoader 的内存区域,ImageLoader 负责处理可执行文件及其依赖关系的抽象类,在本例中,其具体实例为 ImageLoaderMachOCompressed 类,在它的头文件中,也注明了它的作用:

*ImageLoaderMachOCompressed is the concrete subclass of ImageLoader which loads mach-o files that use the compressed LINKEDIT format. *

dyld 创建了负责处理 __LINKEDIT 信息的实例后,程序最终会进入:

1
2
3
4
ImageLoaderMachOCompressed::doBindFastLazySymbol(uint32_t lazyBindingInfoOffset, 
const LinkContext& context,
void (*lock)(),
void (*unlock)())

其中参数 context 为初始化时 dyld 为该实例设置的一系列上下文信息,lockunlock block 则为特定环境中的加解锁方法。简单阅读该方法,可以发现它主要干这么几件事:

首先,通过 fLinkEditBase + fDyldInfo->lazy_bind_off 确定 __LINKEDIT 段中存放 Lazy Binding Info 信息的基址,然后根据我们传入的偏移值(也就是最开始塞进 w16 中的 0x46),定位到具体位置。

回头在 MachOView 上看看这块的信息:

0x46 正好就是基址 F0printf 信息段 36 的偏移量。到了这一步,我们就可以拿到:

  1. __la_symbol_ptr 段中,待改写的指针地址。
  2. 我们想要找到的目标符号以及其对应在哪个 dylib 之中。

接着,调用 bindAt 方法,通过上一步中获取的符号名称,在目标库中获取函数地址(也就是 printflibSystem.B.dylib 中的实际地址),然后调用 bindLocation 改写对应 __la_symbol_ptr 中的指向。

实际运行起来,也证实了我们的想法:

resolve 之前,我们已经拿到了 _printf 符号和待改写指针,在 bindLocation 之前,我们已经拿到了 printf 的真实地址了,而在 bindLocation 之后,我们的 __la_symbol_ptr 就指向实际函数地址,变得不再 lazy 了。

参考资料

Optimizing App Startup Time
App Startup Time: Past, Present, and Future
Behind the Scenes of the Xcode Build Process
iOS Assembly Tutorial: Understanding ARM
iOS开发同学的arm64汇编入门