
能从冰封的土地里
培育出十万朵怒放的蔷薇
Linux 系统篇(三十) —— ELF、链接重定位、动态库加载
文章目录
一、目标文件
1.1 编译到底做了什么?
编译的过程,说白了就是把我们写的源代码,翻译成 CPU 能直接执行的机器码。但注意,gcc -c 只完成“翻译”,不完成“拼装”。
看一个例子:
// hello.c
#include <stdio.h>
void run();
int main() {
printf("hello world!\n");
run();
return 0;
}
// code.c
#include <stdio.h>
void run() {
printf("running...\n");
}
分别编译:
$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c code.o hello.c hello.o
得到两个 .o 文件,它们叫目标文件(Object File)。注意:目标文件不是完整程序,它只是“半成品”。为什么需要这个半成品?因为如果修改了一个源文件,只需要单独编译它,不需要把整个工程重新编译一遍。
用 file 命令看看 .o 到底是什么:
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
关键词:ELF、relocatable(可重定位)。.o 文件是 ELF 格式的一种,而“可重定位”意味着里面的地址还没最终确定,等着链接器来修正。
1.2 目标文件里有什么?
.o 文件是二进制,但它是有结构的二进制。这个结构就是 ELF。要理解链接,必须先理解 ELF 的组成。
二、ELF 文件
2.1 ELF 的四种类型
很多人以为 ELF 就是可执行文件,其实 Linux 下有四类文件都是 ELF:
| 类型 | 英文 | 典型文件 | 用途 |
|---|---|---|---|
| 可重定位文件 | Relocatable File | xxx.o | 与其他目标文件链接,生成可执行文件或共享库 |
| 可执行文件 | Executable File | a.out、main.exe | 可以直接运行 |
| 共享目标文件 | Shared Object File | xxx.so | 运行时被动态链接器加载 |
| 内核转储 | Core Dump | core | 进程崩溃时的执行上下文,用于调试 |
关键认知:.o、可执行文件、.so 本质上是同一种格式的不同“阶段”。
2.2 ELF 的四大组成部分
一个 ELF 文件由四部分组成:
- ELF 头(ELF Header):位于文件开头,描述文件的主要特性,核心作用是定位文件的其他部分(程序头表在哪、节头表在哪)。
- 程序头表(Program Header Table):列举所有有效的段(segment) 及其属性,记录每个段的偏移、长度、虚拟地址。这是给操作系统加载看的。
- 节头表(Section Header Table):描述所有节(section) 的信息。这是给链接器看的。
- 节(Section):ELF 文件的基本组成单位,不同类型的数据放在不同的节里。

