ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

动态链接底层探秘:从GOT表、PLT到程序入口的完整链路

动态链接底层探秘:从GOT表、PLT到程序入口的完整链路 承接上一篇的内容这一篇继续往底层走。上次把静态库和动态库的编译链接流程理了一遍这次的重点是回答三个问题动态库和可执行程序到底是怎么关联起来的、为什么程序启动的真正入口不是 main 函数、GOT 表和 PIC 地址无关代码在中间扮演了什么角色。搞懂了这条链路你就不会再被“动态库不兼容”“找不到符号”“启动就崩”这类问题搞得晕头转向。本文会带着你从装载视角、链接视角、运行视角各看一遍配合几个具体的实验把 GOT、PLT、PIC 这些概念全部落到可观察的产物上。适合已经会写 C/C、能跑通基本编译命令但想真正弄懂动态库底层原理的开发者。1. 动态库和可执行程序是怎么搭上关系的1.1 编译期的“欠条”和装载期的“兑现”动态库和可执行程序的关系很多人只知道“链接的时候指定 -lxxx运行的时候能找到 so 就行”但中间到底经过了多少环节说不清。我打个比方编译链接期链接器在可执行程序里写了一张“欠条”上面写着“我调用了 libmylib.so 里的 my_add 函数将来谁来还债、怎么还债先不管”。到了程序装载阶段操作系统和动态链接器 ld.so 看到这张欠条才会去找到 libmylib.so 文件把里面的代码段和数据段映射进进程的虚拟内存空间。这里的“欠条”具体是什么在 ELF 文件里它是一组动态节区.dynsym是动态符号表.dynstr是符号名字符串表.dynamic里记录着 DT_NEEDED依赖了谁、DT_RPATH/DT_RUNPATH去哪儿找、DT_SYMTAB符号表在哪儿等等。你可以用 readelf -d 直接看到这些信息readelf -d ./myapp Dynamic section at offset 0x2dc8 contains 27 entries: 标记 类型 名称/值 0x0000000000000001 (NEEDED) 共享库[libmylib.so] 0x000000000000000c (INIT) 0x401000 0x000000000000000d (FINI) 0x401240看到 NEEDED 那行没有这就是“欠条”。它记的是“依赖库的名字”而不是完整的绝对路径。将来装载的时候ld.so 会按照一定顺序去搜索这个文件先看 DT_RUNPATH再看 LD_LIBRARY_PATH最后看系统默认目录。1.2 虚拟内存是整个关联机制的基石动态库能跟可执行程序关联起来最根本的支撑是虚拟内存机制。每个进程都有一套独立的虚拟地址空间可执行程序和每个动态库都会被映射到这套空间的某个区域。对 x86-64 Linux 来说可执行程序一般从 0x400000 附近开始共享库则从 0x7f0000000000 附近的高地址往下排列。这些地址不是编译时写死的而是装载时才确定的。为什么必须这样因为多个进程共享同一个动态库时如果每个进程都要求库必须装在同一个固定地址那很容易冲突A 进程把库放在地址 XB 进程的别的映射也想要地址 X总得有人让路。动态链接器采用的办法是“按需分配随机放置”每个进程装载同一个 .so 的起始地址可能都不同这就是地址空间布局随机化的一部分既解决了冲突也提升了安全性。正是因为这个“装载期才确定地址”的设计才引出了后面的 GOT 表和 PIC 代码这两个大问题。如果编译时不确定函数的最终地址那么程序里调用外部函数的那条 call 指令操作数应该填什么这个问题不解决动态库根本没法工作。1.3 静态关联与动态关联的本质差异静态链接的时候函数的相对地址在链接期就算完了可执行程序内部直接生成 call 某个绝对地址的指令代码段和数据段都打包进一个文件启动时全部装载不需要额外解析。优点是启动快、部署简单缺点是每个程序都要拷贝一份浪费磁盘和内存而且库升级了必须重新链接整个程序。动态链接则把“最终地址确定”推迟到装载和运行阶段。程序本体文件小了多个进程可以共享同一份库的物理内存页库升级后不需要重新编译主程序。代价是启动时要多一层符号解析调用外部函数会绕一次 PLT/GOT运行效率略微下降。动态链接还有一层更微妙的地方虽然程序启动时要解析依赖但并不是所有符号都会在启动时解析完。很多函数是第一次被调到的时候才真正确定地址这叫延迟绑定。延迟绑定能显著减少启动时间尤其是那些只用某个大库中很小一部分函数的程序。后面讲 GOT 表时会把这个机制拆开了看。注意这里说的“关联”是运行时视角的关联。编译链接期链接器只需要知道符号存在、能通过编译就可以生成可执行文件符号的真实实现来自哪个 .so是装载时的事。2. 为什么程序入口点不是 main 函数2.1 从 _start 到 main 之间发生了什么很多刚入门的同学会以为程序从 main 函数开始实际上 main 只是 C 语言标准规定的“程序员写的入口”不是操作系统视角的程序入口。在 Linux 上ELF 可执行文件的真正入口地址由 ELF 头的 e_entry 字段指定编译器链接时默认把入口符号设置为_start。用 readelf 可以确认readelf -h ./myapp Entry point 0x401040再反汇编看看这个入口在哪里objdump -d ./myapp | grep -A20 _start 0000000000401040 _start: 401040: 31 ed xor ebp,ebp 401042: 49 89 d1 mov r9,rdx 401045: 5e pop rsi 401046: 48 89 e2 mov rdx,rsp 401049: 48 83 e4 f0 and rsp,0xfffffffffffffff0 40104d: 50 push rax 40104e: 54 push rsp 40104f: 49 c7 c0 f0 11 40 00 mov r8,0x4011f0 401056: 48 c7 c1 80 11 40 00 mov rcx,0x401180 40105d: 48 8d 3d c0 00 00 00 lea rdi,[rip0xc0] 401064: ff 15 6e 2f 00 00 call *0x2f6e(%rip)这段汇编是经典的_start模板它做的事包括清空 ebp 栈帧、把参数指针整理好、对齐栈、把初始化函数指针和终止函数指针传给 libc 的入口最后跳进__libc_start_main。真正干活的其实是 libc 提供的__libc_start_main它负责初始化线程局部存储TLS检查并调用.init_array里的构造函数设置栈上的 argc、argv、envp并把它们转换成 main 函数能接收的参数形式注册__libc_csu_fini作为退出时的清理函数最后调用 mainmain 返回后把返回值传给 exit触发.fini_array里的析构函数所以调用链是内核跳转到_start→_start调用__libc_start_main→__libc_start_main调用main。main 只是被 libc “抬举”到前台的一个普通函数。2.2 如果程序直接以 main 为入口会怎样可以试试用-nostartfiles编译不给它标准启动文件直接指定入口为 main// hello.c #include stdio.h int main(int argc, char* argv[]) { printf(hello\n); return 0; }gcc -nostartfiles -e main -o hello_nostart hello.c编译能通过运行可能也会打出一行 hello但你已经在悬崖边上走。这种用法没经过 libc 初始化printf 能用是因为 stdout 缓冲区的初始化恰好被延迟到了第一次输出时很多其他 libc 功能会直接崩。比如你在 main 里调用malloc它内部可能依赖堆初始化构造函数__attribute__((constructor))也不会执行静态 C 对象的构造没了atexit 注册的清理函数也不会被正确执行。实验更能说明问题__attribute__((constructor)) static void before_main(void) { printf(constructor before main\n); }用正常方式编译运行能看到构造函数先于 main 执行用-nostartfiles -e main构造函数永远不执行。这已经足以说明为什么真正的入口不能是 main大量“程序级别的初始化”必须在 main 之前完成而 main 本身只是业务逻辑的起点。2.3 crt 系列目标文件在入口链路中的作用那些“缺失的初始化逻辑”来自哪里来自编译器自带的启动文件通常是/usr/lib/x86_64-linux-gnu/crt1.o、crti.o、crtn.o以及 libc 里的libc_nonshared.a。crt1.o 里有_start符号crti.o 和 crtn.o 负责提供.init和.fini节区的函数头尾gcc 链接时默认把这些文件塞进了最终链接命令行。你可以用gcc -v看到完整链接命令里面有一长串 crt 开头的文件还有-lc。这解释了为什么我们平时编译一个最简单的程序机器码也远比写的几行 C 代码多得多启动代码和运行时库里的大量逻辑都注入了最终产物动态链接时启动代码注入运行时库动态映射。实操心得调试启动崩溃问题时不要只在 main 上下断点。先在_start或者__libc_start_main上打断点可以看到很多隐蔽的问题比如某个全局对象的构造函数在 main 之前就段错误了。正确处理方式是先跑一次info functions或者info symbol搞清楚当前停在启动链路的哪一环。3. GOT 表动态链接的中转枢纽3.1 为什么单独搞一张表不能直接写死地址既然装载时才能确定动态库的最终地址那 call 指令就不能写死目标地址。最简单的办法是链接器在程序里预留一个指针变量装载时动态链接器往这个指针里填真实的函数地址然后 call 指令改成“间接通过这个指针跳转”。这个“指针数组”就是 GOTGlobal Offset Table全局偏移表。放在 ELF 里GOT 就是.got和.got.plt节区。它本身是数据段的一部分动态链接器有权限在装载时改写它。这个过程叫重定位relocation。为什么不能把“真实地址”直接改成 call 的操作数因为 x86-64 的 call 相对寻址范围有限32 位偏移而动态库可能映射到距离主程序很远的高地址一个 32 位带符号偏移根本够不着。就算后续有 rip 相对寻址它可以用 32 位偏移寻址到 GOT 表所在的位置但没法直接用相对地址调用一个可能被映射到任意位置的库函数。于是调用外部函数的基本套路就是代码段里不直接跳函数而是跳 GOT 表里对应的槽位槽位里存函数的真实地址。3.2 PLT 和 GOT 的配合流程光有 GOT 还不够因为如果所有外部函数都在装载时绑定那启动时间会很难看。于是又引入了 PLTProcedure Linkage Table过程链接表。PLT 是一小段一小段位于代码段的跳板代码每个外部函数都有一小块“包装”。我第一次真正看懂这个流程是在 gdb 里单步跟踪一个 printf 调用。大致步骤是这样的主程序代码里调用printf编译后实际上变成call printfplt。跳进 PLT 段PLT 的第一条指令就是跳转到 GOT 表中 printf 对应槽位的内容。如果这个槽位还没有被填上真实地址它默认指向 PLT 的下一条指令也就是把某个重定位序号压栈然后跳到 PLT 的公共入口。公共入口再跳转到动态链接器内部的_dl_runtime_resolve让它根据序号找到符号名在已装载的库中搜索真实地址。解析完成动态链接器把真实地址写回 GOT 槽位然后跳转到目标函数。以后再调用 printf直接通过 GOT 槽位跳转不再经过解析。这个过程叫延迟绑定。Linux 上的实现默认是针对函数符号的延迟绑定数据符号全局变量不会延迟必须在装载时解析好因为数据访问指令没法像函数调用那样优雅地插入一层“未初始化跳板”。3.3 用 readelf 观察 GOT 和重定位实验最直观。写一个调用外部函数的小程序// main.c extern int add_one(int x); int main() { return add_one(10); }// mylib.c int add_one(int x) { return x 1; }编译成动态库和主程序gcc -fPIC -shared -o libmylib.so mylib.c gcc -o myapp main.c -L. -lmylib -Wl,-rpath,$PWD然后查看可执行程序的重定位项readelf -r ./myapp 重定位节 .rela.plt 包含 2 个条目: 偏移量 信息 类型 符号值 符号名称 000000004018 000200000007 R_X86_64_JUMP_SLOT 0000000000000000 add_one这个条目告诉你程序里 add_one 函数的地址将来会写到 0x4018 这个位置也就是 GOT 表里的某个槽位。类型是 R_X86_64_JUMP_SLOT说明这是通过 PLT 延迟绑定的函数引用。再运行程序并查看 GOT 表地址内容你会发现装载之后那个槽位里填进了 libmylib.so 中 add_one 的真实地址。LD_DEBUGbindings ./myapp输出里会明确显示binding file ./myapp [0] to ./libmylib.so [0]: normal symbol add_one。这里没有黑魔法只是链接器帮你把细节隐藏了。注意GOT 表里的槽位布局、PLT 的偏移量在多架构之间差异很大。x86-64 的 PLT 入口和 ARM64 又有区别不要拿一套结论硬套到所有平台。4. PIC 地址无关代码让库放哪里都能跑4.1 明明写了绝对地址为什么还能“地址无关”PIC 的全称是 Position Independent Code位置无关代码。从编译结果来看PIC 代码的特点是即使被加载到任何虚拟地址不需要重定位代码段也能正常运行。最核心的手段就是“用相对当前指令地址的方式去引用其他东西”。x86-64 上最常用的是 rip 相对寻址。比如有一句 C 代码要读取一个全局变量int global_var 42; int get_var() { return global_var; }非 PIC 编译时编译器可能生成mov 0x402010, %eax操作数是一个绝对地址PIC 编译时它生成的是mov 0x2e9e(%rip), %rax意思是“从当前指令地址向后偏移 0x2e9e 的位置读内容”。指令在哪个地址偏移不变效果就一样。这样代码段里不掺任何绝对地址装载到哪儿都不需要修改代码段。这就是 PIC 和地址无关的本质代码里只记录“相对位置关系”不记录“绝对位置”。4.2 外部函数和数据分别怎么处理对外部函数PIC 代码不会直接调用函数地址而是调用它的 PLT 入口。PLT 本身也是代码段里的相对跳转逻辑最终地址放在 GOT 里由动态链接器填值。所以函数引用在同一个 .so 内部是“相对指令 调用 PLT”跨 .so 也是“相对指令 调用 PLT”没有本质区别。对外部数据PIC 代码必须通过 GOT 访问从 GOT 里取变量地址再通过这个地址读写变量。为什么数据不能也靠“直接相对寻址”解决因为不同动态库的全局符号还涉及符号合并规则如果可执行程序里也定义了一个同名全局变量最终可能解析到可执行程序的那个变量上。这种“运行时才知道到底用谁的变量”的情况只有通过 GOT 这一层中转才能处理。具体到 ELF 重定位类型上函数延迟绑定对应 R_X86_64_JUMP_SLOT数据引用对应 R_X86_64_GLOB_DAT自己库里的全局变量在装载过程会通过 R_X86_64_RELATIVE 这类重定位修正 GOT 表项。4.3 PIC 的代价为什么不是免费的地址无关不是天上掉的馅饼它有代价。首先是间接访存多了几层普通全局变量直接访问一次内存PIC 访问可能要访问 GOT 再访问变量多一次访存甚至多几条指令。其次是 GOT 表本身在数据段里占空间还要维护一堆重定位元数据。最后是延迟绑定让第一次函数调用变慢。但这是值得的。对动态库来说如果代码段里写死了绝对地址那每个进程装载 .so 时都必须给它挑一个固定地址或者对代码段做重定位。代码段重定位不仅耗时还破坏了多个进程共享同一份物理内存页的优势。PIC 让代码段保持纯净装载器和内核可以直接把同一个 .so 的同一份物理页映射进不同进程省内存也省时间。所以现在你看到-fPIC不再是可选项而是必须项。编译动态库时不用-fPIC虽然偶尔也能编过但要么运行时报错要么性能受影响养成好习惯直接加。实操心得编译共享库时建议总是用-fPIC而不是只有在你遇到重定位错误时才加上它。同一个 .so 将来可能还会被另一个 .so 依赖嵌套依赖时非 PIC 代码的兼容性非常差。为了一时的编译省事把麻烦留给运行期不划算。5. 自己动手完整观察一次动态库的装载和重定位5.1 实验一同一个 .so不同进程装载地址不同基于上一段示例写个简单的可执行程序调用 add_one 并打印它的地址#include stdio.h extern int add_one(int x); int main() { printf(add_one address: %p\n, (void*)add_one); return 0; }连续运行几次./myapp ./myapp ./myapp add_one address: 0x7f2a6f3d7119 add_one address: 0x7f707de4f119 add_one address: 0x7f9b7c819119看到规律没有每次都不同尤其是中间几位随着 ASLR 随机变化。这是动态链接的日常同一个函数的真实地址每个进程得到的可能都不一样。相比静态链接可执行程序函数地址是固定写死的这种“不确定”正是动态库必须依赖 PLT/GOT 的根本原因。5.2 实验二查看装载后的 GOT 表内容运行程序时让它停住然后查看内存gdb ./myapp -batch \ -ex start \ -ex info address add_one \ -ex x/gx 0x4018这个 0x4018 就是 readelf -r 里查到的重定位偏移量对应 GOT 槽位。启动后你会发现槽位内容已经变成 libmylib.so 中 add_one 的真实地址和info address add_one显示的一致。如果是在第一次调用 add_one 之前查看某些架构下还能看到它还没被填成最终地址只有在调用发生之后才完成绑定。5.3 实验三修改动态库不动主程序结果跟着变把 libmylib.so 里的 add_one 改成int add_one(int x) { return x 2; }重新编译 .sogcc -fPIC -shared -o libmylib.so mylib.c不需要重新链接 myapp直接运行输出数字从 11 变成 12。这就是“库升级不用重新编译主程序”的直观体现。可执行程序里只保存了符号依赖没有把 add_one 复制进自己的可执行文件所以库换了实现程序自动跟着换。5.4 实验四看 PIC 和非 PIC 的指令差异为了直观理解 PIC可以把同一个源文件分别用-fPIC和不带-fPIC编成目标文件再反汇编对比gcc -fPIC -c mylib.c -o mylib_pic.o gcc -c mylib.c -o mylib_nonpic.o objdump -dr mylib_pic.o objdump -dr mylib_nonpic.o你会看到非 PIC 版本访问全局变量用的是绝对寻址重定位类型是 R_X86_64_32PIC 版本用的是 rip 相对寻址重定位类型是 R_X86_64_PC32 或者通过 GOT 的方式。代码指令长度和访存方式完全不同。这才是“地址无关代码”真正的落地形态。6. 常见问题与排查技巧实录6.1 找不到动态库error while loading shared libraries这是最常见的动态库问题。程序编译过了运行时报./myapp: error while loading shared libraries: libmylib.so: cannot open shared object file: No such file or directory原因就是 ld.so 没找到依赖库。排查顺序ldd ./myapp输出里如果有libmylib.so not found说明链接器的搜索路径里没有这个库。解决办法有几种编译时加-Wl,-rpath,$(pwd)把路径写进可执行文件的 DT_RUNPATH运行时设置LD_LIBRARY_PATH/path/to/lib ./myapp或者把库装到系统目录。注意LD_LIBRARY_PATH 虽然好用但它是全局环境变量会影响你所有命令行程序的动态库解析调试完最好取消设置避免“灵异问题”。6.2 重定位错误relocation R_X86_64_32S against .text can not be used编译动态库时如果忘了-fPIC很容易看到这一大串/usr/bin/ld: /tmp/ccXXXX.o: relocation R_X86_64_32S against .text can not be used when making a shared object; recompile with -fPIC这个错误本质是生成的代码里用绝对地址引用了自己的代码位置动态库装载地址不固定这种绝对地址没法修正。解决方案很简单重新编译目标文件时加上-fPIC。如果代码是第三方库的 Makefile 管理找一下它的 CFLAGS补进去。6.3 符号冲突同一个符号被多个库解析动态链接的符号解析默认是全局的而且有优先级。可执行程序里的符号优先级最高然后是先被装载的 .so。如果两个动态库都导出了同名函数程序调用的可能是先被解析到的那一个。查这种问题用LD_DEBUGbindings ./myapp 21 | grep my_symbol能看到它最终绑定到了哪个文件。这个坑在插件系统里特别容易踩主程序依赖的某个库和插件依赖的另一个库导出了相同符号结果插件调到了主程序那边的实现行为完全变样。处理办法插件尽量使用-Wl,-Bsymbolic让库内部符号优先绑定自身或者用符号版本、隐藏可见性来控制导出范围。6.4 启动变慢延迟绑定失效如果某个动态库有大量外部函数引用且每个函数都在启动阶段第一次调用时触发一次动态解析启动会明显变慢。把延迟绑定关掉可以验证export LD_BIND_NOW1 time ./myapp如果 LD_BIND_NOW1 时启动反而更快说明你的程序里很多库的符号解析本来可以延迟但因为某种原因在启动阶段被触发了。正常的做法是检查启动路径上有没有提前调用大量外部库函数的代码看看能不能把这些调用推迟到真正需要的时候。6.5 实战排查工具速查表场景命令关键信息查看程序依赖哪些库ldd ./applibxxx.so 路径查看 ELF 动态节区readelf -d ./appNEEDED、RUNPATH、BIND_NOW查看重定位项readelf -r ./appR_X86_64_JUMP_SLOT、偏移地址跟踪符号绑定过程LD_DEBUGbindings ./app每个符号绑定到哪个库禁用延迟绑定LD_BIND_NOW1 ./app启动时全量解析符号查看库的导出符号nm -D ./libxxx.so动态符号表内容6.6 一系列学习建议从“会写动态库”到“真正理解动态链接”中间差的就是把这些机制亲手验证一遍。建议按这个顺序往下钻先做实验三那样改库不动主程序体会动态链接的便利然后用 readelf -r 观察重定位项再用 gdb 单步跟踪一次 PLT 跳转最后翻一翻系统里 libc.so 的 PLT/GOT你会发现这些结构无处不在。把这条链路打通后再看任何语言层面的链接报错、加载故障都有了一个统一的底层模型去套用不再靠猜。我个人在实际操作中还有一个习惯每接触一个陌生架构第一件事就是编译一个最小的动态库然后同时跑 readelf -r、readelf -d、objdump -d把三份输出放在一起对照着看。比如 ARM64 的 GOT 表和 x86-64 的布局就有差异但“调用外部函数要经过 GOT/PLT”这个原则是相通的。把这套“观察法”练熟比背一百条命令更有效。希望这篇能帮你在动态库这条路上少踩几个坑接下来你也可以继续沿着链接脚本、ELF 装载、符号版本化这些方向往下深挖。
返回列表