
目录
[二、ELF 文件格式](#二、ELF 文件格式)
[ELF 头部关键字段](#ELF 头部关键字段)
[4.1 为什么需要位置无关码 -fPIC](#4.1 为什么需要位置无关码 -fPIC)
[4.2 GOT 和 PLT,延迟绑定 Lazy Binding](#4.2 GOT 和 PLT,延迟绑定 Lazy Binding)
[4.3 动态链接完整宏观流程](#4.3 动态链接完整宏观流程)
[五、C 程序启动流程:main 不是真正入口](#五、C 程序启动流程:main 不是真正入口)
[六、静态链接 VS 动态链接完整对比表](#六、静态链接 VS 动态链接完整对比表)
[七、常用 ELF 调试命令汇总](#七、常用 ELF 调试命令汇总)
前言
我们上篇学会了怎么制作静态库、动态库,会写 Makefile,解决动态库找不到的问题。但是有几个核心疑问:
.c源文件经过 gcc,如何变成.o目标文件?目标文件如何变成可执行程序?- ELF 是什么?section(节)和 segment(段)有什么区别?链接视图和执行视图?
- 静态链接的时候,链接器做了哪些工作?什么叫重定位?
- 动态库为什么需要
-fPIC位置无关代码?GOT、PLT 是什么?延迟绑定原理? - C 程序不是从 main 函数开始执行?_start、__libc_start_main 做了什么?
一、编译链接整体流程
cpp
add.c sub.c xxx.c 源代码
↓ 编译器 gcc -c
add.o sub.o xxx.o 目标文件(可重定位ELF文件) + 库文件
↓ 链接器 ld
a.out 可执行程序(ELF可执行文件)

示例代码 hello.c
cpp
#include<stdio.h>
void run();
int main() {
printf("hello world!\n");
run();
return 0;
}
code.c
cpp
#include<stdio.h>
void run() {
printf("running...\n");
}
只编译不链接,生成.o目标文件:
cpp
gcc -c hello.c
gcc -c code.c
ls *.o
.o是可重定位 ELF 目标文件,二进制,不能直接运行。
观察反汇编
cpp
objdump -d hello.o
你会发现,调用printf、run的 call 指令,跳转地址写的全部是00 00 00 00。
编译器编译单个
.o的时候,不知道外部函数printf、run到底在内存哪个地址。编译器把未知外部调用地址先填 0,留下重定位条目,交给链接阶段修正。
使用readelf -s hello.o查看符号表.symtab
UND(undefined):未定义符号,本目标文件找不到这个符号,需要外部链接提供。
把多个.o 链接成可执行程序
cpp
gcc hello.o code.o -o main.exe
链接器的两大工作:
- 段合并 :把多个
.o里面同名 section 合并,多个.text合并为可执行文件的.text,.data合并,.bss合并。 - 符号解析 + 重定位:找到 UND 未定义符号对应的真实地址,修正 call 指令里面的 0 占位地址。
静态库本质就是一堆
.o的归档包;链接静态库,就是链接器从归档包中把需要的.o拿出来参与链接,和直接把.o合并是同一个逻辑。
二、ELF 文件格式
Linux 下面 4 种文件都是 ELF 格式:
- 可重定位文件(Relocatable File):
xxx.o目标文件;编译产出,给链接器使用。 - 可执行文件(Executable File):a.out,我们运行的程序。
- 共享目标文件(Shared Object File):
xxx.so动态库。 - 内核转储 core dumps:程序崩溃生成 core 文件。
ELF 文件四大组成部分
- ELF Header ELF 头部:整个文件元信息,魔数、文件类型、机器架构、程序入口地址;记录程序头表、节头表在文件的偏移位置。
- Program Header Table 程序头表 (执行视图):告诉操作系统加载器,如何把文件加载映射到进程内存;描述 Segment 段。可重定位.o 目标文件没有程序头表。
- Section Header Table 节头表(链接视图):描述各个 section 节,链接器使用。
- Sections(各个节):真正存放代码、数据、符号、重定位信息。
两个核心视图(面试高频)
- 链接视图 ------ Section(节),节头表 Section Header Table
给链接器使用,编译链接阶段起作用,粒度细。各个独立 section:
.text代码节、.data初始化全局数据、.rodata只读常量、.bss未初始化全局变量、.symtab符号表、.rel.*重定位节。
- 执行视图 ------ Segment(段),程序头表 Program Header Table
给操作系统加载器使用,程序运行加载阶段。链接器会把多个属性相同的 section 合并为一个 Segment 段。 例如多个只读可执行 section 合并为一个
LOAD类型 segment(R-E 权限);多个读写 section 合并为读写 segment(RW 权限)。
为什么要合并 section 成 segment? 内存按页(4KB)分配。大量细碎小 section,如果每个 section 单独占一页,会造成大量内存页碎片浪费。合并相同权限的 section 到同一个 segment,减少内存页数量,提升内存利用率。
工具命令
cpp
readelf -h main.exe # 查看ELF头部
readelf -S main.exe # 查看所有section节(链接视图)
readelf -l main.exe # 查看program header segment段(执行视图)
objdump -d main.exe # 反汇编代码段.text
关键节说明
表格
| 节名 | 作用 |
|---|---|
.text |
存放编译后的机器指令代码 |
.data |
存放已经初始化的全局、静态变量 |
.rodata |
只读数据:字符串常量等,只读 |
.bss |
未初始化全局 / 静态变量,文件中不占磁盘空间,运行时内存清零 |
.symtab |
符号表,保存函数名、变量名符号信息 |
.got / .got.plt |
全局偏移表,动态链接使用,数据段,可写 |
.plt |
过程链接表,动态链接使用,代码段 |
ELF 头部关键字段
e_ident魔数:7f 45 4c 46,识别 ELF 文件;e_entry:程序入口虚拟地址;注意入口不是 main,是_start;e_phoff:程序头表在文件内偏移;e_shoff:节头表在文件内偏移。
查看目标文件 hello.o:
readelf -h hello.o,目标文件 Type 是 REL(可重定位),Number of program headers = 0,没有程序头表,不能直接加载运行。


三、静态链接原理
静态链接 = 段合并 + 符号解析 + 重定位。
- 段合并:把各个输入
.o同名 section 合并。 - 符号解析:链接器遍历各个目标文件符号表,把 UND 未定义符号找到对应的定义。
- 重定位:根据重定位表,修正代码 / 数据中所有外部符号占位地址,把 0 替换成真实虚拟地址。
静态链接重定位发生在编译链接阶段,程序运行的时候地址全部已经确定。
静态库就是 ar 打包一堆.o,链接静态库等价于取出库内.o 参与链接。链接之后库文件就不再需要。
四、动态链接原理
4.1 为什么需要位置无关码 -fPIC
动态库.so要加载到进程虚拟地址空间,但是每次加载的虚拟基地址不固定,操作系统根据内存使用情况分配。
如果代码写死绝对虚拟地址,换一个加载基地址全部就会失效。 PIC 位置无关代码全部使用相对寻址,不写死绝对地址;不管 so 被映射到哪一块虚拟地址,代码都可以正常执行。
PIC 核心依靠:GOT 全局偏移表 Global Offset Table
问题:代码段
.text是只读权限,运行时不能修改! 如果动态链接需要回填外部函数地址,不能直接修改只读代码段。解决方案:把所有外部符号的地址,存放到可写的数据段的 GOT 表。代码段只使用相对偏移访问 GOT 表项,真正的地址保存在 GOT 里面,运行时修改 GOT。
4.2 GOT 和 PLT,延迟绑定 Lazy Binding
- GOT(Global Offset Table 全局偏移表) 保存在可写数据段,每一个表项存储外部函数 / 变量的虚拟地址。每个进程有自己独立 GOT 副本,所以 GOT 不能多进程共享。
- PLT(Procedure Linkage Table 过程链接表) 存放在代码段,是一小段跳转桩代码,配合 GOT 实现延迟绑定。
延迟绑定(Lazy Binding) :Linux 默认策略。 不过程序一启动就解析所有动态库函数;函数第一次被调用的时候,才解析符号,回填 GOT 表。没有被调用过的函数完全不做解析,加快程序启动速度。
工作流程:
- 第一次调用动态库函数:执行 PLT 桩代码,GOT 表项存的是跳转到动态链接器的占位地址;
- 触发动态链接器
ld-linux.so,查找这个函数真实虚拟地址; - 把真实函数地址回填 GOT 对应表项;
- 跳转到真正库函数执行;
- 第二次调用同一个函数:直接查 GOT 拿到已经填好的真实地址,不再调用动态链接器。
4.3 动态链接完整宏观流程
运行./a.out,execve 系统调用加载 ELF 可执行文件:
- 内核读取 ELF 程序头表 Program Header Table;
- 创建进程
task_struct、mm_struct,建立虚拟地址空间; - 识别
.interp段,找到动态链接器/lib64/ld-linux-x86-64.so.2; - 把动态链接器加载映射进进程虚拟地址空间;
- 动态链接器读取可执行程序的依赖列表,逐个加载所需要的
.so动态库,映射到进程虚拟地址; - 动态链接器处理符号,配合 PLT/GOT,实现延迟绑定;
- 执行控制权交给可执行程序的入口
_start。
物理内存中,动态库的代码段
.text是只读,多进程可以共享;但是数据段、GOT 表每个进程拥有自己独立副本,不能共享。
五、C 程序启动流程:main 不是真正入口
内核执行完 execve,跳转到程序入口
_start,不是 main 函数!_start来自 crt1.o(C 运行时库 crt 启动文件)。
_start函数执行一系列工作:
- 设置进程初始栈,处理
argc、argv; - 处理动态链接相关初始化;
- 调用
__libc_start_main(glibc 提供);
__libc_start_main完成:
- 全局变量、构造函数初始化;
- 注册退出清理函数;
- 调用用户写的 main 函数;
- main 返回之后,处理返回值,调用
_exit结束进程。
所以我们写的 main 函数,仅仅是被库函数调用的普通函数。
六、静态链接 VS 动态链接完整对比表
表格
| 对比项 | 静态链接(-static) | 动态链接(gcc 默认) |
|---|---|---|
| 链接时机 | 编译链接期完成全部符号解析、重定位 | 链接期仅记录依赖;重定位推迟到程序运行时 |
| 库代码处理 | 把静态库.o机器码拷贝复制进可执行程序 |
不拷贝代码;运行时操作系统加载 so |
| 运行依赖 | 运行不需要库文件 | 运行必须存在对应的.so动态库 |
| 可执行文件体积 | 体积大,包含库完整代码 | 体积小,只存引用信息 |
| 内存占用 | 每个进程独立一份库代码,内存浪费 | 物理内存一份 so 代码段,多进程共享,节省内存 |
| 库升级 | 库更新,所有程序必须重新编译链接 | 直接替换 so,程序不需要重新编译 |
| 重定位实现 | 编译期静态重定位 | 运行时 GOT 表动态重定位,PLT 延迟绑定 |
| 启动速度 | 启动快,运行期没有链接开销 | 启动有动态链接解析开销 |
七、常用 ELF 调试命令汇总
cpp
# readelf
readelf -h xxx # ELF头部
readelf -S xxx # 全部section节
readelf -l xxx # program header segment段
readelf -s xxx # 符号表
# objdump
objdump -d xxx # 反汇编.text代码段
objdump -S xxx # 混合源码反汇编
ldd xxx # 查看可执行/so依赖哪些动态库
file xxx # 识别文件类型
nm xxx # 查看符号
下篇小结
- ELF 分链接视图 section(节) 和执行视图 segment(段) ;
.o目标文件只有节没有段;运行加载看 Program Header。 - 静态链接核心:段合并、符号解析、编译期重定位;静态库就是归档的
.o集合。 - 动态库必须
-fPIC位置无关码;依靠 GOT(可写,存放外部符号地址)、PLT 实现延迟绑定,减少程序启动开销。 - C 程序入口是
_start,由 crt 启动文件提供,_start调用__libc_start_main,再调用我们写的 main。 - 动态链接器
ld-linux.so.2,程序运行时负责加载 so、符号解析、回填 GOT 表。
拓展思考题(面试常考)
- 为什么动态库不能修改.text 代码段完成重定位?
- GOT 表为什么每个进程不能共享?
- 延迟绑定有什么优缺点?