2.3 最常见的节
| 节名 | 内容 | 特点 |
|---|---|---|
.text | 机器指令(代码) | 只读、可执行 |
.data | 已初始化的全局变量、局部静态变量 | 可读可写 |
.bss | 未初始化的全局变量、局部静态变量 | 不占文件空间,运行时清零 |
.rodata | 只读数据(如字符串常量) | 只读 |
.symtab | 符号表:函数名、变量名与代码的对应关系 | 链接时用 |
.got / .got.plt | 全局偏移表,动态链接的关键 | 可读可写 |
用 size 命令可以快速看代码段、数据段、bss 段的大小:
$ size code
text data bss dec hex filename
3312 636 4 3952 f70 code
2.4 两个视图:链接视图 vs 执行视图
链接视图(Linking View)——对应节头表
- 粒度更细,按功能模块划分(
.text、.data、.rodata……)- 静态链接分析时关注这个视图,能理解 ELF 里包含哪些部分
- 为了空间布局效率,链接器会把很多小 section 合并、规整成 segment
执行视图(Execution View)——对应程序头表
- 告诉操作系统怎么加载可执行文件,完成进程内存初始化
- 一个可执行程序的格式中,一定有程序头表
- 加载后哪些段可读、可写、可执行,由程序头表描述
总结:节头表管链接,程序头表管加载。
三、ELF 从形成到加载的轮廓
3.1 ELF 形成可执行文件
- Step 1:把多份 C/C++ 源代码翻译成目标
.o文件,外加动静态库(它们也是 ELF)。- Step 2:把多份
.o文件的 section 进行合并。
注意:实际合并是在链接时进行的,而且不是简单拼接——链接器还要处理库的合并、符号解析、地址重定位。
3.2 ELF 加载:section 合并成 segment
一个 ELF 有很多 section,但加载到内存时,操作系统不会按 section 逐个加载,而是把 section 合并成 segment 再加载。
合并原则:相同属性放在一起。比如可读、可写、可执行、需要加载时申请空间等属性相同的 section,会被合并成一个 segment。
这个合并工作在形成 ELF 的时候就已经确定了,合并规则记录在程序头表里。
验证一下。先看可执行文件的 section:
$ readelf -S a.out
There are 31 section headers, starting at offset 0x19d8:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
...
[12] .init PROGBITS 0000000000400390 00000390
...
[16] .text PROGBITS 00000000004003e0 000003e0
...
[18] .rodata PROGBITS 00000000004004f0 000004f0
...
再看 segment:
$ readelf -l a.out
Elf file type is EXEC (Executable file)
Entry point 0x4003e0
There are 9 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040
0x00000000000001f8 0x00000000000001f8 R E 8
INTERP 0x0000000000000238 0x0000000000400238 0x0000000000400238
0x000000000000001c 0x000000000000001c R 1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x0000000000000744 0x0000000000000744 R E 200000
LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
0x0000000000000218 0x0000000000000220 RW 200000
...
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr
.gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text
.fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
...
可以看到:.text、.rodata、.init 等只读/可执行的 section 被合并到第 2 个 LOAD 段(R E);.data、.bss、.got 等可读写的 section 被合并到第 3 个 LOAD 段(RW)。
3.3 为什么要把 section 合并成 segment?
两个原因:
原因一:减少页面碎片,提高内存利用率。
假设页面大小为 4096 字节(这是加载和管理的基本单位)。如果 .text 是 4097 字节,.init 是 512 字节,不合并的话,它们会各自占用整数个页面:.text 占 2 页,.init 占 1 页,总共 3 页。合并后,4097 + 512 = 4609 字节,只需要 2 页。省了一页。
原因二:权限控制。
相同属性的 section 合并成一个 segment,操作系统可以统一设置这段内存的访问权限(只读、可写、可执行)。如果混在一起,权限就没法精细控制。
3.4 ELF 头
ELF 头的作用只有一个:定位文件的其他部分。看一个 .o 文件的头:

再看可执行文件:

对比很明显:.o 没有程序头表、入口地址为 0;可执行文件有程序头表、入口地址非 0。内核中的 Elf64_Ehdr 结构体就是用来解析这些字段的:
typedef struct elf64_hdr {
unsigned char e_ident[EI_NIDENT]; /* ELF "magic number" */
Elf64_Half e_type; /* 文件类型 */
Elf64_Half e_machine; /* 目标平台 */
Elf64_Word e_version;
Elf64_Addr e_entry; /* 入口虚拟地址 */
Elf64_Off e_phoff; /* 程序头表文件偏移 */
Elf64_Off e_shoff; /* 节头表文件偏移 */
...
} Elf64_Ehdr;
四、理解链接与加载(核心)
4.1 静态链接:研究 .o 如何链接
结论先行:不管是自己的 .o,还是静态库里的 .o,静态链接的本质都是把 .o 文件进行连接。所以研究静态链接,就是研究 .o 之间怎么拼起来。
4.1.1 现象:call 指令后面全是 0
先编译:
$ gcc -c *.c
$ gcc *.o -o main.exe
$ ls
code.c code.o hello.c hello.o main.exe
反汇编 .o 文件:
$ objdump -d code.o
code.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <run>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # f <run+0xf>
f: e8 00 00 00 00 callq 14 <run+0x14> # call 后面是 0
14: 90 nop
15: 5d pop %rbp
16: c3 retq
$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # f <main+0xf>
f: e8 00 00 00 00 callq 14 <main+0x14> # 调用 printf,地址是 0
14: b8 00 00 00 00 mov $0x0,%eax
19: e8 00 00 00 00 callq 1e <main+0x1e> # 调用 run,地址是 0
1e: b8 00 00 00 00 mov $0x0,%eax
23: 5d pop %rbp
24: c3 retq
为什么是 0? 因为编译 hello.c 的时候,编译器根本不知道 printf 和 run 在哪里——它们在内存的哪个区块、代码长什么样,编译器一概不知。所以只能先把跳转地址临时设为 0,等链接时再修正。
这个修正动作,依赖于 .o 文件里的重定位表。链接器会根据表里记录的“哪里需要修正、修正成什么符号”,在合并完成后统一改地址。
4.1.2 符号表:谁定义了,谁没定义
用 readelf -s 看符号表:

