Files
2026-03-22 17:40:44 +08:00

4570 lines
65 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 从代码到可执行目标程序的全流程解析
我们主要以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. 汇编层面的“符号引用”
在汇编代码中,重定位标记表现为**直接使用外部函数名**。
```assembly
movl $56, %edi
call malloc # <--- 这里的 malloc 就是一个标记
```
对于 `cc1` 来说,它在翻译这段代码时,并不知道 `malloc` 在内存的哪个地址。于是它就在生成的汇编里写下 `call malloc`。这相当于在告诉后面的汇编器(`as`**“我这里引用了一个叫 malloc 的符号,你帮我记下来,让链接器去填地址。”**
---
### 2. 转换为二进制时的“坑”
`as` 把你这段代码变成二进制目标文件(`.o`)时,它会进行以下操作:
1. **机器码占位**`call` 指令对应的机器码通常是 `E8`。后面需要跟一个 4 字节的偏移量。因为地址未知,`as` 会先填入 `00 00 00 00`
* 对应的二进制看起来像:`E8 00 00 00 00`
2. **创建重定位条目**:在 `.o` 文件的重定位表里写下一行记录。
我们可以用 `objdump` 模拟一下链接器视角看到的景象:
```text
# 执行 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`
```assembly
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 的地址部分
经典重定位算法
``` C
#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
```c
int add(int a,int b);
int main(){
return add(1,2);
}
```
### add.c
```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
```
---
# 七、步骤2Section 合并
链接器会把多个 `.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 中,**符号就是一个名字对应一个地址或对象**。
例如:
```c
int add(int a,int b);
int x;
static int y;
```
对应符号:
| 符号 | 类型 |
| --- | ---- |
| add | 函数 |
| x | 全局变量 |
| y | 局部符号 |
这些都记录在:
```
.symtab
```
查看:
```bash
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
例如:
```c
static int x;
```
```
.Ltmp1
```
特点:
* 只在 **当前 `.o`** 可见
* **不会参与符号解析**
所以链接器 **直接忽略**。
---
### 2 全局符号Global
例如:
```c
int add(int a,int b)
```
特点:
* 可以被其他 `.o` 引用
* 必须 **全程序唯一**
---
### 3 弱符号Weak
例如:
```c
__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
```
例如:
```c
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
```
例如:
```cpp
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` 文件里:
**地址几乎都是假的。**
例如代码:
```c
int add(int a,int b){
return a+b;
}
int main(){
return add(1,2);
}
```
编译后 `main.o` 中的汇编可能是:
```asm
call add
```
但 `.o` 里 **根本不知道 add 在哪里**。
所以编译器会生成:
```asm
call 0
```
然后记录:
> 这里将来需要填入 `add` 的地址。
这个记录就是 **Relocation Entry**。
---
# 二、Relocation Entry 存在哪里
每个需要修补的位置都有一条记录。
在 ELF section 中:
```
.rela.text
.rela.data
```
查看:
```bash
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
```
结构:
```c
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` 文件
代码:
```c
extern int add(int,int);
int main(){
return add(1,2);
}
```
反汇编:
```asm
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 |
---
# 十、访问全局变量的重定位
代码:
```c
extern int x;
int main(){
return x;
}
```
汇编:
```asm
mov x(%rip), %eax
```
这是:
```
RIP relative addressing
```
重定位:
```
R_X86_64_PC32
```
公式:
```
S + A - P
```
---
# 十一、Section relocation
不仅 `.text` 需要重定位。
例如:
```
.data
.rodata
```
示例:
```c
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**
```bash
gcc -fPIC -c foo.c
```
为什么?
因为 `.so` 可能被加载到 **任意虚拟地址**。
如果代码里写死地址:
```
call 0x400800
```
加载地址变化就炸了。
所以要用 **相对地址 + 表间接访问**。
---
然后生成 `.so`
```bash
gcc -shared -o libfoo.so foo.o
```
这一步本质是:
```
ld -shared foo.o
```
生成的 ELF 类型:
```
ET_DYN
```
注意:
`.so` 本质就是 **一个特殊的 ELF 文件**。
---
# 二、程序链接动态库时发生什么
假设:
```c
// main.c
extern void foo();
int main() {
foo();
}
```
编译:
```bash
gcc main.c -L. -lfoo
```
此时:
```
-lfoo
```
意味着:
```
寻找 libfoo.so 或 libfoo.a
```
如果找到 `.so`,就会 **动态链接**。
---
# 三、链接阶段ld做了什么
和静态链接不同:
**ld 不会把 foo.o 代码复制进可执行文件。**
它只做三件事:
---
## 1 记录依赖库
ELF 的 `.dynamic` section 里会出现:
```
NEEDED libfoo.so
```
可以用:
```bash
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
```
可以查看:
```bash
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
```c
#include <stdio.h>
void foo() {
printf("hello foo\n");
}
```
编译:
```bash
gcc -fPIC -c foo.c
gcc -shared -o libfoo.so foo.o
```
---
### main.c
```c
void foo();
int main() {
foo();
}
```
编译:
```bash
gcc main.c -L. -lfoo
```
运行:
```bash
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
编译共享库必须:
```bash
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**
举个例子:
```c
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`**。
你可以看:
```bash
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)
1. 当你执行 `./main.out`,内核首先读取它的 **ELF Header**。
2. 内核发现这个 ELF 有一个 `.interp` 段,里面写着类似 `/lib64/ld-linux-x86-64.so.2` 的路径。
3. 内核**不仅**把 `main.out` 映射进内存,**还**把这个“动态链接器”Interpreter也加载进内存并将控制权交给链接器而不是你的 `main`。
#### 第二阶段:链接器掌权 (User Space - `ld.so`)
1. **加载依赖**:链接器读取 `main.out` 的 `DYNAMIC` 段,看到 `NEEDED: libstudent.so`,就去系统路径找这个文件并把它映射进内存。
2. **符号解析**:链接器扫描 `main.out` 没填好的坑,并在 `libstudent.so` 的导出符号表里找匹配。
3. **填坑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 表**。这就是所谓的“数据延迟绑定”。
### 总结你的疑问:
1. **PIC 模块都这样吗?** 是的,所有参与动态链接的 `.so` 必须是 PIC。
2. **距离固定吗?** 是的代码段和数据段的位移Offset是编译时常量。
3. **加载顺序:**
* 内核加载 `main` 和 `ld.so`。
* `ld.so` 加载所有依赖的 `.so`。
* `ld.so` 修改内存中的 GOT 表(重定向)。
* `ld.so` 跳转到 `main` 的入口开始执行。
**你现在已经把“链接”和“加载”这两个概念完全串起来了。既然你提到了修改地址,想不想了解一下什么是“延迟绑定 (Lazy Binding)”?也就是为什么程序启动时并不急着找所有函数地址,而是等到第一次调用时才去找。**
---
要彻底理解 **PIC**、**GOT** 和 **PLT** 的工作原理,我们可以把这个过程想象成一场**“为了节省内存而精心设计的‘骗局’”**。
整个体系的核心目标只有两个:
1. **代码共享**:让同一份动态库的机器码在物理内存里只存在一份,即便它被映射到不同进程的不同虚拟地址。
2. **延迟加载**:只有当函数真正被调用时,才去昂贵的符号表里查找它的地址。
---
### 第一阶段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. 第一次调用(延迟绑定)
1. **进入 PLT**`main` 执行 `call printf@plt`。
2. **第一跳 (跳向 GOT)**`printf@plt` 的第一条指令是 `jmp *GOT[3]`。
3. **回环**:因为 `GOT[3]` 还没填好,它存的地址直接跳回了 `printf@plt` 的下一行指令。
4. **准备解析**PLT 压入一个编号(代表 `printf`),然后跳到 PLT 段的开头PLT[0])。
5. **进入动态链接器**PLT[0] 调用动态链接器(`ld-linux.so`)的解析函数。
6. **寻址与回填**:链接器在内存中找到 `libc.so` 里的 `printf` 真实地址,并**把这个地址写进 `GOT[3]**`。
7. **执行**:链接器最后跳转到 `printf` 执行业务逻辑。
#### 3. 后续调用(极速模式)
1. **再次进入 PLT**`main` 再次执行 `call printf@plt`。
2. **第一跳 (跳向 GOT)**`printf@plt` 依然执行 `jmp *GOT[3]`。
3. **直接命中**:由于 `GOT[3]` 现在已经是 `printf` 的真实物理地址了CPU 直接飞向 `printf`。
4. **不再经过链接器**:此时不再有压号、不再有解析,速度和静态链接几乎一样快。
---
### 总结三者的关系
* **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 解决 "函数地址运行时才知道"
```
我们一步一步来。
---
# 一、PICPosition 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如何访问全局变量
问题来了:
假设代码:
```c
int global = 5;
int foo() {
return global;
}
```
如果直接访问:
```
mov global, %eax
```
这里需要 **绝对地址**。
PIC不能这么干。
于是就引入了 **GOT**。
---
# 三、GOTGlobal 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延迟绑定。**
---
# 六、PLTProcedure 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
```
---
# 八、第一次调用函数
假设程序:
```c
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 里面确实装的是代码**,而不是数据。
更准确地说:
> **PLTProcedure Linkage Table是一段专门用于“函数调用跳转”的代码表。**
每一个外部函数(比如 `printf`、`malloc`)都会对应 **一个 PLT entry**,而每个 entry 其实就是 **一小段汇编代码**。
我们把结构完整拆开讲一下。
---
# 一、PLT 里到底是什么
在 ELF 文件里:
```id="2x1zno"
.plt section
```
是一段 **可执行代码段**。
典型结构:
```id="tbvy2x"
.plt
├── plt0
├── printf@plt
├── malloc@plt
├── puts@plt
└── ...
```
所以:
* `.plt` 是一整段代码
* 每个函数占 **一个 entry**
---
# 二、PLT entry 的真实代码
假设程序调用:
```c
printf("hello");
```
编译后:
```id="ne0u2z"
call printf@plt
```
而 `printf@plt` 的代码通常长这样x86-64
```id="ysh2sl"
printf@plt:
jmp *printf@GOT(%rip)
pushq $reloc_index
jmp plt0
```
解释每一条指令。
---
## 第一条指令
```id="43zwf3"
jmp *printf@GOT(%rip)
```
意思是:
```id="qs82dy"
跳转到 GOT[printf] 存储的地址
```
如果 GOT 里已经填好真实地址:
```id="49k7iw"
GOT[printf] = libc_printf
```
那么:
```id="6fczst"
jmp libc_printf
```
直接执行。
所以:
**第二次以后调用就只执行这一条指令。**
---
## 第二条指令
```id="rxr7if"
pushq $reloc_index
```
作用:
```id="yop9xi"
告诉动态链接器
要解析哪个符号
```
`reloc_index` 对应:
```id="wt83g0"
.rela.plt
```
中的一个 relocation entry。
例如:
```id="gp6s6g"
printf → relocation #3
```
---
## 第三条指令
```id="2ht3m9"
jmp plt0
```
跳转到:
```id="sfd2oj"
PLT[0]
```
---
# 三、plt0 是什么
`.plt` 的第一个 entry 叫:
```id="4vsmut"
plt0
```
它是所有延迟绑定的入口。
典型结构:
```id="n4o0z9"
plt0:
push GOT+8
jmp *GOT+16
```
这里其实跳到:
```id="9j47ie"
_dl_runtime_resolve
```
这是 **glibc 动态链接器里的函数**。
---
# 四、第一次调用的完整流程
假设第一次调用 `printf`。
程序执行:
```id="ilrln9"
call printf@plt
```
流程:
```id="ruxlre"
main
printf@plt
jmp *GOT[printf]
```
但刚开始:
```id="7v6gdf"
GOT[printf] = printf@plt + 6
```
所以跳到:
```id="1axq9s"
push reloc_index
jmp plt0
```
继续:
```id="k3f4t3"
plt0
_dl_runtime_resolve
```
---
# 五、动态链接器干什么
动态链接器会:
1⃣ 找到 relocation entry
```id="qt9yqs"
.rela.plt[reloc_index]
```
2⃣ 找到符号
```id="85w9x0"
printf
```
3⃣ 在库中查找
```id="d9l5qk"
libc.so → printf
```
4⃣ 得到地址
```id="4z5n1q"
0x7f20abcd1234
```
5⃣ 写入 GOT
```id="ks41eg"
GOT[printf] = 0x7f20abcd1234
```
---
# 六、第二次调用
再次执行:
```id="dehdi5"
call printf@plt
```
流程变成:
```id="zn67j1"
printf@plt
jmp *GOT[printf]
libc_printf
```
不再进入动态链接器。
---
# 七、PLT为什么这样设计
核心原因是:
**lazy binding**
优点:
1⃣ 启动更快
2⃣ 不用解析所有函数
3⃣ 只解析真正调用的函数
例如:
```id="q0ho48"
程序只调用 printf
但 libc 有 2000+ 函数
```
只解析 `printf`。
---
# 八、PLT + GOT 的配合
结构图:
```id="o3t3hl"
call printf@plt
+-------------------+
| printf@plt |
|-------------------|
| jmp *GOT[printf] |
| push reloc_index |
| jmp plt0 |
+-------------------+
+-------------------+
| GOT |
|-------------------|
| printf → addr |
+-------------------+
```
---
# 九、ELF里对应的section
你可以看到这些:
```id="bzbv5m"
.plt → 跳转代码
.got.plt → 函数地址表
.rela.plt → 函数重定位表
.dynsym → 动态符号表
```
---
# 十、简单总结
一句话总结:
```id="yjdfd0"
PLT 是代码
GOT 是数据
```
职责:
```id="jzzbhb"
PIC → 位置无关代码
GOT → 存函数/变量真实地址
PLT → 函数调用跳板
```
工作流程:
```id="rnt6tf"
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 的本质是:
```text
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
```
换成伪代码:
```c
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`。它可以让你指定的动态库先于其他库加载。
**操作流程:**
1. **写一个“桩”函数**:你自己写一个 `malloc` 函数,里面加一句 `printf("哈哈,你申请了内存!\n");`。
2. **编译成 `.so**`。
3. **运行程序**`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`(否则程序会崩溃)。
```c
#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;
}
```
#### 第二步:编译成共享库
```bash
gcc -fPIC -shared hook.c -o libhook.so -ldl
```
#### 第三步:见证奇迹的时刻
随便运行一个系统命令,比如 `ls`
```bash
LD_PRELOAD=./libhook.so ls
```
你会看到满屏幕都是 `ls` 在背后偷偷申请内存的记录。
---
### 总结:这套设计的精妙之处
这种“省去 PLT 第一次访问”的能力,本质上是 **符号抢占Symbol Preemption**
它证明了:**在 Linux 中,程序对自己调用的函数其实没有“最终决定权”,真正的控制权掌握在动态链接器手中。** 只要掌握了 GOT 和加载顺序,你就是这个进程的上帝。
**既然你已经玩转了应用层的打桩,想不想了解一下:如果我想拦截的不是库函数,而是像 `read`、`write` 这样的“内核系统调用”,又该在哪层“打桩”呢?**