﻿# 从源码到运行:编译、链接、加载全过程(结合 ELF 分析)

> 把前面的 ELF 知识串成一条完整链路:一份 `.cpp` 是怎么变成能跑的进程的?中间生成了哪些文件、每个文件是什么 ELF 结构、运行时内核和动态链接器又做了什么、`.so` 如何被找到和绑定。全程结合本仓库的 `main.cpp` / `cpu_demo` 实操。

## 零、全景图

```bash
main.cpp
   │  ① 预处理 (cpp)           展开 #include / #define / 条件编译
   ▼
main.i        ← 纯 C++ 源码(已展开,还是文本)
   │  ② 编译 (cc1plus)         C++ → 汇编
   ▼
main.s        ← 汇编代码(文本,x86 指令)
   │  ③ 汇编 (as)              汇编 → 机器码目标文件
   ▼
main.o        ← 目标文件【ELF, Type=REL 可重定位】
   │  ④ 链接 (ld)              合并 .o + 库,重定位,生成可执行
   ▼
cpu_demo      ← 可执行文件【ELF, Type=DYN(PIE) 或 EXEC】
   │  ⑤ 加载 (execve + ld.so)  内核映射 + 动态链接器加载 .so + 重定位
   ▼
运行中的进程   ← 内存里的段(Segment):代码/数据/堆/栈/映射的 .so
```

> 进程跑起来后**完整的虚拟地址空间布局**（32/64 位各段怎么排、典型地址范围、以及"看地址秒判类型"），见 [memory-layout.md](/concepts/elf/memory-layout.md)。

前四步是 `g++ main.cpp -o cpu_demo` 一条命令**默默替你做完的**;第五步在你敲 `./cpu_demo` 时发生。下面逐段拆开。

## 一、四个编译阶段与中间文件

`g++`(或 `gcc`)是个"驱动器",按需依次调用 `cpp → cc1plus → as → ld`。用 `-save-temps` 可以把中间文件留下来看:

```bash
g++ -save-temps -g -O0 main.cpp -o cpu_demo
ls  # 多出 main.ii(C++预处理产物) main.s main.o
```

也可以让编译停在某一步:

```bash
g++ -E main.cpp -o main.i      # 只预处理(① 后停)
g++ -S main.cpp -o main.s      # 编译到汇编(② 后停)
g++ -c main.cpp -o main.o      # 汇编到目标文件(③ 后停,不链接)
g++    main.cpp -o cpu_demo    # 一路到底(④)
```

### ① 预处理 cpp —— 文本展开,还没 ELF

```bash
g++ -E main.cpp | less
```

做的事:展开 `#include <iostream>` 等头文件(把整个头文件内容贴进来,所以 `.i` 文件动辄几万行)、替换 `#define` 宏、处理 `#if/#ifdef` 条件编译、去掉注释。产物 `main.i` **仍是纯文本 C++**,没有任何二进制/ELF。

> 本仓库里 `main.cpp` 顶部那些 `#include <fstream>`、`<thread>` kkk在这一步被展开成成千上万行 STL 声明。

### ② 编译 cc1plus —— C++ 变汇编

```bash
g++ -S -O0 main.cpp -o main.s
grep -A15 'busy_kernel_cpu' main.s     # 看某函数编成了什么汇编
```

做的事:词法/语法分析、语义检查、生成中间表示、优化(受 `-O` 级别控制)、最后输出目标架构的**汇编代码**(文本)。`main.s` 里能看到 `push %rbp`、`call` 等 x86 指令,和 [objdump.md](/concepts/elf/objdump.md) 反汇编看到的很像——区别是 `.s` 是编译器**正向生成**的,objdump 是从机器码**逆向**还原的。

> 优化就发生在这一步:`-O2`(`make release`)会把 `busy_user_cpu` 的 `sum += i` 空循环优化掉,`main.s` 里那段就没了。

### ③ 汇编 as —— 生成第一个 ELF 文件

```bash
g++ -c main.cpp -o main.o
file main.o        # ELF 64-bit LSB relocatable, x86-64, not stripped
readelf -h main.o  # Type: REL (Relocatable file)
```

做的事:把汇编文本翻译成机器码,打包成 **目标文件 `.o`**——这是链条上**第一个 ELF 文件**,类型是 `REL`(可重定位)。

`.o` 的特点(用 ELF 工具看):

```bash
readelf -S main.o     # 有 .text/.data/.bss/.rodata,但地址栏基本是 0(还没定位)
nm -C main.o          # 看符号:T=本文件定义的函数, U=未定义(要外部提供)
readelf -r main.o     # 重定位表:标记"这里有个地址/符号,链接时来填"
```