code.o 里,run 是 FUNC GLOBAL DEFAULT,有定义;puts(printf 的底层实现之一)是 UND,即 undefined,未定义——本 .o 文件里找不到。

hello.o 里,main 有定义;puts 和 run 都是 UND。这就是“多个 .o 彼此不知道对方”的直接证据——hello.o 不认识 run,code.o 也不认识 puts。
4.1.3 链接器做了什么
链接之后,再看最终可执行文件的符号表:
$ readelf -s main.exe
Symbol table '.dynsym' contains 7 entries:
...
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5 (2)
...
Symbol table '.symtab' contains 67 entries:
...
50: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@@GLIBC_2.2.5
52: 0000000000001149 23 FUNC GLOBAL DEFAULT 16 run
63: 0000000000001160 37 FUNC GLOBAL DEFAULT 16 main
...
变化很明显:
run从UND变成了有具体地址0000000000001149的FUNC,所在 section 下标是16。main也有了具体地址0000000000001160。puts仍然是UND——因为它是动态库里的函数,要到运行时才解析(这里先记住,后面动态链接会讲)。
注意 run 和 main 的 Ndx 都是 16,说明它们的代码被合并到了同一个 section(第 16 个 section,也就是 .text)。
4.1.4 验证:call 地址被修正了
反汇编最终的可执行文件:
$ objdump -d main.exe
...
0000000000001149 <run>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 48 8d 3d ac 0e 00 00 lea 0xeac(%rip),%rdi # 2004 <_IO_stdin_used+0x4>
1158: e8 f3 fe ff ff callq 1050 <puts@plt> # 地址被修正了
115d: 90 nop
115e: 5d pop %rbp
115f: c3 retq
0000000000001160 <main>:
1160: f3 0f 1e fa endbr64
1164: 55 push %rbp
1165: 48 89 e5 mov %rsp,%rbp
1168: 48 8d 3d a0 0e 00 00 lea 0xea0(%rip),%rdi # 200f <_IO_stdin_used+0xf>
116f: e8 dc fe ff ff callq 1050 <puts@plt> # 调用 printf,走 PLT
1174: b8 00 00 00 00 mov $0x0,%eax
1179: e8 cb ff ff ff callq 1149 <run> # 调用 run,地址确定
117e: b8 00 00 00 00 mov $0x0,%eax
1183: 5d pop %rbp
1184: c3 retq
main 里调用 run 的 call 指令,后面不再是 00 00 00 00,而是 cb ff ff ff,跳转到 1149 <run>。调用 printf 的 call 跳转到 1050 <puts@plt>,这是动态链接的入口。
静态链接的完整结论:
- 两个
.o的代码段合并到了一起,并进行了统一编址。- 链接时,链接器根据
.o或静态库中的重定位表,找到需要重定位的函数和全局变量,修正它们的地址。- 这个过程叫编译重定位,也叫静态重定位。
所以,静态链接就是把编译产生的所有目标文件,连同用到的静态库,组合拼装成一个独立的可执行文件,其中包含地址修正。

4.2 ELF 加载与进程地址空间
4.2.1 一个关键问题:没加载时,ELF 有地址吗?
有。 当代计算机都采用“平坦模式”工作,要求 ELF 对自己的代码和数据进行统一编址。用 objdump -S 反汇编,最左侧那一列就是 ELF 的虚拟地址:
$ objdump -d main.exe | head -30
0000000000001060 <_start>:
1060: f3 0f 1e fa endbr64
...
这些地址在程序还没加载到内存时就已经存在了。严格来说,它叫逻辑地址(起始地址 + 偏移量),我们把起始地址视为 0,所以看起来像虚拟地址。
4.2.2 进程的 mm_struct、vm_area_struct 从哪来?
进程刚创建时,内核里的 mm_struct、vm_area_struct 需要初始化数据——这些数据来自 ELF 的各个 segment。
每个 segment 有自己的起始地址和长度,用来初始化内核结构中的 [start, end] 范围;更详细的地址信息用来填充页表。
结论:虚拟地址机制,不光操作系统要支持,编译器也要支持。编译器在生成 ELF 时就已经按虚拟地址编址了。
4.2.3 程序入口不是 main
ELF 头里的 Entry point address 字段记录的是程序入口。但注意:这个入口不是 main 函数。
在 C/C++ 程序中,程序启动后首先跳转到的是 _start,这是由 C 运行时库(通常是 glibc)或链接器提供的特殊函数。_start 会做一系列初始化,最后才调用 main。

