4570 lines
65 KiB
Markdown
4570 lines
65 KiB
Markdown
## 从代码到可执行目标程序的全流程解析
|
||
|
||
我们主要以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
|
||
```
|
||
|
||
---
|
||
|
||
# 七、步骤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 中,**符号就是一个名字对应一个地址或对象**。
|
||
|
||
例如:
|
||
|
||
```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 解决 "函数地址运行时才知道"
|
||
```
|
||
|
||
我们一步一步来。
|
||
|
||
---
|
||
|
||
# 一、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如何访问全局变量
|
||
|
||
问题来了:
|
||
|
||
假设代码:
|
||
|
||
```c
|
||
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
|
||
```
|
||
|
||
---
|
||
|
||
# 八、第一次调用函数
|
||
|
||
假设程序:
|
||
|
||
```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 里面确实装的是代码**,而不是数据。
|
||
更准确地说:
|
||
|
||
> **PLT(Procedure 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` 这样的“内核系统调用”,又该在哪层“打桩”呢?**
|