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

从代码到可执行目标程序的全流程解析

我们主要以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 malloccall strncpy

1. 汇编层面的“符号引用”

在汇编代码中,重定位标记表现为直接使用外部函数名

    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
  1. 创建重定位条目:在 .o 文件的重定位表里写下一行记录。

我们可以用 objdump 模拟一下链接器视角看到的景象:

# 执行 objdump -dr student.o
0000000000000000 <new_student>:
...
  1a:   bf 38 00 00 00          mov    $0x38,%edi
  1f:   e8 00 00 00 00          call   24 <new_student+0x24>
                        20: R_X86_64_PLT32    malloc-0x4

看到那个 20: R_X86_64_PLT32 malloc-0x4 了吗?这就是真正的重定位标记

  • 20: 告诉链接器,修改的位置在 .text 段偏移 0x20 的地方(即 E8 之后的四个 00)。
  • R_X86_64_PLT32: 告诉链接器计算方法(这是一个 32 位的 PC 相对偏移,且目标可能在过程链接表 PLT 中)。
  • malloc: 告诉链接器,去符号表里找这个名字对应的地址。

3. 为什么代码里有些东西不需要重定位?

再看你代码里的 .L2.L3

    jne     .L2
    ...
.L2:
    movq    -8(%rbp), %rax

这些以 .L 开头的标签是局部标签Local Labels

  • cc1 知道 .L2 就在当前这个函数内部,偏移量是确定的。
  • 汇编器 as 在处理时,直接就能算出 jne.L2 之间差了多少个字节,并把数值写死在机器码里。
  • 因此,局部跳转不需要重定位标记。 只有像 mallocstrncpy 这种跨越了当前编译单元(.c 文件)的符号才需要。

4. 你的代码里还隐藏了什么?

你这段汇编显示你正在处理一个 struct Student 之类的结构体:

  • malloc(56):说明你的结构体大小是 56 字节。
  • movb $0, 49(%rax):在偏移 49 的地方放个 \0,说明成员变量(可能是 char name[50])到这里结束。
  • movl %edx, 52(%rax):在偏移 52 的地方存入一个 4 字节整数(可能是 int ageid)。

这些**偏移量49, 52**是 cc1 根据结构体定义直接计算出来的硬编码,也不需要重定位。

总结一下: 在你给出的代码里,只有 call malloccall strncpy 这两处会在最终的 .o 文件中留下重定位标记。

既然你提到了 strncpymalloc,想看看如果不链接标准库(用 -nostdlib),链接器会报什么样的重定位错误吗?或者你想聊聊 PLT 这种动态链接特有的重定位方式?

汇编 => as

main.s => gcc -c main.c -o main.o => main.o

这一步采用汇编器,把汇编代码变成机器码(二进制ELF格式可重定位目标文件 .obj or .o)

想要查看ELF的相关内容可以使用readelf, objdump等工具

readelf -a main.o

objdump main.o

本文件夹中生成的均使用类似 readelf -a main.o > main_obj_decode.txt 的命令

ELF文件包括三部分

  • ELF Header
  • Section Header Table => 有如.text, .symtab等关键字
  • Section

链接 => ld

main.o other.o => gcc main.c other.c -o main => main

主要进行以下两个重要步骤:

  • 符号解析:符号引用 与 符号定义 连接起来
  • 重定位:符号定义 与 内存地址 连接起来 => 修改 .rel.text 的地址部分

经典重定位算法

#include <stdio.h>
#include <stdint.h>

// 模拟 ELF 重定位条目结构
typedef struct {
    uint32_t offset;    // 需要修改的代码在段内的偏移量
    uint32_t info;      // 符号索引(低位)和重定位类型(高位)
} Elf32_Rel;

// 模拟符号条目
typedef struct {
    char* name;
    uint32_t value;     // 符号在内存中的最终绝对地址
} Symbol;

/**
 * 模拟 ld 的重定位算法
 * @param code_section: 目标段的内存缓冲区(.text 或 .data
 * @param rel: 重定位条目
 * @param sym: 对应的目标符号
 */
void apply_relocation_abs32(uint8_t *code_section, Elf32_Rel rel, Symbol sym) {
    // 1. 找到需要修改的内存位置指针
    // 指向段内具体的 4 字节32位位置
    uint32_t *patch_location = (uint32_t *)(code_section + rel.offset);

    // 2. 读取原始的 Addend附加值
    // 在绝对寻址中,这里通常是 0或者是一个偏移
    uint32_t addend = *patch_location;

    // 3. 计算最终地址S + A
    // S = 符号的绝对地址 (Symbol Value)
    // A = 附加值 (Addend)
    uint32_t final_address = sym.value + addend;

    // 4. 将计算后的地址回填到代码段中
    *patch_location = final_address;

    printf("Relocating [%s]: Offset 0x%x, New Address: 0x%08x\n", 
            sym.name, rel.offset, final_address);
}

int main() {
    // 模拟一段机器码:假设 0x04 处是一个 extern 变量的地址占位符(当前为 0x00
    // 汇编逻辑类似于mov eax, [0x00000000]
    uint8_t text_section[] = { 0xB8, 0x00, 0x00, 0x00, 0x00 }; 

    // 模拟符号表:变量 "my_global_var" 已经被 ld 分配到地址 0x08049000
    Symbol my_var = { "my_global_var", 0x08049000 };

    // 模拟重定位项:在偏移 0x01 处修正(即 B8 之后的 4 字节)
    Elf32_Rel rel_item = { .offset = 0x01, .info = 0 };

    printf("Before Relocation: %02x %02x %02x %02x %02x\n", 
            text_section[0], text_section[1], text_section[2], text_section[3], text_section[4]);

    // 执行重定位
    apply_relocation_abs32(text_section, rel_item, my_var);

    printf("After Relocation:  %02x %02x %02x %02x %02x\n", 
            text_section[0], text_section[1], text_section[2], text_section[3], text_section[4]);

    return 0;
}

执行


这个问题其实正好把 编译系统toolchain 的最后一步串起来了:

.c/.cpp
  ↓ (compiler)
.o
  ↓ (linker ld)
ELF 可执行文件
  ↓
loader (内核)
  ↓
运行

你问的是 .o → ld → ELF 的完整链接过程,这个是计算机系统课程里非常核心的一部分。我按 实际发生的顺序给你讲,从 编译器输出 .o 开始,一直到生成 ELF


一、编译器输出 .o 文件

假设我们有两个文件

main.c
add.c

main.c

int add(int a,int b);

int main(){
    return add(1,2);
}

add.c

int add(int a,int b){
    return a+b;
}

编译:

gcc -c main.c
gcc -c add.c

得到

main.o
add.o

这些 .o 叫 relocatable object file可重定位目标文件


二、.o 文件内部结构ELF

.o 本身就是 ELF 格式,但类型是:

ET_REL

而最终可执行文件是

ET_EXEC
或
ET_DYN

.o 结构通常包含:

ELF Header
Section Header Table
Sections

