【Linux库制作与原理进阶】从 ELF 装载到延迟绑定:搞懂动态库、GOT 与 PLT

前言
上篇已经说明多个 .o 如何经过 Section 布局、符号解析和地址重定位形成可执行程序。但生成 ELF 只是第一步,程序真正运行之前,还要完成 Segment 映射、动态库装载和运行时地址修正。

这一篇沿着程序启动过程继续分析:

磁盘中的 ELF 为什么已经带有虚拟地址布局;
Program Header Table 如何指导加载器建立内存映射;
动态库的代码页为什么能够被多个进程共享;
PIC、GOT 和 PLT 如何解决装载基址不固定的问题;
第一次调用动态库函数时,延迟绑定具体经过哪些步骤。
核心主线:Program Header Table 决定 ELF 怎样装入内存,动态链接器再通过重定位、GOT 与 PLT 完成共享库符号的最终绑定。

一、ELF 装载与进程虚拟地址空间
1.1 磁盘中的 ELF 已经包含地址布局
ELF 在磁盘中已经保存虚拟地址布局或相对布局,但尚未占用固定物理内存。

反汇编可执行程序:

objdump -d main | less

指令左侧会显示地址。它不是程序已经占用了某一块固定物理内存,而是链接 器写进 ELF 的虚拟地址布局,或者相对于装载基址的地址布局。

现代 Linux 中常见两种情况:

ELF 类型 常见场景 地址特点
ET_EXEC 非 PIE 可执行程序 通常按照较固定的链接虚拟地址装载
ET_DYN 共享库、PIE 可执行程序 内部地址通常配合装载基址计算,方便地址随机化
不少现代发行版默认生成 PIE,因此执行:

readelf -h main

可能看到:

Type: DYN (Position-Independent Executable file)

这并不代表 main 变成了普通动态库,而是说明它采用了与共享对象相似的位置无关装载方式。

 

磁盘上的 ELF 保存的是虚拟地址布局或相对布局。物理内存地址只有程序真正运行、页面被调入以后才会确定。

1.2 Program Header Table 就是装载计划
当内核准备执行 ELF 时,不需要逐个关心所有 Section。它主要读取 ELF Header 和 Program Header Table。

每一个可装载的 PT_LOAD 都描述了一块映射关系:

文件 offset 开始的一段数据

映射到进程虚拟地址 vaddr

设置 R/W/X 权限

这份信息会帮助内核建立进程的虚拟内存区域。

从概念上看,内核会根据 Segment 描述创建类似下面的区域:

代码和只读区域;
可写数据区域;
BSS 对应的零填充区域;
后续还会建立栈、堆、共享库映射等区域。
1.3 ELF Segment 如何初始化 VMA

创建新进程映像时,内核会读取 ELF 的 Program Header Table,根据各个 PT_LOAD 的:

起始虚拟地址;
长度;
文件偏移;
访问权限;
建立进程的虚拟内存区域,也就是 VMA。Linux 内核中,这些区域由 mm_struct 关联和管理。

这并不表示:

程序启动的一瞬间,内核已经把 ELF 每一页全部复制进物理内存,并把所有页表一次性填完。

更常见的方式是按需调页:

先建立虚拟地址区域和文件映射关系;
CPU 第一次访问尚未装入的页面时触发缺页异常;
内核再把对应文件页调入物理内存;
更新页表后重新执行原指令。
这样程序没有真正访问的代码和数据,就不必提前占用物理内存。

1.4 CR3、MMU 与虚拟地址翻译
在 x86-64 平台上,三者的关系如下:

CR3 寄存器:保存当前地址空间页表根结构的物理地址;
MMU:负责把 CPU 使用的虚拟地址翻译为物理地址;
页表项权限:控制某一页是否可读、可写、可执行以及是否允许用户态访问。
MMU 不只负责地址翻译,还会检查权限。当程序试图向只读页面写入数据,或者执行不可执行页面中的内容时,就可能触发异常。

程序运行时,指令里使用的是虚拟地址。真正访问内存条前,再由 MMU 和页表完成地址转换。

1.5 ELF 入口与程序启动顺序
ELF Header 中有一个字段:

Entry point address

对于一个简单程序,入口通常不是 main,而是 _start。

