1.引言
在软件开发过程中,动态库链接问题时常出现,这可能导致符号冲突,从而引起程序运行异常或崩溃。为深入理解动态链接机制及其工作原理,我重温了《程序员的自我修养》,并通过实践演示与反汇编分析,了解了动态链接的过程。
本文将深入探讨Linux系统中的动态链接库机制,这其中包括但不限于全局符号介入(Global Symbol Interposition)、延迟绑定(Lazy Binding)以及地址无关代码(Position-Independent Code, PIC)等内容。通过对上述概念和技术细节的讨论,希望能够提供一个更加清晰的认知框架,从而揭示符号冲突背后隐藏的本质原因。这样一来,在实际软件开发过程中遇到类似问题时,开发者们便能更加游刃有余地采取措施进行预防或解决,确保程序稳定运行的同时提升整体质量与用户体验。
为便于读者查阅,本文中提及的一些基本概念,例如ELF、PIC、GOT、PLT、常用的section等,被归纳整理于附录部分。
2.先举个🌰
我们将通过一个简单的 C 语言程序,逐步探讨动态链接库在模块内部及模块间的运行机制,其中涉及变量和函数之间的交互过程。同时,我们将使用 -fPIC 选项,以确保生成位置无关代码。
#include <stdio.h> // 静态变量 a 仅在本模块中可见 static int a; // 用 extern 声明外部全局变量 b extern int b; // 在本模块访问的全局变量 c int c = 3; // 声明外部函数 ext() extern void ext(); // 静态函数 inner() 的作用域仅限于本模块 static void inner() {} // bar() 函数修改静态变量 a 和外部全局变量 b void bar() { a = 1; // 修改静态变量 a 的值 b = 2; // 修改外部全局变量 b 的值 c = 4; // 修改模块内的全局变量 c 的值 } // foo() 函数内调用了 inner、bar 和 ext,并打印变量值 void foo() { inner(); // 调用静态函数 inner() bar(); // 调用函数 bar() ext(); // 调用外部函数 ext() printf("a = %d, b = %d, c = %d\n", a, b, c); // 输出变量的值 }
// 定义外部全局变量 b int b = 1; // 外部函数 ext() 修改外部全局变量 b 的值 void ext() { b = 3; // 修改外部全局变量 b 的值 } // main.c int main() { foo(); // 调用 foo() 函数,演示模块间交互 return 0; // 程序正常结束 }
gcc -shared -fPIC -o libpic.so pic.c -g
gcc -o main main.c -L. -lpic
在此代码示例中,使用 -fPIC 编译选项可以生成位置无关的代码,适用于创建共享库。代码中包含了多个场景:
- 模块内函数调用:foo 函数中调用了 inner 和 bar 函数。由于 inner 是静态函数,其作用域仅限于本模块。bar 函数操作了模块内的静态变量 a 和全局变量 c。
- 模块间函数调用:foo 函数调用了外部函数 ext,这是一个在其他模块中定义的函数。ext 负责修改外部全局变量 b。
不同类型的变量:
- 静态变量 a 仅在本模块可见,其值不会在程序的其他模块中改变,也不会因函数调用而丢失。
- 外部全局变量 b 可以在多个模块间共享,其值在整个程序中是唯一且可改变的。
- 模块内的全局变量 c 仅能在当前模块访问和修改。
我们都知道动态链接库需要能够在多个进程之间共享同一段代码。为了实现这一点,代码必须是位置无关的,从而可以在加载时按需被链接到不同的地址,编译时添加编译选项-fPIC 可以生成地址无关代码,那这些函数和变量运行时,如何做到呢?接下来将逐步分析动态链接的过程。
3.从例子来深入动态链接库
3.1 模块内函数调用
例子中 foo 函数实现中有两个函数调用:静态函数 inner()和非静态函数 bar(),反汇编后结果。
Disassembly of section .plt: 0000000000000670 <bar@plt-0x10>: 670: ff 35 92 09 20 00 push QWORD PTR [rip+0x200992] # 201008 <_GLOBAL_OFFSET_TABLE_+0x8> 676: ff 25 94 09 20 00 jmp QWORD PTR [rip+0x200994] # 201010 <_GLOBAL_OFFSET_TABLE_+0x10> 67c: 0f 1f 40 00 nop DWORD PTR [rax+0x0] 0000000000000680 <bar@plt>: 680: ff 25 92 09 20 00 jmp QWORD PTR [rip+0x200992] # 201018 <_GLOBAL_OFFSET_TABLE_+0x18> 686: 68 00 00 00 00 push 0x0 68b: e9 e0 ff ff ff jmp 670 <_init+0x20> ... 00000000000007e8 <foo>: foo(): 00000000000007e2 <inner>: inner(): /mnt/share/demo1/pic.c:12 static void inner() {} 7e2: 55 push rbp 7e3: 48 89 e5 mov rbp,rsp 7e6: 5d pop rbp 7e7: c3 ret ... /mnt/share/demo1/pic.c:15 inner(); 7ec: b8 00 00 00 00 mov eax,0x0 7f1: e8 ec ff ff ff call 7e2 <inner> /mnt/share/demo1/pic.c:16 bar(); 7f6: b8 00 00 00 00 mov eax,0x0 7fb: e8 80 fe ff ff call 680 <bar@plt>
3.1.1 静态函数调用:inner()函数调用
和静态编译重定位相似,这里更简单,具体如下:
7f1: e8 ec ff ff ff call 7e2
- e8:相对偏移调用指令
- ec ff ff ff:小端 0XFFFFFFEC 是-20 的补码,该数值为目的地址相对于当前指令下一条指令的偏移。即 inner 地址为 0x7f6(下一条指令偏移) - 0x14 = 0x7e2
结论:静态函数调用很简单,通过相对地址偏移就可以跳转。
3.1.2 全局函数调用:bar()函数调用
首次调用
7fb: e8 80 fe ff ff call 680
- 解析规则同上,不展开,但是跳转的地址为 0x680 ,
- 第一条指令为jmp QWORD PTR [rip+0x200992],这是一个间接跳转(jmp)指令,运行跳转地址 0x201018,该地址是什么?
objdump -s libpic.so Contents of section .got: 200fc8 00000000 00000000 00000000 00000000 ................ 200fd8 00000000 00000000 00000000 00000000 ................ 200fe8 00000000 00000000 00000000 00000000 ................ 200ff8 00000000 00000000 ........ Contents of section .got.plt: 201000 080e2000 00000000 00000000 00000000 .. ............. 201010 00000000 00000000 86060000 00000000 ................ 201020 96060000 00000000 a6060000 00000000 ................ 201030 b6060000 00000000 c6060000 00000000 ................
- 发现这个地址在.got.plt section,0x00000686, 该地址存的地址为
0000000000000680 <bar@plt>: 680: ff 25 92 09 20 00 jmp QWORD PTR [rip+0x200992] # 201018 <_GLOBAL_OFFSET_TABLE_+0x18> 686: 68 00 00 00 00 push 0x0 68b: e9 e0 ff ff ff jmp 670 <_init+0x20>
那上面一系列地址跳转是在干什么?用一个示意图表示 bar 首次地址重定位过程(橙色是调用入口,蓝色是运行的指令,紫色代表修正的地址)。
_dl_runtime_resolve()函数实现不展开,该函数的入参为入栈的符号索引 index 和库 ID,解析过程会依赖.dynamic、.rela.plt 等 section 信息,解析后重定向地址后填入地址0x201018 。可以查看下.rela.plt 段内容有什么。
[root@docker-desktop demo1]# readelf -r libpic.so Relocation section '.rela.dyn' at offset 0x4e8 contains 10 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000200de8 000000000008 R_X86_64_RELATIVE 780 000000200df0 000000000008 R_X86_64_RELATIVE 740 000000200e00 000000000008 R_X86_64_RELATIVE 200e00 000000200fc8 000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0 000000200fd0 000300000006 R_X86_64_GLOB_DAT 0000000000000000 b + 0 000000200fd8 000500000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0 000000200fe0 000e00000006 R_X86_64_GLOB_DAT 0000000000201040 c + 0 000000200fe8 000700000006 R_X86_64_GLOB_DAT 0000000000000000 _Jv_RegisterClasses + 0 000000200ff0 000800000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0 000000200ff8 000900000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize + 0 Relocation section '.rela.plt' at offset 0x5d8 contains 5 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000201018 000b00000007 R_X86_64_JUMP_SLO 00000000000007b8 bar + 0 000000201020 000400000007 R_X86_64_JUMP_SLO 0000000000000000 printf + 0 000000201028 000500000007 R_X86_64_JUMP_SLO 0000000000000000 __gmon_start__ + 0 000000201030 000600000007 R_X86_64_JUMP_SLO 0000000000000000 ext + 0 000000201038 000900000007 R_X86_64_JUMP_SLO 0000000000000000 __cxa_finalize + 0
.rela.plt是 ELF 文件中包含了函数跳转槽重定位信息。具体代表含义:
- Offset - 表示在内存中的偏移地址,即在 GOT 中重定位项的地址。
- Info - 包含两个部分:符号的索引和重定位类型。在这种情况下,重定位类型是 R_X86_64_JUMP_SLOT,用于处理函数调用的跳转。
- Type - 描述了重定位的类型,这里是 R_X86_64_JUMP_SLOT,用于通过懒加载解析符号的PLT入口。其他类型还有很多,常见的还有
- R_X86_64_GLOB_DAT - 设置全局偏移表的内容。
- R_X86_64_64 - 64位直接重定位;修改64位的值。
- R_X86_64_PC32 - 32位PC相对重定位;修改指令内偏移的32位值。
- R_X86_64_GOT32 - 32位的全局偏移表(GOT)入口。
- R_X86_64_PLT32 - 用于函数调用的32位PLT重定位。
- R_X86_64_GLOB_DAT - 设置全局偏移表的内容。
- R_X86_64_RELATIVE - 需要基地址重置,用于模块加载专用的相对地址调整。
- R_X86_64_GOTPCREL - 访问GOT的PC相对重定位。
- Sym. Value - 是符号在它本身定义模块内的值。在重定位发生之前,符号可能还没有最终的运行时地址。对于本地符号(比如 bar 函数),这里通常是它们在当前模块中的偏移地址。对于外部符号(比如 printf),在重定位前这里通常是 0,表示地址还未确定。
- Sym. Name + Addend - 显示了符号的名称以及添加量。添加量在这里是 0,因为我们正在查看 .rela 格式的重定位项,添加量已经包含在每个重定位项中。
在运行时,动态链接器会依据这些重定位项进行地址解析工作。例如,当程序第一次调用 printf 时,控制流首先跳转到 printf 在 PLT 中的对应项,PLT 中会有一段存根代码触发动态链接器,动态链接器解析出 printf 的真实地址并更新 GOT 中对应的地址。
第二次调用
运行后地址重定位后,第二次调用就会简单很多,如下图所示:
使用 GDB 调试运行后,单步调试地址重定向.got.plt 段内容(基地址为:0x7F7A97F75000)。
201000 080e2000 00000000 00000000 00000000 .. .............
(gdb) x/16a 0x7f7a98176000 0x7f7a98176000: 0x200e08 0x7f7a983976a8 0x7f7a98176010: 0x7f7a9818d890 <_dl_runtime_resolve_xsave> 0x7f7a97f75686 <bar@plt+6> 0x7f7a98176020: 0x7f7a97f75696 <printf@plt+6> 0x7f7a97f756a6 <__gmon_start__@plt+6> 0x7f7a98176030: 0x7f7a97f756b6 <ext@plt+6> 0x7f7a97f756c6 <__cxa_finalize@plt+6> 0x7f7a98176040 <c>: 0x3 0x0 0x7f7a98176050: 0x31303220352e382e 0x5228203332363035 0x7f7a98176060: 0x3420746148206465 0x2936332d352e382e 0x7f7a98176070: 0x20000002c00 0x8000000
.got.plt 中 bar 地址 = 0x201018 + 0x7F7A97F75000(基地址) = 0x7F7A98176018,0x7F7A98176018 内容为0x7f7a97f75686 ,和上图的相对地址偏移相同,重定向后结果如下
(gdb) x/16a 0x7f7a98176000 0x7f7a98176000: 0x200e08 0x7f7a983976a8 0x7f7a98176010: 0x7f7a9818d890 <_dl_runtime_resolve_xsave> 0x7f7a97f757b8 <bar> 0x7f7a98176020: 0x7f7a97f75696 <printf@plt+6> 0x7f7a97f756a6 <__gmon_start__@plt+6> 0x7f7a98176030: 0x7f7a97f756b6 <ext@plt+6> 0x7f7a97f756c6 <__cxa_finalize@plt+6> 0x7f7a98176040 <c>: 0x3 0x0 0x7f7a98176050: 0x31303220352e382e 0x5228203332363035 0x7f7a98176060: 0x3420746148206465 0x2936332d352e382e 0x7f7a98176070: 0x20000002c00 0x8000000
0x7f7a97f757b8 为代码段,0x7f7a97f757b8 - 0x7F7A97F75000(基地址)= 0x7B8,该偏移在.text 的 bar 入口地址,也对应起来了。
抽象一下,如下示意图:
通过上图指令跳转得出,.plt,利用.got.plt 可写权限,在程序运行时,修正.got.plt 对应函数指向的.text (不可写)地址,从而实现了地址无关代码。
该过程还隐藏了一个知识点,延迟绑定(lazy binding)。动态链接器在运行时完成,若已一开始执行,要加载完所有的符号的话,想必会减慢程序的启动速度,影响性能。所以当函数第一次被用到时再进行绑定,如果没有用就不绑定,这样可以大大加快程序启动速度。本例子中的 bar 也是在调用时才进行重定向,不调用不进行地址重定向绑定,即实现了延迟绑定效果。
是不是外部函数重定向一定在 .rela.plt?
不是,如果是PIC 编译,会在.rela.plt;如果不是PIC 编译,会在.rela.dyn 出现。
原因:开启 PIC 调用指令会指向 PLT 中的一个条目,需要.rela.plt section 配合实现 Lazy Binding,.rela.dyn 段用于动态链接器在加载时将符号绑定到其运行时地址的重定位条目。它包含了不特定于PLT条目的其他动态重定位信息,.rela.plt 主要针对PLT进行重定位,用于动态链接时解析函数地址,实现惰性绑定,而 .rela.dyn 用于更广泛的动态重定位需求。
疑问?
- 问题一:模块内全局函数调用和模块间全局函数调用有什么区别?
- 问题二:为什么都是函数调用,静态函数和全局函数调用跳转差别这么大?
这两个问题先不着急回答,我们接着看模块间函数调用。
3.2 模块间函数调用
例子中是 foo() 对 ext()函数的调用,查看汇编,发现和模块内函数调用方式一模一样。汇编指令如下:
/mnt/share/demo1/pic.c:17 ext(); 800: b8 00 00 00 00 mov eax,0x0 805: e8 a6 fe ff ff call 6b0 <ext@plt>
那现在回答上一节的第一个问题,模块内和模块间全局函数调用没有区别,为什么呢?
先回忆下加载过程,动态链接器完成自举后,会将可执行文件和链接器本身的符号表都合并到一个符号表中,该符号表叫做全局符号表(Global Symbol Table)。当一个符号需要被加入全局符号表时,如果相同的符号已经存在,则后加入的符号被忽略,这种规则叫做全局符号介入。
由于全局符号介入规则,若上一节的模块内部函数调用 bar() 直接采用相对地址调用话,可能会被其他模块的同名函数符号覆盖,那相对地址就是无法准确找到正确的函数地址,故模块内和模块外的函数调用,都需要通过.got.plt 重定位方法间接调用。
那上一节第二个问题答案也显而易见,静态函数不涉及全局符号介入问题,可以通过模块内部相对地址跳转就可以。这样调用的寻址速度也比全局函数的寻址速度快。
为了更深入理解全局符号介入,我们再举个例子。
/* a1.c*/ #include <stdio.h> void a() { printf("a1.c\n"); } /* a2.c */ #include <stdio.h> void a() { printf("a2.c\n"); } /* b1.c */ void a(); void b1() { a(); } /* b2.c */ void a(); void b2() { a(); } /* main.c */ #include <stdio.h> void b1(); void b2(); int main() { b1(); b2(); return 0; }
[root@docker-desktop priority]# g++ -fPIC -shared a1.c -o a1.so [root@docker-desktop priority]# g++ -fPIC -shared a2.c -o a2.so [root@docker-desktop priority]# g++ -fPIC -shared b1.c a1.so -o b1.so [root@docker-desktop priority]# g++ -fPIC -shared b2.c a2.so -o b2.so [root@docker-desktop priority]# ldd b1.so a1.so (0x0000004001c2a000) libstdc++.so.6 => /usr/local/gcc-5.4.0/lib64/libstdc++.so.6 (0x0000004001e2c000) libm.so.6 => /lib64/libm.so.6 (0x00000040021ad000) libgcc_s.so.1 => /usr/local/gcc-5.4.0/lib64/libgcc_s.so.1 (0x00000040024b0000) libc.so.6 => /lib64/libc.so.6 (0x00000040026c7000) /lib64/ld-linux-x86-64.so.2 (0x0000004000000000) [root@docker-desktop priority]# ldd b2.so a2.so (0x0000004001c2a000) libstdc++.so.6 => /usr/local/gcc-5.4.0/lib64/libstdc++.so.6 (0x0000004001e2c000) libm.so.6 => /lib64/libm.so.6 (0x00000040021ad000) libgcc_s.so.1 => /usr/local/gcc-5.4.0/lib64/libgcc_s.so.1 (0x00000040024b0000) libc.so.6 => /lib64/libc.so.6 (0x00000040026c7000) /lib64/ld-linux-x86-64.so.2 (0x0000004000000000) [root@docker-desktop priority]# g++ main.c b1.so b2.so -o main [root@docker-desktop priority]# ./main a1.c a1.c
在上述例子中,虽然 b1.so 和 b2.so 中都调用了 a() 函数,但由于 main 程序首先链接了 b1.so,导致 a() 的实现使用了 a1.so 中的定义。因此,无论 b2.so 如何变化,main 程序中调用的都始终是 a1.so 的实现。这种现象强调了在动态链接库中符号的解析顺序及如何影响最终的执行结果,开发者在设计接口时需谨慎考虑符号的命名和库的加载顺序,以避免潜在的符号冲突和不确定性。
3.3 模块内变量 和模块间变量
例子中的静态变量 a 、外部全局变量 b、 内部全局变量 c,看下反汇编后结果:
void bar() { 7b8: 55 push rbp 7b9: 48 89 e5 mov rbp,rsp /mnt/share/demo1/pic.c:7 a = 1; 7bc: c7 05 82 08 20 00 01 mov DWORD PTR [rip+0x200882],0x1 # 201048 <__TMC_END__> 7c3: 00 00 00 /mnt/share/demo1/pic.c:8 b = 2; 7c6: 48 8b 05 03 08 20 00 mov rax,QWORD PTR [rip+0x200803] # 200fd0 <_DYNAMIC+0x1c8> 7cd: c7 00 02 00 00 00 mov DWORD PTR [rax],0x2 /mnt/share/demo1/pic.c:9 c = 4; 7d3: 48 8b 05 06 08 20 00 mov rax,QWORD PTR [rip+0x200806] # 200fe0 <_DYNAMIC+0x1d8> 7da: c7 00 04 00 00 00 mov DWORD PTR [rax],0x4 /mnt/share/demo1/pic.c:10 }
Idx Name Size VMA LMA File off Algn CONTENTS, ALLOC, LOAD, DATA 20 .got 00000038 0000000000200fc8 0000000000200fc8 00000fc8 2**3 CONTENTS, ALLOC, LOAD, DATA 21 .got.plt 00000040 0000000000201000 0000000000201000 00001000 2**3 CONTENTS, ALLOC, LOAD, DATA 22 .data 00000004 0000000000201040 0000000000201040 00001040 2**2 CONTENTS, ALLOC, LOAD, DATA 23 .bss 0000000c 0000000000201044 0000000000201044 00001044 2**2 ALLOC
static int a; # 201048 <__TMC_END__> ==> .bss
extern int b; # 200fd0 <_DYNAMIC+0x1c8> ==> .got
int c; # 200fe0 <_DYNAMIC+0x1d8> ==> .got
结合上面了解的函数调用,变量调用跳转类似,static 变量的访问直接通过偏移量完成,这种方式更高效,因为 static 变量的作用域限制在同一个编译单元,所以它们的地址可以在编译时确定(相对于 rip)。而非 static 变量(包括定义在当前模块的全局变量和 extern 变量)可能被其他模块引用或修改,其地址需要在运行时通过动态链接器解析,对于全局和 extern 变量,共享库使用基于 rip 的寻址加上 运行时重定位.got 段中地址,以确保位置无关。
全局变量的地址不存在延迟绑定,因为通常会在加载时解析,并通过全局偏移表(Global Offset Table, GOT)来访问,而不是延迟到首次使用时。因此,把它们的地址解析延迟将不会带来明显的优势,而且会在运行时增加额外的性能负担。
4.地址无关延伸
4.1 隐藏符号影响
如果把 bar 和变量 c 使用__attribute__((visibility("hidden")))隐藏的符号,那函数调用跳转会有什么变化?
#include <stdio.h> static int a; extern int b; __attribute__((visibility("hidden"))) int c = 3; extern void ext(); void bar() __attribute__((visibility("hidden"))); void bar() { a = 1; b = 2; c = 4; } static void inner() {} void foo() { inner(); bar(); ext(); printf("a = %d, b = %d, c = %d\n", a, b, c); }
反汇编后结果
[root@docker-desktop demo1]# objdump -d -M intel -S -l libpic_hidden.so Disassembly of section .text: ... 0000000000000738 <bar>: bar(): /mnt/share/demo1/pic_hidden.c:7 static int a; extern int b; __attribute__((visibility("hidden"))) int c = 3; extern void ext(); void bar() __attribute__((visibility("hidden"))); void bar() { 738: 55 push rbp 739: 48 89 e5 mov rbp,rsp /mnt/share/demo1/pic_hidden.c:8 a = 1; 73c: c7 05 fa 08 20 00 01 mov DWORD PTR [rip+0x2008fa],0x1 # 201040 <__TMC_END__> 743: 00 00 00 /mnt/share/demo1/pic_hidden.c:9 b = 2; 746: 48 8b 05 8b 08 20 00 mov rax,QWORD PTR [rip+0x20088b] # 200fd8 <_DYNAMIC+0x1c8> 74d: c7 00 02 00 00 00 mov DWORD PTR [rax],0x2 /mnt/share/demo1/pic_hidden.c:10 c = 4; 753: c7 05 db 08 20 00 04 mov DWORD PTR [rip+0x2008db],0x4 # 201038 <c> 75a: 00 00 00 ... /mnt/share/demo1/pic_hidden.c:17 bar(); 773: b8 00 00 00 00 mov eax,0x0 778: e8 bb ff ff ff call 738 <bar>
[root@docker-desktop demo1]# readelf -S libpic_hidden.so There are 34 section headers, starting at offset 0x1470: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align ...... [23] .data PROGBITS 0000000000201038 00001038 0000000000000004 0000000000000000 WA 0 0 4
- bar: 反汇编后看到调用 bar 直接可以通过相对地址跳转,不需要运行重定位。
- int c; # 201038 ==> .data section
查看.rela.plt section
[root@docker-desktop demo1]# readelf -r libpic_hidden.so Relocation section '.rela.dyn' at offset 0x4a8 contains 9 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000200df0 000000000008 R_X86_64_RELATIVE 700 000000200df8 000000000008 R_X86_64_RELATIVE 6c0 000000200e08 000000000008 R_X86_64_RELATIVE 200e08 000000200fd0 000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0 000000200fd8 000300000006 R_X86_64_GLOB_DAT 0000000000000000 b + 0 000000200fe0 000500000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0 000000200fe8 000700000006 R_X86_64_GLOB_DAT 0000000000000000 _Jv_RegisterClasses + 0 000000200ff0 000800000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0 000000200ff8 000900000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize + 0 Relocation section '.rela.plt' at offset 0x580 contains 4 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000201018 000400000007 R_X86_64_JUMP_SLO 0000000000000000 printf + 0 000000201020 000500000007 R_X86_64_JUMP_SLO 0000000000000000 __gmon_start__ + 0 000000201028 000600000007 R_X86_64_JUMP_SLO 0000000000000000 ext + 0 000000201030 000900000007 R_X86_64_JUMP_SLO 0000000000000000 __cxa_finalize + 0
.rela.plt 中已经没有 bar(),.rela.dyn中没有变量 c ,所以隐藏后,bar() 不需要重定位,变量 c也不需要间接跳转。隐藏的符号 bar() 和 c 也不会出现在动态链接库的动态符号表(.dynsym)中,因此它们在链接时不可见于其他共享对象或者可执行文件,所以隐藏符号不存在全局符号介入的场景。
4.2 关于 PIC 回答几个小问题
1.如何区分一个 DSO 是否为 PIC
readelf -d xxx.so | grep TEXTREL
如果没有输出,则动态库是使用 PIC 生成的。文本重定位(TEXTREL)意味着代码部分(.text section)需要修改以引用正确的地址,在非PIC的代码中,会存在基于绝对地址的引用,这就需要在加载时进行修改,从而使得代码能够正确运行,这个过程就是文本重定位。
2. 如何区分一个静态库是否为 PIC
ar -t xxx.a readelf -r xxx.o
你需要检查输出中是否有基于绝对地址的重定位类型比如 R_X86_64_GOTPCREL 或其他类似的不是专为 PIC 代码的重定位类型。
3. 假设静态编译库编译不使用-fPIC,动态库编译使用-fPIC,是否 ok?
不行。实测静态库 a.a 不使用-fPIC,动态库 b.so 使用-fPIC,可执行程序 main 链接两个库会编译失败。报错日志如下:
g++ -c nopic_common.c -o nopic_common.o ar rcs libnopic_common.a nopic_common.o g++ -shared -o libnopic.so pic.c -L. -lnopic_common -fPIC /usr/bin/ld: ./libnopic_common.a(nopic_common.o): relocation R_X86_64_PC32 against symbol `b' can not be used when making a shared object; recompile with -fPIC /usr/bin/ld: final link failed: Bad value collect2: error: ld returned 1 exit status
nopic_common.o 对象文件是没有使用 -fPIC 编译的,因此包含以 PC 相对的方式(R_X86_64_PC32 relocation type)引用全局变量 b。这种类型的重定位不兼容于动态库的创建,因为它要求代码必须在特定地址执行,而动态库加载的地址在运行时是未知的,甚至每次运行都可能不同。即静态库的代码假定某些数据或函数存在于固定地址,而该地址已经被其他代码或库占用,则可能会导致链接错误或运行时错误。
要修复这个错误,你需要重新编译 nopic_common.o,将其中的代码编译为位置无关代码(PIC)。
4. 为什么动态库编译时不默认采用PIC:
- 历史原因:历史惯性,较早的编译器版本中没有将生成PIC作为默认选项。
- 选项传递的问题:-fPIC是编译器的选项,是在源代码编译阶段决定的,而-shared是链接器的选项, 是在不同阶段,所以无法通过-shared自动启用-fPIC。
- 性能:虽然PIC对于共享库的高效运行是很重要的,但在某些情况下PIC代码也可能稍微慢于非PIC代码,因为它需要使用间接地址引用全局变量和函数。这种性能影响一般是很小的,但在对性能要求非常高的应用程序中,这可能是一个因素。
- 编译器和构建系统设计:编译器和构建系统往往允许开发者根据项目需求选择是否生成PIC。允许灵活配置使开发者能够根据具体的使用场景和需求,选择最合适的编译选项。
4.3 动态和静态链接的重定向区别
静态链接 |
动态链接 |
|
阶段 |
编译链接阶段 |
装载运行阶段 |
执行控制权 |
控制权直接交给可执行文件 |
控制权限交给动态链接器,映射完成后再交给可执行文件 |
运行寻址速度 |
速度快 |
由于间接跳转,比静态链接慢约 1%~5%,使用 lazy binding 改善 |
重定位表名 |
.rela.text 代码段重定位表 .rela.data 数据段重定位表 |
.rela.plt 代码段重定位表 .rela.dyn 数据段重定位表 |
5.如何指定全局变量和函数装载时的顺序
上面主要介绍了动态装载过程,在初始化和反初始化的时候,特别需要关注全局变量和函数的构造与析构顺序。这些过程直接影响到模块间的依赖关系和对象之间的交互。因此,我们需要了解如何通过使用特定的属性来控制这些顺序,以确保程序的稳定性和预期行为。特别是在多模块动态库的环境中,合理安排初始化和反初始化的顺序,是避免运行时错误和崩溃的重要措施。
5.1 全局变量初始化顺序
对于跨共享库的全局变量,其初始化顺序受这些共享库之间的依赖关系影响。如果共享库 A 依赖于共享库 B,那么 B 的初始化代码将会在 A 的初始化代码之前执行,因此 B 中的全局变量会在 A 中的全局变量之前被初始化。
再来看一下《第一章 2 模块间函数调用》例子中,通过LD_DEBUG=files ./main命令看链接顺序和初始化顺序。
[root@docker-desktop]# LD_DEBUG=files ./main 112: find library=b1.so [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64/tls/i686:/usr/local/gcc-5.4.0/lib64/tls:/usr/local/gcc-5.4.0/lib64/i686:/usr/local/gcc-5.4.0/lib64:tls/i686:tls:i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/tls/i686/b1.so 112: trying file=/usr/local/gcc-5.4.0/lib64/tls/b1.so 112: trying file=/usr/local/gcc-5.4.0/lib64/i686/b1.so 112: trying file=/usr/local/gcc-5.4.0/lib64/b1.so 112: trying file=tls/i686/b1.so 112: trying file=tls/b1.so 112: trying file=i686/b1.so 112: trying file=b1.so 112: 112: find library=b2.so [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686:tls:i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/b2.so 112: trying file=tls/i686/b2.so 112: trying file=tls/b2.so 112: trying file=i686/b2.so 112: trying file=b2.so 112: 112: find library=libstdc++.so.6 [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686:tls:i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/libstdc++.so.6 112: 112: find library=libm.so.6 [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686:tls:i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/libm.so.6 112: trying file=tls/i686/libm.so.6 112: trying file=tls/libm.so.6 112: trying file=i686/libm.so.6 112: trying file=libm.so.6 112: search cache=/etc/ld.so.cache 112: trying file=/lib64/libm.so.6 112: 112: find library=libgcc_s.so.1 [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686:tls:i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/libgcc_s.so.1 112: 112: find library=libc.so.6 [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686:tls:i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/libc.so.6 112: trying file=tls/i686/libc.so.6 112: trying file=tls/libc.so.6 112: trying file=i686/libc.so.6 112: trying file=libc.so.6 112: search cache=/etc/ld.so.cache 112: trying file=/lib64/libc.so.6 112: 112: find library=a1.so [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686:tls:i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/a1.so 112: trying file=tls/i686/a1.so 112: trying file=tls/a1.so 112: trying file=i686/a1.so 112: trying file=a1.so 112: 112: find library=a2.so [0]; searching 112: search path=/usr/local/gcc-5.4.0/lib64:tls/i686:tls:i686: (LD_LIBRARY_PATH) 112: trying file=/usr/local/gcc-5.4.0/lib64/a2.so 112: trying file=tls/i686/a2.so 112: trying file=tls/a2.so 112: trying file=i686/a2.so 112: trying file=a2.so 112: 112: 112: calling init: /lib64/libc.so.6 112: 112: 112: calling init: /lib64/libm.so.6 112: 112: 112: calling init: /usr/local/gcc-5.4.0/lib64/libgcc_s.so.1 112: 112: 112: calling init: /usr/local/gcc-5.4.0/lib64/libstdc++.so.6 112: 112: 112: calling init: a2.so 112: 112: 112: calling init: a1.so 112: 112: 112: calling init: b2.so 112: 112: 112: calling init: b1.so 112: 112: 112: initialize program: ./main 112: 112: 112: transferring control: ./main 112: a1.c a1.c ......
从日志中可以看到,动态库的加载顺序如下:b1.so,b2.so,a1.so,a2.so,这些库根据依赖关系进行加载,使用 find library 语句可以看到它们被搜索并找到成功的路径。
初始化的顺序则是:a2.so,a1.so,b2.so,b1.so
这个顺序展示了在执行 main 函数之前,各个库的构造函数是如何被调用的。从中可以看出,动态库的初始化是按照依赖顺序进行的,即一个库的初始化会在它所依赖的库都初始化完成后进行。
__attribute__((__init_priority__(PRIORITY)))是GCC提供的一个特性,用于对一个全局变量或函数的初始化优先级进行控制。只能用于全局或静态对象的声明。它改变了对象构造函数的调用顺序,其作用是在程序启动时(即 main() 函数执行之前)确保不同对象的构造函数按照指定的优先级顺序调用。PRIORITY 必须是一个介于 101 和 65535 之间的整数,其中 101 是最高优先级(最先初始化),65535 是最低优先级(最后初始化)。
- 若都没有定义优先级, 其初始化顺序取决于链接时,全局变量定义所在’.o’ 在命令行参数中的出现顺序。
- 若部分全局变量使用了init_priority,部分没有; 所有使用了init_priority的全局变量其初始化顺序均先于未使用init_priority 的全局变量。
使用方式如下:
TestClass obj __attribute__((init_priority(102)))
5.2 函数的构造/析构顺序
函数可使用 __attribute__(constructor(PRIORITY)) 和 __attribute__(destructor(PRIORITY)) 。
__attribute__(constructor(PRIORITY))属性用于标记函数,它告诉编译器这个函数应该在 main() 函数执行之前自动执行。如果指定了 PRIORITY,则可以影响多个此类函数的执行顺序:数值较小的 PRIORITY 意味着该初始化函数将更早执行。
__attribute__(destructor(PRIORITY)) 修饰的函数可让系统在main()函数退出或者调用了exit()之后调用。优先级同上。
使用方式如下:
void __attribute__((constructor(102))) test()
5.3 注意事项
- 可移植性:__attribute__ 是 GCC 特有的,虽然许多其他编译器也提供类似的扩展,但它们在不同编译器之间并不兼容,应考虑使用其他机制或添加兼容性条件编译。
- 初始化依赖:当使用这些属性来修改初始化顺序时,必须非常小心地管理对象之间的依赖关系。错误地规划初始化顺序会导致程序在使用未初始化或半初始化状态的对象时崩溃。
- 默认优先级:对于没有指定优先级的全局对象,编译器也会分配一个默认的初始化优先级。然而,这个默认优先级可能因编译器而异,所以最好显式指定优先级以避免不确定性。
- 与其他特性的兼容性:使用构造函数属性时,请考虑它们可能与其他语言特性(如智能指针、静态局部变量的延迟初始化等)的兼容性。
6.总结
上述内容阐述了动态链接的过程。从程序的整体运行流程来看,可以分为编译、链接、装载和执行几个关键阶段,以下将对这几个阶段进行简要总结。
主要工作 |
示例命令 |
|
编译(Compile) |
源文件被gcc/g++转换为ELF格式对象文件,该文件包含编译后的代码但未绑定到依赖的地址。会在磁盘生成.o 文件 |
gcc -fPIC -c test.c -o test.o gcc -c main.c -o main.o
|
链接 (Linking) |
设置必要的信息供链接器(ld.so)使用,为运行时动态链接准备各种表结构和引用占位符。会在磁盘生成.so 文件。 详细过程:
|
gcc-shared-o libtest.so test.o gcc -o main main.o -L. -ltest
|
装载(Loading) (本文的重点) |
动态链接器工作过程,负责动态库装载到内存,并结合动态链接器解析符号、进行重定向和重新定位,确保程序可以在内存中正确运行。 详细过程: 1.启动动态链接器,通过GOT、.dynamic信息进行自身的重定位工作,完成自举。 2.装载共享目标文件:将可执行文件和链接器本身符号合并入全局符号表,依次广度优先遍历共享目标文件,它们的符号表会不断合并到全局符号表中,如果多个共享对象有相同的符号,则优先载入的共享目标文件会屏蔽掉后面的符号 4. 重定位(内存):对需要修正的函数调用、变量地址等进行重定位,使它们指向正确的内存地址。 5. 初始化 。运行动态库的初始化代码,如.init和构造函数等。 |
./main |
运行(Running) |
控制权交给main函数运行,在需要时(如延迟绑定的情况),解析并更新更多的符号引用。 |
附录 1:几个关键概念
ELF (Executable and Linkable Format)
一种执行和链接格式标准,被用来作为Unix系统中的标准二进制文件格式,包括可执行文件、对象代码、共享库和核心转储(core dumps)。ELF文件包含了程序运行所需的所有信息,如程序指令、程序入口点、数据和符号表等。
PIC (Position Independent Code)
- 概念: 地址无关代码, 指不依赖于具体加载地址能够执行的代码。编译为 PIC 意味着生成的代码可以在进程的地址空间中的任何位置运行。这在动态库中尤为重要,因为多个程序可能共享同一动态库的单个副本,但这个库可能被加载到这些程序的地址空间中的不同位置。
- 使用阶段: 编译阶段。使用 `-fPIC` 选项进行编译就可以生成位置独立的代码。
GOT (Global Offset Table)
- 概念: 全局偏移表,提供了一个固定的位置,用于存储外部符号的绝对地址,由链接器进行填充。用于支持共享库中的位置无关代码(PIC)。
- 使用阶段: 链接/装载。链接器创建 GOT,并在程序启动时由动态链接器(装载器的一部分)填充。
PLT (Procedure Linkage Table)
- 概念: 程序连接表,与GOT共同工作用于动态链接中的函数调用。存有从.got.plt 中查找外部函数地址的代码,若是第一次调用该函数,则会触发链接器解析函数地址并填充在.got.plt 相应的位置;若函数地址已经存储在.got.plt 中则直接跳转到对应地址继续执行。
- 使用阶段: 链接/装载。与 GOT 类似,PLT 的创建发生在链接阶段,其填充和更新则是在程序开始运行时、动态符号被首次访问时发生。
ld.so
Linux系统中的动态链接器程序,负责加载共享库并进行动态链接和绑定。它读取可执行文件指定的动态库依赖并将这些库加载到内存中,同时也处理符号的解析和重定位。当你运行一个动态链接的可执行文件时,它首先运行的实际上是ld.so,然后才是你的程序本身。ld.so会查看程序所需要的库,并将它们加载到内存中去。
关键 section
section 名 |
查看命令 |
实例结果 |
|
.interp |
保存了动态链接器的路径 |
objdump -s xxx # 查看所有 section |
|
.dynsym RA |
仅包含程序运行中需要动态链接的符号,若GCC中通过__attribute__((visibility("hidden")))隐藏的符号,在这里不会出现。 |
readelf-S xxx/objdump-h XXX #查看 section 地址分布 |
|
.rela.dyn 和rela.plt RA |
重定位表段,用于存储重定位信息。
|
readelf -r xxx #查看重定位表内容 readelf-S xxx/objdump-h XXX #查看 section 地址分布 |
|
.plt RA |
一组跳板函数,用于实现共享库函数的延迟绑定。 |
readelf-S xxx/objdump-h XXX #查看 section 地址分布 |
|
.text RA |
代码 section |
readelf-S xxx/objdump-h XXX #查看 section 地址分布 |
|
.dynamic RWA |
.dynamic中保存的是动态链接器用到的基本信息,如动态链接符号表(.dynsym),字符串表(.dynstr),重定位表 (.rela.dyn/rela.plt),依赖的运行时库,库查找路径等 |
readelf-dxxx # 查看.dynmaic段地址 |
|
.got 和.got.plt RWA |
存储重定位指针的地方 |
readelf-S xxx/objdump-h XXX #查看 section 地址分布 readelf-x <section> <xxx.so> # 查看特定 section 内容 |
|
.data RWA |
用于存储初始化的全局变量和静态变量 |
readelf-S xxx/objdump-h XXX #查看 section 地址分布 |
|
.bss RWA |
用于存储未初始化的全局变量和静态变量,.bss 并不占据实际的磁盘空间,它只是一个占位符. |
readelf-S xxx/objdump-h XXX #查看 section 地址分布 |
|
.symtab |
不仅包括导出和导入的符号,也包括局部符号(如静态函数和静态全局变量)和调dynsym试符号。 |
readelf -s xxx # 查看所有符号 |
|
附录 2:常用命令
- 显示运行时链接
- dlopen:加载动态链接库(.so 文件),返回一个句柄。
- dlsym:通过给定的动态链接库句柄和符号名称,查找并返回符号的地址。
- dlclose:关闭由 dlopen 打开的动态链接库句柄,释放资源。
- dlerror:返回描述最后一次错误的字符串。如果没有发生错误,则返回NULL。
- 环境变量:
- LD_LIBRARY_PATH: 为动态链接器指定额外的库搜索路径,预先定义路径。
- LD_PRELOAD:指定在所有其他库之前加载的共享库列表。动态链接器查看".dynamic"段里 NEEDED 类型,查找路径依次为LD_LIBRARY_PATH、/etc/ld.so.conf (/etc/ld.so.cache)配置文件指定目录、/lib、/usr/lib、进行查找。即LD_PRELOAD 环境变量的库会最先被加载。
- LD_DEBUG: 设置此环境变量可以让动态链接器打印出调试信息,帮助开发者了解链接过程中发生了什么,包括库搜索路径、符号解析等。当被设置时,会输出大量的信息到标准输出,这可能会导致性能下降,所以通常只在调试期间使用它。格式为:LD_DEBUG=[参数值] ./[程序名称] ,例如LD_DEBUG=libs ./your_program。参数如下:
- libs打印出每个需要加载的库的信息,包括库的搜索和加载过程。
- files报告输入文件即二进制对象(程序或库)的打开、关闭操作。
- symbols报告符号解析的详细信息,包括符号查找和绑定到具体地址的过程。
- bindings提供绑定到全局和局部符号的信息。
- versions输出有关版本化符号信息,可以显示库的版本绑定情况。
- all输出上述所有调试信息,提供最全面的调试信息。
- 工具使用
- ldd:用于打印共享库的依赖关系。例如,运行 ldd /path/to/your/program 可以列出程序运行所需的所有动态链接库。
- strip:用于去除程序或库中的调试信息、符号表.symtab等,可以减小产生的二进制文件大小。使用该命令时,需要注意由于去除了一些信息,会使得调试变得更加困难。使用方法:strip --strip-debug /path/to/library.so
附录 3:参考文档
《程序员的自我修养》书籍
来源 | 阿里云开发者公众号
作者 | 羽沐