主要 section

.text      代码
.data      已初始化全局变量
.bss       未初始化全局变量
.rodata    常量
.symtab    符号表
.strtab    字符串表
.rela.text 重定位表

可以用:

readelf -S main.o

查看。


三、符号Symbol

链接最核心的概念是 符号解析

符号有三种:

1 定义符号

例如

add
main
global_variable

2 未定义符号

例如 main.o 里面:

add

只是声明:

int add(int,int);

但没有定义。

3 本地符号

static int x;

只在当前 .o 可见。


符号信息存储在:

.symtab

可以查看:

readelf -s main.o

示例:

Symbol table '.symtab'

Num: Value Size Type Bind Vis Ndx Name
1:   0     0    FUNC GLOBAL UND add
2:   0     34   FUNC GLOBAL 1   main

注意:

add  -> UND (undefined)

说明 main.o 需要别的文件提供 add


四、重定位Relocation

这是 .o 文件和最终 ELF 最大的区别。

.o 里的地址 全部是假地址

例如:

call add

汇编里其实写成:

call 0

然后记录:

需要在这里填入 add 的地址

这个记录存在:

.rela.text

查看:

readelf -r main.o

例如:

Offset    Type            Symbol
00000005  R_X86_64_PLT32  add

意思:

.text + 5 这个地方
需要填入 add 的地址

五、链接器 ld 开始工作

当执行:

gcc main.o add.o

实际调用:

ld

链接器做 3 个主要步骤

1 符号解析 (symbol resolution)
2 section 合并
3 重定位 (relocation)

我们逐个说。


六、步骤1符号解析Symbol Resolution

链接器扫描所有 .o

main.o
add.o

构建 全局符号表

例如:

符号 定义位置
main main.o
add add.o

然后检查:

main.o 需要 add

add.o 找到了。

于是建立:

add -> add.o

如果找不到:

undefined reference to add

七、步骤2Section 合并

链接器会把多个 .o 的 section 合并。

例如:

main.o
  .text
  .data

add.o
  .text
  .data

合并成:

最终ELF

.text
    main
    add

.data

示意:

.text

| main.o:text |
| add.o:text  |

地址例如:

.text = 0x401000

main = 0x401000
add  = 0x401020

八、步骤3重定位Relocation

现在我们知道:

add = 0x401020

之前 main.o 有:

call add

但机器码里是:

call 0

现在 linker 会:

修改机器码
填入正确地址

例如 x86

call relative_offset

计算:

offset = add - next_instruction

然后写入机器码。

这一步叫:

relocation fixup

九、静态库链接(顺带说)

如果有:

libmath.a

这是:

archive

其实是

多个 .o 的集合

可以查看:

ar t libmath.a

链接器只会提取:

真正需要的 .o

例如:

只用到 add.o

就只加载 add.o。


十、动态库(动态链接)

如果链接:

libc.so

链接器 不会复制代码

而是生成:

PLT
GOT

并在 ELF 中记录:

NEEDED libc.so

运行时:

ld-linux.so

加载动态库。


十一、生成最终 ELF

最终文件结构:

ELF Header
Program Header Table

.text
.rodata
.data
.bss

.dynamic
.got
.plt

Section Header Table

可以查看:

readelf -a a.out

ELF 类型:

ET_EXEC

或 PIE

ET_DYN

十二、ELF 中最关键的两个表

Section Header Table

链接器 用。

例如:

.text
.data
.symtab

Program Header Table

操作系统 loader 用。

例如:

LOAD
DYNAMIC
INTERP

查看:

readelf -l a.out

十三、程序运行时

运行:

./a.out

内核:

1 创建进程 2 读取 ELF header 3 根据 program header 映射内存

例如:

.text -> RX
.data -> RW

如果有动态库:

加载 ld-linux.so

完成 动态链接


十四、完整流程总结

完整 pipeline

源代码
   ↓
预处理
   ↓
编译
   ↓
汇编
   ↓
.o (ET_REL)
   ↓
ld
   ├─ 符号解析
   ├─ section 合并
   └─ relocation
   ↓
ELF 可执行文件 (ET_EXEC / ET_DYN)
   ↓
内核 loader
   ↓
程序运行

十五、一个非常重要的理解(很多人忽略)

.o 文件几乎没有真实地址。

所有地址:

都是 linker 最后填进去的

所以 .o 叫:

Relocatable Object

如果你愿意,我可以继续给你讲 更硬核的一部分(这部分是 MIT / CMU 体系结构课程重点):

  • ELF relocation entry 结构
  • R_X86_64_PC32 / PLT32 / GOTPCREL
  • PLT/GOT 动态链接原理
  • Lazy Binding
  • 为什么 call printf 先跳 PLT

这一块其实是 理解 Linux 程序执行最关键的底层机制之一


符号解析(Symbol Resolution)其实是 链接器最核心的一步。简单一句话:

把所有 .o 里出现的符号引用,找到唯一的定义位置。

但真正实现起来其实很复杂,因为要处理:

  • 多个 .o
  • 静态库 .a
  • 强符号 / 弱符号
  • 重复定义
  • 未定义符号
  • 链接顺序

我按 真实链接器的工作流程给你完整讲一遍。


一、什么是符号Symbol

在 ELF 中,符号就是一个名字对应一个地址或对象

例如:

int add(int a,int b);
int x;
static int y;

对应符号:

符号 类型
add 函数
x 全局变量
y 局部符号

这些都记录在:

.symtab

查看:

readelf -s main.o

例子:

Num: Value Size Type Bind Vis Ndx Name
1:   0     0    FUNC GLOBAL UND add
2:   0     34   FUNC GLOBAL 1   main

关键字段:

字段 含义
Name 符号名
Type FUNC / OBJECT
Bind GLOBAL / LOCAL / WEAK
Ndx 定义在哪个section

如果:

Ndx = UND

说明:

这个符号在当前文件没有定义。


二、符号分为三种

链接器把符号分成三类:

1 本地符号Local

例如:

static int x;

.Ltmp1

特点:

  • 只在 当前 .o 可见
  • 不会参与符号解析

所以链接器 直接忽略


2 全局符号Global

例如:

int add(int a,int b)

特点:

  • 可以被其他 .o 引用
  • 必须 全程序唯一

3 弱符号Weak

例如:

__attribute__((weak)) int x;

或:

gcc 的某些库函数

特点:

  • 可以被强符号覆盖

三、符号解析的目标

链接器要保证:

每个引用符号
↓
都能找到唯一的定义

否则就报错:

undefined reference to xxx

或者:

multiple definition of xxx

四、链接器的核心数据结构

链接器会维护一个 全局符号表

可以理解为:

HashTable

结构:

symbol_name -> symbol_definition

例如:

main -> main.o
add  -> add.o
printf -> libc.so

五、符号解析算法(核心)

假设链接:

ld main.o add.o libx.a

链接器按顺序处理文件。


Step1 读取第一个目标文件

main.o

