跟着 WWDC 一起探秘符号解析的魔法
source link: http://blog.gocy.tech/2018/08/01/behindthescenes-symbol-resolve/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
在 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
又到底存放了哪些信息呢?
从汇编说起
要弄清楚这些问题,就要先看看我们的代码经过编译,究竟会变成什么样,考虑如下代码:
@implementation ViewController - (void)viewDidLoad { NSDictionary *obj = [NSDictionary new]; NSLog(@"%@",obj); } @end
对应的汇编在精简后大概是这样(所有 .开头的均不是实际汇编代码):
"-[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
如果你不熟悉汇编,也没太大关系,其中大部分代码都是在往“正确”的寄存器里面塞参数,保证函数调用的正确性,我们关心的是这几句:
// 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_
,最终可以发现他落在这样一个地方:
.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
,还有诸如 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 流程。
//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
信息的实例后,程序最终会进入:
ImageLoaderMachOCompressed::doBindFastLazySymbol(uint32_t lazyBindingInfoOffset, const LinkContext& context, void (*lock)(), void (*unlock)())
其中参数 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 了。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK