Linux的共享库的重定位和符号绑定
动态共享库在进程间共享时,有两种符号链接方式:
- 装载时重定位
- PIC(位置无关代码)
装载时重定位
在首次使用某个动态库的时候,动态链接器会把磁盘上的这个库文件,映射到某块物理内存上。
在此之后,动态链接器加载动态库的时候,会根据当前内存的情况,分配对应的一块虚拟内存来映射这个动态库。所以不论有多少进程在使用这个动态库,物理内存中这个动态库始终只有一份。
然后…因为载入后不同进程的基地址不同,加上动态库内都是绝对寻址,所以需要修改动态库TEXT段内的指令。
所以最终动态库内的寻址地址都是由动态库被加载的虚拟内存的基地址来决定的,而不同进程的基地址必然不会一样,那最终导致这部分的代码不可复用。
另外,如果这部分的寻址过多,还容易引起链接时间过长(启动时间过久)。
再另外,TEXT段的可写入,使得安全性的大大降低。
由此引入了PIC
PIC(位置无关代码)
PIC实际上需要解决两个问题,一个是寻址问题,另一个是绑定问题。解决这两个问题,至少可以缓解以上的所有问题。
- 寻址问题
如果可以把绝对地址寻址改为相对地址寻址,那么也就不需要根据基地址来修改TEXT段的指令了。
在此之前,相对地址有两个要素 基址+偏移。
先看偏移,编译器可以把所有的TEXT段和DATA段分离(即使不放在一起也没关系)。当前指令需要的数据与指令的偏移,与编译器是可以确定的。
即在链接器链接的时候,也是可以知道,当前指令需要的数据在此之后偏移多少。具体看下图:
再看基址,基址可以通过一些“手段”获取到。
例如在x86上,
1 | call TMPLABEL |
call TMPLABEL 调用的时候,会把TMPLABEL的第一个命令地址(即 pop ebx)的地址入栈。而执行到当前命令的时候,又会弹出栈顶的值到ebx,那么此时ebx代表的就是当前的指令的地址了。
- 绑定问题
在做到相对寻址之后,如何把对应的符号绑定到对应真正的符号上?这里引入了GOT(全局偏移表)。GOT是一张全局的地址表,记录了指令内使用的符号或者数据的具体地址。
寻址访问的过程中,TEXT段中某一个指令想要访问某一个全局数据的地址,(姑且叫使用全局数据A吧)TEXT段不会直接访问这个数据A的地址。而是编译器在会把这个地址,指向GOT中的某一个记录,这个记录就是数据A的地址,这样通过相对寻址来指向这个数据。
但是这个过程中,还缺少一步就是对GOT表的重定向,因为数据在动态库被加载至虚拟内存的时才会被确定基地址。所以,在加载后,还需要通过重定向来修改这个数据的地址。
似乎过程比装载时重定位一点都没有少,那么PIC的优势又在哪呢?
- PIC可以复用的GOT表。装载时重定位需要对所有的引用这个符号或者数据的地方都做指令修改,而PIC只需要修改GOT表一次就可以了。
- PIC无需修改代码段。上文提及的修改代码段是及其危险的一件事,而数据段本身也不是进程间共享的,PIC可以做到只修改数据段。而且这样可以让代码段在进程间共享。
PIC函数调用时延迟绑定
PIC解决了以上两个问题,但是还有一个启动时间的问题。虽然PIC只需要修改一次GOT的地址就可以了,但是还是架不住函数多啊,函数的数量肯定远多于全局变量的数量。
程序80%的时间都在执行20%的代码
所以,函数地址的重定位可以延迟到第一次调用的时候。
另外,对于函数的地址的解析,我们称为绑定(Bind)。
通俗的来说,延迟绑定的机制就是:
编译器在代码段中,插入一段“桩代码”,源代码指令中的函数跳转会通过相对寻址,直接指向PLT表中这个符号对应的桩代码。这段“桩代码”的执行过是:
- 执行”桩代码”,先跳转到GOT表中这个符号指向的函数地址.
- 编译器会把GOT表中这个地址的默认值写为“桩代码”中的下一个指令地址。即跳转回桩代码中的下一条指令。
- 而下一个指令地址就是执行GOT表中这个符号的绑定过程(一般都由动态链接器负责)
这个桩代码,称为PLT(程序链接表)。具体的流程看下图:
等到下一次再执行的时候,GOT表内的符号地址已经是真正函数对应的地址了(Bind过程就是把GOT表中的符号地址替换为真正的函数地址):

