文章目录
- 一、动态库面临的地址难题
- 二、什么是位置无关代码 PIC
- 三、磁盘 .so 中的地址可以先看成相对偏移
- 四、为什么不能直接修改代码段
- 1. 代码段通常是只读的
- 2. 代码段需要被多个进程共享
- 五、GOT 是什么
- 六、为什么每个进程都有自己的 GOT
- 七、PIC = 相对编址 + GOT
- 八、用 readelf 查看 GOT
- 九、用 objdump 观察间接访问
- 十、全局变量访问为什么也需要 GOT
- 十一、GOT 解决了什么,没解决什么
- 十二、为什么 -fPIC 对动态库很重要
- 十三、一个完整实验
- 十四、总结
上一篇我们讲到,动态库会在程序运行时被映射到进程地址空间中,而且加载地址并不固定。进程 A 中的libmyc.so可能在一个地址,进程 B 中的同一个库可能在另一个地址。
这就带来一个核心问题:动态库内部的函数调用、全局变量访问,如何在不同加载地址下都能正常工作?
答案是 PIC 和 GOT。
一、动态库面临的地址难题
静态链接时,链接器可以在生成可执行程序时确定大部分地址。
但动态库不同。.so文件在磁盘上时,并不知道未来会被加载到哪个进程、哪个虚拟地址。
例如同一个libmyc.so:
进程 A 中加载基址:0x7f1000000000 进程 B 中加载基址:0x7f2000000000如果动态库代码中写死绝对地址:
跳转到 0x7f1000001234那么它在进程 B 中就可能完全错误。
所以动态库必须避免依赖固定绝对地址。
二、什么是位置无关代码 PIC
PIC 是 Position Independent Code,位置无关代码。
它的目标是:
同一份代码无论被加载到进程地址空间的哪个位置,都能正常执行。编译动态库时常用:
gcc-fPIC-clib.c-olib.o-fPIC告诉编译器生成适合动态库使用的位置无关代码。
通俗理解,PIC 不把绝对地址写死在代码里,而是尽量使用:
相对位置 基址 + 偏移 查表跳转三、磁盘 .so 中的地址可以先看成相对偏移
为了理解动态库地址,可以先建立一个模型。
磁盘上的libmyc.so内部,某个函数foo位于文件内部偏移:
0x2000某个全局变量位于偏移:
0x3500这些偏移不是进程中的最终虚拟地址。它们只是库文件内部相对位置。
当动态库被加载进进程时,动态链接器为它分配一个运行时基址。
假设:
运行时基址 = 0x7f1000000000 foo 偏移 = 0x2000那么foo的运行时虚拟地址就是:
0x7f1000000000 + 0x2000 = 0x7f1000002000换一个进程,基址不同,最终地址也不同。
所以动态库必须能接受:
真实地址 = 运行时基址 + 内部偏移四、为什么不能直接修改代码段
一种看似简单的办法是:动态库加载后,把代码段中所有地址都改成真实地址。
但这会带来严重问题。
1. 代码段通常是只读的
动态库的.text代码段通常映射为只读、可执行:
r-xp运行时直接修改代码段不符合权限设计。
2. 代码段需要被多个进程共享
动态库节省内存的关键是多个进程共享同一份只读代码页。
如果每个进程都要把代码段改成自己专属地址,那么代码页就不能简单共享了。
所以动态链接需要避免频繁修改.text。
解决思路是:
代码段尽量保持不变; 把需要变化的地址放到可写数据区中。这就引出了 GOT。
五、GOT 是什么
GOT 是 Global Offset Table,全局偏移表。
它可以理解为动态链接中的一张地址表。
表里保存:
外部函数的真实地址 全局变量的真实地址 运行时需要修正的地址信息程序调用某些动态库函数或访问某些全局符号时,不直接把最终地址写死在代码里,而是通过 GOT 查表。
简化流程:
代码中使用相对方式找到 GOT -> 从 GOT 表项中取出真实地址 -> 跳转或访问该地址GOT 位于可写区域,动态链接器可以在加载时修改 GOT 表项。
六、为什么每个进程都有自己的 GOT
动态库代码段可以共享,但 GOT 通常不能简单共享。
原因是不同进程中动态库的加载地址可能不同,依赖库也可能映射在不同位置。
所以:
进程 A 的 GOT 里记录的是进程 A 地址空间中的真实地址; 进程 B 的 GOT 里记录的是进程 B 地址空间中的真实地址。它们的代码段可以指向同一份物理页,但 GOT 表项需要各自独立。
代码段只读可共享; GOT 表在每个进程中独立维护。七、PIC = 相对编址 + GOT
可以用一句话概括 PIC 的核心思想:
PIC = 相对编址 + GOT在单个.so内部,.text和.got的相对位置是固定的。即使整个库被加载到不同基址,代码仍然可以通过相对寻址找到自己的 GOT。
然后通过 GOT 中的表项访问真正地址。
这就实现了:
动态库加载到任意地址,代码仍然可以正常运行。八、用 readelf 查看 GOT
可以用readelf -S查看动态库中的 section:
readelf-Slibmyc.so|grep-E"got|plt"你可能看到:
.got .got.plt .plt这些 section 就和动态链接密切相关。
也可以查看可执行程序:
readelf-Smain|grep-E"got|plt"动态链接程序中通常也能看到 GOT/PLT 相关区域。
九、用 objdump 观察间接访问
反汇编动态链接程序:
objdump-dmain|less你可能看到类似:
callq puts@plt或者看到通过寄存器、GOT 表项进行间接跳转的指令。
初学阶段不需要逐条汇编完全读懂,但要观察到一点:
动态链接下,外部函数调用并不总是直接 call 一个固定绝对地址。它往往会经过 PLT/GOT 这样的间接结构。
十、全局变量访问为什么也需要 GOT
函数调用需要地址,全局变量访问同样需要地址。
如果动态库中访问一个外部全局变量,变量最终在哪个模块、哪个地址,可能要到加载后才能确定。
于是编译器也会通过 GOT 访问这类符号。
这样,无论变量最终地址在哪里,只要动态链接器把 GOT 表项填好,代码就能通过查表访问正确位置。
十一、GOT 解决了什么,没解决什么
GOT 解决的是:
动态库加载地址不固定时,如何找到正确函数和变量地址。但 GOT 本身还不完全等于“高效”。
如果程序启动时就把所有动态库函数的 GOT 表项全部解析好,启动成本可能很高。
因为很多库函数在整个程序运行期间可能一次都不会被调用。
于是系统又做了进一步优化:
函数第一次被调用时再解析。这就是 PLT 和延迟绑定要解决的问题。
十二、为什么 -fPIC 对动态库很重要
回到动态库制作命令:
gcc-fPIC-clib.c-olib.o gcc-shared-olibdemo.so lib.o现在就能理解-fPIC的意义了:
它让编译器生成适合被加载到任意地址的代码。如果不使用-fPIC,某些平台或某些代码情况下,生成动态库可能出现警告或错误,或者导致运行时重定位成本上升、代码段难以共享。
所以写动态库时,-fPIC是非常重要的习惯。
十三、一个完整实验
准备lib.c:
#include<stdio.h>intg_val=100;voidshow(void){printf("g_val=%d\n",g_val);}编译动态库:
gcc-fPIC-clib.c-olib.o gcc-shared-olibdemo.so lib.o观察 GOT/PLT:
readelf-Slibdemo.so|grep-E"got|plt"objdump-dlibdemo.so|less再写main.c:
voidshow(void);intmain(){show();return0;}链接并观察:
gcc main.c -L.-ldemo-Wl,-rpath=.-omain readelf-Smain|grep-E"got|plt"objdump-dmain|grep-A8"show"通过这些命令,你可以看到动态链接程序中确实存在 GOT/PLT 相关结构。
十四、总结
动态库可以加载到任意地址,不是因为它不需要地址,而是因为它不把绝对地址写死在代码里。
核心机制是:
PIC = 相对编址 + GOT代码段保持只读、可共享;运行时会变化的函数和变量地址放在 GOT 中,由动态链接器填充和修正。
下一篇,我们继续讲 PLT 和延迟绑定,看看动态库函数第一次被调用时,到底发生了什么。