给实习生讲明白 Lazy/Non-lazy Binding

1,895

0x0 参考资料

《程序员的自我修养》

Mach-O 与动态链接 | 张不坏的博客

iOS程序员的自我修养-MachO文件动态链接(四)

0x1 背景

最近被实习生问到了 Mach-O__stubs__stub_stub_helper__la_symbol_ptr__got 相关的概念。

自己之前研究过很多遍这些 section 的运作方式,却一时无法系统地给别人描述清楚(比较尴尬)。

这块要解释清楚确实不太容易,趁着周末又系统地总结了一遍。

我怕下次别人问我的时候,又解释不清,就决定再水一篇文章记录一下吧。

Ps:实习生已经读完了 《程序员的自我修养》的前三章,读者也需要了解这三章

0x2 基本概念

链接分为静态链接和动态链接。代码中引用的「可重定位目标文件」中的符号在静态链接时被解析;引用的「动态库」中的符号将在动态链接时被解析,这个过程称为 Binding。Binding 又有Lazy Binding Non-lazy Binding 的概念。

以上提到的 section 都是在动态链接过程中和 Binding 相关的 section,也是比较难理解的。

0x3 Demo 体验

为了解释清楚这个问题,必须要从一个 Demo 入手。

制作一个动态库

首先编写一个 say.c 文件:

// say.c
#include <stdio.h>
char *kHelloPrefix = "Hello";
void say(char *prefix, char *name) {
    printf("%s, %s\n", prefix, name);
}

该文件定义了一个字符串常量 kHelloPrefix 以及一个函数 say

使用 clang 指令将其编译成动态库。

$ clang -shared say.c -o say.dylib

其中 -shared 指明生成一个动态库,可以和可执行文件进行动态链接。
参考资料中在编译动态库时还使用 -fPIC 选项,笔者测试 clang -shared 应该是默认打开了该选项,加不加该选项生成的动态库没有差异,所以笔者没有加。

完成之后,可以使用 file 指令可以查看该文件的类型: dynamically linked shared library

$ file say.dylib
> say.dylib: Mach-O 64-bit dynamically linked shared library x86_64

Ps:say.dylib 动态库中定义的 kHelloPrefixsay 符号信息在 Mach-OExport Info 中也有保存,在 Export Info 中的符号是 GLOBAL 性质的,可以供其它模块使用:

制作一个可重定位中间文件

再编写一个 main.c 文件:

//main.c
void say(char *prefix, char *name);
extern char *kHelloPrefix;
int main(void) {
    say(kHelloPrefix, "Jack");
    return 0;
}

main.c 中使用了 saykHelloPrefix 这两个符号。

使用 clang 将其编译成可重定位目标文件:

$ clang -c main.c -o main.o
# -c 的含义可以使用通过 man clang 查询,意味着只生成目标文件,而不进行链接生成可执行文件
$ file main.o
> main.o: Mach-O 64-bit object x86_64

main.o 可重定位目标文件中,符号都被标记为 relocations ,在链接时链接器会对它们进行重定位:

制作可执行文件并执行

使用 clangmain.olibsay.dylib 链接成可执行文件:

$ clang main.o -o main -L . -l say
$ file main
> main: Mach-O 64-bit executable x86_64

-L . . 代表当前路径,即在当前路径下寻找要链接的库
-l say 表示链接 libsay.dylib

由于在链接时指定了要链了 libsay.dylib,链接器在 libsay.dylib 中找到了 kHelloPrefixsay,并将他们在可执行文件 main 中标记出来,但并没有计算出它们的地址,如图:

等到执行可执行文件 main 时,mainlibsay.dylib 将被加载,这些符号都将通过动态链接器 dyld 进行动态绑定,程序也将正确地被执行:

$ ./main # 执行程序
> Hello, Jack # 正确输出

0x4 Binding

从逻辑的角度,符号分为两类,「数据」和「函数」。

对这两种符号的绑定称为 Non-lazy binding 和 lazy binding。

Non-lazy binding 是指在动态链接期间立即进行绑定,解析出符号的真实地址。

lazy binding 是指在符号被用到的时候才进行绑定。

Non-Lazy Binding

动态链接期间会解析程序使用的动态库中的「数据型符号」和 dyld_stub_binder 这个函数符号的地址,这些符号的地址都存储于 __DATA __got 中,初值都是 0,在解析完毕后将被真实地址覆盖。这就是所谓的 Non-lazy binding。

程序访问动态库中数据类型符号的流程如下文所述。

使用 otool 查看 main 可执行文件 __TEXT __text 的汇编代码:

1:  $ otool -tv main
2:  main:
3:  (__TEXT,__text) section
4:  _main:
5:  0000000100003f60        pushq        %rbp
6:  0000000100003f61        movq        %rsp, %rbp
7:  0000000100003f64        subq        $0x10, %rsp
8:  0000000100003f68        movq        0x91(%rip), %rax
9:  0000000100003f6f        movl        $0x0, -0x4(%rbp)
10: 0000000100003f76        movq        (%rax), %rdi
11: 0000000100003f79        leaq        0x2e(%rip), %rsi
12: 0000000100003f80        callq        0x100003f8e
13: 0000000100003f85        xorl        %eax, %eax
14: 0000000100003f87        addq        $0x10, %rsp
15: 0000000100003f8b        popq        %rbp
16: 0000000100003f8c        retq

8 行是访问 kHelloPrefix 符号的指令。

movq 0x91(%rip), %rax 的含义是将 0x91(%rip) 的值存储到 rax 寄存器当中。

rip 寄存器中存储的是下一条指令的地址 0000000100003f6f

