前置知识
符号
静态链接
汇编基础
虚拟内存
跳转指令的编码:PC相对地址与绝对地址
汇编跳转指令:直接跳转与间接跳转
正文开始
静态库有两个主要缺点:
- 一份代码在所有的进程和可执行文件中都有一份拷贝,极大浪费磁盘和内存空间
- 给程序的更新、发布带来很多麻烦。例如:假如
UIKit
是个静态库,苹果爸爸更新了UIKit
修改了个小bug,那么所有用到UIKit
的app都得用更新后的UIKit
库重新链接产生一个新的app,然后上传AppStore.....否则你的app中用的老旧的UIKit
。但是现实中并没有那么麻烦,系统每次升级都可能改了你项目中用到系统库,但是并不需要重新链接生成新app,升级系统后启动app时才会去查找系统中的动态库,利用动态连接器动态加载到内存中供app使用。完美解决静态库的第二个缺点
第一个缺点也好解决:假如多个进程都依赖于同一个库,那么只需要在系统中放一份,用到的时候加载到内存中,另一个进程也需要用的时候直接使用已经加载到内存的库代码即可。例如UIKit
。在系统中放一份动态库代码简单,但是内存中多个进程共享一份库代码怎么做到呢?
难点在于:1. 进程中用到了动态库里的符号,进程需要知道去哪里找这个符号 2. 动态库内部有函数调用和变量常量等符号的引用,动态库编译链接时就需要给符号的引用方明确符号定义地址,以便在运行中去寻找。如果动态库每次加载到一个固定的地址就好了,这样进程知道动态库的符号的地址,编译链接动态库时也能把所有引用符号的地方都填上固定的地址。
这种方法很简单,但是它也造成了一些严重的问题。它对地址空间的使用效率不高,因为即使一个进程不使用这个库,那部分空间还是会被分配出来。它也难以管理。我们必须保证没有片会重叠。每当一个库修改了后,我们必须确认已分配给它的片还适合它的大小。如果不适合,必须要找一个新的片。并且如果创建了一个新的库,我们还必须为它寻找空间。随着时间的发展,假设在一个系统中有了成百上千个库和库的各个版本库,就很难避免地址空间分裂成大量小的、未使用而又不能使用的小洞。更糟的是,对每个系统而言,库在内存中的分配都是不同的,这就引起了更多令人头痛的管理问题。
如果利用虚拟内存,多个进程使用同一个共享库时,系统控制不同进程下将该共享库映射到随意位置,这需要动态链接器在加载的时候修正进程中所有共享库符号的引用,将符号的真实地址修正至引用的内存中。但是这样本来只读可运行的代码段就要变为可读可写可运行的了,不符合安全需求也会导致加载时大量修改造成进程变慢。
要避免这些问题,现在系统以这样一种方式编译共享模块的代码段,使得可以把他们加载到内存的任何位置而无需链接器修改。使用这种方法 ,无限多个进程可以共享一个共享模块的代码段的单一副本。(当然,每个进程仍然会有它自己的读/写数据块,因为不同进程可能会给不同全局变量分配不同的值,所以能共享的只是代码段,对于共享模块的数据段在多个进程中还是有个多个副本的)
这种可以加载而无需重定位的代码成为位置无关代码(Position-Independent Code, PIC)。用户对GCC使用-fpic选项指示GUN编译系统生成PIC代码。共享库的编译必须总是使用该选项。
编译器通过运用一下这个有趣的事实来生成全局变量的PIC引用:无论我们在内存中的何处加载一个目标模块(包括运行模块和共享目标模块),数据段与代码段的距离总是保持不变。因此代码段中任何指令和数据段中的任何变量之间的距离都是一个运行时常量,即:当前指令的地址加上某个常量可以指向任何数据段中的符号地址。
上面讲到本来想在加载共享库的时候修改代码段中对于共享库定义全局符号的引用地址,由于代码段是不可写而不能这么做。那么我们可以在数据段建立一个条目A
,条目中存放共享库定义的全局符号symbolA
的地址,让代码段对于该符号的引用都指向A
。编译时A
默认为0,运行加载动态库时根据动态库加载位置修改A
的存放内容为symbolA
的地址。那么一次加载时修改所有符号对应的数据段条目造成的效率低下问题怎么解决呢?可以先不修改,直到第一次用到该符号的时候才去数据段里读取该条目的内容,如果发现为0就触发查找符号真实地址的逻辑,找到后填写到A
中,方便以后的调用。
以上就是动态链接的基本过程,当然不同系统会有些差异或者更复杂一下,但是基本思路是一致的。
MachO与动态链case
首先,有一个文件 say.c:
#include
char *kHelloPrefix = "Hello";
void say(char *prefix, char *name)
{
printf("%s, %s\n", prefix, name);
}
该模块很简单,定义了两个符号:常量字符串kHelloPrefix,以及函数say。使用 gcc 把say.c编译成 dylib:
gcc -fPIC -shared say.c -o libsay.dylib
# 生成 libsay.dylib
再定义一个使用 say 模块的 main.c:
void say(char *prefix, char *name);
extern char *kHelloPrefix;
int main(void)
{
say(kHelloPrefix, "Jack");
return 0;
}
把 main.c 编译成可重定位中间文件(只编译不链接):
gcc -c main.c -o main.o
# 生成可重定位中间文件:main.o
此时的 main.o 是不可执行的,需要使用链接器 ld 将 sayHello 链接进来:
ld main.o -macosx_version_min 10.14 -o main.out -lSystem -L. -lsay
# -macosx_version_min 用于指定最小系统版本,这是必须的
# -lSystem 用于链接 libSystem.dylib
# -lsay 用于链接 libsay.dylib
# -L. 用于新增动态链接库搜索目录
# 生成可执行文件:main.out
这样就生成了可执行文件 main.out,执行该文件,打印「Hello, Jack」。此时若使用xcrun dyldinfo -dylibs查看 main.out 的依赖库,会发现有两个依赖库:
xcrun dyldinfo -dylibs main.out
attributes dependent dylibs
/usr/lib/libSystem.B.dylib
libsay.dylib
这两个动态库的依赖在 Mach-O 文件中对应两条 type 为LC_LOAD_DYLIB的 load commands,使用otool -l查看如下:
Load command 12
cmd LC_LOAD_DYLIB
cmdsize 56
name /usr/lib/libSystem.B.dylib (offset 24)
time stamp 2 Thu Jan 1 08:00:02 1970
current version 1252.200.5
compatibility version 1.0.0
Load command 13
cmd LC_LOAD_DYLIB
cmdsize 40
name libsay.dylib (offset 24)
time stamp 2 Thu Jan 1 08:00:02 1970
current version 0.0.0
LC_LOAD_DYLIB命令的顺序和 ld 的链接顺序一致。
LC_LOAD_DYLIB命令参数描述了 dylib 的基本信息,结构比较简单:
struct dylib {
union lc_str name; // dylib 的 path
uint32_t timestamp; // dylib 构建的时间戳
uint32_t current_version; // dylib 的版本
uint32_t compatibility_version; // dylib 的兼容版本
};
无论是静态链接,还是动态链接,符号都是最重要的分析对象;来看看 main.out 的符号表(symbol table):
可以看到,symbol table 中有三个未绑定的外部符号:
_kHelloPrefix
、_say
、dyld_stub_binder
;本文接下来对 Mach-O 文件结构的分析将围绕这 3 个符号进行展开。
结构分析
先将 Mach-O 中与动态链接相关的结构给罗列出来:
- Section
- __TEXT __stubs
- __TEXT __stub_helper
- __DATA __nl_symbol_ptr
- __DATA __got
- __DATA __la_symbol_ptr
- Load Command
- LC_LOAD_DYLIB
- LC_SYMTAB
- LC_DYSYMTAB
- Symbol Table
- Indirect Symbol Table
- Dynamic Loader Info
- Binding Info
- Lazy Binding Info
涉及若干个 sections、load commands,以及 indirect symbol table、dynamic loader info 等。其中LC_LOAD_DYLIB
这个命令上文已经提到,它描述了镜像依赖的 dylibs。LC_SYMTAB
定义的符号表(symbol table)是镜像所用到的符号(包括内部符号和外部符号)的集合。
Indirect Symbol Table
每一个可执行的镜像文件,都有一个 symbol table,由LC_SYMTAB
命令定义,包含了镜像所用到的所有符号信息。那么 indirect symbol table 是一个什么东西呢?本质上,indirect symbol table 是 index 数组,即每个条目的内容是一个 index 值,该 index 值(从 0 开始)指向到 symbol table 中的条目。Indirect symbol table 由LC_DYSYMTAB
定义,后者的参数类型是一个dysymtab_command
结构体,详见dysymtab_command,该结构体内容非常丰富,目前我们只需要关注indirectsymoff
和nindirectsyms
这两个字段:
struct dysymtab_command {
uint32_t cmd; /* LC_DYSYMTAB */
uint32_t cmdsize; /* sizeof(struct dysymtab_command) */
// ...
uint32_t indirectsymoff; /* file offset to the indirect symbol table */
uint32_t nindirectsyms; /* number of indirect symbol table entries */
// ...
};
indirectsymoff
字段定义了 indirect symbol table 在MachO文件中的偏移,nindirectsyms
定义了 indirect symbol table 每一个条目是一个 4 bytes 的 index 值。这个index指的是symbol table
的下标,表明当前条目指的是定义在symbol table
中下标为index的符号。
main.out的五个条目如下(标注部分Data为 indirect symbol table 的真实内容):
__text 里的外部符号
回到上文提到的 main.out,查看 main.out 代码段的反汇编内容下:
上述是 main 函数的反汇编代码,注意第 5 行和第 9 行,这两行的指令分别引用了_kHelloPrefix和_say符号;这两个符号未绑定,如果是静态链接,这俩处的地址值是 0;但此处是动态链接,符号目标地址值分别指向的是偏移 0x99 和 0x09,本文所在环境,采用的 PC 相对地址,所以_kHelloPrefix和_say的目标地址分别是:
_kHelloPrefix 的目标地址 = 0xf6f + 0x99 = 0x1008
_say 的目标地址 = 0xf85 + 0x09 = 0xf8e
0x1008
和0xf8e
分别对应 main.out 中的哪个结构呢?答案是 section(__DATA __got) 和 section(__TEXT __stubs):
Mach-O 的代码段对 dylib 外部符号的引用地址,要么指向到__got
,要么指向到__stubs
。什么时候指向到前者,什么时候指向到后者呢?
站在逻辑的角度,符号有两种:数据型和函数型;前者的值指向到全局变量/常量,后者的值指向到函数。在动态链接的概念里,对这两种符号的绑定称为:non-lazy binding
、lazy binding
。对于前者,在程序运行前(加载时)就会被绑定;对于后者,在符号被第一次使用时(运行时)绑定。
section(__DATA __got)
对于程序段__text里的代码,对数据型符号的引用,指向到了__got;可以把__got看作是一个表,每个条目是一个地址值。
在符号绑定(binding)前,__got里所有条目的内容都是 0,当镜像被加载时,dyld 会对__got每个条目所对应的符号进行重定位,将其真正的地址填入,作为条目的内容。换句话说,__got各个条目的具体值,在加载期会被 dyld 重写,这也是为啥这个 section 被分配在 __DATA segment 的原因。
问题来了,dyld 是如何知道__got
中各个条目对应的符号信息(譬如符号名字、目标库等)呢?每个 segment 由LC_SEGMENT
命令定义,该命令后的参数描述了 segment 包含的 section 信息,是谓 section header,对应结构体(x86_64架构)是section_64:
struct section_64 { /* for 64-bit architectures */
char sectname[16]; /* name of this section */
char segname[16]; /* segment this section goes in */
// ...
uint32_t reserved1; /* reserved (for offset or index) */
uint32_t reserved2; /* reserved (for count or sizeof) */
uint32_t reserved3; /* reserved */
};
对于__got、__stubs、__nl_symbol_ptr、__la_symbol_ptr这几个 section,其reserved1描述了该 list 中的符号在间接符号表(IndirectSymbolTable)的起始index:__got中第一个条目在间接符号表(IndirectSymbolTable)的下标为reserved1,第二个条目的下标为reserved1+1......
由上文知间接符号表(IndirectSymbolTable)中存储的内容也是index,指向符号表的下标,由此可得:
index0 = IndirectSymbolTable[got.section_64.reserved1];
symbolTable[index0] 就是got数据段的第一个符号。
index1 = IndirectSymbolTable[got.section_64.reserved1+1];
symbolTable[index1] 就是got数据段的第二个符号。
...依次类推
看我们的例子:
__got section64 header指明__got在间接符号表(IndirectSymbolTable)的起始index为2:
__got中共有两个条目:
__got在间接符号表(IndirectSymbolTable)的起始index为2,__got中共有两个条目,则__got在IndirectSymbolTable中的对应条目如下 :
__got最终在符号表中对应的符号:
总之一句话,__got为 dyld 服务,用来存放 non-lazy 符号的最终地址值。
section(__TEXT __stubs)
对于程序段__text里的代码,对函数型符号的引用,指向到了__stubs。和__got一样,__stubs也是一个表,每个表项是一小段jmp代码,称为「符号桩」。和__got不同的是,__stubs存在于 __TEXT segment 中,所以其中的条目内容是不可更改的。
查看__stubs里的反汇编内容:
$ otool -v main.out -s __TEXT __stubsM
main.out:
Contents of (__TEXT,__stubs) section
0000000100000f8e jmpq *0x84(%rip)
那么具体跳转地址是多少呢?是 *(0000000100000f8e + 0x84 + 当前跳转指令长度)),
查看原始数据得知当前指令长度为6
jmp地址为 *(0000000100000f8e + 0x84 + 6) = *(0x100001018), 注意这里是间接跳转
0x100001018是哪个部分呢?答案是 section(__DATA __la_symbol_ptr)...
section(__DATA __la_symbol_ptr)
__la_symbol_ptr内容:
所以__stubs第一个 stub 的 jump 目标地址是 0xFA4。该地址位于 section(__TEXT __stub_helper)。
section(__TEXT __stub_helper)
这几条汇编代码比较简单,可以看出,代码最终会跳到0xFD9的位置;之后该何处何从?
不难计算,0xFD9的跳转目标地址是 0x1010 (0xfa3 + 0x6d)存储的内容,0x1010 在哪里呢?0x1010 坐落于 section(__DATA __got)。是__got的第二个条目:dyld_stub_binder:
dyld_stub_binder是一个函数,为什么它被当作non-lazy symbol 处理?这是因为它是所有lazy bingding的基础,lazy symbol都要靠dyld_stub_binder去查找符号的真实地址,如果dyld_stub_binder也是lazy symbol要通过什么函数去查找呢,总不能通过自己去找把:递归死循环了!
dyld_stub_binder
dyld_stub_binder
也是一个函数,定义于dyld_stub_binder.S,由 dyld 提供。
Lazy binding symbol 的绑定工作正是由 dyld_stub_binder 触发,通过调用dyld::fastBindLazySymbol来完成。
Lazy Binding 分析
上文结合 main.out 实例,对 Mach-O 与动态链接相关的结构做了比较全面的分析。Non-lazy binding 比较容易理解,这里稍微对如上内容进行整合,整体对 lazy binding 基本逻辑进行概述。
对于__text代码段里需要被 lazy binding 的符号引用(如上文 main.out 里的_say),访问它时总会跳转到 stub 中,该 stub 的本质是一个 jmp 指令,该 stub 的跳转目标地址存储于__la_symbol_ptr。(因为是间接跳转:先计算PC相对地址的真实值,找到__la_symbol_ptr中的条目,取出条目中存储的地址addr,跳转到这个地址addr)
首次访问符号A流程:
后续访问符号A流程:
有如下代码:
void test() {
printf("Hello ");
printf("world!\n");
}
看两次调用printf
函数时,stub跳转地址的差异:
因为第一次已经找到了
printf
的地址并记录到了__la_symbol_ptr
中,所以第二次调用可以直接获取printf
的地址
结语
最终MachO通过以上方式实现可以加载而无需重定位无需修改代码的方式实现了PIC,动态库可以加载到任意位置。一般数据符号会在动态库加载的时候解析,函数符号用到的时候才解析,因为数据符号的数量一般不会太多,多了动态库和可执行文件的耦合就严重了。
参考
本文大部分抄自Mach-O 与动态链接
iOS程序员的自我修养-MachO文件动态链接(四)
感谢前方的诸位大佬分享,传道授业