iOS动态库的符号
接下来,我们通过代码和实现来看看iOS的动态库是否和Linux的动态库一样。
写一个简单Demo,看看NSLog的符号绑定吧。
1 | @interface ViewController () |
静态分析
编译完成后,因为代码量不多,我们先做静态分析。
在 __TEXT 段的 __text 节中所有代码可以一眼望到底了。

迅速找到第一个NSLog,这里bl 跳转到 0x100006558。(第二个NSLog也是跳转到这个地址)
那找到偏移为0x6558的地址(Text段的虚拟地址起始地址是0x100000000,在Load Commands中声明了)。
0x6558是在__TEXT段的__stubs节中,顾名思义是符号桩。根据上面Linux的动态库的理解,符号桩是一个假想的NSLog的实现,所有的指令都会向这个地址跳转。
但是很僵硬…MachOView这个工具把某些细节汇编隐藏了。
我们换个工具…
找到__stubs节,第一个符号就是imp__sthubs_NSLog。
1 | ldr x16,#0x10000c000 |
这个假想的NSLog实现就是跳转到 #0x10000c000 地址内存储的地址。再找这个地址…
这个地址是数据段的!__la_symbol_ptr,通过名字也可以看出来—懒加载符号指针表。也就是对应上面提到的GOT表。GOT表内的NSLog又指向了0x660C。

__stub_helper…按照上面的逻辑,这个地址应该就是对应的PLT(程序链接表)。
1 | ldr w16,0x100006614 |
把 0x100006614 的值存储到w16内,也就是0,然后跳转到 0x1000065f4
0x1000065f4 就是这个__stub_helper的开头,可以观察到最终会跳转到 dyld_stub_binder,这个代码是在动态链接器内。
而 0x100006614 的值是为了告诉dyld需要链接的是哪个符号。
至此第一次绑定的绑定过程已经全部完成了,如果第二次再调用的时候 __la_symbol_ptr 内函数符号对应的地址就已经是Bind之后的值了。
iOS动态库的首次绑定过程
- 指令跳转到符号的”桩代码“
- ”桩代码“直接跳转GOT,GOT指向”__stub_helper“代码。
- 这个helper代码的目的是跳转到dyld链接器的Bind入口处,执行Bind。
可以看到,iOS和Linux的动态库绑定还是有点区别的:iOS把”桩代码“后面的跳转dyld部分直接抽离出来,放入了”__stub_helper” 中。(其实本质上是没有区别的)
运行时分析
我们在第一个NSLog处打一个断点,然后看汇编代码…
直接 bl 0x100f46558 (第二个NSLog也是)
我不会 Xcode 设置地址断点(不知道支不支持),用dis看一下这个地址的汇编代码吧。
这里改成了相对寻址ldr x16, #0x5aa4 x1 最终的值是 当前指令地址+0x5aa4 = 0x100F4C000 使用Xcode工具查看内存值。
根据静态分析的逻辑,这个地址 0x100f4660c(注意大小端) 应该是 __la_symbol_ptr 中存储的 __stubs_helper 内这个符号的地址,
再看看这个地址的代码确认一下:
在下一个NSLog处再打一个断点…验证一下第二次执行。
依旧是通过查看内存地址,看 0x100F4C000 这个地址的值。
值变成了 0x109c76e754
而这个地址就已经变成真正的NSLog的函数地址了~
本文只是通过Linux 的动态库的实现来理解iOS的动态库,通过一些实验和汇编分析验证我们的猜想。关于iOS的dyld的具体源码和细节,我会单开文章。