关键理解:`.o` 里的地址还没确定(`.text` 起始地址是 0),对外部符号(如 `std::ofstream::write`、`printf`)只留了 `U`(undefined)记号和**重定位项**,等着链接阶段来填。这就是"可重定位"的含义。

### ④ 链接 ld —— 合并、重定位,生成可执行文件

```bash
g++ main.cpp -o cpu_demo       # 内部调 ld,还自动带上 CRT 启动代码和默认库
file cpu_demo                  # ELF ... pie executable, dynamically linked
readelf -h cpu_demo            # Type: DYN(PIE) 或 EXEC
```

链接器做三件核心事:

1. **合并 section**:把多个 `.o`(和静态库 `.a`)里同名的 `.text` 拼到一起、`.data` 拼一起……
2. **符号解析(symbol resolution)**:把每个 `U` 未定义符号,在其他 `.o`/库里找到定义并绑定。找不到就报经典的 **`undefined reference to ...`**(用 [nm.md](/concepts/elf/nm.md) 的 `nm -u` 排查)。
3. **重定位(relocation)**:符号地址确定后,回填所有重定位项里那些"待填的地址"。

还会**自动加入你没写的东西**:

- **CRT 启动代码**(`crt1.o`/`crti.o`/`crtn.o`):提供真正的入口 `_start`,它做完初始化后才调用你的 `main`。所以 `readelf -h` 里 `Entry point` 指向的是 `_start` 不是 `main`。
- 默认库:`libstdc++`、`libc`、(用了线程则)`libpthread` 等,以**动态链接**方式记为依赖(`readelf -d cpu_demo | grep NEEDED`)。

下面把最容易含糊的两步——**合并**和**重定位**——拆开讲透。

#### ④.1 什么叫"合并 section"

每个 `.o` 里都有自己的 `.text`/`.data`/`.bss`,而且**各自的地址都从 0 开始编号**(因为编译单个 `.cpp` 时，编译器根本不知道最终会和谁链在一起、自己会被放到哪)。链接器要做的第一步，就是把所有 `.o` 的**同名 section 首尾相接拼成一大段**,并给每段分配最终地址:

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<t>> #E3F2FD
  BorderColor<<t>>     #1976D2
  BackgroundColor<<d>> #C8E6C9
  BorderColor<<d>>     #388E3C
  BackgroundColor<<out>> #FFF9C4
  BorderColor<<out>>     #F9A825
}
rectangle "main.o" {
  rectangle ".text (0起)\nmain, buggy…" <<t>> as MT
  rectangle ".data (0起)" <<d>> as MD
}
rectangle "util.o" {
  rectangle ".text (0起)\nhelper…" <<t>> as UT
  rectangle ".data (0起)" <<d>> as UD
}
rectangle "可执行文件 cpu_demo" <<out>> {
  rectangle ".text 合并段\nmain.o的.text 接 util.o的.text\n分配到 0x4019a0…" <<t>> as OT
  rectangle ".data 合并段\n各 .data 拼接" <<d>> as OD
}
MT --> OT
UT --> OT
MD --> OD
UD --> OD
note bottom of OT : 同名 section 首尾相接,\n再整体分配最终虚拟地址
@enduml
```

要点：

- **按 section 类型归类拼接**:所有 `.text` 拼一起、所有 `.data` 拼一起、所有 `.bss` 拼一起——这样最终才能按权限打包成 [段(Segment)](/concepts/elf/elf-format.md)(代码段 R+X、数据段 R+W)。
- **拼接后每段拿到确定的虚拟地址**:比如 `main.o` 的 `.text` 可能被放到 `0x4019a0`,`util.o` 的 `.text` 紧随其后。**一旦地址定了,下一步"重定位"才有真实数值可填。**
- 静态库 `.a` 本质是一堆 `.o` 的打包,链接器**只挑出用到的那些 `.o` 成员**参与合并(不是整个库都拷进来)。

可以亲眼看合并前后 `.text` 的地址变化:

```bash
readelf -S main.o    | grep '\.text'   # .o 里 .text 的 Address 是 0（未定位）
readelf -S cpu_demo  | grep '\.text'   # 可执行里 .text 有了真实地址（如 0x4019a0）
```

#### ④.2 什么是"可重定位"、如何重定位

**可重定位(relocatable)** 的意思是:`.o` 里凡是"引用了一个现在还不知道最终地址的符号"的地方,编译器都**没有把死地址写进去**,而是留了一个**占位 + 一条重定位记录(relocation entry)**,告诉链接器:"等你定了地址,回来把这个位置填上"。

为什么编译器填不了?因为编译 `main.cpp` 时:

- 它调用的 `printf`/`std::ofstream::write` 在别的 `.o`/库里,地址未知;
- 甚至它自己的函数 `main`,最终会被放到哪个地址也还没定(见 ④.1,地址是链接时才分配的)。

所以编译器在 `.o` 里对这些引用先填 0（或一个临时值）,并在 `.rela.text`/`.rela.data` 等**重定位表**里记一条。看这张表:

```bash
readelf -r main.o
```

```bash
Relocation section '.rela.text' at offset ...:
  Offset          Type              Symbol's Name + Addend
  000000000012    R_X86_64_PLT32    printf - 4          ← “.text 偏移 0x12 处，引用了 printf”
  00000000001f    R_X86_64_PC32     _ZSt4cout - 4       ← 引用了 std::cout
  ...