4.3 动态链接与动态库加载
4.3.1 为什么默认用动态链接?
先看一个可执行程序依赖哪些动态库:

libc.so.6 就是 C 运行时库,提供标准输入输出、字符串处理等功能。
为什么编译器默认不静态链接? 静态链接会把所有目标文件和用到的库合并成一个独立可执行文件,不需要额外依赖,听起来很方便。但问题在于:
- 文件体积大:每个程序都自带一份 libc,磁盘浪费严重。
- 内存浪费:多个程序同时运行,每个进程内存里都有一份相同的库代码。
- 更新困难:库有 bug 修复,所有静态链接的程序都得重新编译。
动态链接的优势就体现出来了:把需要共享的代码单独提取成动态库,程序运行时才加载。同一个模块在内存中只需要保留一份副本,可以被不同进程共享。
4.3.2 动态链接的核心思想
结论:动态链接实际上把链接的整个过程推迟到了程序加载的时候。
运行一个程序时,操作系统会先把程序的数据代码连同它用到的一系列动态库加载到内存。每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况动态分配一段内存。当动态库被加载、内存地址确定后,再去修正那些函数跳转地址。
4.3.3 进程如何看到动态库?进程间如何共享?


两个关键点:
- 动态库也是文件,要访问就要先打开、加载。让进程找到动态库的本质,还是文件操作。但访问库函数是通过虚拟地址跳转的,所以需要把动态库映射到进程的地址空间。
- 进程间共享:物理内存中只有一份动态库代码,通过虚拟内存机制映射到多个进程的地址空间。每个进程看到的虚拟地址可能不同,但物理页是同一份。
4.3.4 可执行程序被编译器“动了手脚”
在 C/C++ 程序中,程序开始执行时并不会直接跳到 main。实际流程是:
_start → __libc_start_main → main
_start 由 C 运行时库或链接器提供,它做这些事:
- 设置堆栈:为程序创建初始堆栈环境。
- 初始化数据段:把全局变量、静态变量从初始化数据复制到内存,清零未初始化数据段。
- 动态链接:调用动态链接器(如
ld-linux.so),解析和加载程序依赖的动态库,处理符号解析和重定位,确保函数调用和变量访问能映射到动态库中的实际地址。- 调用
__libc_start_main:glibc 提供的函数,做额外初始化(信号处理、线程库初始化等)。- 调用
main:执行控制权正式交给用户代码。- 处理
main返回值:最终调用_exit终止程序。
动态链接器在加载动态库时,会按以下规则搜索:
- 环境变量:
LD_LIBRARY_PATH- 配置文件:
/etc/ld.so.conf及其子配置文件- 缓存文件:
/etc/ld.so.cache(包含系统中所有已知动态库路径,动态链接器优先搜索)
4.3.5 动态库中的相对地址
动态库要能被加载到任意进程的任意位置,所以它对内部方法的编址采用相对编址方案。可执行程序其实也一样遵守平坦模式,只不过可执行文件是直接加载的,而动态库的加载地址是运行时才确定的。
# Ubuntu 下查看任意一个库的反汇编
$ objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less
4.3.6 程序怎么和库映射起来?

关键链条:
- 动态库是一个文件,要访问先要打开、加载。
- 让进程找到动态库的本质是文件操作,但库函数调用要通过虚拟地址跳转,所以需要把动态库映射到进程地址空间。
- 映射完成后,库的虚拟起始地址就确定了。
4.3.7 程序怎么进行库函数调用?

