跟着 WWDC 一起探秘符号解析的魔法
在 Objective-C
的世界中,对对象的方法调用都会被转为消息发送,通过 self
和 _cmd
,在 method_list 中寻找对应的函数指针,最终触发函数调用。对于我们自己代码中的类,这很好理解,Runtime 会维护一个巨大的列表,存储着我们的类的信息。但对于系统 framework 中的类型呢?难道 Runtime 会在启动时预先将所有系统类也加载进来吗?更广义一点说,诸如 printf
这样的方法又是如何找到函数指针的呢?
剧透在前
我们都知道,在 .m
中编写代码时,我们只需要引入 .h
头文件,就可以使用对应文件中定义的类与方法,而不需要关心对应实现。这是因为,在静态阶段,我们并不真正执行任何二进制代码,只是生成指令而已,因此,我们只需要知道我们希望调用的函数的 符号,而不需要知道地址。而这些符号,将在运行时被转化为实际的内存地址,以供调用。
事实上,由于大量系统库以及 ASLR 的存在,我们也不可能在静态阶段就得到所有实际代码的执行地址。
在 macOS
和 iOS
系统中,可执行文件的格式为 Mach-O,Mach-O
内部一般由三个部分组成:
其中 __TEXT
段为存放应用代码以及常量的地方,为只读属性。__DATA
段存放全局变量、静态变量等数据,为读写属性。__LINKEDIT
存放着加载该二进制时的一些元数据。这张截图自 2016 年的 Optimizing App Startup Time,对以上三个段的解释简短清晰,但也留下了一些问题:在我们自己编写的代码中,夹杂了对系统库的调用,这些调用在编译后会变成符号,但我们的代码最终是放在只读的 __TEXT
段的,系统要怎么把符号转成地址呢?另外 __LINKEDIT
又到底存放了哪些信息呢?
从汇编说起
要弄清楚这些问题,就要先看看我们的代码经过编译,究竟会变成什么样,考虑如下代码:
1 | @implementation ViewController |
对应的汇编在精简后大概是这样(所有 .开头的均不是实际汇编代码):
1 | "-[ViewController viewDidLoad]": ; @"\01-[ViewController viewDidLoad]" |
如果你不熟悉汇编,也没太大关系,其中大部分代码都是在往“正确”的寄存器里面塞参数,保证函数调用的正确性,我们关心的是这几句:
1 | // selector 以及 class 的符号 |
你可以在 ARM Information Center 搜到 adrp 的详细文档,简单地说它就是用来取某个“符号”的地址的
限于篇幅,我们先只关注其中 NSDictionary
相关的,在汇编代码中搜索 l_OBJC_CLASSLIST_REFERENCES_
,最终可以发现他落在这样一个地方:
1 | .section __DATA,__objc_classrefs,regular,no_dead_strip |
可以看到,这个符号实际是在 __DATA
段中的,也就是说,我们在 __TEXT
中的符号,实际指向的是 __DATA
段里的东西
挖掘 Mach-O
到这一步,我们就需要借助一些工具,来继续看看 __DATA
段又发生了什么事情,我个人使用 MachOView 来查看文件内部大体布局,用 Hopper 来查看具体内存地址是什么。
根据上面汇编代码给出的信息,我们很容易就可以在 __objc_classrefs
中找到我们的指针。
我们利用 offset,在 Hopper
中跳到对应地址,将会看到另一条非常简单的汇编语句:
Hopper
提供非常强大的跳转功能,双击图中黄色高亮的符号,又可以跳到该符号定义的地方:
如果你稍稍向上滑动一下,会发现这段代码对应在 __DATA
段的 External Symbols Segment
中,而除了我们的 _OBJC_CLASS_$_NSDictionary
,还有诸如 UIResponder
、NSObject
以及 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 | //main.m |
重新编译后,用 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 | ImageLoaderMachOCompressed::doBindFastLazySymbol(uint32_t lazyBindingInfoOffset, |
其中参数 context
为初始化时 dyld
为该实例设置的一系列上下文信息,lock
,unlock
block 则为特定环境中的加解锁方法。简单阅读该方法,可以发现它主要干这么几件事:
首先,通过 fLinkEditBase + fDyldInfo->lazy_bind_off
确定 __LINKEDIT
段中存放 Lazy Binding Info
信息的基址,然后根据我们传入的偏移值(也就是最开始塞进 w16
中的 0x46
),定位到具体位置。
回头在 MachOView
上看看这块的信息:
0x46
正好就是基址 F0
到 printf
信息段 36
的偏移量。到了这一步,我们就可以拿到:
__la_symbol_ptr
段中,待改写的指针地址。- 我们想要找到的目标符号以及其对应在哪个
dylib
之中。
接着,调用 bindAt
方法,通过上一步中获取的符号名称,在目标库中获取函数地址(也就是 printf
在 libSystem.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汇编入门