```

每条重定位项 = **(在哪填 Offset, 填什么符号 Symbol, 怎么算 Type/Addend)**。

**重定位的过程**（链接器第③步）：

```plantuml
@startuml
skinparam shadowing false
skinparam activity {
  BackgroundColor #E3F2FD
  BorderColor #1976D2
  DiamondBackgroundColor #FFF9C4
  DiamondBorderColor #F9A825
}
start
:④.1 合并 section 完成,\n每个符号(main/printf…)都有了最终地址;
:遍历每张重定位表(.rela.*)的每一条记录;
:取出这条: 在 Offset 处、引用符号 S、类型 Type、加数 A;
:从符号表查到 S 的最终地址(符号解析的结果);
if (Type 是绝对寻址?) then (是 R_X86_64_64 等)
  :填入 = S + A（符号绝对地址）;
else (相对寻址 R_X86_64_PC32/PLT32 等)
  :填入 = S + A − P\n(P=被修补处的地址，算“到目标的相对偏移”);
endif
:把算出的值写回 Offset 指定的那几个字节;
:下一条…;
stop
@enduml
```

用一句话概括：**重定位 = 链接器拿着"符号的最终地址",按每条重定位记录的公式,把 `.o` 里那些先前留空的引用位置逐个填上真实地址/偏移。**

两类最常见的重定位方式：

| 类型 | 填入的值 | 用在哪 |
|------|---------|--------|
| **绝对重定位**(如 `R_X86_64_64`) | `S + A`(符号的绝对地址) | 非 PIE、数据里存函数指针等 |
| **相对重定位**(如 `R_X86_64_PC32`) | `S + A − P`(相对当前指令的偏移) | `call`/`jmp` 指令、PIE 代码——因为算的是"相对位置",整个程序换个基址加载也不用改，这正是 **PIE 位置无关**的基础 |

> 补充：调用**动态库**函数(如 `printf`)的重定位不在链接期直接填死地址,而是指向 **PLT/GOT**(见第六节),真实地址推迟到运行时由 `ld.so` 填——但"留占位 + 重定位记录"的机制是一样的,只是填的对象从"直接地址"变成"GOT 表项"。

小结这两步的关系：**先合并(④.1)让每个符号有确定地址 → 再重定位(④.2)把引用这些符号的地方填上**。缺了合并,重定位没有真实地址可填;缺了重定位,合并出来的代码里全是指向 0 的坏引用。

## 二、静态链接 vs 动态链接(链接阶段的两条路)

同样是第④步,链接器可以走两条路:

| | 动态链接(默认)| 静态链接(`-static`)|
|---|---|---|
| 库代码去向 | **不**拷进可执行文件,只记"我需要 libc.so.6" | 把库里用到的代码**整段拷进**可执行文件 |
| 文件大小 | 小 | 大(把 libc 等打包进来)|
| `file` 显示 | `dynamically linked` | `statically linked` |
| 运行时 | 靠 `ld.so` 加载 `.so` | 无外部依赖,直接跑 |
| 关键 ELF 结构 | 有 `.interp`/`.dynamic`/`.plt`/`.got` | 无这些动态节 |
| 升级库 | 换 `.so` 即可,程序不用重编 | 必须重新编译链接 |

```bash
g++ main.cpp -o demo_dyn                 # 动态(默认)
g++ -static main.cpp -o demo_static      # 静态
ls -lh demo_dyn demo_static              # static 大很多
file demo_dyn demo_static
readelf -d demo_dyn | grep NEEDED        # 有 NEEDED 依赖
readelf -d demo_static                   # 基本没有动态节
ldd demo_static                          # "not a dynamic executable"
```

> 权衡:动态省内存(多进程共享同一份 `.so`)、易升级(修 libc 漏洞不用重编所有程序);静态部署简单(无依赖)、启动略快,但体积大、库有漏洞要全量重编。容器时代静态链接(或 musl 静态)又流行起来,因为镜像干净、无依赖地狱。

## 三、可执行文件的 ELF 结构(为运行做的准备)

链接产物 `cpu_demo` 已经有了完整的**运行视角**结构。回顾 [elf-format.md](/concepts/elf/elf-format.md) 的两种视角,运行时靠的是 **Program Header(段/Segment)**:

```bash
readelf -l cpu_demo
```

关键段:

| Segment | 作用 |
|---------|------|
| `INTERP` | 存**动态链接器路径** `/lib64/ld-linux-x86-64.so.2`——内核加载时先把控制权交给它 |
| `LOAD`(R E) | 代码段:映射 `.text`/`.rodata`,只读可执行 |
| `LOAD`(RW) | 数据段:映射 `.data`/`.bss`,可读写 |
| `DYNAMIC` | 动态链接信息(依赖哪些库、符号表位置、重定位表)——`ld.so` 的工作清单 |
| `GNU_STACK` | 标记栈是否可执行(安全相关,通常不可执行)|

`.interp` 里的路径可以直接看:

```bash
readelf -p .interp cpu_demo       # /lib64/ld-linux-x86-64.so.2
```

## 四、运行时:`./cpu_demo` 发生了什么

敲下 `./cpu_demo` 后,大致这样(动态链接程序):

```bash
① shell 调 fork() + execve("./cpu_demo")
② 内核检查 ELF 魔数/架构,读 Program Header
③ 内核按 LOAD 段用 mmap 把代码段/数据段映射进新进程的虚拟地址空间
④ 内核发现有 INTERP 段 → 不直接跳 _start,而是先加载 .interp 指定的动态链接器 ld.so,把控制权交给它
⑤ ld.so 接手:
     - 读可执行文件的 .dynamic,列出所有 NEEDED 的 .so
     - 按搜索顺序找到这些 .so,逐个 mmap 映射进地址空间(递归:.so 还依赖别的 .so)
     - 做重定位:回填 .got(全局偏移表)里外部符号的真实地址
     - 处理 PLT(过程链接表),视配置做延迟绑定(lazy binding)