符号表:

main   (defined)
add    (undefined)

链接器更新全局表:

main -> main.o
add  -> unresolved

Step2 读取第二个目标文件

add.o

符号表:

add (defined)

更新:

add -> add.o

现在:

main -> main.o
add  -> add.o

六、遇到重复定义怎么办

例如:

a.o
b.o

都定义:

int x;

规则:

强符号 vs 强符号

error: multiple definition

例如:

int x=1;

强符号 vs 弱符号

强符号赢:

use strong symbol

弱符号 vs 弱符号

任选一个。


总结规则:

情况 结果
strong + strong error
strong + weak strong
weak + weak 任意

七、静态库符号解析(最容易错)

假设:

ld main.o libmath.a

而:

libmath.a = {add.o sub.o mul.o}

链接器 不会全部加载

它只会:

按需加载

算法:

Step1

处理:

main.o

符号:

main (defined)
add  (undefined)

Step2

扫描:

libmath.a

找到:

add.o

定义了:

add

于是:

加载 add.o

更新:

add -> add.o

如果 sub.o 没被用到:

不会加载

所以静态库 非常节省空间


八、链接顺序问题

这是很多人第一次写 C 会遇到的坑。

例如:

gcc main.o -lmath

如果:

main.o 需要 add
libmath.a 提供 add

OK。

但如果:

gcc -lmath main.o

就可能:

undefined reference

原因:

链接器是单向扫描

顺序:

-lmath
main.o

扫描 -lmath 时:

还不知道需要 add

所以:

不会加载 add.o

九、符号解析完成后的状态

链接器最终得到:

Symbol Table

main -> 0x401000
add  -> 0x401020
printf -> libc

.text 里的代码还没改。

例如:

call add

机器码还是:

call 0

于是进入:

Relocation

十、符号解析 vs 重定位

很多人会混淆。

区别:

阶段 做什么
Symbol Resolution 找到符号定义
Relocation 修改机器码地址

例子:

call add

符号解析:

add -> add.o

重定位:

call 0x401020

十一、真实链接器的内部算法(简化)

GNU ld / lld 基本逻辑:

global_symbol_table = {}

for file in input_files:

    for symbol in file.symbols:

        if symbol.defined:

            if symbol.name not in table:
                table[symbol.name] = symbol

            else:
                resolve_conflict()

        else:
            mark_as_undefined(symbol)

然后:

verify_all_symbols_resolved()

十二、一个真实例子

代码:

main.c

extern int x;

int main(){
    return x;
}

a.c

int x=10;

b.c

int x=20;

编译:

gcc main.o a.o b.o

链接器发现:

x defined in a.o
x defined in b.o

结果:

multiple definition of x

十三、为什么 C 允许 extern

例如:

extern int x;

只生成:

UND symbol

让链接器去找。


十四、C++ 额外复杂性(名字改编)

C++ 还有:

name mangling

例如:

int add(int,int)

符号变成:

_Z3addii

所以链接器解析的是:

mangled name

十五、整个符号解析流程总结

完整过程:

1 读取 .o / .a
2 构建全局符号表
3 解析 undefined symbol
4 处理 strong/weak 冲突
5 从静态库按需加载 object
6 确保所有符号都有定义

最终得到:

symbol -> object location

然后进入:

Relocation

如果你愿意,我可以再给你讲 一个真正非常关键但很多书没讲清楚的部分

为什么函数调用需要 PLT / GOT

也就是:

call printf
↓
call printf@plt
↓
GOT
↓
libc

这个其实是 动态链接器的核心机制,理解之后你基本就彻底理解 Linux 程序是怎么运行的了。


重定位(Relocation)是链接阶段最“硬核”的一部分。你可以把它理解为一句话:

.o 里所有“还不知道地址”的地方,改写成真正的地址。

但在真实 ELF / x86-64 系统里,这个过程其实涉及:

  • relocation entry 结构
  • relocation type
  • section layout
  • PC-relative addressing
  • GOT / PLT动态链接

我按 真实链接器的执行步骤给你完整讲清楚。


一、为什么需要重定位

.o 文件里:

地址几乎都是假的。

例如代码:

int add(int a,int b){
    return a+b;
}

int main(){
    return add(1,2);
}

编译后 main.o 中的汇编可能是:

call add

.o根本不知道 add 在哪里

所以编译器会生成:

call 0

然后记录:

这里将来需要填入 add 的地址。

这个记录就是 Relocation Entry


二、Relocation Entry 存在哪里

每个需要修补的位置都有一条记录。

在 ELF section 中:

.rela.text
.rela.data

查看:

readelf -r main.o

示例:

Relocation section '.rela.text'

Offset   Type             Symbol
00000005 R_X86_64_PLT32   add

意思是:

.text + 0x5
需要根据符号 add 修补

三、Relocation Entry 的结构

x86-64 使用:

Elf64_Rela

结构:

typedef struct {
    Elf64_Addr  r_offset;
    Elf64_Xword r_info;
    Elf64_Sxword r_addend;
} Elf64_Rela;

含义:

字段 含义
r_offset 要修改的位置
r_info 符号 + relocation type
r_addend 额外常数

四、重定位的核心公式

ELF 的重定位统一使用:

result = S + A

或者:

result = S + A - P

各个符号:

符号 含义
S Symbol address
A Addend
P Patch location

五、最典型的重定位:函数调用

假设最终链接后地址:

.text base = 0x401000

main = 0x401000
add  = 0x401020

机器码:

call rel32

x86 call 使用:

relative offset

公式:

offset = S + A - P

其中:

S = add 地址
A = addend
P = 下一条指令地址

举例:

call 指令地址 = 0x401010
下一条指令 = 0x401015

计算:

offset = 0x401020 - 0x401015
       = 0xB

于是机器码变成:

E8 0B 00 00 00

六、重定位执行流程

链接器流程:

for each relocation entry:

    1 找到 symbol
    2 获取 symbol address (S)
    3 读取 addend (A)
    4 计算 patch value
    5 写入 r_offset 指向的机器码

七、示例:真实 .o 文件

代码:

extern int add(int,int);

int main(){
    return add(1,2);
}

反汇编:

0000000000000000 <main>:
   0:  bf 01 00 00 00   mov $1,%edi
   5:  be 02 00 00 00   mov $2,%esi
   a:  e8 00 00 00 00   call 0

注意:

call 0

同时 relocation

Offset: a
Type: R_X86_64_PLT32
Symbol: add

八、链接器如何修补

假设:

add = 0x401050
call instruction = 0x40100a
next instruction = 0x40100f

计算:

offset = 0x401050 - 0x40100f
       = 0x41

写入:

E8 41 00 00 00

于是:

call add

成立。


九、不同 relocation 类型

常见 x86-64 relocation

类型 用途
R_X86_64_PC32 PC relative
R_X86_64_PLT32 call function
R_X86_64_32 absolute address
R_X86_64_GOTPCREL 访问 GOT
R_X86_64_RELATIVE runtime relocation