三个已知条件:
- 库已经映射到当前进程地址空间。
- 库的虚拟起始地址已知。
- 库中每个方法的偏移量已知。
所以:访问库中任意方法,只需要“库的起始虚拟地址 + 方法偏移量”。整个调用过程是从代码区跳转到共享区,调用完再返回代码区,完全在进程地址空间内进行。
4.3.8 GOT:全局偏移量表(重点)
现在问题来了:程序运行前,库还没加载,库的起始虚拟地址还不知道;但代码段里调用库函数的指令已经生成了。怎么在不修改代码段的前提下,实现动态跳转?
先明确一个约束:代码段在进程中是只读的,不能直接修改。
动态链接的解决方案是:在 .data(可执行文件或库自己)中专门预留一片区域,用来存放函数的跳转地址,这叫全局偏移表 GOT(Global Offset Table)。表中每一项都是本运行模块要引用的一个全局变量或函数的地址。
因为 .data 是可读写的,所以支持动态修改。
$ readelf -S a.out
...
[24] .got PROGBITS 0000000000003fb8 00002fb8
0000000000000048 0000000000000008 WA 0 0 8
...
WA 表示可写、可分配。加载时,.got 会和 .data 合并成一个 segment 一起加载:
$ readelf -l a.out
...
Section to Segment mapping:
Segment Sections...
03 .init_array .fini_array .jcr .dynamic .got .data .bss
...
GOT 的几条关键性质:
- 代码段只读,不能直接改代码;有了 GOT,代码可以被所有进程共享。但不同进程中动态库的绝对地址、相对位置不同,所以每个进程的每个动态库都有独立的 GOT 表,进程间不能共享 GOT。
- 单个
.so下,GOT 与.text的相对位置是固定的,所以可以用 CPU 的相对寻址找到 GOT 表。- 调用函数时先查表,再根据表中的地址跳转。这些地址在动态库加载时被修改为真正的地址。
- 这种实现方式叫 PIC(Position Independent Code,地址无关代码)。动态库不需要做任何修改,被加载到任意内存地址都能正常运行,并且能被所有进程共享。这也是编译动态库要加
-fPIC的原因——PIC = 相对编址 + GOT。
看反汇编验证:
$ objdump -S a.out
...
0000000000001050 <puts@plt>:
1050: f3 0f 1e fa endbr64
1054: f2 ff 25 75 2f 00 00 bnd jmpq *0x2f75(%rip) # 3fd0 <puts@GLIBC_2.2.5>
...
0000000000001160 <main>:
...
116f: e8 dc fe ff ff callq 1050 <puts@plt>
...
main 调用 puts 时,先跳到 puts@plt,puts@plt 再通过 GOT 表(0x2f75(%rip) 指向的地址)间接跳转到真正的 puts。

4.3.9 PLT:延迟绑定(简单说明)
动态链接在程序加载时需要对大量函数做重定位,这很耗时。为了降低开销,操作系统做了优化,叫延迟绑定,也叫 PLT(Procedure Linkage Table,过程链接表)。
思路是:GOT 表中的跳转地址默认指向一段桩代码(stub)。第一次调用函数时,桩代码负责查询真正的函数地址,并更新 GOT 表。之后再次调用,直接跳转到动态库中的真正实现。
这样,绝大多数动态库函数在程序运行期间可能一次都不会被用到,就不会产生重定位开销。
4.3.10 库间依赖
注意:不仅仅可执行程序调用库,库也会调用其他库。库之间是有依赖的,怎么做到库和库之间互相调用也是地址无关的?
答案:库中也有 GOT,和可执行文件一样。这就是为什么大家都是 ELF 格式。
解析依赖关系的过程,就是加载并完善互相之间 GOT 表的过程。
4.4 动态链接 vs 静态链接
- 静态链接:编译时完成所有地址修正,叫编译重定位(静态重定位)。程序独立,但体积大、内存浪费、更新困难。
- 动态链接:把链接过程推迟到加载时,通过 GOT 表完成运行重定位(动态地址重定位)。节省磁盘和内存,方便更新,实现二进制级别代码复用,但牺牲了一定性能和加载时间。
附录:操作大全
ELF结构

查看 ELF 头
readelf -h main
显示文件类型、机器类型、入口地址、程序头表/节头表位置和大小等。内核中对应 Elf64_Ehdr 结构体。
查看程序头表(段)
readelf -l main
显示所有 segment 的偏移、虚拟地址、大小、权限。LOAD 类型的段是将来要加载到内存的部分。PhysAddr 在现代 OS 中可以忽略。内核中对应 Elf64_Phdr 结构体。
查看节头表(节)
readelf -S main
显示所有 section 的地址、偏移、大小、标志。.got 通常与全局偏移表相关。
查看符号表
readelf -s main
显示符号名、类型(FUNC/OBJECT/NOTYPE)、绑定(GLOBAL/LOCAL/WEAK)、所在 section 下标、是否 UND。
反汇编
objdump -d main # 只反汇编代码段
objdump -S main # 反汇编并夹杂源码(如果有调试信息)
查看依赖
ldd main # 查看可执行文件或库依赖的共享库列表
查看各段大小
size main # text、data、bss 段大小
查看文件类型
file hello.o # 识别 ELF 类型:relocatable / executable / shared object
结语:
今天的内容到这里就结束了,希望你能有所收获~
干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/Huang_jin007/article/details/168219648




