Post

链接和加载

链接和加载

什么是可执行文件 — “双击可以弹出窗口的那个东西”

  • 一个操作系统中的对象 (文件)
  • 一个字节序列 (我们可以把它当字符串编辑)
  • 一个描述了进程初始内存布局的数据结构

什么是执行? — execve()

execve() transforms the calling process into a new process. The new process is constructed from an ordinary file, whose name is pointed to by path, called the new process file. This file is either an executable object file, or a file of data for an interpreter. An executable object file consists of an identifying header, followed by pages of data representing the initial program (text) and initialized data pages.

1
2
3
4
5
int execve(
const char *path, 
char *const argv[],
char *const envp[]
);

a.out 的意思其实是 assembler output

静态链接和加载

假设一个程序有三个文件

1
2
3
main.c   →  main.o
utils.c  →  utils.o
math.c   →  math.o

三个.c文件在独立编译为.o文件后,假设main.o 里调用了 utils.o 里的 add() 函数,但编译 main.c 时,编译器根本不知道 add() 在哪里

而处理方式是:留下一个placeholder:

; main.o 里的伪汇编
call  0x????????   ; add() 的地址未知,暂时为空

所以.o文件就是可以天然不完整、无法直接执行的;多个编译单元之间存在跨文件引用,每个 .o 只知道自己内部的地址,不知道其他模块在哪里。链接的本质,就是把这些碎片缝合成一个完整的整体。

静态链接核心过程

Step 1:符号解析(Symbol Resolution)

每个 .o 文件都有一张符号表(Symbol Table):

符号类型状态
add函数已定义(在 utils.o)
add函数未定义(在 main.o 中引用)

链接器扫描所有 .o 文件,建立一张全局符号表,把所有“引用”和“定义”匹配起来

如果找不到定义,那就会报undefined reference to 'add'错误,如果一个符号有两个定义,那就会报 multiple definition 错误

副作用:有了全局符号表,每个符号现在有了唯一的归属,绝对不可以重复定义add()

Step 2:重定位(Relocation)

目标:填上placeholder

链接器把所有 .o 的代码段、数据段合并,并决定每个符号的最终地址。这样,就可以填上之前的空洞了:

; 链接后
call  0x401100   ; add() 的地址已确定,填入

每个 .o 里都有一张重定位表,记录哪些地方需要被修改,改成什么。链接器按表操作

ELF(Executable and Linkable Format) intro

ELF 同时服务于两个场景(链接 & 加载),内部有两套“索引”

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────┐
│      ELF Header         │  ← 文件类型、架构、入口点地址等
├─────────────────────────┤
│    Program Headers      │  ← 告诉操作系统怎么把文件加载进内存(segment 视图)
├─────────────────────────┤
│      .text section      │  ← 所有代码(已合并)
│      .data section      │  ← 初始化的全局变量
│      .bss section       │  ← 未初始化的全局变量(只占位,不占磁盘空间)
│      .rodata section    │  ← 只读数据(如字符串字面量)
│       ... 等 sections   │
├─────────────────────────┤
│    Section Headers      │  ← 告诉链接器怎么理解文件(section 视图)
└─────────────────────────┘

ELF是一个“描述” — 基本信息 (版本、体系结构……)、内存布局 (哪些部分是什么数据)、其他 (调试信息、符号表、重定位……)

加载

可执行文件在磁盘上是静止的字节序列。要运行它,操作系统需要把它映射到进程的虚拟地址空间,这个过程就叫加载,由操作系统的加载器(loader)完成

加载器做的事:

  1. 读取 ELF Header,找到 Program Headers(segment 信息)
  2. 为进程创建虚拟地址空间
  3. 按 segment 描述,将文件内容 mmap 到对应虚拟地址
    • 代码段(LOAD, R+X)→ 映射到 0x401000…
    • 数据段(LOAD, R+W)→ 映射到 0x601000…
  4. .bss 段不占磁盘空间,但需要在内存中分配并清零
  5. 设置栈(stack)和堆(heap)
  6. 跳转到 ELF Header 里记录的入口地址(entry point),开始执行

