图解 Mach-O 中的 got

370次阅读  |  发布于3年以前

got 是什么

iOS 开发中,动态库是个绕不开的话题,系统库基本上是动态库。它的一大优势是节约内存,可让多个程序映射同一份的动态库,实现代码共享。动态库本身也是一个 Mach-O 文件,也有数据段、代码段等。其中代码段可读可执行,数据段可读可写。

动态库共享的只是代码段部分,为了达到代码段共享的目的,其符号地址在生成时就不能写死,因为它映射到每个程序中虚拟内存空间中的位置可能不一样。对于数据段部分,由于各个程序会对其进行修改,因此每个程序会单独映射一份。

那么如何解决代码段共享的问题呢?聪明的人们,想出一种精妙的解决方式。通过添加一个中间层,到另一个表中去查找符号的地址。这个表就叫 got,global offset table,全局符号偏移表,然后在运行时绑定地址信息,将地址填入到 got 中。这样代码段中的符号就与具体地址无关,只和 got 有关。这种方式就叫 PIC,Program Independent Code,程序地址无关代码。

或许你可能会想到,got 中保存的是符号地址,而每个程序的地址是不一样的,那 got 肯定是不能共享的。没错,所以 got 会保存在数据段中,每个程序单独一份。在进行符号绑定时,更新 got 中对应符号的地址即可。

got 的位置

在了解 got 是什么之后,我们再来看看 Mach-O 中 got 到底放在了哪里。

通过下图可以看出,有个专门的 __got section 存放 got 数据,而它是属于 __DATA segment。

对于 segment 和 section,可能大家会有些困惑。下面来简单解释一下。

section

section 称为节,是编译器对 .o 内容的划分,将同类资源在逻辑上划分到一起。常见的 section 有:

segment

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 这个陌生人。从字面意思,我们大致可以猜到,它是用来做符号绑定用的。前面提到过,函数符号都是在第一次使用时才进行绑定,其实是通过 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 进行符号地址更新。

lazy 符号重定位

上面我们说到,函数符号的重定位是通过 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 地址。

这样,一切都清楚了。

got 符号值查找

查找原理

变量和函数统称为符号,所有符号信息都在符号表 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