在 x86-64 中,下一条指令地址由 RIP 寄存器表示;32 位 x86 中对应的是 EIP。内核完成装载准备后,会让 CPU 从相应入口继续执行。

 

 

 

动态链接程序的典型启动顺序如下:

内核检查 ELF Magic 和基本格式;
根据 PT_LOAD 映射主程序;
读取 PT_INTERP,找到动态链接器,例如 ld-linux-x86-64.so.2;
映射并启动动态链接器;
动态链接器加载依赖库、进行符号解析和必要的运行时重定位;
动态链接器把控制权交给主程序入口 _start;
_start 准备参数并调用 __libc_start_main;
C 运行时完成初始化后调用 main;
main 返回后,由运行时完成退出和清理。
注意:不是 _start 先调用动态链接器。对于动态链接程序,动态链接器通常在主程序 _start 之前就已经获得控制权并完成主要装载工作。

二、动态库映射与进程间共享
2.1 动态库的文件属性与映射
静态库参与链接后,所需代码已经进入最终可执行文件 ;动态库则必须在运行时继续存在。

要让程序使用一个 .so,至少要完成两件事:

让进程看到它:把动态库对应的文件区域映射进当前进程的虚拟地址空间;
让进程调用它:解决函数和全局变量的地址引用,使控制流能够跳转到库代码。

动态库本身也是 ELF,动态链接器同样会读取它的 Program Header Table,把需要的 Segment 映射进进程地址空间。

2.2 动态库代码页的进程间共享

实际情况是:

动态库中只读、可执行的代码页可以由多个进程共享同一批物理页;
每个进程都有自己的虚拟地址映射;
可写数据页通常不能直接被所有进程共享修改,而是每个进程拥有自己的私有映射,必要时通过写时拷贝分离;
GOT 等运行时需要修改的内容,也是进程私有的。
动态库节省内存的关键在于:

不同进程可以共享同一份只读库代码,而不必在每个可执行文件中都静态塞入一份相同实现。

静态链接时,同一套库代码会进入不同可执行文件。它们成为不同文件中的代码页,通常不能像同一个 .so 的文件映射页那样自然共享。

2.3 使用 ldd 查看动态依赖
ldd ./main

可能看到:

linux-vdso.so.1
libc.so.6 => /lib64/libc.so.6
/lib64/ld-linux-x86-64.so.2

libc.so.6 提供 C 运行库函数;ld-linux-x86-64.so.2 是用户态动态链接器,也常被称为 ELF interpreter。

 

动态链接器负责:

找到程序依赖的 .so;
递归处理库之间的依赖;
映射各个共享对象;
解析动态符号;
执行运行时重定位;
处理 GOT、PLT;
最后把控制权交给主程序。
动态库搜索路径、LD_LIBRARY_PATH、ld.so.conf.d 和 ldconfig 已经在上一篇做过实验,这里不再重复展开。

2.4 位置无关代码与装载基址
地址空间随机化开启后,同一个动态库在不同进程中的虚拟起始地址可能不同。

动态库不能假设自己永远从某个固定绝对地址开始,否则换一个装载位置,内部引用就会全部失效。

因此共享库通常使用位置无关代码(PIC):

gcc -fPIC -c source.c
gcc -shared -o libdemo.so source.o

查看动态库反汇编:

# Ubuntu 示例
objdump -d /lib/x86_64-linux-gnu/libc.so.6 | less

# CentOS 示例路径可能不同
objdump -d /lib64/libc.so.6 | less

动态库函数的运行时地址满足:

函数运行时地址 = 动态库装载基址 + 函数在库内的偏移量

编译器、链接器和动态链接器会通过 PC 相对寻址、GOT 与 PLT 完成这类地址计算。

2.5 动态库映射到进程地址空间

动态链接器找到库文件后,会根据它的 PT_LOAD 建立文件映射:

库的代码 Segment 映射为只读、可执行;
只读数据映射为只读;
可写数据映射为可读写;
BSS 对应范围在内存中补零;
动态链接需要的表项也映射进当前进程。
虽然不同进程中共享库的虚拟地址可能不同,但它们的虚拟页可以指向同一批只读物理页。