加载的本质就是,建立”磁盘文件”与”虚拟内存”之间的映射关系,让 CPU 能开始执行第一条指令

 链接(Linking)加载(Loading)
时机运行前(编译阶段末尾)运行时(exec 系统调用后)
输入多个 .o 文件一个可执行文件
输出一个可执行文件一个运行中的进程
核心工作符号解析 + 重定位内存映射 + 跳转执行
操作对象文件中的符号和地址进程的虚拟地址空间
谁来做链接器 ld操作系统 loader

链接 = 把多个不完整的代码碎片,拼成一个逻辑上完整的文件

加载 = 把这个完整的文件,变成 CPU 可以执行的活进程

ELF文件结构

ELF讲解详细版,使用小猫神力展开领域!

为什么一个文件需要两套描述自己的方式?

答案是:因为这个文件服务于两个完全不同的使用者,他们关心的东西根本不一样。

  • 链接器关心:这块数据是”代码”还是“符号表”还是“重定位信息”?
  • 操作系统关心:这块数据该放到内存哪里?权限是读/写/执行?

这两个问题的粒度和维度完全不同,所以 ELF 设计了两套独立的“目录”来回答它们。

ELF 文件的物理结构

先看文件本身长什么样(以可执行 ELF 为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────────────────┐  ← 偏移 0
│           ELF Header            │  64 字节(64位系统)
├─────────────────────────────────┤
│        Program Header Table     │  ← 加载视图的"目录"
│  (若干个 Phdr,每个描述一个segment)│
├─────────────────────────────────┤
│                                 │
│         实际数据内容              │  ← 这里才是真正的代码、数据等
│   .text / .data / .bss / ...    │
│                                 │
├─────────────────────────────────┤
│        Section Header Table     │  ← 链接视图的"目录"
│  (若干个 Shdr,每个描述一个section)│
└─────────────────────────────────┘  ← 文件末尾

Section Header Table 和 Program Header Table 都只是目录/索引,真正的数据只有一份,在文件中间。两套目录指向的是同一块字节,只是切割方式不同

ELF Header:文件的“总纲”

readelf -h /bin/ls 可以看到:

1
2
3
4
5
6
7
8
9
10
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 ...   ← 魔数,前4字节是 \x7fELF
  Class:   ELF64
  Type:    ET_EXEC (Executable file)      ← 或 ET_DYN / ET_REL
  Machine: Advanced Micro Devices X86-64
  Entry point address: 0x401080           ← 加载后从这里开始执行
  Start of program headers: 64            ← PHT 在文件的偏移
  Start of section headers: 108576        ← SHT 在文件的偏移
  Number of program headers: 13
  Number of section headers: 30

Header 本身不包含代码,它的作用是告诉你“目录在哪里”

Section:链接器的视图

Section 是什么

Section 是有语义标签的数据块。链接器把不同用途的数据分开存放,每块都有名字和类型。

常见 section:

Section 名内容链接器用途
.text机器指令(代码)合并所有 .o 的代码
.data已初始化全局变量合并数据
.bss未初始化全局变量仅记录大小,不占磁盘空间
.rodata只读数据(字符串等)合并只读数据
.symtab符号表符号解析用
.strtab字符串表(存符号名)配合 symtab
.rela.text代码段的重定位信息重定位用
.dynsym动态符号表动态链接用
.dynamic动态链接元数据loader 读取依赖信息
.pltPLT 存根代码动态链接跳板
.gotGOT 表存运行时地址

Section Header 的结构

每个 Section Header(Shdr)描述一个 section:

1
2
3
4
5
6
7
8
9
typedef struct {
    uint32_t sh_name;       // section 名字(在字符串表中的偏移)
    uint32_t sh_type;       // 类型:代码/数据/符号表/重定位表...
    uint64_t sh_flags;      // 属性:可写?可执行?需要分配内存?
    uint64_t sh_addr;       // 运行时虚拟地址(链接后填入)
    uint64_t sh_offset;     // 在文件中的偏移
    uint64_t sh_size;       // 大小
    ...
} Elf64_Shdr;

readelf -S /bin/ls 可以看到所有 section 的信息。

链接器为什么需要这种粒度?

考虑链接 main.outils.o

1
2
3
4
5
6
7
8
9
链接器需要做:
1. 把 main.o 的 .text 和 utils.o 的 .text 合并
   → 必须知道哪些字节是“代码”
   
2. 处理 main.o 的 .rela.text(重定位条目)
   → 必须知道哪些字节是“重定位信息”,不是代码本身
   
3. 合并符号表 .symtab
   → 必须能找到符号表,知道每个符号在哪里定义

如果没有 section 的细粒度划分,链接器根本无法区分”这是代码”还是”这是给我看的元数据”。

Segment:操作系统的视图

Segment 是什么

Segment(段)是按内存权限组合的数据块,专门告诉操作系统:

“请把文件的这个范围,映射到虚拟内存的这个地址,设置这些权限。”

每个 Program Header(Phdr)描述一个 segment:

1
2
3
4
5
6
7
8
9
10
typedef struct {
    uint32_t p_type;     // 类型:LOAD / DYNAMIC / INTERP ...
    uint32_t p_flags;    // 权限:R / W / X 的组合
    uint64_t p_offset;   // 在文件中的起始偏移
    uint64_t p_vaddr;    // 映射到虚拟内存的地址
    uint64_t p_paddr;    // 物理地址(通常忽略)
    uint64_t p_filesz;   // 在文件中的大小
    uint64_t p_memsz;    // 在内存中的大小(可能 > filesz,差值清零)
    uint64_t p_align;    // 对齐要求
} Elf64_Phdr;

p_memsz > p_filesz 的典型情况:.bss 不占磁盘空间,但内存中需要清零的空间。

常见 segment 类型

类型含义
LOAD核心:需要被加载进内存的内容
DYNAMIC动态链接信息(指向 .dynamic section)
INTERP动态链接器路径(如 /lib/ld-linux.so.2
GNU_STACK栈的权限设置

操作系统为什么只关心 segment?

操作系统调用 mmap 来映射文件到内存,mmap 的最小操作单位是页(通常 4KB)

操作系统根本不需要知道”这页里哪些字节是 .text,哪些是 .rodata“——它只需要知道:

  • 这块内存放在哪个虚拟地址
  • 权限是什么(R/W/X)

.text(可执行)和 .rodata(只读)合并进同一个 LOAD segment(R+X)完全合理,没有必要分开映射两次。

分得太细反而浪费: 每次 mmap 都要页对齐,合并 section 可以减少映射次数和内存浪费。

两套视图的关系:同一数据,不同切割

用一个具体例子来看,假设文件内容如下:

1
2
3
4
文件偏移:  0      64     200    3000   4000   5000   6000
           │ ELF  │ PHT  │      │      │      │      │
           │Header│      │.text │.roda │.data │.bss  │
                          ta

Section 的切割方式(细粒度):

1
2
3
4
.text    [偏移200, 大小2800]   代码
.rodata  [偏移3000, 大小1000]  只读数据
.data    [偏移4000, 大小1000]  可写数据
.bss     [偏移5000, 大小0]     (磁盘无内容,内存需1000字节)

Segment 的切割方式(粗粒度,按权限合并):

1
2
3
4
5
LOAD segment 1 [偏移200, 文件大小3800]  → 虚拟地址 0x400200, 权限 R+X
  包含: .text + .rodata

LOAD segment 2 [偏移4000, 文件大小1000] → 虚拟地址 0x601000, 权限 R+W
  包含: .data + (.bss 在内存中额外分配1000字节清零)

同一块字节,链接器用 section 的眼光看,操作系统用 segment 的眼光看。

两套视图各自的”使用生命周期”

1
2
3
4
5
编译阶段        链接阶段         加载阶段         运行阶段
    │               │                │               │
  .o 文件        Section            Segment          进程
  只有 SHT       SHT 被大量使用     PHT 被使用       两个都不再需要
  没有 PHT       PHT 开始填写       SHT 可以丢弃

事实上,strip 命令就是删除可执行文件中的 Section Header Table(和调试信息),因为运行时根本用不到。strip 后的文件可以正常运行,但无法被链接器重新处理。

这进一步证明:SHT 是给链接用的,PHT 是给加载用的,两者在时间维度上也是分离的。


用一个具体类比收尾

把 ELF 文件想象成一栋建筑的设计文档:

Section 视图 = 施工图纸(按功能划分:电路图、水管图、结构图…) → 施工队(链接器)需要这种精细分类,才能知道每根线接哪里

Segment 视图 = 消防分区图(按使用权限划分:公共区域、仓库、禁区…) → 消防局(操作系统)只关心哪个区域能进、哪个区域禁止明火

同一栋建筑,同一套物理空间,两种完全不同的”看待方式”,服务于不同的目的。


总结

 SectionSegment
服务对象链接器操作系统 loader
划分依据数据的语义/用途内存权限
粒度细(可达几十个)粗(通常 2-4 个 LOAD)
对应目录Section Header TableProgram Header Table
使用时机链接阶段加载阶段
可以没有吗运行时可以没有(strip)可执行文件必须有

Section 是“内容的语义标签”,Segment 是“内存的映射指令”。 前者让链接器能操作数据,后者让操作系统能建立进程。

核心结构

ELF(Executable and Linkable Format)文件有三类核心组成:

1
2
3
4
5
6
7
8
9
┌───────────────┐
│  ELF Header   │  ← 16字节魔数 + 文件类型(ET_EXEC/ET_DYN/ET_REL) + 架构 + 入口地址
├───────────────┤
│   Sections    │  ← 内容本体:.text / .data / .bss / .symtab / .rela.text ...
├───────────────┤
│Section Headers│ ← section 的索引(名字、偏移、大小、属性)
├───────────────┤
│Program Headers│ ← segment 的索引(虚拟地址、权限、对齐)
└───────────────┘

Section 和 Segment 是同一份数据的两种”看法”,不是两份数据。

链接看 Section,加载看 Segment

Section(节) 是给链接器设计的:

  • 细粒度划分(.text.data.rela.text.symtab 等)
  • 链接器需要精确知道“代码在哪里”、“重定位信息在哪里”、“符号表在哪里”
  • 可执行文件里可能有几十个 section

Segment(段) 是给操作系统设计的:

  • 粗粒度划分,按内存权限合并(可读可执行 / 可读可写)
  • 操作系统不关心 .text.rodata 的区别,它只关心”这块内存要不要可执行”
  • 一个 segment 通常包含多个 section
1
2
3
4
5
6
7
8
9
链接器视角:                 操作系统视角:
.text      ─┐               LOAD segment (R+X)
.rodata    ─┘  →  合并  →   [0x400000 - 0x401fff]
                             权限:读 + 执行

.data      ─┐
.bss       ─┘  →  合并  →   LOAD segment (R+W)
                             [0x600000 - 0x601fff]
                             权限:读 + 写

动态链接与动态加载

静态链接的缺点

静态链接把所有库代码(包括 libc)都打包进可执行文件。

问题链:

1
2
3
4
5
6
7
8
9
10
11
问题1:体积膨胀
  → 100个程序都用 printf,磁盘上有 100 份 printf 的代码拷贝

问题2:内存浪费
  → 这 100 个程序同时运行,物理内存里有 100 份 printf 代码

问题3:更新困难
  → libc 有 bug,需要重新编译所有程序

问题4:无法按需加载
  → 程序启动就加载全部代码,即使某些功能从未被用到

解决思路: 库代码不打包进可执行文件,运行时再去找

这就是动态链接的根本动机:推迟绑定,按需共享。

动态链接的核心机制

基本设计

动态库(.so 文件,Shared Object)单独存放,多个进程共享同一份物理内存中的代码。

1
2
3
4
5
进程A的虚拟地址空间             物理内存
[0x7f...] libc.so ─────────→ [libc 代码,只有一份]
                                       ↑
[0x7f...] libc.so ─────────→ [同一物理页]
进程B的虚拟地址空间

核心挑战:位置无关代码(PIC)

动态库被加载到哪个虚拟地址是不确定的(取决于当时地址空间的状态)。

如果代码里有硬编码地址,共享就无法实现。

解决方案:编译动态库时加 -fPIC,生成位置无关代码(Position Independent Code)

代码中不使用绝对地址,而是用相对偏移或通过一个间接表来访问外部符号。

GOT 和 PLT(延迟绑定机制)

这是动态链接最精妙的设计,逻辑链如下:

1
2
3
4
5
6
7
问题:调用动态库函数(如 printf),编译时不知道它的地址
↓
朴素解决:加载时由 loader 把所有地址填进来
↓
副作用:启动慢(哪怕很多函数从未被调用)
↓
改进:延迟绑定(Lazy Binding)—— 第一次调用时才解析地址

两个数据结构:

  • GOT(Global Offset Table,全局偏移表):一张存放运行时地址的表,位于数据段(可写)
  • PLT(Procedure Linkage Table,过程链接表):一段存根代码(stub),位于代码段(只读)

完整流程

假设第一次调用 printf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
① 你的代码执行:call printf@PLT
   ↓
② 跳转到 PLT 中 printf 的存根代码
   PLT[printf]:
     jmp  *GOT[printf]    ← 跳到 GOT 里存的地址
   ↓
③ 【第一次调用】GOT[printf] 里存的不是 printf 真实地址,
   而是 PLT 里的下一条指令(resolver 入口)
   ↓
④ 【第一次调用】进入动态链接器(ld-linux.so)的 resolver
   → 在 libc.so 的符号表中查找 printf
   → 找到其在内存中的实际地址(如 0x7f3a...)
   → 把这个地址写入 GOT[printf]
   → 跳转到 printf 执行
   ↓
⑤ 【第二次及以后调用】
   call printf@PLT
   → jmp *GOT[printf]
   → GOT[printf] 已经是真实地址了
   → 直接跳转,不再走 resolver

图示:

1
2
3
4
5
第一次调用:
你的代码 → PLT[printf] → GOT[printf](存的是 resolver)→ resolver → 填写 GOT → printf

第二次调用:
你的代码 → PLT[printf] → GOT[printf](已是真实地址)→ printf

动态加载时 loader 额外做了什么

相比静态加载,动态链接的加载更复杂:

1
2
3
4
5
6
7
1. 读取 ELF,发现 .dynamic section(记录了依赖哪些 .so)
2. 递归加载所有依赖的动态库(如 libc.so, libm.so...)
3. 为每个库选择合适的虚拟地址(ASLR 随机化)
4. 处理库与库之间的符号依赖(库也可能依赖另一个库)
5. 执行"立即绑定"的符号(非延迟绑定部分)
6. 初始化各库的 .init 段和构造函数
7. 跳转到程序入口

整个过程由动态链接器完成——它本身也是一个 ELF,但操作系统直接加载它,由它再去加载你的程序

静态链接和动态链接的“第一条指令”

  1. CPU 执行的第一条指令在哪里?是谁的代码?
  2. 用户程序的 main() 之前,到底经历了什么?

静态链接

入口在哪里?

静态链接的可执行文件,ELF Header 里的 entry point 指向的是:

1
_start   ← 不是 main(),是 C 运行时(CRT)的入口

_start 来自 crt1.o,它是 GCC 在链接时自动加入的目标文件

执行路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
操作系统 exec()
    │
    ├─ 读取 ELF Header,找到 entry point 地址
    ├─ 把 LOAD segment mmap 进虚拟地址空间
    ├─ 设置栈,把 argc / argv / envp 压栈
    └─ 直接跳转到 entry point(_start)
           │
           ▼
        _start   ← CPU 第一条指令在这里
           │
           ├─ 初始化栈帧(对齐)
           ├─ 从栈上取出 argc, argv, envp
           ├─ 调用 __libc_start_main()
                   │
                   ├─ 初始化 C 标准库(malloc、stdio 等)
                   ├─ 注册 atexit() 处理函数
                   ├─ 调用全局构造函数(.init_array)
                   └─ 调用 main()
                           │
                           ▼
                        你的代码

静态链接时,操作系统 exec 完成后直接跳转到 _start,中间没有任何其他程序介入。整个过程只有一个执行者:你的程序自身(包含了 CRT 和 libc 的所有代码)

动态链接

入口在哪里?

动态链接的可执行文件,ELF Header 里同样有 entry point,也指向 _start

但是,在跳到 _start 之前,有一个完全不同的阶段。

关键在于 ELF 里还有一个特殊 segment:

1
INTERP segment:/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

这告诉操作系统:这个程序需要一个解释器(动态链接器),先运行它。

执行路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
操作系统 exec()
    │
    ├─ 读取 ELF,发现 INTERP segment
    ├─ 加载动态链接器 ld-linux.so 进内存
    ├─ 设置栈(argc/argv/envp + 辅助向量 auxv)
    └─ 跳转到 ld-linux.so 的入口
           │
           ▼
    ld-linux.so 的 _start  ← CPU 真正的第一条指令在这里
           │
           ├─ 读取可执行文件的 .dynamic section
           ├─ 找到所有依赖的 .so(DT_NEEDED 列表)
           ├─ 递归加载每个 .so 到虚拟地址空间
           ├─ 处理各库的重定位(非延迟绑定部分)
           ├─ 初始化每个库的 .init / .init_array
           └─ 跳转到可执行文件的 entry point(_start)
                   │
                   ▼
                _start(你的程序的 CRT)
                   │
                   └─ ... 同静态链接流程
                           └─ main()

动态链接时,CPU 的第一条指令在 ld-linux.so,不在你的程序里。操作系统把控制权先交给动态链接器,由它把运行环境准备好,再转交给你的程序

##

 静态链接动态链接
CPU 第一条指令你程序的 _startld-linux.so 的入口
操作系统交接给谁直接交给你的程序先交给动态链接器
到达 main()CRT 初始化动态链接器 + CRT 初始化
main() 开始时所有符号早已解析大部分符号尚未解析(延迟绑定)

操作系统怎么知道要先跑 ld-linux.so

它看的是 INTERP segment,这个 segment 里只存了一个字符串:动态链接器的路径

1
2
3
$ readelf -l /bin/ls | grep INTERP
  INTERP         0x0000000000000318  ...
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

静态链接的可执行文件没有 INTERP segment,所以操作系统直接跳转到 entry point。有无 INTERP,是静态与动态加载流程分叉的根本判断点。

总结
  • 静态链接exec → _start(你的程序)→ main(),全程只有一个执行者
  • 动态链接exec → ld-linux.so → _start(你的程序)→ main(),动态链接器是”幕后准备者”,在你的程序开始前把舞台布置好

总结

链接 vs 加载 的本质区别

 链接加载
解决的问题多模块间的符号引用如何连接文件如何变成可执行的进程
操作层面文件级别(修改字节、填地址)系统级别(内存映射、权限设置)
能否推迟静态:不能;动态:可以推迟到运行时必须在执行前完成(至少部分)
谁来做链接器 ld / 动态链接器 ld.so操作系统 exec + loader

最本质的一句话:

  • 链接 = 解决“名字”问题(这个符号是谁、在哪里)
  • 加载 = 解决“空间”问题(这段代码住进内存哪里、以什么权限运行)

静态 vs 动态 的本质区别

 静态链接动态链接
绑定时机编译时(链接阶段)运行时(第一次调用时)
可执行文件自包含,体积大依赖外部 .so,体积小
内存共享无(每个进程一份)有(多进程共享物理页)
升级灵活性需重新编译替换 .so 即可
启动速度快(无需解析依赖)稍慢(需加载 .so)
适用场景嵌入式、无依赖部署桌面/服务器通用程序

工程视角:动态链接是一种时间换空间、灵活性换复杂性的工程折衷

This post is licensed under CC BY 4.0 by the author.

Trending Tags