⑥ ld.so 跳到可执行文件的 _start(CRT 启动代码)
⑦ _start 初始化 C 运行时(全局构造、TLS、argc/argv/环境变量)后 call main
⑧ main 开始跑你的代码 —— cpu_demo 打印菜单、起线程……
```

用工具观察这个过程:

```bash
strace -f ./cpu_demo 2>&1 | head -40    # 看 execve、mmap 一堆 .so、openat libstdc++...
LD_DEBUG=libs ./cpu_demo                # ld.so 打印它查找/加载每个库的详细过程
LD_DEBUG=help ./cpu_demo                # 看 LD_DEBUG 支持哪些类别
```

> `strace` 里能清楚看到:先 `execve`,然后 `openat`/`mmap` 加载 `ld-linux`、`libstdc++.so.6`、`libc.so.6`——这就是第⑤步动态链接器在干活(见 [../code/strace.md](/tools/code/strace.md))。

## 五、动态库如何被找到(搜索顺序)

第⑤步"找到这些 `.so`",`ld.so` 按固定优先级搜索,这也是排查"找不到库"的排查顺序:

1. **`DT_RPATH`**(旧,已不推荐)—— 编进可执行文件的路径
2. **`LD_LIBRARY_PATH`** 环境变量 —— 临时指定/调试用,生产慎用
3. **`DT_RUNPATH`** —— 编进文件的路径(比 RPATH 新,`-Wl,-rpath` 设置)
4. **`/etc/ld.so.cache`** —— `ldconfig` 根据 `/etc/ld.so.conf(.d)` 生成的缓存(主力)
5. **默认目录** `/lib`、`/usr/lib`、`/lib64`、`/usr/lib64`

```bash
ldd cpu_demo                     # 看每个库最终解析到哪个路径(⚠️ 会执行程序,见 ldd.md 安全警告)
readelf -d cpu_demo | grep -E 'NEEDED|RPATH|RUNPATH'   # 安全:只读依赖和 rpath
ldconfig -p | grep libstdc++     # 看缓存里注册的库
LD_DEBUG=libs ./cpu_demo 2>&1 | grep -i 'trying\|found'   # 看实际查找轨迹
```

排查 `error while loading shared libraries: xxx.so: cannot open shared object`:

- `ldd` / `readelf -d` 看是哪个库 `not found`;
- 库在非标准路径 → 加进 `/etc/ld.so.conf.d/*.conf` 后 `ldconfig`,或临时 `export LD_LIBRARY_PATH=...`;
- 版本号对不上(要 `.so.6` 只有 `.so.5`)→ 装对应版本。

(详见 [ldd.md](/concepts/elf/ldd.md)。)

## 六、PLT / GOT 与延迟绑定(动态调用的机制)

动态库函数的地址在**运行时才知道**,程序里怎么调用一个"编译时还不知道在哪"的函数?靠 **PLT + GOT** 两张表(在 [elf-format.md](/concepts/elf/elf-format.md) 提过):

- **GOT**(`.got`/`.got.plt`,全局偏移表):存外部符号的**真实地址**,由 `ld.so` 在加载/首次调用时回填。
- **PLT**(`.plt`,过程链接表):一小段跳板代码。你的代码 `call printf@plt` 实际跳到 PLT 桩,PLT 通过 GOT 找到真实地址。

**延迟绑定(lazy binding)**:默认情况下,一个库函数**第一次被调用**时才由 `ld.so` 解析真实地址并填进 GOT(之后直接走 GOT,不再解析)。好处是启动快(没被调到的函数不解析)。

```bash
objdump -d -j .plt cpu_demo      # 看 PLT 跳板
readelf -r cpu_demo              # 看 .rela.plt(哪些 GOT 项待绑定)
objdump -R cpu_demo              # 动态重定位项(GOT 条目 → 符号)
```

安全加固相关:`-Wl,-z,now`(启动时全部绑定,配合 `-z relro` 让 GOT 只读,防 GOT 覆写攻击)会关闭延迟绑定;`LD_BIND_NOW=1 ./cpu_demo` 也能强制启动时全绑定。

## 七、结合本仓库:完整走一遍

```bash
cd performance_profile
# ① 保留所有中间文件,观察四阶段产物
g++ -save-temps -g -O0 main.cpp -o cpu_demo
ls main.ii main.s main.o cpu_demo         # 预处理/汇编/目标/可执行
# ② 每个文件的 ELF 身份
file main.o cpu_demo                       # REL 目标 vs DYN 可执行
readelf -h main.o | grep Type              # REL
readelf -h cpu_demo | grep Type            # DYN(PIE)
# ③ 目标文件里的未定义符号(等链接来填)
nm -C main.o | grep ' U ' | head           # U printf / ofstream::write / pthread...
# ④ 可执行文件的运行视角结构
readelf -l cpu_demo | grep -E 'INTERP|LOAD|DYNAMIC'
readelf -p .interp cpu_demo                # 动态链接器路径
readelf -d cpu_demo | grep NEEDED          # 依赖 libstdc++/libc/libpthread
# ⑤ 运行时加载过程
LD_DEBUG=libs ./cpu_demo                    # ld.so 查找/加载库的轨迹(选个场景快速退出)
strace -f -e trace=execve,openat,mmap ./cpu_demo 2>&1 | head -30
```

> ⚠️ 平台提醒:`.interp`、`ld-linux`、PLT/GOT 延迟绑定、`LD_DEBUG`/`ldconfig` 这套是 **Linux + glibc + GNU binutils** 的机制。macOS 用的是 Mach-O 格式和 `dyld`,命令和结构都不同。要完整体验请在 Linux 上做(和本仓库其他文档一致)。

## 八、一句话总结

`.cpp` 经 **预处理(.i)→ 编译(.s)→ 汇编(.o,第一个 ELF)→ 链接(可执行 ELF)** 四步落地;运行时内核 `mmap` 映射 LOAD 段、把控制交给 `.interp` 指定的 **动态链接器**,由它按搜索顺序加载 `.so`、通过 **GOT/PLT** 做(延迟)重定位,最后经 CRT 的 `_start` 才进 `main`。每一步都能用 `readelf`/`nm`/`objdump`/`ldd`/`strace`/`LD_DEBUG` 看得见。

> 相关:ELF 格式基础 [elf-format.md](/concepts/elf/elf-format.md);逐个工具 [readelf.md](/concepts/elf/readelf.md) / [objdump.md](/concepts/elf/objdump.md) / [nm.md](/concepts/elf/nm.md) / [ldd.md](/concepts/elf/ldd.md) / [misc.md](/concepts/elf/misc.md);符号分离 [../crash/symbol-separation.md](/crash/symbol-separation.md)。