2.6 外部函数调用 的定位条件

程序调用一个外部函数时,至少已经具备:

该动态库已经映射进当前进程;
动态链接器知道库的装载基址;
动态符号表中能够找到函数对应的符号;
重定位记录说明了程序中哪些位置需要使用这个符号。
理论上,只要得到“库基址 + 函数偏移”,就能定位库函数。但代码区通常只读,而且动态库地址每次运行可能变化,不能简单地把所有绝对地址硬写进 .text。

因此还需要 GOT 与 PLT 处理运行时地址。

三、GOT:不修改只读代码,也能修正运行时地址
3.1 将可变地址从 .text 中分离
程序启动后才能确定 puts 的真实地址,但不能直接改写调用指令:

把调用指令中的地址改成 puts 的真实地址。

但这样会带来几个问题:

.text 通常只读、可执行,不允许普通写入;
修改代码页会破坏多个进程共享同一份只读代码的能力;
每个进程中动态库地址可能不同,修改结果也不同。
所以动态链接把需要变化的地址放到单独的数据表中,而不是反复修改代码指令。

3.2 GOT 是什么?
GOT 全称为 Global Offset Table,全局偏移表。

GOT 是当前 ELF 模块的运行时地址表。程序或动态库引用外部函数、外部全局变量时,可以通过对应表项取得最终地址。

查看 .got:

readelf -S a.out

可能看到:

[24] .got PROGBITS ... WA ...

继续查看它所在的 Segment:

readelf -l a.out

常见映射中,.got 会与 .data、.bss 等一起进入可写的 LOAD Segment。

 

GOT 是独立的 Section,不等于 .data。只是在装载视角下,它常与其他可写 Section 被组织进同一个 Segment。

动态链接器执行重定位时,会把符号的真实运行时地址写入相应 GOT 表项。

3.3 PIC 如何通过 GOT 访问外部地址
在单个 ELF 模块内部,.text 与 .got 的相对位置在链接完成后是确定的。

因此代码可以使用 RIP 相对寻址找到 GOT,再通过 GOT 表项间接取得目标地址:

当前指令位置
↓ 相对寻址
GOT 表项
↓ 读取运行时地址
真实函数或全局变量

这带来几个好处:

.text 不需要根据每次装载位置被改写;
只读代码页可以在多个进程之间共享;
每个进程拥有自己的可写 GOT 映射,可以保存本进程中的真实地址;
动态库被放到不同基址时仍能正常运行。
入门阶段可将 PIC 概括为:

优先使用 PC/RIP 相对寻址;遇到运行时才能确定的外部地址时,再通过 GOT 间接访问。

某些 GOT 区域在重定位完成后会通过 RELRO 机制改成只读,并不是整个 GOT 永远保持可写。入门阶段先记住“动态链接器需要在合适阶段写入 GOT”即可。

四、PLT:把函数绑定推迟到第一次调用
4.1 直接观察 puts@plt
objdump -d a.out | less

可能看到:

0000000000001050 <puts@plt>:
...
jmpq *0x...(%rip) # 跳转到 puts 对应 GOT 表项

0000000000001149 <main>:
...
callq 1050 <puts@plt>

主程序并不是直接 call 到 libc 中 puts 的真实地址,而是先调用本程序中的 puts@plt 入口。

4.2 PLT 是什么?
PLT 全称为 Procedure Linkage Table,过程链接表。

它是一组用于外部函数调用的跳转桩。每一个需要动态调用的外部函数,通常都有对应的 PLT 入口。

PLT 和 GOT 配合后,函数调用大致变成:

main
↓ call puts@plt
PLT 入口
↓ 查看 GOT[puts]
真实 puts 地址,或动态解析器入口

4.3 首次调用与后续调用
如果程序启动时立刻解析所有动态函数,会做大量符号查询和重定位。但一个程序依赖的库中,很多函数整个运行期间可能一次都不会被调用。

为降低启动阶段的解析开销,动态链接支持 延迟绑定(Lazy Binding):

第一次调用
传统 glibc/x86-64 模型下,调用链可以概括为:

call puts@plt

进入 puts 对应的 PLT 入口

读取 .got.plt 中的 puts 表项

表项尚未保存真实地址,跳回 PLT 的解析桩