0x91(%rip) 的值是 0x91 + 0x000000100003f6f = 0x100004000

0x100004000__DATA __got 中第一个元素的地址,里面存储的是 kHelloPrefix 的地址。

由此可见,程序在访问动态库中数据类型符号时,实际上会从 __DATA __got 中寻找该地址。

dyld_stub_binder 比较特殊,后文会介绍,必须得在动态链接阶段就提前解析出它的地址。

Lazy Binding

动态库中函数类型的符号,并不是在动态链接期间就绑定的,因为程序会大量使用动态库中的函数符号(远比数据符号多),如果在动态链接期间就解析这些函数符号的地址,会拖慢程序的启动速度。而且即使解析了这些符号,在程序运行的过程中也不一定会使用。所以为了避免浪费启动时间,这些函数符号在第一次被使用的时候才会被解析,这就是所谓的 Lazy Binding。

下文详细说明了程序首次访问动态库中函数符号的流程。

  1. 使用 otool 查看 main 可执行文件 __TEXT __text 的汇编代码:
1:  $ otool -tv main
2:  main:
3:  (__TEXT,__text) section
4:  _main:
5:  0000000100003f60        pushq        %rbp
6:  0000000100003f61        movq        %rsp, %rbp
7:  0000000100003f64        subq        $0x10, %rsp
8:  0000000100003f68        movq        0x91(%rip), %rax
9:  0000000100003f6f        movl        $0x0, -0x4(%rbp)
10: 0000000100003f76        movq        (%rax), %rdi
11: 0000000100003f79        leaq        0x2e(%rip), %rsi
12: 0000000100003f80        callq        0x100003f8e
13: 0000000100003f85        xorl        %eax, %eax
14: 0000000100003f87        addq        $0x10, %rsp
15: 0000000100003f8b        popq        %rbp
16: 0000000100003f8c        retq

12 行是在调用 say 函数,会调用到 0x100003f8e 这个地址。

0x100003f8e 位于 __TEXT __stubs 中,__TEXT,__text里中对动态库的函数型符号的引用,指向到了__stubs

  1. 使用 otool 查看 __TEXT __stubs 的汇编代码:
$ otool -v main -s __TEXT __stubs
main:
Contents of (__TEXT,__stubs) section
0000000100003f8e        jmpq        *0x406c(%rip)

本例中只有一行指令 jmpq *0x406c(%rip)

jmpq 会计算出 0x406c(%rip) 所指的地址,并取里面的值(* 理解为取值)作为要跳转的地址。

rip 寄存器中存储的是下一条指令的地址,也就是 0x100003f949 = 0x0000000100003f8e + 0x6 (该指令占 6 字节)

0x406c(%rip) = 0x100008000= 0x406c + 0x100003f949

  1. 地址 0x100008000 位于 __DATA __la_symbol_ptr 中,存储的值是 100003FA4
  2. 100003FA4__TEXT __stub_helper 中汇编指令的地址。
  3. 使用 otool 查看 __TEXT __stub_helper 的汇编代码:
1: $ otool -v main -s __TEXT __stub_helper
2: main:
3: Contents of (__TEXT,__stub_helper) section
4: 0000000100003f94        leaq        0x406d(%rip), %r11
5: 0000000100003f9b        pushq        %r11
6: 0000000100003f9d        jmpq        *0x65(%rip)
7: 0000000100003fa3        nop
8: 0000000100003fa4        pushq        $0x0
9: 000000100003fa9        jmp        0x100003f94

100003FA4 位于第 8 行,指令执行到第 9 行时将回到第 4 行继续执行,在第 6 行时又会跳转到 100004008 = 0x65 + 0x``100003fa3 地址中存储的值去执行。

  1. 100004008__DATA __got 中的第二个元素,里面存储着 dyld_stub_binder 函数的地址。

此时 dyld_stub_binder 函数被调用(dyld_stub_binder 的地址已经在动态链接期间解析出来了),会去寻找 say 函数的地址,寻址到了,就把 函数符号say 的地址写入到第 3 步的 __DATA __la_symbol_ptr 数据段,替换掉原来的 100003FA4,并调用 say

以上,我将程序首次访问动态库中函数符号的过程分为了 6 步。之后,当程序再调用 say 时,在第 3 步的 __DATA __la_symbol_ptr 中就可以直接找到 say 的地址,直接调用。

0x5 总结

程序引用的动态库中的数据型符号和dyld_stub_binder 这个函数符号,在动态链接期间进行绑定,是 Non-Lazy Binding。

程序引用的动态库中的函数型符号,会进行 Lazy Binding,即首次调用的时候才会绑定。

会从 __text 调用到 __stubs,再从 __stubs 找到 __la_symbol_ptr 中存储的 __stub_helper 中指令的地址并执行,然后会跳转到 __got 中执行 dyld_stub_binder 函数进行寻址并调用函数,最终找到地址后会调用函数并修改 __la_symbol_ptr 中的值且调用函数。

第二次调用该函数时,会从 __text 调用到 __stubs,再从 __stubs 找到 __la_symbol_ptr 中存储的函数地址并进行调用。

__stubs可以理解为一个表,每个表项是一小段jmp代码,称为「符号桩」,用于寻找并跳转到动态库的函数符号执行。

由于动态库函数符号是懒加载的,所以 __stub 首次 jmp 时需要找到 __stub_helper 中的指令,去执行 dyld_stub_binder 寻址函数进行寻址和修改。因此 __stub_helper 可以理解为 __stubs 的辅助 section

再作一张图进行总结:

我觉得实习生应该能明白了。。