十、访问全局变量的重定位

代码:

extern int x;

int main(){
    return x;
}

汇编:

mov x(%rip), %eax

这是:

RIP relative addressing

重定位:

R_X86_64_PC32

公式:

S + A - P

十一、Section relocation

不仅 .text 需要重定位。

例如:

.data
.rodata

示例:

int x=10;
int *p=&x;

编译器会生成:

p:
    .quad x

.o 里不知道 x 地址。

于是:

.rela.data

记录:

p -> x

链接器填入:

x 的地址

十二、静态 vs 动态重定位

两种情况:

静态链接

ld main.o add.o

所有 relocation

link time 完成

程序运行时:

不再修改

动态链接

例如:

printf

链接器不知道 libc 地址。

所以:

生成 GOT / PLT

运行时:

dynamic loader 完成 relocation

十三、动态重定位

动态重定位表:

.rela.plt
.rela.dyn

运行时 loader

ld-linux.so

执行:

for relocation in .rela.dyn:
    apply relocation

十四、为什么 PIE 需要 runtime relocation

现代 Linux 默认:

PIE executable

地址随机化:

ASLR

程序加载地址:

不固定

例如:

0x555555554000

所以 loader 需要:

重新修补地址

十五、完整流程总结

整个 relocation pipeline

编译器
   ↓
生成 relocation entry
   ↓
.o 文件
   ↓
linker
   ↓
1 解析 symbol
2 layout sections
3 遍历 relocation entries
4 计算地址
5 修改机器码
   ↓
生成 ELF
   ↓
动态链接器
   ↓
runtime relocation

十六、一个非常重要的理解

.o 文件中:

代码 ≠ 最终机器码

很多地方只是:

placeholder

只有 链接完成后

机器码才最终确定

如果你愿意,我可以再给你讲 一个几乎所有系统书都会略过,但其实是最关键的部分

为什么 x86-64 必须大量使用 RIP-relative addressing

这件事直接关系到:

  • PIE
  • ASLR
  • 动态库加载
  • GOT / PLT 的设计

理解这个,你对 Linux 程序加载机制会突然豁然开朗。


好问题,这其实正好是 ELF 链接体系里另一条分支动态链接Dynamic Linking。 你前面已经在研究 .o → ELF静态链接流程,那么 .so 的逻辑其实是:

把一部分链接工作从 ld 推迟到程序运行时,由动态链接器完成。

我给你按 完整生命周期讲一遍(从编译到运行)。


一、动态库 .so 是怎么生成的

首先需要把代码编译成 位置无关代码PIC

gcc -fPIC -c foo.c

为什么?

因为 .so 可能被加载到 任意虚拟地址

如果代码里写死地址:

call 0x400800

加载地址变化就炸了。

所以要用 相对地址 + 表间接访问


然后生成 .so

gcc -shared -o libfoo.so foo.o

这一步本质是:

ld -shared foo.o

生成的 ELF 类型:

ET_DYN

注意:

.so 本质就是 一个特殊的 ELF 文件


二、程序链接动态库时发生什么

假设:

// main.c
extern void foo();

int main() {
    foo();
}

编译:

gcc main.c -L. -lfoo

此时:

-lfoo

意味着:

寻找 libfoo.so 或 libfoo.a

如果找到 .so,就会 动态链接


三、链接阶段ld做了什么

和静态链接不同:

ld 不会把 foo.o 代码复制进可执行文件。

它只做三件事:


1 记录依赖库

ELF 的 .dynamic section 里会出现:

NEEDED libfoo.so

可以用:

readelf -d a.out

看到:

0x0000000000000001 (NEEDED)             Shared library: [libfoo.so]

2 生成 PLT / GOT

调用 foo() 不会直接 call foo。

而会变成:

call foo@plt

ELF 里会生成:

.plt
.got
.got.plt

3 生成重定位表

因为地址运行时才知道。

所以会生成:

.rela.plt
.rela.dyn

这些记录:

foo 的地址需要运行时解析

四、程序运行时发生什么

程序启动时:

内核执行:

execve()

读取 ELF header。

如果看到:

PT_INTERP

就会加载动态链接器:

Linux通常是

/lib64/ld-linux-x86-64.so.2

可以查看:

readelf -l a.out

会看到:

INTERP

动态链接器启动后做几件事。


五、动态链接器干的事情

加载顺序大概是:

ld-linux.so
   ↓
加载主程序
   ↓
加载所有 .so
   ↓
符号解析
   ↓
重定位
   ↓
跳到 main()

六、共享库加载

动态链接器根据:

DT_NEEDED

加载:

libfoo.so
libc.so

查找路径:

1 LD_LIBRARY_PATH 2 rpath 3 /etc/ld.so.cache 4 /lib /usr/lib


七、符号解析

和静态链接一样:

foo -> libfoo.so::foo

但发生在 运行时


八、PLT / GOT 机制(核心)

假设:

call foo()

编译后:

call foo@plt

流程是:

main
  ↓
foo@plt
  ↓
got entry
  ↓
dynamic linker
  ↓
真实 foo()

第一次调用:

main
 ↓
foo@plt
 ↓
dynamic linker
 ↓
解析 foo
 ↓
写入 GOT
 ↓
调用 foo

第二次:

main
 ↓
foo@plt
 ↓
GOT
 ↓
foo

不再解析。

这叫:

Lazy Binding


九、PLT结构

x86-64 典型结构:

foo@plt:
jmp *foo@GOT
push relocation_index
jmp plt0

plt0

jmp dynamic linker

十、重定位类型

动态链接常见:

R_X86_64_JUMP_SLOT
R_X86_64_GLOB_DAT
R_X86_64_RELATIVE

例子:

.rela.plt

内容:

foo  R_X86_64_JUMP_SLOT

意思:

运行时填 GOT

十一、总结(完整生命周期)

动态库流程:

源码
 ↓
gcc -fPIC
 ↓
.o
 ↓
ld -shared
 ↓
libfoo.so

程序:

main.c
 ↓
gcc main.c -lfoo
 ↓
ELF executable

运行:

kernel
 ↓
ld-linux.so
 ↓
加载 libfoo.so
 ↓
符号解析
 ↓
重定位
 ↓
执行 main

十二、一个真实示例

写个最小 demo

foo.c

#include <stdio.h>

void foo() {
    printf("hello foo\n");
}

编译:

gcc -fPIC -c foo.c
gcc -shared -o libfoo.so foo.o

main.c

void foo();

int main() {
    foo();
}

编译:

gcc main.c -L. -lfoo

运行:

LD_LIBRARY_PATH=. ./a.out

十三、推荐你观察几个东西(非常有意思)

你现在在研究 ELF强烈建议

看依赖库

ldd a.out

看动态表

readelf -d a.out

看 PLT

objdump -d a.out | less

搜:

@plt

看重定位

readelf -r a.out

