ELF加载、动态链接、GOT与PLT
前面已经弄清楚了 .o、ELF、Section、Segment 和静态链接。
程序编译链接完成以后,还差最后一步:
它到底是怎么跑起来的?
比如我在终端输入:
./main
程序显然不会凭空出现在内存里。
操作系统需要先读取 ELF 文件,建立进程的地址空间,把程序需要的内容加载进去。
如果程序还依赖了:
libc.so
libmystdio.so
这些动态库也得处理。
这一篇主要把这部分串起来。
一、ELF还没加载进内存的时候,有地址吗?
刚开始接触这个问题时,我会觉得:
程序都还在磁盘里,没有进入内存,怎么可能已经有地址?
但 ELF 文件本身已经有自己的地址组织方式了。
在现代计算机的平坦模式下,程序中的代码和数据会进行统一编址。
所以,即使程序此时还在磁盘上,这些代码和数据也已经拥有对应的逻辑地址关系。
使用:
objdump -S main
或者查看 ELF Header:
readelf -h main
都可以看到这些信息。
二、程序从哪里开始执行?
一个 ELF 可执行文件里面有一个很重要的字段:
Entry point address
也就是程序入口地址。
比如:
Entry point address: 0x1060
程序启动的时候并不是直接跑到我们写的:
main()
而是先从入口位置开始。
Linux 下通常会先进入:
_start
然后经过 C 运行时环境的初始化,最后才进入:
main
所以:
程序启动
↓
_start
↓
初始化
↓
动态链接等处理
↓
__libc_start_main
↓
main
这个过程平时写 C/C++ 的时候基本看不到,但真正运行的时候确实存在。
三、Segment和进程地址空间
前面研究 ELF 的时候已经知道,Section 最终会根据属性组合成 Segment。
程序加载的时候,真正和内存布局关系比较密切的就是 Segment。
一个 Segment 会有自己的:
起始地址
长度
权限
这些信息可以拿来初始化进程地址空间中的对应区域。
可以粗略地理解成:
ELF
↓
Program Header Table
↓
找到各个需要加载的 Segment
↓
建立进程地址空间
↓
把对应内容映射进去
所以 Program Header Table 对运行时加载非常重要。
四、是不是所有Section都会加载到内存?
不是。
ELF 里有很多 Section 是为了链接、调试或者描述文件结构准备的,并不是每一个都需要作为程序内容加载到内存。
真正和加载有关的是 Program Header Table 里面的 Segment。
例如:
readelf -l main
可以看到:
LOAD
LOAD
DYNAMIC
...
其中 LOAD 表示这一部分后面要被加载。
所以:
Section
↓
ELF文件内部怎么组织
Segment
↓
运行时怎么加载
这个区别到这里就比较好理解了。
五、静态链接和动态链接最大的区别
静态链接的时候:
.o
+
静态库中的.o
↓
一起链接
↓
独立可执行文件
库里的代码已经进入最终程序。
动态链接则不一样。
比如:
ldd main.exe
可能看到:
libc.so.6
/lib64/ld-linux-x86-64.so.2
这些动态库并没有在编译链接时把完整代码直接塞进可执行文件。
程序真正启动以后,动态链接器才会去处理这些库。
所以可以把动态链接理解成:
把一部分链接工作推迟到程序运行的时候。
六、动态库为什么不会每个进程都复制一份?
假设机器上同时运行:
程序A
程序B
程序C
它们都需要:
libc.so
如果每个进程都在内存里完整复制一份,那肯定浪费。
动态链接的一个重要特点就是:
多个进程可以共享同一份动态库代码。
动态库加载到内存以后,不同进程都可以通过自己的地址空间去访问它,从而减少内存和磁盘空间的浪费。
七、动态库加载以后,地址一定一样吗?
不一定。
一个进程和另一个进程的地址空间并不相同。
而且一个动态库每次加载到内存的位置也可能不同。
所以动态库不能简单地写死:
我永远在 0x12345678
这种绝对地址。
它更适合使用:
相对地址。
也就是:
动态库起始地址
+
函数在库中的偏移量
=
函数真正的地址
这样动态库换一个位置以后,只要起始地址变化,内部的偏移关系仍然有效。
八、动态库到底怎么和当前进程对应起来?
可以把过程想成这样:
进程
↓
加载动态库
↓
动态库映射到进程地址空间
↓
知道动态库起始地址
↓
知道某个函数在库里的偏移量
↓
找到函数
比如某个函数在动态库中的偏移是:
0x500
当前动态库加载到:
0x70000000
那么函数地址就可以理解成:
0x70000000 + 0x500
核心思想就是:
起始地址 + 偏移量。
九、问题来了:代码区不是只读的吗?
这里就出现一个很有意思的问题。
程序中的 .text 一般是代码区,而代码区不能随便修改。
但是动态链接又需要在运行时确定函数地址。
那怎么办?
总不能直接把:
.text
里面的指令改来改去吧。
于是就出现了:
GOT(Global Offset Table,全局偏移量表)
十、GOT到底是干什么的?
GOT 可以理解成程序专门准备的一张“地址表”。
它通常位于可读写的数据区域,因此运行时可以修改。
里面保存的是程序当前需要访问的外部函数或者全局变量的地址。
所以原来的思路:
代码区
↓
直接写死外部函数地址
变成:
代码区
↓
查GOT
↓
找到真正地址
↓
跳过去
这样就不用直接修改代码区。
十一、为什么每个进程都需要自己的GOT?
假设:
进程A
里面的:
libc.so
加载到了:
0x70000000
而:
进程B
里的:
libc.so
可能加载到了另一个位置。
那么:
进程A里的GOT
和:
进程B里的GOT
当然不能完全一样。
所以动态库代码本身可以共享,但 GOT 不能简单地让所有进程共用。每个进程都要根据自己的地址空间建立对应关系。
十二、PIC为什么和GOT有关?
动态库制作的时候,我们执行过:
gcc -fPIC -c my_stdio.c
以前只知道:
-fPIC就是生成位置无关代码。
现在再看就容易理解很多了。
动态库需要能够:
加载到不同的地址
还要:
正常访问其中的函数和数据
这就需要相对寻址和 GOT 一起工作。
可以简单记成:
PIC
=
相对编址
+
GOT
这也是为什么生成动态库的时候需要:
-fPIC
十三、PLT又是干什么的?
如果一个程序依赖很多动态库函数,那么程序启动的时候把所有函数地址全部处理一遍,会比较耗时间。
假设程序依赖:
printf
write
close
strlen
...
但是实际运行的时候,可能只调用其中几个。
那一开始把所有函数都重定位一遍,就有点浪费了。
所以又出现了一种优化:
延迟绑定。
对应的就是:
PLT(Procedure Linkage Table,过程链接表)
十四、延迟绑定是怎么工作的?
一开始:
GOT中的函数地址
↓
辅助代码
第一次调用某个函数的时候:
调用函数
↓
进入PLT
↓
查找真正函数地址
↓
修改GOT
↓
跳转到真正函数
等第二次再调用:
调用函数
↓
PLT
↓
GOT里已经有真正地址
↓
直接跳转
也就是说:
第一次调用的时候麻烦一点,后面就可以直接用了。
这样就避免了程序启动时把大量暂时用不到的函数全部处理一遍。
十五、把整个动态链接过程串起来
到这里,可以把动态链接整个过程串起来了。
程序运行:
./main
↓
读取 ELF
↓
找到程序需要的 Segment
↓
建立进程地址空间
↓
加载程序本身
↓
根据依赖关系找到动态库
↓
把动态库映射到当前进程地址空间
↓
确定动态库的加载地址
↓
处理 GOT
↓
调用动态库函数
↓
通过 PLT/GOT 找到真正地址
↓
进入动态库函数执行
这样整个过程就顺起来了。
十六、静态链接和动态链接放在一起
最后再对比一下。
静态链接
hello.o
code.o
静态库
↓
链接
↓
地址重定位
↓
独立可执行程序
程序生成以后,静态库本身不需要再参与运行。
动态链接
.o
↓
生成可执行程序
↓
程序运行
↓
加载动态库
↓
建立地址映射
↓
GOT / PLT
↓
调用动态库函数
动态链接把一部分工作放到了程序运行的时候完成,因此需要处理库加载和运行时地址重定位。
十七、这部分知识终于串起来了
以前看到:
.a
.so
ELF
GOT
PLT
Segment
Section
感觉一个比一个陌生。
现在把它们按照程序运行的顺序排起来,就比较容易理解:
源代码
↓
编译
↓
.o
↓
链接
↓
ELF可执行文件
↓
加载
↓
进程地址空间
↓
动态库
↓
GOT
↓
PLT
↓
真正的库函数
而静态库和动态库的区别,也可以放在这里一起看:
静态库
↓
链接阶段进入程序
动态库
↓
运行阶段加载和处理
这样再去看:
ldd
readelf
objdump
这些命令,就不只是记它们的用法了,而是知道自己到底在看什么。
这一部分对我来说比较重要的一点,就是开始慢慢把“编译出来的文件”和“真正运行起来的程序”联系到了一起。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)