进入动态解析器(常见实现可概括为 `_dl_runtime_resolve`)

查找 puts、计算运行时地址并回填 .got.plt

跳转到 libc 中真正的 puts

具体的 PLT 指令布局、解析器符号和安全机制会随架构、链接器版本及 CET/RELRO 等选项变化,但“首次解析并回填 GOT 表项”的核心过程不变。

 

后续调用
call puts@plt

PLT 读取已经更新的 .got.plt 表项

直接跳转到 libc 中真正的 puts

后续调用不再重复执行符号查询与地址回填。

 

PLT 负责提供调用入口,GOT 负责保存最终地址;动态链接器负责第一次把正确地址写进去。

现代程序也可以选择在启动阶段一次性完成绑定,例如使用 LD_BIND_NOW=1,或者链接时启用 -Wl,-z,now。此时仍可能存在 PLT/GOT 结构,但不会等到第一次调用再解析。

4.4 库也会依赖其他库
动态链接并不只是“可执行程序调用一个库”。一个 .so 内部也可能继续调用另一个 .so。

 

查看动态依赖项:

readelf -d libdemo.so | grep NEEDED

动态链接器会递归处理依赖关系:

读取主程序的 DT_NEEDED;
找到并映射对应共享库;
再读取这些库自己的 DT_NEEDED;
建立整个共享对象依赖集合;
按符号解析规则查找定义;
完成各模块的重定位和 GOT/PLT 初始化。
库本身也有 .dynsym、.dynstr、.rela.*、.got 和可能存在的 .plt,因此它既可以向外提供符号,也可以继续引用其他库中的符号。

五、静态链接、装载与动态链接的完整流程
下面按时间顺序整理完整流程。

5.1 编译阶段
hello.c ──编译──> hello.o
code.c ──编译──> code.o

编译器完成:

把源代码翻译为机器指令;
生成 Section;
生成符号表;
对暂时不能确定的引用生成重定位记录。
此时不同 .o 彼此独立,hello.o 不知道 run 的最终位置。

5.2 静态链接阶段
hello.o + code.o + 静态库成员

链接器

可执行 ELF

链接器完成:

选择静态库中需要的成员;
合并和布局输出 Section;
解析符号;
分配地址;
按重定位表修正引用;
生成 Program Header Table;
确定 Section 到 Segment 的映射关系。
5.3 程序装载阶段
内核读取 ELF Header

读取 Program Header Table

建立进程虚拟地址区域

映射主程序和动态链接器

对于动态链接程序,内核还会根据 PT_INTERP 启动动态链接器。

5.4 动态链接阶段
读取依赖关系

映射各个 .so

解析动态符号

执行运行时重定位

初始化 GOT / PLT

如果使用延迟绑定,一部分函数符号会等到第一次调用时再解析。

5.5 正式执行
动态链接器完成工作

主程序入口 _start

__libc_start_main

main

进入 main 时,装载、依赖解析和必要的重定位已经完成。

总结
这一篇围绕 ELF 装载和动态链接整理了以下结论:

ELF 在磁盘中已经记录虚拟地址布局或相对布局,但尚未固定占用某块物理内存;
Program Header Table 描述需要加载的 Segment、虚拟地址、文件偏移、大小和访问权限;
加载器根据 Segment 初始化进程地址空间,物理页通常在运行过程中按需分配和调入;
动态库被映射到各进程的共享区,只读代码页可以共享,可写数据通常保持进程私有;
PIC 使用相对寻址并借助 GOT 保存运行时地址,避免修改共享的只读代码;
PLT 为外部函数提供调用入口,并可将符号解析推迟到第一次真正调用;
首次调用会进入动态解析流程并回填 .got.plt,后续调用可以直接跳转到真实函数;
库也可能依赖其他库,动态链接器会统一处理依赖关系、符号查询与运行时重定位。
静态链接在生成可执行文件时解决目标文件之间的关系;动态链接在装载和运行阶段继续解决可执行程序与共享库之间的关系。
————————————————
版权声明:本文为CSDN博主「爱和冰阔落」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/2402_87731470/article/details/163081648

上一篇 ORA-00979: not a GROUP BY expression
下一篇 网闸