《iOS15动态链接fixup chain原理详解》[1]对 iOS15+ 动态链接过程性能优化的深度解析,引发了字节跳动APM团队对MachO文件的编译链接过程探索的兴趣。在学习的过程中,初学者常常会因为对该领域的不熟悉而陷入误区。本文整理了初学者比较容易犯的三大认知误区,避免大家重蹈覆辙。如果有理解不准确的地方,还望指正。
Xcode
中打开Debug Workflow
=>Always Show Disassembly
就可以看到当前正在调试代码的汇编版本,想必大家非常熟悉,特别是对于外部方法调用bl 0x10239e3a4
也非常熟悉,带有返回地址的跳转到0x10239e3a4
这个代码段地址,理解起来非常简单,然而事实真的如此吗?想探究这个原理,就得必须先理解b/bl指令内存结构到底是如何约定的。
就以bl指令为例,查看一下arm64汇编的官网解释[2]:
这时候才恍然大悟:原来b和bl指令存放的都是跳转之后的相对地址!
其中高6位是保留位(用于区分不同的跳转指令类型),低26位用来保存跳转之后的相对地址。
然后到demo验证一下:
(lldb) memory read 0x10239dd38
0x10239dd38: 9b 01 00 94 fd 7b 42 a9 ff c3 00 91 c0 03 5f d6 .....{B......._.
0x10239dd48: ff 43 00 d1 e0 07 00 f9 e1 03 00 f9 e8 07 40 f9 .C............@.
取前4字节内容也就是0x9b010094
,因为arm芯片默认是采用小端模式编码,还原为真实的内存就是0x9400019b
, 0x94
转为二进制就是10010100
,和上图中高6位能够对的上。那么上图中的imm26
其实就是0x19b。
根据上图中的计算公式,跳转之后的绝对地址应该为:
绝对地址 = 0x10239dd38 + 0x19b * 4 = 0x10239e3a4
刚好和Xcode
中给出的绝对地址是一致的,可见,之所以被误导是因为开发者对bl指令的内存结构理解不到位,并且轻信了Xcode
反汇编器的展示优化。
根据这个公式,b/bl最大的寻址范围是: 1bit符号位 | 2^25 * 4Byte = +/- 128MB,和官方文档也是完全可以对应的。
__TEXT
段中b/bl指令存放的地址需要rebase/bind其实这里被误导的原因与误区一是息息相关的,正是误以为b/bl指令存放的是跳转后的绝对地址,才认为__TEXT
段中的b/bl指令引用到的地址都需要rebase/bind。
其实这个误区相对来说比较低级,因为无论是rebase还是bind都属于运行时的fixup,如果按照上面的理解需要在__TEXT
段中做fixup,但是这个显然是不科学的,因为__TEXT
段的执行权限是r-x
,也就是可读可执行,就是不可写,和fixup的语意是矛盾的。
对于外部模块的函数或者变量引用,MachO中采用的是一种PIC
(Position Independent Code,即地址无关代码)的技术,具体细节这里不过多展开,大家可以参考这篇博客:《iOS程序员的自我修养-MachO文件动态链接(四)》[3]。
援引另外一篇博客《深入理解MachO结构与运行时系统》[4]中的结论:
符号又分为两种:
non lazy symbols
指的是不能延迟加载的符号,必须在编译时就确定好内存地址,这些符号往往时是动态链接依赖的符号。而lazy symbols
指的是可以延迟加载的符号。前者存在于__DATA_CONST
segment的__got
section,后者存在于__DATA
segment下的__la_symbos_ptr
setction。通过它们可以获取到程序中所有引用到的符号,因此如果想通过这里查找符号并进行处理一定得是先使用这个符号,保证在mach-o
中存在。
__DATA
或者__DATA_CONST
Segment中的符号需要做运行时的fixup。__TEXT
段中b/bl后面需要引用的符号在编译期间就可以完成链接,并且跳转地址和当前PC地址的偏移是固定的,而偏移本身和ASLR
无关。但是对于__DATA
或者 __DATA_CONST
中指针的值,和PC地址并没有任何相对关系,所以只能挨个fixup。不过经过实测,这个工具链在dyld4版本(Xcode13.0+)上的兼容性并不好,这里提供一个依据dyld4源码[6]自行编译的新版工具链dyld_info
。
用法:
./dyld_info -fixups ./machoName > fixups.txt
如果想分别查看rebase和bind信息,可以用grep
命令简单过滤下:
./dyld_info -fixups ./machoName | grep 'rebase' > rebase.txt ./dyld_info -fixups ./machoName | grep 'bind' > bind.txt
MachO文件仅有__DATA
和__DATA_CONST
2个Segment中的地址需要做运行时的fixup。
__TEXT
Segment以及 __DATA_CONST
Segment都符合这种规律,就误以为是一种普遍的规律。//找到__LINKEDIT的基地址
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
//找到符号表的地址
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
//找到字符串表的地址
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
//indirect symbol table
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
__TEXT.vmaddr
貌似确实是等价的,但是当分析__DATA
Segmentload command
的时候,发现了猫腻:果然与上面博客中的结论一致:计算FileOffset时各Segment紧挨着,没有任何空隙,也就是说File Size不需要保证4KB对齐;但是计算VM Address时各Segment不一定是紧挨着,可能存在空隙,VM Size需要保证4KB对齐。
看下面这张图相信大家就能够很容易理解了。
__LINKEDIT
和Symbol Table
因为在同一个Segment中,所以他们之间不存在4KB对齐的问题,那么无论是VM Size的口径还是File Size的口径,他们的首地址之间相对偏移一定是相同的,那么就有:symtab.vmaddr - __LINKEDIT.vmaddr = symtab.fileoff - __LINKEDIT.fileoff
symtab.vmaddr = symtab.fileoff - __LINKEDIT.fileoff + _LINKEDIT.vmaddr
ASLR
考虑进去,等式两边同时加上随机偏移slide则有:symtab_base = symtab.fileoff - __LINKEDIT.fileoff + _LINKEDIT.vmaddr + slide
linkedit_base = slide + _LINKEDIT.vmaddr - __LINKEDIT.fileoff
那么则有:
symtab_base = linkedit_base + symtab.fileoff
上面最后两个公式以一种更容易理解的方式被推导出来,与本章节一开始的代码实现完全一致。
不探究底层原理,仅仅依靠归纳法的认知和理解方式有可能并不科学。
文章引用