如果你愿意,我可以 带你从0手动解析一个动态链接程序,包括:

  • .plt 每一条指令干嘛
  • .got.plt 怎么改写
  • R_X86_64_JUMP_SLOT 怎么执行
  • ld-linux 怎么完成解析

这个过程其实是 理解 Linux 程序加载机制的关键一课。 很多人学系统软件多年都没真正搞懂这一块。


你已经抓到 动态链接的大致思路了,但里面有几个关键点需要纠正。我按你的问题一条一条讲清楚,这些地方其实正好是 ELF + 动态链接最容易误解的地方


一、PIC 是不是每个模块都这样?

基本是,但要稍微精确一点。

1 只有 共享库 (.so) 必须 PIC

编译共享库必须:

gcc -fPIC

原因:

.so 可能被加载到 任何虚拟地址

例如:

程序A:
libc.so -> 0x7f2000000000

程序B:
libc.so -> 0x7f1000000000

所以 .so 里的代码不能写死地址。


2 可执行文件通常不是 PIC

传统 Linux 可执行文件:

ET_EXEC

默认假设加载地址:

0x400000

所以可以直接使用绝对地址。

不过现代 Linux 也经常:

PIE (Position Independent Executable)

编译:

gcc -fPIE -pie

这时 主程序也变成 PIC


二、PIC是不是“代码段和数据段差一个固定距离”

不是这样理解的。

真正原理是:

代码访问数据通过 GOT

举个例子:

int global = 5;

int foo() {
    return global;
}

普通代码可能是:

mov global(%rip), %eax

但 PIC 里:

mov global@GOTPCREL(%rip), %rax
mov (%rax), %eax

流程:

RIP
 ↓
GOT entry
 ↓
global 地址

所以 PIC 的关键是:

所有全局地址通过 GOT 间接访问。


三、程序启动是不是因为 PLT 才加载动态链接器?

不是。

真正触发的是 ELF Program Header 中的 PT_INTERP

你可以看:

readelf -l a.out

会看到:

INTERP
Requesting program interpreter:
/lib64/ld-linux-x86-64.so.2

内核看到这个后:

execve()
 ↓
加载 ld-linux.so
 ↓
把控制权交给 ld-linux

注意:

不是因为 PLT。


四、程序启动完整流程(非常重要)

你说的流程我帮你改成 真正的顺序


1 内核加载程序

执行:

execve("./main")

内核:

读取 ELF header
读取 Program Header

2 发现动态链接器

Program Header 中有:

PT_INTERP

例如:

/lib64/ld-linux-x86-64.so.2

于是:

加载 ld-linux

3 内核启动动态链接器

此时:

ld-linux.so

成为第一个运行的用户代码。

它接收:

main.out 的信息

五、动态链接器开始工作

它读取:

.dynamic section

里面包含:

DT_NEEDED
DT_SYMTAB
DT_STRTAB
DT_RELA

1 加载依赖库

例如:

DT_NEEDED libc.so
DT_NEEDED libfoo.so

动态链接器:

mmap libc.so
mmap libfoo.so

2 构建全局符号表

动态链接器会把:

main.out
libc.so
libfoo.so

的符号表合并到一个查找结构里。


3 执行重定位

动态链接器读取:

.rela.dyn
.rela.plt

执行:

R_X86_64_RELATIVE
R_X86_64_GLOB_DAT
R_X86_64_JUMP_SLOT

比如:

GOT entry -> foo 地址

六、不会修改 .symtab

你这里理解错了一点。

你说:

修改 main.out 中的 .symtab 地址

实际上:

.symtab 不参与动态链接。

动态链接使用的是:

.dynsym

区别:

作用
.symtab 静态链接调试
.dynsym 动态链接

.symtab 甚至可以被 strip 掉。


七、真正被修改的是 GOT

动态链接时修改:

.got
.got.plt

例如:

GOT[foo] = foo_real_address

八、PLT 的作用

PLT 只是:

函数调用入口

例如:

call foo@plt

第一次调用:

foo@plt
 ↓
dynamic linker
 ↓
解析 foo
 ↓
写 GOT

以后调用:

foo@plt
 ↓
GOT
 ↓
foo

九、整个系统结构(完整图)

main.out
   │
   │ execve
   ▼
Kernel
   │
   │ 读取 PT_INTERP
   ▼
ld-linux.so
   │
   │ 读取 .dynamic
   ▼
加载 libfoo.so libc.so
   │
   │ 符号解析
   ▼
重定位 GOT
   │
   ▼
调用 main()

十、你现在其实已经接近理解一个很核心的东西

就是:

Linux 程序其实不是直接启动 main。

真实入口是:

_start
 ↓
ld-linux
 ↓
__libc_start_main
 ↓
main

如果你愿意,我可以再给你讲一个 非常关键但90%人没真正理解的点

PLT 第一次调用函数时,动态链接器是怎么被触发的?

里面涉及:

plt0
link_map
_dl_runtime_resolve

这是 glibc 动态链接最核心的机制,理解这个基本就把 Linux 动态链接彻底打通了。

你的理解已经非常接近底层真相了,尤其是在**“相对偏移量固定”“动态链接器”**这两个点上,你抓住了动态链接的灵魂。

我们可以把这个过程拆解得更硬核一点,看看 Linux 内核和动态链接器(ld-linux.so)到底是怎么配合演戏的。

1. 关于 PIC 的“固定距离” (关键所在)

你说得完全正确。 在生成的 .so 文件中,代码段(.text)和数据段(.data / .got)在文件里的相对位置是固定的

  • 为什么要固定? 虽然 .so 被加载到内存的基地址Base Address每次可能不同受 ASLR 随机化影响),但由于整个 .so 文件是作为一个整体映射进内存的,代码段某条指令到数据段某个变量的“跨度”Offset在编译时就定死了。
  • 怎么利用这个固定距离? x86-64 支持 RIP-relative addressing相对指令指针寻址。CPU 执行代码时,可以直接通过“当前指令位置 + 固定偏移量”来找到对应的全局变量,而不需要知道变量的绝对地址。

2. 动态链接的启动全流程

你的描述基本正确,但顺序上有一个细微且重要的区别:修改的不是 .out 文件本身,而是在内存里修改。

