65 KiB
从代码到可执行目标程序的全流程解析
我们主要以C代码为例
预编译 => cpp
main.c => gcc -E main.c -o main.i => main.i
这一步主要是展开头文件,详细一点就是递归地将头文件(#include)、宏定义(#define)全部展开。
编译 => cc1
main.i => gcc -S main.c -o main.s => main.s
这一步采用编译器,主要进行词法分析,语法分析,构建语法树(AST),语义分析;然后进行优化并输出为.s格式的文件,里面存储了汇编语言编写的代码,(分段是为了)方便人类查改。
cc1很聪明,只会写入用过的内容,未使用的代码将被舍弃;并且头文件中多是声明,cc1只是把他们存入符号表,不需要产生任何CPU指令。
详细内容将在编译原理课程中讲解
你给出的这段汇编代码非常典型。这是一个 C 语言源文件编译成 .s 后的样子。我们可以直接从这段代码里揪出那些**“重定位标记”**的具体表现。
注意看代码里的 call malloc 和 call strncpy:
1. 汇编层面的“符号引用”
在汇编代码中,重定位标记表现为直接使用外部函数名。
movl $56, %edi
call malloc # <--- 这里的 malloc 就是一个标记
对于 cc1 来说,它在翻译这段代码时,并不知道 malloc 在内存的哪个地址。于是它就在生成的汇编里写下 call malloc。这相当于在告诉后面的汇编器(as):“我这里引用了一个叫 malloc 的符号,你帮我记下来,让链接器去填地址。”
2. 转换为二进制时的“坑”
当 as 把你这段代码变成二进制目标文件(.o)时,它会进行以下操作:
- 机器码占位:
call指令对应的机器码通常是E8。后面需要跟一个 4 字节的偏移量。因为地址未知,as会先填入00 00 00 00。
- 对应的二进制看起来像:
E8 00 00 00 00
- 创建重定位条目:在
.o文件的重定位表里写下一行记录。
我们可以用 objdump 模拟一下链接器视角看到的景象:
# 执行 objdump -dr student.o
0000000000000000 <new_student>:
...
1a: bf 38 00 00 00 mov $0x38,%edi
1f: e8 00 00 00 00 call 24 <new_student+0x24>
20: R_X86_64_PLT32 malloc-0x4
看到那个 20: R_X86_64_PLT32 malloc-0x4 了吗?这就是真正的重定位标记:
- 20: 告诉链接器,修改的位置在
.text段偏移 0x20 的地方(即E8之后的四个00)。 - R_X86_64_PLT32: 告诉链接器计算方法(这是一个 32 位的 PC 相对偏移,且目标可能在过程链接表 PLT 中)。
- malloc: 告诉链接器,去符号表里找这个名字对应的地址。
3. 为什么代码里有些东西不需要重定位?
再看你代码里的 .L2 和 .L3:
jne .L2
...
.L2:
movq -8(%rbp), %rax
这些以 .L 开头的标签是局部标签(Local Labels)。
cc1知道.L2就在当前这个函数内部,偏移量是确定的。- 汇编器
as在处理时,直接就能算出jne到.L2之间差了多少个字节,并把数值写死在机器码里。 - 因此,局部跳转不需要重定位标记。 只有像
malloc、strncpy这种跨越了当前编译单元(.c文件)的符号才需要。
4. 你的代码里还隐藏了什么?
你这段汇编显示你正在处理一个 struct Student 之类的结构体:
malloc(56):说明你的结构体大小是 56 字节。movb $0, 49(%rax):在偏移 49 的地方放个\0,说明成员变量(可能是char name[50])到这里结束。movl %edx, 52(%rax):在偏移 52 的地方存入一个 4 字节整数(可能是int age或id)。
这些**偏移量(49, 52)**是 cc1 根据结构体定义直接计算出来的硬编码,也不需要重定位。
总结一下:
在你给出的代码里,只有 call malloc 和 call strncpy 这两处会在最终的 .o 文件中留下重定位标记。
既然你提到了 strncpy 和 malloc,想看看如果不链接标准库(用 -nostdlib),链接器会报什么样的重定位错误吗?或者你想聊聊 PLT 这种动态链接特有的重定位方式?
汇编 => as
main.s => gcc -c main.c -o main.o => main.o
这一步采用汇编器,把汇编代码变成机器码(二进制ELF格式可重定位目标文件 .obj or .o)
想要查看ELF的相关内容,可以使用readelf, objdump等工具
readelf -a main.o
objdump main.o
本文件夹中生成的均使用类似 readelf -a main.o > main_obj_decode.txt 的命令
ELF文件包括三部分:
- ELF Header
- Section Header Table => 有如
.text,.symtab等关键字 - Section
链接 => ld
main.o other.o => gcc main.c other.c -o main => main
主要进行以下两个重要步骤:
- 符号解析:符号引用 与 符号定义 连接起来
- 重定位:符号定义 与 内存地址 连接起来 => 修改 .rel.text 的地址部分
经典重定位算法
#include <stdio.h>
#include <stdint.h>
// 模拟 ELF 重定位条目结构
typedef struct {
uint32_t offset; // 需要修改的代码在段内的偏移量
uint32_t info; // 符号索引(低位)和重定位类型(高位)
} Elf32_Rel;
// 模拟符号条目
typedef struct {
char* name;
uint32_t value; // 符号在内存中的最终绝对地址
} Symbol;
/**
* 模拟 ld 的重定位算法
* @param code_section: 目标段的内存缓冲区(.text 或 .data)
* @param rel: 重定位条目
* @param sym: 对应的目标符号
*/
void apply_relocation_abs32(uint8_t *code_section, Elf32_Rel rel, Symbol sym) {
// 1. 找到需要修改的内存位置指针
// 指向段内具体的 4 字节(32位)位置
uint32_t *patch_location = (uint32_t *)(code_section + rel.offset);
// 2. 读取原始的 Addend(附加值)
// 在绝对寻址中,这里通常是 0,或者是一个偏移
uint32_t addend = *patch_location;
// 3. 计算最终地址:S + A
// S = 符号的绝对地址 (Symbol Value)
// A = 附加值 (Addend)
uint32_t final_address = sym.value + addend;
// 4. 将计算后的地址回填到代码段中
*patch_location = final_address;
printf("Relocating [%s]: Offset 0x%x, New Address: 0x%08x\n",
sym.name, rel.offset, final_address);
}
int main() {
// 模拟一段机器码:假设 0x04 处是一个 extern 变量的地址占位符(当前为 0x00)
// 汇编逻辑类似于:mov eax, [0x00000000]
uint8_t text_section[] = { 0xB8, 0x00, 0x00, 0x00, 0x00 };
// 模拟符号表:变量 "my_global_var" 已经被 ld 分配到地址 0x08049000
Symbol my_var = { "my_global_var", 0x08049000 };
// 模拟重定位项:在偏移 0x01 处修正(即 B8 之后的 4 字节)
Elf32_Rel rel_item = { .offset = 0x01, .info = 0 };
printf("Before Relocation: %02x %02x %02x %02x %02x\n",
text_section[0], text_section[1], text_section[2], text_section[3], text_section[4]);
// 执行重定位
apply_relocation_abs32(text_section, rel_item, my_var);
printf("After Relocation: %02x %02x %02x %02x %02x\n",
text_section[0], text_section[1], text_section[2], text_section[3], text_section[4]);
return 0;
}
执行
这个问题其实正好把 编译系统(toolchain) 的最后一步串起来了:
.c/.cpp
↓ (compiler)
.o
↓ (linker ld)
ELF 可执行文件
↓
loader (内核)
↓
运行
你问的是 .o → ld → ELF 的完整链接过程,这个是计算机系统课程里非常核心的一部分。我按 实际发生的顺序给你讲,从 编译器输出 .o 开始,一直到生成 ELF。
一、编译器输出 .o 文件
假设我们有两个文件
main.c
add.c
main.c
int add(int a,int b);
int main(){
return add(1,2);
}
add.c
int add(int a,int b){
return a+b;
}
编译:
gcc -c main.c
gcc -c add.c
得到
main.o
add.o
这些 .o 叫 relocatable object file(可重定位目标文件)。
二、.o 文件内部结构(ELF)
.o 本身就是 ELF 格式,但类型是:
ET_REL
而最终可执行文件是
ET_EXEC
或
ET_DYN
.o 结构通常包含:
ELF Header
Section Header Table
Sections
主要 section:
.text 代码
.data 已初始化全局变量
.bss 未初始化全局变量
.rodata 常量
.symtab 符号表
.strtab 字符串表
.rela.text 重定位表
可以用:
readelf -S main.o
查看。
三、符号(Symbol)
链接最核心的概念是 符号解析。
符号有三种:
1 定义符号
例如
add
main
global_variable
2 未定义符号
例如 main.o 里面:
add
只是声明:
int add(int,int);
但没有定义。
3 本地符号
static int x;
只在当前 .o 可见。
符号信息存储在:
.symtab
可以查看:
readelf -s main.o
示例:
Symbol table '.symtab'
Num: Value Size Type Bind Vis Ndx Name
1: 0 0 FUNC GLOBAL UND add
2: 0 34 FUNC GLOBAL 1 main
注意:
add -> UND (undefined)
说明 main.o 需要别的文件提供 add。
四、重定位(Relocation)
这是 .o 文件和最终 ELF 最大的区别。
.o 里的地址 全部是假地址。
例如:
call add
汇编里其实写成:
call 0
然后记录:
需要在这里填入 add 的地址
这个记录存在:
.rela.text
查看:
readelf -r main.o
例如:
Offset Type Symbol
00000005 R_X86_64_PLT32 add
意思:
.text + 5 这个地方
需要填入 add 的地址
五、链接器 ld 开始工作
当执行:
gcc main.o add.o
实际调用:
ld
链接器做 3 个主要步骤
1 符号解析 (symbol resolution)
2 section 合并
3 重定位 (relocation)
我们逐个说。
六、步骤1:符号解析(Symbol Resolution)
链接器扫描所有 .o:
main.o
add.o
构建 全局符号表。
例如:
| 符号 | 定义位置 |
|---|---|
| main | main.o |
| add | add.o |
然后检查:
main.o 需要 add
在 add.o 找到了。
于是建立:
add -> add.o
如果找不到:
undefined reference to add
七、步骤2:Section 合并
链接器会把多个 .o 的 section 合并。
例如:
main.o
.text
.data
add.o
.text
.data
合并成:
最终ELF
.text
main
add
.data
示意:
.text
| main.o:text |
| add.o:text |
地址例如:
.text = 0x401000
main = 0x401000
add = 0x401020
八、步骤3:重定位(Relocation)
现在我们知道:
add = 0x401020
之前 main.o 有:
call add
但机器码里是:
call 0
现在 linker 会:
修改机器码
填入正确地址
例如 x86:
call relative_offset
计算:
offset = add - next_instruction
然后写入机器码。
这一步叫:
relocation fixup
九、静态库链接(顺带说)
如果有:
libmath.a
这是:
archive
其实是
多个 .o 的集合
可以查看:
ar t libmath.a
链接器只会提取:
真正需要的 .o
例如:
只用到 add.o
就只加载 add.o。
十、动态库(动态链接)
如果链接:
libc.so
链接器 不会复制代码。
而是生成:
PLT
GOT
并在 ELF 中记录:
NEEDED libc.so
运行时:
ld-linux.so
加载动态库。
十一、生成最终 ELF
最终文件结构:
ELF Header
Program Header Table
.text
.rodata
.data
.bss
.dynamic
.got
.plt
Section Header Table
可以查看:
readelf -a a.out
ELF 类型:
ET_EXEC
或 PIE:
ET_DYN
十二、ELF 中最关键的两个表
Section Header Table
给 链接器 用。
例如:
.text
.data
.symtab
Program Header Table
给 操作系统 loader 用。
例如:
LOAD
DYNAMIC
INTERP
查看:
readelf -l a.out
十三、程序运行时
运行:
./a.out
内核:
1 创建进程 2 读取 ELF header 3 根据 program header 映射内存
例如:
.text -> RX
.data -> RW
如果有动态库:
加载 ld-linux.so
完成 动态链接。
十四、完整流程总结
完整 pipeline:
源代码
↓
预处理
↓
编译
↓
汇编
↓
.o (ET_REL)
↓
ld
├─ 符号解析
├─ section 合并
└─ relocation
↓
ELF 可执行文件 (ET_EXEC / ET_DYN)
↓
内核 loader
↓
程序运行
十五、一个非常重要的理解(很多人忽略)
.o 文件几乎没有真实地址。
所有地址:
都是 linker 最后填进去的
所以 .o 叫:
Relocatable Object
如果你愿意,我可以继续给你讲 更硬核的一部分(这部分是 MIT / CMU 体系结构课程重点):
- ELF relocation entry 结构
- R_X86_64_PC32 / PLT32 / GOTPCREL
- PLT/GOT 动态链接原理
- Lazy Binding
- 为什么 call printf 先跳 PLT
这一块其实是 理解 Linux 程序执行最关键的底层机制之一。
符号解析(Symbol Resolution)其实是 链接器最核心的一步。简单一句话:
把所有
.o里出现的符号引用,找到唯一的定义位置。
但真正实现起来其实很复杂,因为要处理:
- 多个
.o - 静态库
.a - 强符号 / 弱符号
- 重复定义
- 未定义符号
- 链接顺序
我按 真实链接器的工作流程给你完整讲一遍。
一、什么是符号(Symbol)
在 ELF 中,符号就是一个名字对应一个地址或对象。
例如:
int add(int a,int b);
int x;
static int y;
对应符号:
| 符号 | 类型 |
|---|---|
| add | 函数 |
| x | 全局变量 |
| y | 局部符号 |
这些都记录在:
.symtab
查看:
readelf -s main.o
例子:
Num: Value Size Type Bind Vis Ndx Name
1: 0 0 FUNC GLOBAL UND add
2: 0 34 FUNC GLOBAL 1 main
关键字段:
| 字段 | 含义 |
|---|---|
| Name | 符号名 |
| Type | FUNC / OBJECT |
| Bind | GLOBAL / LOCAL / WEAK |
| Ndx | 定义在哪个section |
如果:
Ndx = UND
说明:
这个符号在当前文件没有定义。
二、符号分为三种
链接器把符号分成三类:
1 本地符号(Local)
例如:
static int x;
或
.Ltmp1
特点:
- 只在 当前
.o可见 - 不会参与符号解析
所以链接器 直接忽略。
2 全局符号(Global)
例如:
int add(int a,int b)
特点:
- 可以被其他
.o引用 - 必须 全程序唯一
3 弱符号(Weak)
例如:
__attribute__((weak)) int x;
或:
gcc 的某些库函数
特点:
- 可以被强符号覆盖
三、符号解析的目标
链接器要保证:
每个引用符号
↓
都能找到唯一的定义
否则就报错:
undefined reference to xxx
或者:
multiple definition of xxx
四、链接器的核心数据结构
链接器会维护一个 全局符号表
可以理解为:
HashTable
结构:
symbol_name -> symbol_definition
例如:
main -> main.o
add -> add.o
printf -> libc.so
五、符号解析算法(核心)
假设链接:
ld main.o add.o libx.a
链接器按顺序处理文件。
Step1 读取第一个目标文件
main.o
符号表:
main (defined)
add (undefined)
链接器更新全局表:
main -> main.o
add -> unresolved
Step2 读取第二个目标文件
add.o
符号表:
add (defined)
更新:
add -> add.o
现在:
main -> main.o
add -> add.o
六、遇到重复定义怎么办
例如:
a.o
b.o
都定义:
int x;
规则:
强符号 vs 强符号
error: multiple definition
例如:
int x=1;
强符号 vs 弱符号
强符号赢:
use strong symbol
弱符号 vs 弱符号
任选一个。
总结规则:
| 情况 | 结果 |
|---|---|
| strong + strong | error |
| strong + weak | strong |
| weak + weak | 任意 |
七、静态库符号解析(最容易错)
假设:
ld main.o libmath.a
而:
libmath.a = {add.o sub.o mul.o}
链接器 不会全部加载。
它只会:
按需加载
算法:
Step1
处理:
main.o
符号:
main (defined)
add (undefined)
Step2
扫描:
libmath.a
找到:
add.o
定义了:
add
于是:
加载 add.o
更新:
add -> add.o
如果 sub.o 没被用到:
不会加载
所以静态库 非常节省空间。
八、链接顺序问题
这是很多人第一次写 C 会遇到的坑。
例如:
gcc main.o -lmath
如果:
main.o 需要 add
libmath.a 提供 add
OK。
但如果:
gcc -lmath main.o
就可能:
undefined reference
原因:
链接器是单向扫描
顺序:
-lmath
main.o
扫描 -lmath 时:
还不知道需要 add
所以:
不会加载 add.o
九、符号解析完成后的状态
链接器最终得到:
Symbol Table
main -> 0x401000
add -> 0x401020
printf -> libc
但 .text 里的代码还没改。
例如:
call add
机器码还是:
call 0
于是进入:
Relocation
十、符号解析 vs 重定位
很多人会混淆。
区别:
| 阶段 | 做什么 |
|---|---|
| Symbol Resolution | 找到符号定义 |
| Relocation | 修改机器码地址 |
例子:
call add
符号解析:
add -> add.o
重定位:
call 0x401020
十一、真实链接器的内部算法(简化)
GNU ld / lld 基本逻辑:
global_symbol_table = {}
for file in input_files:
for symbol in file.symbols:
if symbol.defined:
if symbol.name not in table:
table[symbol.name] = symbol
else:
resolve_conflict()
else:
mark_as_undefined(symbol)
然后:
verify_all_symbols_resolved()
十二、一个真实例子
代码:
main.c
extern int x;
int main(){
return x;
}
a.c
int x=10;
b.c
int x=20;
编译:
gcc main.o a.o b.o
链接器发现:
x defined in a.o
x defined in b.o
结果:
multiple definition of x
十三、为什么 C 允许 extern
例如:
extern int x;
只生成:
UND symbol
让链接器去找。
十四、C++ 额外复杂性(名字改编)
C++ 还有:
name mangling
例如:
int add(int,int)
符号变成:
_Z3addii
所以链接器解析的是:
mangled name
十五、整个符号解析流程总结
完整过程:
1 读取 .o / .a
2 构建全局符号表
3 解析 undefined symbol
4 处理 strong/weak 冲突
5 从静态库按需加载 object
6 确保所有符号都有定义
最终得到:
symbol -> object location
然后进入:
Relocation
如果你愿意,我可以再给你讲 一个真正非常关键但很多书没讲清楚的部分:
为什么函数调用需要 PLT / GOT
也就是:
call printf
↓
call printf@plt
↓
GOT
↓
libc
这个其实是 动态链接器的核心机制,理解之后你基本就彻底理解 Linux 程序是怎么运行的了。
重定位(Relocation)是链接阶段最“硬核”的一部分。你可以把它理解为一句话:
把
.o里所有“还不知道地址”的地方,改写成真正的地址。
但在真实 ELF / x86-64 系统里,这个过程其实涉及:
- relocation entry 结构
- relocation type
- section layout
- PC-relative addressing
- GOT / PLT(动态链接)
我按 真实链接器的执行步骤给你完整讲清楚。
一、为什么需要重定位
在 .o 文件里:
地址几乎都是假的。
例如代码:
int add(int a,int b){
return a+b;
}
int main(){
return add(1,2);
}
编译后 main.o 中的汇编可能是:
call add
但 .o 里 根本不知道 add 在哪里。
所以编译器会生成:
call 0
然后记录:
这里将来需要填入
add的地址。
这个记录就是 Relocation Entry。
二、Relocation Entry 存在哪里
每个需要修补的位置都有一条记录。
在 ELF section 中:
.rela.text
.rela.data
查看:
readelf -r main.o
示例:
Relocation section '.rela.text'
Offset Type Symbol
00000005 R_X86_64_PLT32 add
意思是:
.text + 0x5
需要根据符号 add 修补
三、Relocation Entry 的结构
x86-64 使用:
Elf64_Rela
结构:
typedef struct {
Elf64_Addr r_offset;
Elf64_Xword r_info;
Elf64_Sxword r_addend;
} Elf64_Rela;
含义:
| 字段 | 含义 |
|---|---|
| r_offset | 要修改的位置 |
| r_info | 符号 + relocation type |
| r_addend | 额外常数 |
四、重定位的核心公式
ELF 的重定位统一使用:
result = S + A
或者:
result = S + A - P
各个符号:
| 符号 | 含义 |
|---|---|
| S | Symbol address |
| A | Addend |
| P | Patch location |
五、最典型的重定位:函数调用
假设最终链接后地址:
.text base = 0x401000
main = 0x401000
add = 0x401020
机器码:
call rel32
x86 call 使用:
relative offset
公式:
offset = S + A - P
其中:
S = add 地址
A = addend
P = 下一条指令地址
举例:
call 指令地址 = 0x401010
下一条指令 = 0x401015
计算:
offset = 0x401020 - 0x401015
= 0xB
于是机器码变成:
E8 0B 00 00 00
六、重定位执行流程
链接器流程:
for each relocation entry:
1 找到 symbol
2 获取 symbol address (S)
3 读取 addend (A)
4 计算 patch value
5 写入 r_offset 指向的机器码
七、示例:真实 .o 文件
代码:
extern int add(int,int);
int main(){
return add(1,2);
}
反汇编:
0000000000000000 <main>:
0: bf 01 00 00 00 mov $1,%edi
5: be 02 00 00 00 mov $2,%esi
a: e8 00 00 00 00 call 0
注意:
call 0
同时 relocation:
Offset: a
Type: R_X86_64_PLT32
Symbol: add
八、链接器如何修补
假设:
add = 0x401050
call instruction = 0x40100a
next instruction = 0x40100f
计算:
offset = 0x401050 - 0x40100f
= 0x41
写入:
E8 41 00 00 00
于是:
call add
成立。
九、不同 relocation 类型
常见 x86-64 relocation:
| 类型 | 用途 |
|---|---|
| R_X86_64_PC32 | PC relative |
| R_X86_64_PLT32 | call function |
| R_X86_64_32 | absolute address |
| R_X86_64_GOTPCREL | 访问 GOT |
| R_X86_64_RELATIVE | runtime relocation |
十、访问全局变量的重定位
代码:
extern int x;
int main(){
return x;
}
汇编:
mov x(%rip), %eax
这是:
RIP relative addressing
重定位:
R_X86_64_PC32
公式:
S + A - P
十一、Section relocation
不仅 .text 需要重定位。
例如:
.data
.rodata
示例:
int x=10;
int *p=&x;
编译器会生成:
p:
.quad x
但 .o 里不知道 x 地址。
于是:
.rela.data
记录:
p -> x
链接器填入:
x 的地址
十二、静态 vs 动态重定位
两种情况:
静态链接
ld main.o add.o
所有 relocation:
link time 完成
程序运行时:
不再修改
动态链接
例如:
printf
链接器不知道 libc 地址。
所以:
生成 GOT / PLT
运行时:
dynamic loader 完成 relocation
十三、动态重定位
动态重定位表:
.rela.plt
.rela.dyn
运行时 loader:
ld-linux.so
执行:
for relocation in .rela.dyn:
apply relocation
十四、为什么 PIE 需要 runtime relocation
现代 Linux 默认:
PIE executable
地址随机化:
ASLR
程序加载地址:
不固定
例如:
0x555555554000
所以 loader 需要:
重新修补地址
十五、完整流程总结
整个 relocation pipeline:
编译器
↓
生成 relocation entry
↓
.o 文件
↓
linker
↓
1 解析 symbol
2 layout sections
3 遍历 relocation entries
4 计算地址
5 修改机器码
↓
生成 ELF
↓
动态链接器
↓
runtime relocation
十六、一个非常重要的理解
在 .o 文件中:
代码 ≠ 最终机器码
很多地方只是:
placeholder
只有 链接完成后:
机器码才最终确定
如果你愿意,我可以再给你讲 一个几乎所有系统书都会略过,但其实是最关键的部分:
为什么 x86-64 必须大量使用 RIP-relative addressing?
这件事直接关系到:
- PIE
- ASLR
- 动态库加载
- GOT / PLT 的设计
理解这个,你对 Linux 程序加载机制会突然豁然开朗。
好问题,这其实正好是 ELF 链接体系里另一条分支:动态链接(Dynamic Linking)。
你前面已经在研究 .o → ELF 的 静态链接流程,那么 .so 的逻辑其实是:
把一部分链接工作从
ld推迟到程序运行时,由动态链接器完成。
我给你按 完整生命周期讲一遍(从编译到运行)。
一、动态库 .so 是怎么生成的
首先需要把代码编译成 位置无关代码(PIC):
gcc -fPIC -c foo.c
为什么?
因为 .so 可能被加载到 任意虚拟地址。
如果代码里写死地址:
call 0x400800
加载地址变化就炸了。
所以要用 相对地址 + 表间接访问。
然后生成 .so:
gcc -shared -o libfoo.so foo.o
这一步本质是:
ld -shared foo.o
生成的 ELF 类型:
ET_DYN
注意:
.so 本质就是 一个特殊的 ELF 文件。
二、程序链接动态库时发生什么
假设:
// main.c
extern void foo();
int main() {
foo();
}
编译:
gcc main.c -L. -lfoo
此时:
-lfoo
意味着:
寻找 libfoo.so 或 libfoo.a
如果找到 .so,就会 动态链接。
三、链接阶段(ld)做了什么
和静态链接不同:
ld 不会把 foo.o 代码复制进可执行文件。
它只做三件事:
1 记录依赖库
ELF 的 .dynamic section 里会出现:
NEEDED libfoo.so
可以用:
readelf -d a.out
看到:
0x0000000000000001 (NEEDED) Shared library: [libfoo.so]
2 生成 PLT / GOT
调用 foo() 不会直接 call foo。
而会变成:
call foo@plt
ELF 里会生成:
.plt
.got
.got.plt
3 生成重定位表
因为地址运行时才知道。
所以会生成:
.rela.plt
.rela.dyn
这些记录:
foo 的地址需要运行时解析
四、程序运行时发生什么
程序启动时:
内核执行:
execve()
读取 ELF header。
如果看到:
PT_INTERP
就会加载动态链接器:
Linux通常是:
/lib64/ld-linux-x86-64.so.2
可以查看:
readelf -l a.out
会看到:
INTERP
动态链接器启动后做几件事。
五、动态链接器干的事情
加载顺序大概是:
ld-linux.so
↓
加载主程序
↓
加载所有 .so
↓
符号解析
↓
重定位
↓
跳到 main()
六、共享库加载
动态链接器根据:
DT_NEEDED
加载:
libfoo.so
libc.so
查找路径:
1️⃣ LD_LIBRARY_PATH
2️⃣ rpath
3️⃣ /etc/ld.so.cache
4️⃣ /lib /usr/lib
七、符号解析
和静态链接一样:
foo -> libfoo.so::foo
但发生在 运行时。
八、PLT / GOT 机制(核心)
假设:
call foo()
编译后:
call foo@plt
流程是:
main
↓
foo@plt
↓
got entry
↓
dynamic linker
↓
真实 foo()
第一次调用:
main
↓
foo@plt
↓
dynamic linker
↓
解析 foo
↓
写入 GOT
↓
调用 foo
第二次:
main
↓
foo@plt
↓
GOT
↓
foo
不再解析。
这叫:
Lazy Binding
九、PLT结构
x86-64 典型结构:
foo@plt:
jmp *foo@GOT
push relocation_index
jmp plt0
plt0:
jmp dynamic linker
十、重定位类型
动态链接常见:
R_X86_64_JUMP_SLOT
R_X86_64_GLOB_DAT
R_X86_64_RELATIVE
例子:
.rela.plt
内容:
foo R_X86_64_JUMP_SLOT
意思:
运行时填 GOT
十一、总结(完整生命周期)
动态库流程:
源码
↓
gcc -fPIC
↓
.o
↓
ld -shared
↓
libfoo.so
程序:
main.c
↓
gcc main.c -lfoo
↓
ELF executable
运行:
kernel
↓
ld-linux.so
↓
加载 libfoo.so
↓
符号解析
↓
重定位
↓
执行 main
十二、一个真实示例
写个最小 demo:
foo.c
#include <stdio.h>
void foo() {
printf("hello foo\n");
}
编译:
gcc -fPIC -c foo.c
gcc -shared -o libfoo.so foo.o
main.c
void foo();
int main() {
foo();
}
编译:
gcc main.c -L. -lfoo
运行:
LD_LIBRARY_PATH=. ./a.out
十三、推荐你观察几个东西(非常有意思)
你现在在研究 ELF,强烈建议:
看依赖库
ldd a.out
看动态表
readelf -d a.out
看 PLT
objdump -d a.out | less
搜:
@plt
看重定位
readelf -r a.out
如果你愿意,我可以 带你从0手动解析一个动态链接程序,包括:
.plt每一条指令干嘛.got.plt怎么改写R_X86_64_JUMP_SLOT怎么执行ld-linux怎么完成解析
这个过程其实是 理解 Linux 程序加载机制的关键一课。 很多人学系统软件多年都没真正搞懂这一块。
你已经抓到 动态链接的大致思路了,但里面有几个关键点需要纠正。我按你的问题一条一条讲清楚,这些地方其实正好是 ELF + 动态链接最容易误解的地方。
一、PIC 是不是每个模块都这样?
基本是,但要稍微精确一点。
1 只有 共享库 (.so) 必须 PIC
编译共享库必须:
gcc -fPIC
原因:
.so 可能被加载到 任何虚拟地址。
例如:
程序A:
libc.so -> 0x7f2000000000
程序B:
libc.so -> 0x7f1000000000
所以 .so 里的代码不能写死地址。
2 可执行文件通常不是 PIC
传统 Linux 可执行文件:
ET_EXEC
默认假设加载地址:
0x400000
所以可以直接使用绝对地址。
不过现代 Linux 也经常:
PIE (Position Independent Executable)
编译:
gcc -fPIE -pie
这时 主程序也变成 PIC。
二、PIC是不是“代码段和数据段差一个固定距离”?
不是这样理解的。
真正原理是:
代码访问数据通过 GOT
举个例子:
int global = 5;
int foo() {
return global;
}
普通代码可能是:
mov global(%rip), %eax
但 PIC 里:
mov global@GOTPCREL(%rip), %rax
mov (%rax), %eax
流程:
RIP
↓
GOT entry
↓
global 地址
所以 PIC 的关键是:
所有全局地址通过 GOT 间接访问。
三、程序启动是不是因为 PLT 才加载动态链接器?
不是。
真正触发的是 ELF Program Header 中的 PT_INTERP。
你可以看:
readelf -l a.out
会看到:
INTERP
Requesting program interpreter:
/lib64/ld-linux-x86-64.so.2
内核看到这个后:
execve()
↓
加载 ld-linux.so
↓
把控制权交给 ld-linux
注意:
不是因为 PLT。
四、程序启动完整流程(非常重要)
你说的流程我帮你改成 真正的顺序:
1 内核加载程序
执行:
execve("./main")
内核:
读取 ELF header
读取 Program Header
2 发现动态链接器
Program Header 中有:
PT_INTERP
例如:
/lib64/ld-linux-x86-64.so.2
于是:
加载 ld-linux
3 内核启动动态链接器
此时:
ld-linux.so
成为第一个运行的用户代码。
它接收:
main.out 的信息
五、动态链接器开始工作
它读取:
.dynamic section
里面包含:
DT_NEEDED
DT_SYMTAB
DT_STRTAB
DT_RELA
1 加载依赖库
例如:
DT_NEEDED libc.so
DT_NEEDED libfoo.so
动态链接器:
mmap libc.so
mmap libfoo.so
2 构建全局符号表
动态链接器会把:
main.out
libc.so
libfoo.so
的符号表合并到一个查找结构里。
3 执行重定位
动态链接器读取:
.rela.dyn
.rela.plt
执行:
R_X86_64_RELATIVE
R_X86_64_GLOB_DAT
R_X86_64_JUMP_SLOT
比如:
GOT entry -> foo 地址
六、不会修改 .symtab
你这里理解错了一点。
你说:
修改 main.out 中的 .symtab 地址
实际上:
.symtab 不参与动态链接。
动态链接使用的是:
.dynsym
区别:
| 表 | 作用 |
|---|---|
.symtab |
静态链接调试 |
.dynsym |
动态链接 |
.symtab 甚至可以被 strip 掉。
七、真正被修改的是 GOT
动态链接时修改:
.got
.got.plt
例如:
GOT[foo] = foo_real_address
八、PLT 的作用
PLT 只是:
函数调用入口
例如:
call foo@plt
第一次调用:
foo@plt
↓
dynamic linker
↓
解析 foo
↓
写 GOT
以后调用:
foo@plt
↓
GOT
↓
foo
九、整个系统结构(完整图)
main.out
│
│ execve
▼
Kernel
│
│ 读取 PT_INTERP
▼
ld-linux.so
│
│ 读取 .dynamic
▼
加载 libfoo.so libc.so
│
│ 符号解析
▼
重定位 GOT
│
▼
调用 main()
十、你现在其实已经接近理解一个很核心的东西
就是:
Linux 程序其实不是直接启动 main。
真实入口是:
_start
↓
ld-linux
↓
__libc_start_main
↓
main
如果你愿意,我可以再给你讲一个 非常关键但90%人没真正理解的点:
PLT 第一次调用函数时,动态链接器是怎么被触发的?
里面涉及:
plt0
link_map
_dl_runtime_resolve
这是 glibc 动态链接最核心的机制,理解这个基本就把 Linux 动态链接彻底打通了。
你的理解已经非常接近底层真相了,尤其是在**“相对偏移量固定”和“动态链接器”**这两个点上,你抓住了动态链接的灵魂。
我们可以把这个过程拆解得更硬核一点,看看 Linux 内核和动态链接器(ld-linux.so)到底是怎么配合演戏的。
1. 关于 PIC 的“固定距离” (关键所在)
你说得完全正确。 在生成的 .so 文件中,代码段(.text)和数据段(.data / .got)在文件里的相对位置是固定的。
- 为什么要固定? 虽然
.so被加载到内存的基地址(Base Address)每次可能不同(受 ASLR 随机化影响),但由于整个.so文件是作为一个整体映射进内存的,代码段某条指令到数据段某个变量的“跨度”(Offset)在编译时就定死了。 - 怎么利用这个固定距离? x86-64 支持 RIP-relative addressing(相对指令指针寻址)。CPU 执行代码时,可以直接通过“当前指令位置 + 固定偏移量”来找到对应的全局变量,而不需要知道变量的绝对地址。
2. 动态链接的启动全流程
你的描述基本正确,但顺序上有一个细微且重要的区别:修改的不是 .out 文件本身,而是在内存里修改。
第一阶段:内核加载 (Kernel Space)
- 当你执行
./main.out,内核首先读取它的 ELF Header。 - 内核发现这个 ELF 有一个
.interp段,里面写着类似/lib64/ld-linux-x86-64.so.2的路径。 - 内核不仅把
main.out映射进内存,还把这个“动态链接器”(Interpreter)也加载进内存,并将控制权交给链接器,而不是你的main。
第二阶段:链接器掌权 (User Space - ld.so)
- 加载依赖:链接器读取
main.out的DYNAMIC段,看到NEEDED: libstudent.so,就去系统路径找这个文件并把它映射进内存。 - 符号解析:链接器扫描
main.out没填好的坑,并在libstudent.so的导出符号表里找匹配。 - 填坑(Relocation):重点来了! 链接器不会去改磁盘上的
main.out文件,它修改的是 内存中 的main.out进程映像。
- 它主要修改 GOT (Global Offset Table)。
- 你的
main代码里调用函数时,实际上是call到了 PLT (Procedure Linkage Table)。 - PLT 是一小段跳板代码,它会去 GOT 里读地址。链接器只需要把
new_student的真实内存地址填进 GOT,你的程序就通了。
3. 关于地址修改的修正
你提到“修改 .symtab 的地址数据”,实际上:
.symtab(符号表) 在程序运行时其实是不需要的,它主要给gdb或strip工具用。- 真正起作用的是
.dynsym(动态符号表)。 - 而且链接器几乎从不修改代码段(.text),因为代码段通常是只读的。它只修改数据段里的 GOT 表。这就是所谓的“数据延迟绑定”。
总结你的疑问:
- PIC 模块都这样吗? 是的,所有参与动态链接的
.so必须是 PIC。 - 距离固定吗? 是的,代码段和数据段的位移(Offset)是编译时常量。
- 加载顺序:
- 内核加载
main和ld.so。 ld.so加载所有依赖的.so。ld.so修改内存中的 GOT 表(重定向)。ld.so跳转到main的入口开始执行。
你现在已经把“链接”和“加载”这两个概念完全串起来了。既然你提到了修改地址,想不想了解一下什么是“延迟绑定 (Lazy Binding)”?也就是为什么程序启动时并不急着找所有函数地址,而是等到第一次调用时才去找。
要彻底理解 PIC、GOT 和 PLT 的工作原理,我们可以把这个过程想象成一场**“为了节省内存而精心设计的‘骗局’”**。
整个体系的核心目标只有两个:
- 代码共享:让同一份动态库的机器码在物理内存里只存在一份,即便它被映射到不同进程的不同虚拟地址。
- 延迟加载:只有当函数真正被调用时,才去昂贵的符号表里查找它的地址。
第一阶段:PIC(位置无关代码)的基石
在动态链接中,.so 文件可能被加载到内存的任何地方。
- 传统做法:如果代码中写死了
call 0x401000,那么库必须每次都加载到0x401000,这不现实(容易地址冲突,且不利于安全)。 - PIC 做法:利用 RIP(指令指针)相对寻址。
正如你之前发现的,代码段(.text)和数据段(.data)在 ELF 文件里的相对偏移量是恒定的。
PIC 的逻辑: “我不知道
printf在哪,但我知道存printf地址的那个‘本子’,离我现在的指令位置永远只有0x2000字节远。”
第二阶段:GOT(全局偏移表)—— 那个“本子”
GOT (Global Offset Table) 位于 数据段。它是一个指针数组,每个条目对应一个外部符号(变量或函数)。
- 为什么在数据段? 因为代码段(
.text)是只读的,不能在运行时修改。而数据段是每个进程私有的且可写的。 - 动态链接器的任务:在程序运行期间,把外部函数的真实绝对地址填进这个表里。
第三阶段:PLT(过程链接表)—— 那个“跳板”
PLT (Procedure Linkage Table) 位于 代码段。它是一小段一小段的机器码,每个外部函数对应一个 PLT 条目(比如 printf@plt)。
它扮演了“中间商”的角色,负责在 main 函数和 GOT 之间牵线搭桥。
第四阶段:完整详细的工作流程(以 printf 为例)
这是最精彩的协同过程,分为**“第一次调用”和“后续调用”**。
1. 编译与加载
- 编译器在
main里写下call printf@plt。 - 链接器在 GOT 里为
printf预留一个位置(假设地址是GOT[3])。 - 程序启动时,
GOT[3]里的初始值并不是printf的地址,而是指向printf@plt内部的第二条指令。
2. 第一次调用(延迟绑定)
- 进入 PLT:
main执行call printf@plt。 - 第一跳 (跳向 GOT):
printf@plt的第一条指令是jmp *GOT[3]。 - 回环:因为
GOT[3]还没填好,它存的地址直接跳回了printf@plt的下一行指令。 - 准备解析:PLT 压入一个编号(代表
printf),然后跳到 PLT 段的开头(PLT[0])。 - 进入动态链接器:PLT[0] 调用动态链接器(
ld-linux.so)的解析函数。 - 寻址与回填:链接器在内存中找到
libc.so里的printf真实地址,并**把这个地址写进GOT[3]**。 - 执行:链接器最后跳转到
printf执行业务逻辑。
3. 后续调用(极速模式)
- 再次进入 PLT:
main再次执行call printf@plt。 - 第一跳 (跳向 GOT):
printf@plt依然执行jmp *GOT[3]。 - 直接命中:由于
GOT[3]现在已经是printf的真实物理地址了,CPU 直接飞向printf。 - 不再经过链接器:此时不再有压号、不再有解析,速度和静态链接几乎一样快。
总结三者的关系
- PIC (Position Independent Code) 是设计思想:通过相对偏移,让代码段可以被多进程共享。
- GOT (Global Offset Table) 是地址簿:存放在可写的数据段,由动态链接器在运行时填入真实的绝对地址。
- PLT (Procedure Linkage Table) 是跳板代码:存放在只读的代码段,负责触发地址解析(第一次)或直接跳转(后续)。
为什么这个设计很伟大?
如果没有这套机制,每个进程运行 main 时都要修改自己的 .text 段来填入地址,这样 libc.so 在物理内存里就会有无数个副本。有了 PIC+GOT+PLT,无论系统运行多少个程序,libc.so 的机器码在内存里永远只需要一份,极大地节省了内存。
你想在你的 Fedora 上亲手验证这个过程吗?我们可以用 gdb 观察 x/gx 某个 GOT 条目在调用前后的变化。
这个问题其实就是 Linux 动态链接机制的核心三件套:PIC、GOT、PLT。 如果把它们彻底理解,你基本就把 ELF 动态链接的底层原理吃透了。
我给你按 逻辑顺序讲,而不是按名词顺序,因为很多教材是反着讲的,导致很难理解。
整体结构是:
PIC 解决 "代码能在任意地址运行"
↓
GOT 解决 "全局数据地址不确定"
↓
PLT 解决 "函数地址运行时才知道"
我们一步一步来。
一、PIC:Position Independent Code
PIC 的目标只有一个:
让代码可以加载到任意地址运行。
为什么要这样?
因为共享库 .so 在不同程序中加载地址不同,例如:
程序A
libc.so -> 0x7f2000000000
程序B
libc.so -> 0x7f1000000000
如果代码写死地址:
call 0x7f2000123456
那程序B直接炸。
所以共享库必须编译为:
gcc -fPIC
PIC的核心思想
PIC 永远不直接使用绝对地址。
而是:
相对地址 + 间接访问
在 x86-64 上最重要的工具就是:
RIP-relative addressing
例如:
mov x(%rip), %rax
意思是:
地址 = 当前RIP + offset
这样无论代码被加载到哪里,offset都一样。
二、PIC如何访问全局变量
问题来了:
假设代码:
int global = 5;
int foo() {
return global;
}
如果直接访问:
mov global, %eax
这里需要 绝对地址。
PIC不能这么干。
于是就引入了 GOT。
三、GOT:Global Offset Table
GOT 的作用是:
保存全局符号的真实地址。
它是一个表,存放在:
.got
.got.plt
里面内容类似:
GOT
------------------
global → 0x7f2000012340
printf → 0x7f2000023450
foo → 0x7f2000031110
PIC访问变量流程
编译后的代码会变成:
mov global@GOTPCREL(%rip), %rax
mov (%rax), %eax
执行流程:
RIP
↓
找到 GOT entry
↓
读取 global 地址
↓
读取 global 值
图示:
code
↓
RIP-relative
↓
GOT entry
↓
真实地址
↓
变量
所以:
代码只需要知道 GOT 的位置。
而 GOT 里的地址由 动态链接器填写。
四、动态链接时如何填充 GOT
程序启动时:
动态链接器 ld-linux.so 会解析:
.rela.dyn
里面记录:
R_X86_64_GLOB_DAT
例如:
global → relocation entry
动态链接器执行:
GOT[global] = real address
之后程序访问 global 时就能正确读取。
五、函数调用的问题
变量解决了。
但函数还有个更麻烦的问题:
printf()
地址可能在:
libc.so
而 libc.so 运行时才加载。
如果每次调用都通过 GOT:
call *printf@GOT
理论上可以。
但 Linux 使用了一个更复杂的机制:
PLT
原因:
支持 lazy binding(延迟绑定)。
六、PLT:Procedure Linkage Table
PLT 是一个 函数跳板表。
它的作用:
第一次调用 → 动态解析
之后调用 → 直接跳转
每个外部函数都会有一个 PLT entry。
例如:
printf@plt
malloc@plt
puts@plt
七、PLT结构
一个典型的 PLT entry:
printf@plt:
jmp *printf@GOT
push relocation_index
jmp plt0
结构图:
printf@plt
│
├─ jmp GOT[printf]
│
├─ push relocation id
│
└─ jmp plt0
八、第一次调用函数
假设程序:
printf("hello");
编译后:
call printf@plt
执行流程:
main
↓
printf@plt
↓
jmp *GOT[printf]
但一开始:
GOT[printf] = printf@plt + 6
所以会继续执行:
push relocation_index
jmp plt0
九、plt0入口
plt0 是所有 PLT entry 的统一入口:
plt0:
push link_map
jmp _dl_runtime_resolve
这里会调用:
glibc dynamic linker
函数:
_dl_runtime_resolve()
十、动态解析过程
动态链接器会:
1️⃣ 根据 relocation index 找到:
.rela.plt
里面的记录:
printf → libc.so
2️⃣ 在所有已加载库中查找符号:
libc.so → printf
3️⃣ 得到地址:
0x7f2000023450
4️⃣ 写入 GOT:
GOT[printf] = 0x7f2000023450
5️⃣ 跳转执行:
printf()
十一、第二次调用函数
再次执行:
call printf@plt
流程:
printf@plt
↓
jmp *GOT[printf]
↓
printf()
不再进入动态链接器。
十二、整体结构总结
整个系统结构:
动态链接器
│
▼
+--------------------+
| GOT |
|--------------------|
| printf → addr |
| global → addr |
+--------------------+
▲
│
+--------------------+
| PLT |
|--------------------|
| printf@plt |
| malloc@plt |
+--------------------+
▲
│
+--------------------+
| PIC |
|--------------------|
| call printf@plt |
| mov global@GOT |
+--------------------+
关系:
PIC
↓
需要间接地址
↓
GOT
↓
函数需要lazy binding
↓
PLT
十三、真实 ELF 中的位置
你可以在 ELF 中看到:
.text → 代码
.plt → PLT表
.got → 全局GOT
.got.plt → 函数GOT
.rela.dyn → 数据重定位
.rela.plt → 函数重定位
.dynsym → 动态符号表
.dynamic → 动态链接信息
十四、完整执行流程
程序启动:
execve
↓
kernel加载ELF
↓
加载 ld-linux.so
↓
加载所有 .so
↓
处理 .rela.dyn
↓
执行 main
函数调用:
call printf@plt
↓
plt
↓
got
↓
第一次 → dynamic linker
↓
写 GOT
↓
以后直接跳转
💡 给你一个建议(非常适合你现在的学习阶段):
你可以在 Linux 上自己观察:
objdump -d a.out
readelf -r a.out
readelf -d a.out
重点看:
.plt
.got.plt
.rela.plt
你会看到 printf@plt 的真实汇编结构。
如果你愿意,我可以再给你讲一个 系统课一般不会讲,但其实是动态链接最核心的东西:
“GOT 初始值为什么会指向 PLT+6?”
理解这个你会彻底看懂 PLT第一次调用的自举过程。
对,你理解得很接近了。PLT 里面确实装的是代码,而不是数据。 更准确地说:
PLT(Procedure Linkage Table)是一段专门用于“函数调用跳转”的代码表。
每一个外部函数(比如 printf、malloc)都会对应 一个 PLT entry,而每个 entry 其实就是 一小段汇编代码。
我们把结构完整拆开讲一下。
一、PLT 里到底是什么
在 ELF 文件里:
.plt section
是一段 可执行代码段。
典型结构:
.plt
├── plt0
├── printf@plt
├── malloc@plt
├── puts@plt
└── ...
所以:
.plt是一整段代码- 每个函数占 一个 entry
二、PLT entry 的真实代码
假设程序调用:
printf("hello");
编译后:
call printf@plt
而 printf@plt 的代码通常长这样(x86-64):
printf@plt:
jmp *printf@GOT(%rip)
pushq $reloc_index
jmp plt0
解释每一条指令。
第一条指令
jmp *printf@GOT(%rip)
意思是:
跳转到 GOT[printf] 存储的地址
如果 GOT 里已经填好真实地址:
GOT[printf] = libc_printf
那么:
jmp libc_printf
直接执行。
所以:
第二次以后调用就只执行这一条指令。
第二条指令
pushq $reloc_index
作用:
告诉动态链接器
要解析哪个符号
reloc_index 对应:
.rela.plt
中的一个 relocation entry。
例如:
printf → relocation #3
第三条指令
jmp plt0
跳转到:
PLT[0]
三、plt0 是什么
.plt 的第一个 entry 叫:
plt0
它是所有延迟绑定的入口。
典型结构:
plt0:
push GOT+8
jmp *GOT+16
这里其实跳到:
_dl_runtime_resolve
这是 glibc 动态链接器里的函数。
四、第一次调用的完整流程
假设第一次调用 printf。
程序执行:
call printf@plt
流程:
main
↓
printf@plt
↓
jmp *GOT[printf]
但刚开始:
GOT[printf] = printf@plt + 6
所以跳到:
push reloc_index
jmp plt0
继续:
plt0
↓
_dl_runtime_resolve
五、动态链接器干什么
动态链接器会:
1️⃣ 找到 relocation entry
.rela.plt[reloc_index]
2️⃣ 找到符号
printf
3️⃣ 在库中查找
libc.so → printf
4️⃣ 得到地址
0x7f20abcd1234
5️⃣ 写入 GOT
GOT[printf] = 0x7f20abcd1234
六、第二次调用
再次执行:
call printf@plt
流程变成:
printf@plt
↓
jmp *GOT[printf]
↓
libc_printf
不再进入动态链接器。
七、PLT为什么这样设计
核心原因是:
lazy binding
优点:
1️⃣ 启动更快 2️⃣ 不用解析所有函数 3️⃣ 只解析真正调用的函数
例如:
程序只调用 printf
但 libc 有 2000+ 函数
只解析 printf。
八、PLT + GOT 的配合
结构图:
call printf@plt
│
▼
+-------------------+
| printf@plt |
|-------------------|
| jmp *GOT[printf] |
| push reloc_index |
| jmp plt0 |
+-------------------+
│
▼
+-------------------+
| GOT |
|-------------------|
| printf → addr |
+-------------------+
九、ELF里对应的section
你可以看到这些:
.plt → 跳转代码
.got.plt → 函数地址表
.rela.plt → 函数重定位表
.dynsym → 动态符号表
十、简单总结
一句话总结:
PLT 是代码
GOT 是数据
职责:
PIC → 位置无关代码
GOT → 存函数/变量真实地址
PLT → 函数调用跳板
工作流程:
call foo@plt
↓
PLT entry
↓
GOT
↓
第一次 → 动态解析
↓
以后 → 直接调用
如果你愿意,我可以再给你讲一个 非常关键但大多数教材不讲清楚的细节:
为什么 GOT 初始值是 plt_entry + 6?
这个设计其实是 PLT self-bootstrap(自举)机制,理解了这个你会彻底看懂整个动态链接过程。
你这个类比其实 已经抓住了核心感觉,但如果从系统角度说,我们可以稍微修正一下,让它更准确一点。你的直觉方向是对的 👍
先给结论版:
- GOT 更像一个“地址缓存表”
- PLT 更像一个“带 miss 处理逻辑的调用入口”
但它们不像 CPU cache + page table 那么严格对应。
我给你用你熟悉的 Cache / Page Table 思维重新类比一遍。
一、GOT ≈ 地址缓存表(有点像 cache / 页表)
GOT 的本质是:
symbol → runtime address
例如:
GOT
---------------------
printf → 0x7f12ab34c000
malloc → 0x7f12ab352000
puts → 0x7f12ab341000
程序访问函数或全局变量时:
代码
↓
查 GOT
↓
得到真实地址
所以它确实像:
- cache:缓存解析后的地址
- page table:symbol → address 的映射
但区别是:
| 结构 | 映射 |
|---|---|
| 页表 | virtual addr → physical addr |
| GOT | symbol → function addr |
二、PLT ≈ 带 miss handler 的 stub
PLT 每个 entry 其实就是一个 函数调用 stub。
结构:
printf@plt:
jmp *GOT[printf]
push reloc_index
jmp plt0
逻辑非常像:
if (GOT entry valid)
jump to function
else
call resolver
换成伪代码:
call_printf():
if (got.printf != unresolved)
jump got.printf
else
resolve_printf()
三、第一次调用 = cache miss
第一次调用:
call printf@plt
↓
jmp GOT[printf]
但 GOT 里是:
printf@plt+6
于是执行:
push reloc_index
jmp plt0
进入:
_dl_runtime_resolve()
动态链接器:
symbol = printf
library = libc.so
address = lookup(symbol)
然后:
GOT[printf] = address
这一步非常像:
cache fill
四、第二次调用 = cache hit
第二次:
call printf@plt
↓
jmp GOT[printf]
↓
printf()
完全绕过 dynamic linker。
这就是:
lazy binding
五、完整类比图
可以这么理解:
程序代码
│
▼
PLT stub
│
▼
GOT entry (address cache)
│
├── 已解析 → 直接调用函数
│
└── 未解析 → resolver
│
▼
动态链接器
│
▼
更新 GOT
六、为什么设计成 PLT + GOT
原因其实是三个:
1 PIC 代码不能写绝对地址
必须:
RIP relative
访问 GOT。
2 函数调用需要 lazy binding
如果不用 PLT:
程序启动必须解析:
printf
malloc
scanf
...
几千个符号。
PLT 让:
第一次调用才解析
3 多个模块共享同一个库
GOT 可以在运行时填充。
七、再给你一个更接近 CPU cache 的类比
其实整个系统更像这样:
+------------------+
| dynamic linker |
| (miss handler) |
+------------------+
▲
│
miss │
│
+-------+ +---------------+
| code | ---> | PLT (stub) |
+-------+ +---------------+
│
▼
+-------------+
| GOT (cache) |
+-------------+
│
▼
real func
八、一个很多人第一次意识到的点
其实 Linux 程序 绝大多数函数调用都是间接调用:
call printf@plt
而不是:
call printf
这也是为什么:
LD_PRELOAD
能 hook 几乎所有函数。
如果你愿意,我可以带你看一个 真实 ELF 的 PLT + GOT 反汇编,逐条解释:
printf@plt:
401030: jmp *0x404018(%rip)
401036: push $0x0
40103b: jmp 401020
那一刻你会突然发现:
动态链接其实就是几十条汇编在配合工作。
你提到的**“打桩”(Stubs / Hooking)**,在动态链接的语境下,其实就是 PLT 条目的本质功能。
但在计算机科学里,“打桩”这个词通常有两种含义:一种是系统自动生成的(就像 PLT 那样),另一种是你手动干预的(为了替换掉原本的函数)。
既然你已经理解了 PLT 和 GOT 的“骗局”,咱们来看看这种机制是怎么被利用来实现“打桩”的。
1. 编译器的“自动桩” (PLT Stubs)
在链接阶段,当 ld 发现你调用了一个外部函数时,它会在可执行文件里自动生成一个“桩”(Stub)。
- 作用:占位。
- 逻辑:它并不包含真实的业务逻辑,只是负责把控制权转交给动态链接器。
- 形象理解:你在代码里调用的
printf并不是真正的printf,而是一个**“替身”**(即 PLT 条目)。这个替身负责在运行时去“请”真身。
2. 程序员的“库打桩” (Library Interpositioning)
这才是最硬核、最好玩的。因为有 GOT/PLT 的存在,你可以在不重新编译程序的情况下,偷偷换掉它调用的函数。
机制:LD_PRELOAD
在 Linux(比如你的 Fedora)中,有一个环境变量叫 LD_PRELOAD。它可以让你指定的动态库先于其他库加载。
操作流程:
- 写一个“桩”函数:你自己写一个
malloc函数,里面加一句printf("哈哈,你申请了内存!\n");。 - **编译成
.so**。 - 运行程序:
LD_PRELOAD=./mymalloc.so ./ls。
为什么能成功?
当动态链接器(ld.so)启动程序并开始填 GOT 表的“坑”时,它会先扫描 LD_PRELOAD 里的符号。它发现了你写的 malloc,就直接把你的地址填进了 GOT 表。原本系统库里的 malloc 就被**“屏蔽”**了。
3. 运行时的“符号替换”(GOT Hooking)
这是高级黑客技术(或性能分析工具)常用的手段。
- 原理:既然 GOT 表在内存里是可写的,那么程序运行到一半时,我可以直接用
mprotect修改内存权限,然后强行把 GOT 表里的地址改掉。 - 结果:原本程序调用
printf会跳到 A 地址,改完之后,它会毫无察觉地跳到你指定的 B 地址。
4. 总结:打桩的本质
打桩机制的核心就是“间接层”。
- 如果没有 PLT/GOT:地址硬编码在代码里,你想换掉函数就得修改二进制指令,这极其困难且不安全。
- 有了 PLT/GOT:地址存在一个集中的“记事本”(GOT)里。你只需要改掉记事本上的一个 8 字节指针,就能改变整个程序的行为。
打桩的应用场景:
- 安全监控:监控所有
open、connect系统调用。- 性能分析:统计
malloc被调用的次数和分配的总量。- 故障注入:模拟网络超时,看看程序在
recv返回错误时会不会崩溃。
你的下一个硬核实验:
你想不想自己写一个简单的 malloc 打桩库?只需要几行 C 代码,就能让你在运行任何 Linux 命令(如 ls 或 top)时,看到它们背后疯狂申请内存的过程。
要不要我把这个“打桩”库的源码发给你试试?
完全正确!你已经把动态链接、PLT/GOT 和 打桩(Interpositioning) 这三者之间的逻辑闭环彻底锁死了。
当你使用 LD_PRELOAD 这种打桩技术时,发生的事情确实如你所说:动态链接器(ld.so)在程序还没开始运行、还没来得及搞什么“延迟绑定”的时候,就把你的 malloc 地址填进去了。
深度拆解这个过程:
1. 符号解析的优先级(插队)
动态链接器在填 GOT 表的“坑”时,是有搜索顺序的。
- 默认顺序:先找程序本身,再找链接时指定的库(如
libc.so)。 - 打桩模式:
LD_PRELOAD里的库拥有最高优先级。 当链接器寻找malloc这个符号时,它第一眼就看到了你提供的库。一旦找到了匹配,它就停止搜索,并把这个地址记下来。
2. “跳过”第一次访问的逻辑
- 正常情况:链接器在 GOT 对应位置填入一个指向 PLT[0] 的地址,诱导程序去走一遍“解析流程”。
- 打桩情况:链接器认为
malloc的地址已经是确定的了(就是你那个库里的地址),于是它直接把你的malloc绝对地址写进 GOT 项。
结果就是: 虽然程序第一次调用 malloc 还是会经过 malloc@plt,但 PLT 的第一条指令 jmp *GOT[entry] 就会直接跳到你的代码里。那段压栈、找链接器的“第一次访问逻辑”确实被完全绕过了。
动手写一个“打桩”小工具
既然你已经明白了原理,不妨在你的 Fedora 上亲自感受一下。这个过程只需要几分钟:
第一步:编写桩代码 (hook.c)
这段代码会拦截 malloc,打印一句话,然后再调用真实的 malloc(否则程序会崩溃)。
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>
// 我们的伪装 malloc
void* malloc(size_t size) {
static void* (*real_malloc)(size_t) = NULL;
if (!real_malloc) {
// 通过 dlsym 找回“真正的” malloc 地址
real_malloc = dlsym(RTLD_NEXT, "malloc");
}
void *p = real_malloc(size);
// 这里的 printf 只是演示,实际上 malloc 内部调 printf 可能会导致递归死循环
// 因为 printf 内部也会调 malloc,工程上一般用 write 系统调用
fprintf(stderr, "Gotcha! malloc(%zu) = %p\n", size, p);
return p;
}
第二步:编译成共享库
gcc -fPIC -shared hook.c -o libhook.so -ldl
第三步:见证奇迹的时刻
随便运行一个系统命令,比如 ls:
LD_PRELOAD=./libhook.so ls
你会看到满屏幕都是 ls 在背后偷偷申请内存的记录。
总结:这套设计的精妙之处
这种“省去 PLT 第一次访问”的能力,本质上是 符号抢占(Symbol Preemption)。
它证明了:在 Linux 中,程序对自己调用的函数其实没有“最终决定权”,真正的控制权掌握在动态链接器手中。 只要掌握了 GOT 和加载顺序,你就是这个进程的上帝。
既然你已经玩转了应用层的打桩,想不想了解一下:如果我想拦截的不是库函数,而是像 read、write 这样的“内核系统调用”,又该在哪层“打桩”呢?