iOS 开发中,动态库是个绕不开的话题,系统库基本上是动态库。它的一大优势是节约内存,可让多个程序映射同一份的动态库,实现代码共享。动态库本身也是一个 Mach-O 文件,也有数据段、代码段等。其中代码段可读可执行,数据段可读可写。
动态库共享的只是代码段部分,为了达到代码段共享的目的,其符号地址在生成时就不能写死,因为它映射到每个程序中虚拟内存空间中的位置可能不一样。对于数据段部分,由于各个程序会对其进行修改,因此每个程序会单独映射一份。
那么如何解决代码段共享的问题呢?聪明的人们,想出一种精妙的解决方式。通过添加一个中间层,到另一个表中去查找符号的地址。这个表就叫 got,global offset table,全局符号偏移表,然后在运行时绑定地址信息,将地址填入到 got 中。这样代码段中的符号就与具体地址无关,只和 got 有关。这种方式就叫 PIC,Program Independent Code,程序地址无关代码。
或许你可能会想到,got 中保存的是符号地址,而每个程序的地址是不一样的,那 got 肯定是不能共享的。没错,所以 got 会保存在数据段中,每个程序单独一份。在进行符号绑定时,更新 got 中对应符号的地址即可。
在了解 got 是什么之后,我们再来看看 Mach-O 中 got 到底放在了哪里。
通过下图可以看出,有个专门的 __got section 存放 got 数据,而它是属于 __DATA segment。
对于 segment 和 section,可能大家会有些困惑。下面来简单解释一下。
section 称为节,是编译器对 .o 内容的划分,将同类资源在逻辑上划分到一起。常见的 section 有:
segment 称为段,它是权限属性相同 section 的集合。
在程序装载时,操作系统并不关心 section 的数量和内容,只对其权限敏感,因此没必要一个个加载 section,只需将权限相同的 section 合到一起加载即可。
另外,这样还可节省内存。由于内存按页分配,即使不满一个页也得分配一整页。若单个 section 大小非系统页长度的整数倍,会造成内存碎片。而将其合并后,会有效缓解这种情况。
举个栗子, .text 和 .init 的权限都是只读可执行,.init 是程序初始化代码。
假设页的大小是 4 KB,.text 大小为 4098 字节,.init 大小为 900 字节。如下图所示,若将它们单独映射,.text 会占用 2 个页,.init 占用 1 个页,整体占用 3 个页。
如果它们合并成代码段,那么只需占用 2 个页,减少内存浪费。如下图所示。
可执行文件是由多个 .o 文件链接而成的,每个 .o 文件有各自的 section。因此链接器将所有 .o 文件中权限相同的 section 合并到一起,形成 segment。操作系统只需将 segment 映射到虚拟内存空间即可。
平常我们所说的代码段、数据段,便是指链接后的 segment。
动态库中的符号分为 non-lazy symbol 和 lazy symbol。
为啥要分为两种类型呢?我们试想一下,如果所有动态库的符号都是启动时链接,一个程序随随便便依赖的系统动态库就有大几十个。每个动态库中符号还不少,并且也不是所有符号都会用到,这样势必会拖慢启动速度。所以采用延迟绑定技术,只需在第一次用到时进行绑定,可提高性能。而数据符号相对较少,则可以采用 non-lazy 的方式,放到启动时就链接。
因此,Mach-O 中划分了两个 section 来保存 non-lazy symbol 和 lazy symbol。其中 got 中保存的是 non-lazy symbol,la_symbol_ptr 保存的是 lazy symbol。
下面,我们来实践一下,验证上述说法的正确性。请将以下文件放在同一个目录下。
print.c:
#include <stdio.h>
char *global = "hello";
void print(char *str)
{
printf("%s\n", str);
}
main.c:
void print(char *str);
extern char *global;
int main()
{
print(global);
return 0;
}
run.sh:
// 生成 main.o,目标版本 14.0
xcrun -sdk iphoneos clang -c main.c -o main.o -target arm64-apple-ios14.0
// 生成 libPrint.dylib 动态库
xcrun -sdk iphoneos clang -fPIC -shared print.c -o libPrint.dylib -target arm64-apple-ios14.0
// 链接生成可执行文件,"-L .", 表示在当前目录中查找。"-l Print",链接 libPrint.dylib 动态库
xcrun -sdk iphoneos clang main.o -o main -L . -l Print -target arm64-apple-ios14.0
给 run.sh 添加可执行权限后再运行,生成可执行文件。
chmod +x run.sh
./run.sh
执行完毕后,在目录中会生成 libPrint.dylib 动态库和 main 可执行文件。
将 main 拖到 MachOView 中,如下图所示:
右边红框中的 _global 就是动态库 libPrint.dylib 中的符号。它被放到了 __got 中,并且其初始地址为 0。它是表的第一项,表地址是 0x10008000,那么 0x10008000 中的值就是符号地址。
另外,我们还发现,在 __got 中还有一条记录 dyld_stub_binder,初始地址也是 0。它是表的第二项,也就是 0x10008008 地址中的值为符号地址。稍后会讲它的作用。
_global 在启动时会进行链接,那么如何知道需要链接哪个动态库呢?我们点开 Symbol Table,会看到如下信息:
可见,符号表中已经包含了 global 所属动态库的信息,libPrint.dylib。同样 dyldstub_binder ,它在 libSystem.B.dylib 中。
虽然动态库中的符号,在生成可执行文件时,没有进行链接,但是在符号表中记录了它在哪个动态库中。这样在运行时进行链接,才能到相应动态库中找到。
在上节中,我们遇到了 dyld_stub_binder 这个陌生人。从字面意思,我们大致可以猜到,它是用来做符号绑定用的。前面提到过,函数符号都是在第一次使用时才进行绑定,其实是通过 dyld_stub_binder 来进行符号查找与地址重定位。鉴于它肩负重大使命,因此必须预先绑定好地址,所以会放到 __got 中。
dyld_stub_binder 是用汇编实现的,在 dyld_stub_binder.s 中。它的调用链路如下:
// 汇编中调用 fastBindLazySymbol
1. dyld::fastBindLazySymbol
// 调用 ImageLoader 处理
2. ImageLoaderMachOCompressed::doBindFastLazySymbol
// 符号绑定
3. ImageLoaderMachOCompressed::bindAt
// 符号地址解析
4. ImageLoaderMachOCompressed::resolve
// 符号地址更新
5. ImageLoaderMachO::bindLocation
其中 resolve 是解析符号地址,bindLocation 进行符号地址更新。
上面我们说到,函数符号的重定位是通过 dyld_stub_binder 来做的,那么有没有依据可寻呢?当然有啦。
从下图可以看出,_print 的地址是 0x100007FAC,不是说在第一次调用时才绑定地址吗?为什么该函数的地址会有值呢?没错,但它需要有人帮忙来进行地址重定位,这个帮手就是 0x100007FAC 处的神秘嘉宾。
这个地址处在 __TEXT 段范围,通过查看 __TEXT 段各个 section 的地址范围,我们很容易发现它处在 __stub_helper 中。如下图所示:
请注意看图上的 1、2、3 标号。地址 0x100007FAC 处于 1 号。它对应的汇编代码功能是:
然后,从 2 号处开始执行,一直到 3 号位置。3 号区域的功能是:
所以,最主要是得弄清楚 0x10008008 地址里面的内容是啥,根据 br 指令推断,它肯定是个函数地址。
有没有觉得 0x10008008 有些熟悉呢?再看看下面这张图,其实在第一节的图中我们已经看到过它。got 中第二项的地址就是 0x10008008,而它正好存储的是 dyld_stub_binder 地址。
这样,一切都清楚了。
变量和函数统称为符号,所有符号信息都在符号表 Symbol Table 中,符号值在字符串表 String Table 中。符号表只是记录了它在字符串表中的下标,因为这样可以节省空间。
而我们上文中提到的 global 是个外部全局变量,那么它存在了符号表中的哪里?可以通过何种路径找到它呢?下面来探寻一下。
首先让我们回到 Mach-O 的 Load Commands 中。它里面有一系列的加载命令,告诉系统如何加载不同的 segment。加载命令中包含了 Section Header 的数组,header 里面包含了每个 section 的基础信息,比如节名称、所属 segment 的名称、地址、大小、偏移、保留字段等等。
既然 got 是一个 section,那么肯定也有对应的头信息。从下图可以看到,在 LG_SEGMENT_64(DATA_CONST) 中,包含了 __got 的 header。
注意右边红框中 Indirect Sym Indx 部分,它表示了 __got 中的第一个符号在间接表中的下标,间接表其实就是动态库符号表。如果 __got 中有多个符号,那么下标依次 +1 即可。
举个栗子,假设 __got 第一个符号在间接表中的下标是 x,那么第二个符号的下标为 x+1,第三个为 x+2,以此类推。如下图所示:
而间接表中的内容是该符号在符号表的下标,取出内容,然后到符号表中查找,便可找到符号信息。到这里还没完,由于符号值并不是直接存在符号表中,而是在字符串表。最后拿字符串下标到字符串表中查找。
这里有点绕,流程如下:
1. 通过 __got section header,拿到 indirectSymIndex。
2. 拿 indirectSymIndex 到间接表中(indirect symbol table)取到符号表中的下标 symIndex。
3. 拿 symIndex 到符号表中取到最终的符号信息,这里有它在字符串表中的下标 strIndex。
4. 拿 strIndex 到字符串表中取到符号字符字符串。
整体图示如下(注:符号表中仅画出了下标,省略了其他信息):
光说不练假把式,下面我们来验证一下。
got section header 中在间接符号表的下标为 1,也就是说第一个符号下标为 1。从上文图中可以看到,got 中总共有 2 个符号,分别为 global 和 dyldstub_bind。如果找到的符号为 _global,那么表示上述结论是正确的。
此时 __got section header 的数据如下图所示,indirect sym index = 1:
那我们到 dynamic symbol table 中去瞧一瞧,找到下标为 1 的数据信息,即第二个数据。如下所示:
从上图可以看出,在对应的 Data 一列中,内容为 3,表示它在符号表中的下标为 3。
此时 indirect symbol table 中的数据如下所示:
然后继续到符号表中看看下标为 3 的数据是啥。如下图所示:
第四项数据 String Table Index,它的值是 0x1c,转换为十进制为 28,这就是字符串表中的下标。
此时符号表中的数据如下所示:
最后一步,来到字符串表中。看看下标为 28 的内容是什么?一行是 16 字节,第二行倒数第四个数就是符号开始处(不放心的可以自己数一数)。
其中,5F 是 _ 的 ascii 码,67 是 g 的 ascii 码,...,一直到 . 号为止。正好对应的是 _global,也就证明了查找过程的正确性。
此时字符串表数据如下:
那对于第二个符号 dyld_stub_binder ,你是否可以自行实践出来呢?
其实,以上查找不仅限于 __got 中的符号,对于延迟加载符号一样适用。下图中 __la_symbol 同样也有 Indirect Sym Index。动态库中的符号都是这种查找方式。
这篇文章中,我们介绍了什么是 got、got 在 mach-o 中的位置、函数符号如何与 dyld_stub_binder 进行关联,以及如何一步步查找动态库符号的值。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8