第一阶段:内核加载 (Kernel Space)

  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.outDYNAMIC 段,看到 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(符号表) 在程序运行时其实是不需要的,它主要给 gdbstrip 工具用。
  • 真正起作用的是 .dynsym(动态符号表)
  • 而且链接器几乎从不修改代码段(.text,因为代码段通常是只读的。它只修改数据段里的 GOT 表。这就是所谓的“数据延迟绑定”。

总结你的疑问:

  1. PIC 模块都这样吗? 是的,所有参与动态链接的 .so 必须是 PIC。
  2. 距离固定吗? 是的代码段和数据段的位移Offset是编译时常量。
  3. 加载顺序:
  • 内核加载 mainld.so
  • ld.so 加载所有依赖的 .so
  • ld.so 修改内存中的 GOT 表(重定向)。
  • ld.so 跳转到 main 的入口开始执行。

你现在已经把“链接”和“加载”这两个概念完全串起来了。既然你提到了修改地址,想不想了解一下什么是“延迟绑定 (Lazy Binding)”?也就是为什么程序启动时并不急着找所有函数地址,而是等到第一次调用时才去找。


要彻底理解 PICGOTPLT 的工作原理,我们可以把这个过程想象成一场**“为了节省内存而精心设计的‘骗局’”**。

整个体系的核心目标只有两个:

  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. 进入 PLTmain 执行 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. 再次进入 PLTmain 再次执行 call printf@plt
  2. 第一跳 (跳向 GOT)printf@plt 依然执行 jmp *GOT[3]
  3. 直接命中:由于 GOT[3] 现在已经是 printf 的真实物理地址了CPU 直接飞向 printf
  4. 不再经过链接器:此时不再有压号、不再有解析,速度和静态链接几乎一样快。

总结三者的关系

  • PIC (Position Independent Code)设计思想:通过相对偏移,让代码段可以被多进程共享。
  • GOT (Global Offset Table)地址簿:存放在可写的数据段,由动态链接器在运行时填入真实的绝对地址。
  • PLT (Procedure Linkage Table)跳板代码:存放在只读的代码段,负责触发地址解析(第一次)或直接跳转(后续)。

为什么这个设计很伟大?

如果没有这套机制,每个进程运行 main 时都要修改自己的 .text 段来填入地址,这样 libc.so 在物理内存里就会有无数个副本。有了 PIC+GOT+PLT无论系统运行多少个程序libc.so 的机器码在内存里永远只需要一份,极大地节省了内存。

你想在你的 Fedora 上亲手验证这个过程吗?我们可以用 gdb 观察 x/gx 某个 GOT 条目在调用前后的变化。


这个问题其实就是 Linux 动态链接机制的核心三件套PIC、GOT、PLT。 如果把它们彻底理解,你基本就把 ELF 动态链接的底层原理吃透了。

我给你按 逻辑顺序讲,而不是按名词顺序,因为很多教材是反着讲的,导致很难理解。

整体结构是:

PIC 解决 "代码能在任意地址运行"
      ↓
GOT 解决 "全局数据地址不确定"
      ↓
PLT 解决 "函数地址运行时才知道"

我们一步一步来。


一、PICPosition Independent Code

PIC 的目标只有一个:

让代码可以加载到任意地址运行。

为什么要这样?

因为共享库 .so 在不同程序中加载地址不同,例如:

程序A
libc.so -> 0x7f2000000000

程序B
libc.so -> 0x7f1000000000

如果代码写死地址:

call 0x7f2000123456

那程序B直接炸。

所以共享库必须编译为:

gcc -fPIC

PIC的核心思想

PIC 永远不直接使用绝对地址

而是:

相对地址 + 间接访问

在 x86-64 上最重要的工具就是:

RIP-relative addressing

例如:

mov x(%rip), %rax

意思是:

地址 = 当前RIP + offset

这样无论代码被加载到哪里offset都一样。


二、PIC如何访问全局变量

问题来了:

假设代码:

int global = 5;

int foo() {
    return global;
}

如果直接访问:

mov global, %eax

这里需要 绝对地址

PIC不能这么干。

于是就引入了 GOT


三、GOTGlobal Offset Table

GOT 的作用是:

保存全局符号的真实地址。

它是一个表,存放在:

.got
.got.plt

里面内容类似:

GOT
------------------
global → 0x7f2000012340
printf → 0x7f2000023450
foo    → 0x7f2000031110

PIC访问变量流程

编译后的代码会变成:

mov global@GOTPCREL(%rip), %rax
mov (%rax), %eax

执行流程:

RIP
 ↓
找到 GOT entry
 ↓
读取 global 地址
 ↓
读取 global 值

图示:

code
 ↓
RIP-relative
 ↓
GOT entry
 ↓
真实地址
 ↓
变量

所以:

代码只需要知道 GOT 的位置。

而 GOT 里的地址由 动态链接器填写


四、动态链接时如何填充 GOT

程序启动时:

动态链接器 ld-linux.so 会解析:

.rela.dyn

里面记录:

R_X86_64_GLOB_DAT

例如:

global → relocation entry

动态链接器执行:

GOT[global] = real address

之后程序访问 global 时就能正确读取。


五、函数调用的问题

变量解决了。

但函数还有个更麻烦的问题:

printf()

地址可能在:

libc.so

而 libc.so 运行时才加载。

如果每次调用都通过 GOT

call *printf@GOT

理论上可以。

但 Linux 使用了一个更复杂的机制:

PLT

原因:

支持 lazy binding延迟绑定


六、PLTProcedure Linkage Table

PLT 是一个 函数跳板表

它的作用:

第一次调用 → 动态解析
之后调用 → 直接跳转

每个外部函数都会有一个 PLT entry。

例如:

printf@plt
malloc@plt
puts@plt

七、PLT结构

一个典型的 PLT entry

printf@plt:

jmp *printf@GOT
push relocation_index
jmp plt0

结构图:

printf@plt
  │
  ├─ jmp GOT[printf]
  │
  ├─ push relocation id
  │
  └─ jmp plt0

八、第一次调用函数

假设程序:

printf("hello");

编译后:

call printf@plt

执行流程:

main
 ↓
printf@plt
 ↓
jmp *GOT[printf]

但一开始:

GOT[printf] = printf@plt + 6

所以会继续执行:

push relocation_index
jmp plt0

九、plt0入口

plt0 是所有 PLT entry 的统一入口:

plt0:

push link_map
jmp _dl_runtime_resolve

这里会调用:

glibc dynamic linker

函数:

_dl_runtime_resolve()

十、动态解析过程

动态链接器会:

1 根据 relocation index 找到:

.rela.plt

里面的记录:

printf → libc.so

2 在所有已加载库中查找符号:

libc.so → printf

3 得到地址:

0x7f2000023450

4 写入 GOT

GOT[printf] = 0x7f2000023450

5 跳转执行:

printf()

十一、第二次调用函数

再次执行:

call printf@plt

流程:

printf@plt
 ↓
jmp *GOT[printf]
 ↓
printf()

不再进入动态链接器。


十二、整体结构总结

整个系统结构:

               动态链接器
                 │
                 ▼
      +--------------------+
      |        GOT         |
      |--------------------|
      | printf → addr      |
      | global → addr      |
      +--------------------+
              ▲
              │
      +--------------------+
      |        PLT         |
      |--------------------|
      | printf@plt         |
      | malloc@plt         |
      +--------------------+
              ▲
              │
      +--------------------+
      |        PIC         |
      |--------------------|
      | call printf@plt    |
      | mov global@GOT     |
      +--------------------+

关系:

PIC
 ↓
需要间接地址
 ↓
GOT
 ↓
函数需要lazy binding
 ↓
PLT

十三、真实 ELF 中的位置

你可以在 ELF 中看到:

.text        → 代码
.plt         → PLT表
.got         → 全局GOT
.got.plt     → 函数GOT
.rela.dyn    → 数据重定位
.rela.plt    → 函数重定位
.dynsym      → 动态符号表
.dynamic     → 动态链接信息

十四、完整执行流程

程序启动:

execve
 ↓
kernel加载ELF
 ↓
加载 ld-linux.so
 ↓
加载所有 .so
 ↓
处理 .rela.dyn
 ↓
执行 main

函数调用:

call printf@plt
 ↓
plt
 ↓
got
 ↓
第一次 → dynamic linker
 ↓
写 GOT
 ↓
以后直接跳转

💡 给你一个建议(非常适合你现在的学习阶段):

你可以在 Linux 上自己观察:

objdump -d a.out
readelf -r a.out
readelf -d a.out

重点看:

.plt
.got.plt
.rela.plt

你会看到 printf@plt 的真实汇编结构


如果你愿意,我可以再给你讲一个 系统课一般不会讲,但其实是动态链接最核心的东西

“GOT 初始值为什么会指向 PLT+6

理解这个你会彻底看懂 PLT第一次调用的自举过程

对,你理解得很接近了。PLT 里面确实装的是代码,而不是数据。 更准确地说:

PLTProcedure Linkage Table是一段专门用于“函数调用跳转”的代码表。

每一个外部函数(比如 printfmalloc)都会对应 一个 PLT entry,而每个 entry 其实就是 一小段汇编代码

我们把结构完整拆开讲一下。


一、PLT 里到底是什么

在 ELF 文件里:

.plt section

是一段 可执行代码段

典型结构:

.plt
├── plt0
├── printf@plt
├── malloc@plt
├── puts@plt
└── ...

所以:

  • .plt 是一整段代码
  • 每个函数占 一个 entry

二、PLT entry 的真实代码

假设程序调用:

printf("hello");

编译后:

call printf@plt

printf@plt 的代码通常长这样x86-64

printf@plt:

jmp *printf@GOT(%rip)
pushq $reloc_index
jmp plt0

解释每一条指令。


第一条指令

jmp *printf@GOT(%rip)

意思是:

跳转到 GOT[printf] 存储的地址

如果 GOT 里已经填好真实地址:

GOT[printf] = libc_printf

那么:

jmp libc_printf

直接执行。

所以:

第二次以后调用就只执行这一条指令。


第二条指令

pushq $reloc_index

作用:

告诉动态链接器
要解析哪个符号

reloc_index 对应:

.rela.plt

中的一个 relocation entry。

例如:

printf → relocation #3

第三条指令

jmp plt0

跳转到:

PLT[0]

三、plt0 是什么

.plt 的第一个 entry 叫:

plt0

它是所有延迟绑定的入口。

典型结构:

plt0:

push GOT+8
jmp *GOT+16

这里其实跳到:

_dl_runtime_resolve

这是 glibc 动态链接器里的函数


四、第一次调用的完整流程

假设第一次调用 printf

程序执行:

call printf@plt

流程:

main
 ↓
printf@plt
 ↓
jmp *GOT[printf]

但刚开始:

GOT[printf] = printf@plt + 6

所以跳到:

push reloc_index
jmp plt0

继续:

plt0
 ↓
_dl_runtime_resolve

五、动态链接器干什么

动态链接器会:

1 找到 relocation entry

.rela.plt[reloc_index]

2 找到符号

printf

3 在库中查找

libc.so → printf

4 得到地址

0x7f20abcd1234

5 写入 GOT

GOT[printf] = 0x7f20abcd1234

六、第二次调用

再次执行:

call printf@plt

流程变成:

printf@plt
 ↓
jmp *GOT[printf]
 ↓
libc_printf

不再进入动态链接器。


七、PLT为什么这样设计

核心原因是:

lazy binding

优点:

1 启动更快 2 不用解析所有函数 3 只解析真正调用的函数

例如:

程序只调用 printf
但 libc 有 2000+ 函数

只解析 printf


八、PLT + GOT 的配合

结构图:

call printf@plt
        │
        ▼
+-------------------+
|   printf@plt      |
|-------------------|
| jmp *GOT[printf]  |
| push reloc_index  |
| jmp plt0          |
+-------------------+
        │
        ▼
+-------------------+
|      GOT          |
|-------------------|
| printf → addr     |
+-------------------+

九、ELF里对应的section

你可以看到这些:

.plt        → 跳转代码
.got.plt    → 函数地址表
.rela.plt   → 函数重定位表
.dynsym     → 动态符号表

十、简单总结

一句话总结:

PLT 是代码
GOT 是数据

职责:

PIC  → 位置无关代码

GOT  → 存函数/变量真实地址

PLT  → 函数调用跳板

工作流程:

call foo@plt
 ↓
PLT entry
 ↓
GOT
 ↓
第一次 → 动态解析
 ↓
以后 → 直接调用

如果你愿意,我可以再给你讲一个 非常关键但大多数教材不讲清楚的细节

为什么 GOT 初始值是 plt_entry + 6

这个设计其实是 PLT self-bootstrap自举机制,理解了这个你会彻底看懂整个动态链接过程。


你这个类比其实 已经抓住了核心感觉,但如果从系统角度说,我们可以稍微修正一下,让它更准确一点。你的直觉方向是对的 👍

先给结论版:

  • GOT 更像一个“地址缓存表”
  • PLT 更像一个“带 miss 处理逻辑的调用入口”

但它们不像 CPU cache + page table 那么严格对应。

我给你用你熟悉的 Cache / Page Table 思维重新类比一遍。


一、GOT ≈ 地址缓存表(有点像 cache / 页表)

GOT 的本质是:

symbol  →  runtime address

例如:

GOT
---------------------
printf  → 0x7f12ab34c000
malloc  → 0x7f12ab352000
puts    → 0x7f12ab341000

程序访问函数或全局变量时:

代码
 ↓
查 GOT
 ↓
得到真实地址

所以它确实像:

  • cache:缓存解析后的地址
  • page tablesymbol → address 的映射

但区别是:

结构 映射
页表 virtual addr → physical addr
GOT symbol → function addr

二、PLT ≈ 带 miss handler 的 stub

PLT 每个 entry 其实就是一个 函数调用 stub

结构:

printf@plt:
    jmp *GOT[printf]
    push reloc_index
    jmp plt0

逻辑非常像:

if (GOT entry valid)
    jump to function
else
    call resolver

换成伪代码:

call_printf():
    if (got.printf != unresolved)
        jump got.printf
    else
        resolve_printf()

三、第一次调用 = cache miss

第一次调用:

call printf@plt
      ↓
jmp GOT[printf]

但 GOT 里是:

printf@plt+6

于是执行:

push reloc_index
jmp plt0

进入:

_dl_runtime_resolve()

动态链接器:

symbol = printf
library = libc.so
address = lookup(symbol)

然后:

GOT[printf] = address

这一步非常像:

cache fill

四、第二次调用 = cache hit

第二次:

call printf@plt
     ↓
jmp GOT[printf]
     ↓
printf()

完全绕过 dynamic linker。

这就是:

lazy binding

五、完整类比图

可以这么理解:

程序代码
   │
   ▼
PLT stub
   │
   ▼
GOT entry  (address cache)
   │
   ├── 已解析 → 直接调用函数
   │
   └── 未解析 → resolver
                   │
                   ▼
           动态链接器
                   │
                   ▼
            更新 GOT

六、为什么设计成 PLT + GOT

原因其实是三个:

1 PIC 代码不能写绝对地址

必须:

RIP relative

访问 GOT。


2 函数调用需要 lazy binding

如果不用 PLT

程序启动必须解析:

printf
malloc
scanf
...

几千个符号。

PLT 让:

第一次调用才解析

3 多个模块共享同一个库

GOT 可以在运行时填充。


七、再给你一个更接近 CPU cache 的类比

其实整个系统更像这样:

             +------------------+
             |  dynamic linker  |
             |  (miss handler)  |
             +------------------+
                       ▲
                       │
             miss      │
                       │
+-------+      +---------------+
| code  | ---> |  PLT (stub)   |
+-------+      +---------------+
                       │
                       ▼
                +-------------+
                | GOT (cache) |
                +-------------+
                       │
                       ▼
                   real func

八、一个很多人第一次意识到的点

其实 Linux 程序 绝大多数函数调用都是间接调用

call printf@plt

而不是:

call printf

这也是为什么:

LD_PRELOAD

hook 几乎所有函数


如果你愿意,我可以带你看一个 真实 ELF 的 PLT + GOT 反汇编,逐条解释:

printf@plt:
401030: jmp *0x404018(%rip)
401036: push $0x0
40103b: jmp 401020

那一刻你会突然发现:

动态链接其实就是几十条汇编在配合工作。


你提到的**“打桩”Stubs / Hooking**,在动态链接的语境下,其实就是 PLT 条目的本质功能。

但在计算机科学里,“打桩”这个词通常有两种含义:一种是系统自动生成的(就像 PLT 那样),另一种是你手动干预的(为了替换掉原本的函数)。

既然你已经理解了 PLT 和 GOT 的“骗局”,咱们来看看这种机制是怎么被利用来实现“打桩”的。


1. 编译器的“自动桩” (PLT Stubs)

在链接阶段,当 ld 发现你调用了一个外部函数时它会在可执行文件里自动生成一个“桩”Stub

  • 作用:占位。
  • 逻辑:它并不包含真实的业务逻辑,只是负责把控制权转交给动态链接器
  • 形象理解:你在代码里调用的 printf 并不是真正的 printf,而是一个**“替身”**(即 PLT 条目)。这个替身负责在运行时去“请”真身。

2. 程序员的“库打桩” (Library Interpositioning)

这才是最硬核、最好玩的。因为有 GOT/PLT 的存在,你可以在不重新编译程序的情况下,偷偷换掉它调用的函数。

机制LD_PRELOAD

在 Linux比如你的 Fedora有一个环境变量叫 LD_PRELOAD。它可以让你指定的动态库先于其他库加载。

操作流程:

  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 字节指针,就能改变整个程序的行为。

打桩的应用场景:

  • 安全监控:监控所有 openconnect 系统调用。
  • 性能分析:统计 malloc 被调用的次数和分配的总量。
  • 故障注入:模拟网络超时,看看程序在 recv 返回错误时会不会崩溃。

你的下一个硬核实验:

你想不想自己写一个简单的 malloc 打桩库?只需要几行 C 代码,就能让你在运行任何 Linux 命令(如 lstop)时,看到它们背后疯狂申请内存的过程。

要不要我把这个“打桩”库的源码发给你试试?

完全正确!你已经把动态链接PLT/GOT打桩Interpositioning 这三者之间的逻辑闭环彻底锁死了。

当你使用 LD_PRELOAD 这种打桩技术时,发生的事情确实如你所说:动态链接器ld.so在程序还没开始运行、还没来得及搞什么“延迟绑定”的时候就把你的 malloc 地址填进去了。

深度拆解这个过程:

1. 符号解析的优先级(插队)

动态链接器在填 GOT 表的“坑”时,是有搜索顺序的。

  • 默认顺序:先找程序本身,再找链接时指定的库(如 libc.so)。
  • 打桩模式LD_PRELOAD 里的库拥有最高优先级。 当链接器寻找 malloc 这个符号时,它第一眼就看到了你提供的库。一旦找到了匹配,它就停止搜索,并把这个地址记下来。

2. “跳过”第一次访问的逻辑

  • 正常情况:链接器在 GOT 对应位置填入一个指向 PLT[0] 的地址,诱导程序去走一遍“解析流程”。
  • 打桩情况:链接器认为 malloc 的地址已经是确定的了(就是你那个库里的地址),于是它直接把你的 malloc 绝对地址写进 GOT 项。

结果就是: 虽然程序第一次调用 malloc 还是会经过 malloc@plt,但 PLT 的第一条指令 jmp *GOT[entry] 就会直接跳到你的代码里。那段压栈、找链接器的“第一次访问逻辑”确实被完全绕过了。


动手写一个“打桩”小工具

既然你已经明白了原理,不妨在你的 Fedora 上亲自感受一下。这个过程只需要几分钟:

第一步:编写桩代码 (hook.c)

这段代码会拦截 malloc,打印一句话,然后再调用真实的 malloc(否则程序会崩溃)。

#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>

// 我们的伪装 malloc
void* malloc(size_t size) {
    static void* (*real_malloc)(size_t) = NULL;
    if (!real_malloc) {
        // 通过 dlsym 找回“真正的” malloc 地址
        real_malloc = dlsym(RTLD_NEXT, "malloc");
    }

    void *p = real_malloc(size);
    // 这里的 printf 只是演示,实际上 malloc 内部调 printf 可能会导致递归死循环
    // 因为 printf 内部也会调 malloc工程上一般用 write 系统调用
    fprintf(stderr, "Gotcha! malloc(%zu) = %p\n", size, p);
    return p;
}

第二步:编译成共享库

gcc -fPIC -shared hook.c -o libhook.so -ldl

第三步:见证奇迹的时刻

随便运行一个系统命令,比如 ls

LD_PRELOAD=./libhook.so ls

你会看到满屏幕都是 ls 在背后偷偷申请内存的记录。


总结:这套设计的精妙之处

这种“省去 PLT 第一次访问”的能力,本质上是 符号抢占Symbol Preemption

它证明了:在 Linux 中,程序对自己调用的函数其实没有“最终决定权”,真正的控制权掌握在动态链接器手中。 只要掌握了 GOT 和加载顺序,你就是这个进程的上帝。

既然你已经玩转了应用层的打桩,想不想了解一下:如果我想拦截的不是库函数,而是像 readwrite 这样的“内核系统调用”,又该在哪层“打桩”呢?