## 从代码到可执行目标程序的全流程解析 我们主要以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 : ... 1a: bf 38 00 00 00 mov $0x38,%edi 1f: e8 00 00 00 00 call 24 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 #include // 模拟 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
: 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 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 #include #include // 我们的伪装 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` 这样的“内核系统调用”,又该在哪层“打桩”呢?**