0%

从编译器角度理解 C++ 代码的编译和链接

这是一段简单的 C++ 的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// main.cpp
// 引用 sum.cpp 文件里面定义的全局变量以及函数
extern int gdata;
int sum(int, int);

int data = 20;

int main()
{
int a = gdata;
int b = data;

int ret = sum(a, b);

return 0;
}
1
2
3
4
5
6
// sum.cpp
int gdata = 10;
int sum(int a, int b)
{
return a + b;
}

这里有两个源文件:main.cpp 用 extern 声明引用了 sum.cpp 里定义的全局变量 gdata 和函数 sum,sum.cpp 负责定义它们。

平时我们只需要在 IDE 里编译运行,或者在命令行敲一句 g++ main.cpp sum.cpp -o main,就得到可执行文件了。不需要关心具体的细节。

但为了深入学习C++,我们需要深入挖掘,打牢基础,修炼“内功”。

接下来,我们从三个问题引入:

  1. 汇编器产出的**.o 文件里有什么?它为什么不能直接运行?**
  2. 符号什么时候分配虚拟地址?
  3. 可执行文件和 .o 文件有什么区别?

如果不清楚这几个问题的概念,没关系,看完本文,你会有一个比较清晰的认知。

编译链接全景图

C++ 代码的编译链接过程全景

整个过程分两大阶段:

  • 编译过程:每个源文件独立走完三步——预编译 → 编译 → 汇编,产出 .o 文件(二进制可重定位目标文件)
  • 链接过程:所有 .o 文件 + 静态库交给链接器,经过两个核心步骤——段合并 + 符号解析、符号重定位——产出可执行文件

每个阶段都可以用单独的命令执行(以 g++ 为例):

阶段 命令 产物
预编译 g++ -E main.cpp -o main.i main.i(预编译后的源文件)
编译 g++ -S main.cpp -o main.s main.s(汇编代码)
汇编 g++ -c main.cpp -o main.o main.o(二进制可重定位目标文件)
链接 g++ main.o sum.o -o main main(可执行文件)

本文的实测输出来自 Windows + MinGW(GCC 12.3.0)

本文用到的命令一览

后文会反复用到下面这些命令,先在这里统一汇总,方便随时回查。它们随 GCC / binutils 一起安装,在 Windows + MinGW 和 Linux 下通用。

(一)编译四阶段

命令 含义与参数
g++ -E main.cpp -o main.i -E(preprocess):只做预编译,不编译不汇编
g++ -S main.cpp -o main.s -S:编译到汇编代码为止,产出 .s,不继续汇编
g++ -c main.cpp -o main.o -c:编译 + 汇编,产出 .o,不链接
g++ main.o sum.o -o main 输入是 .o 时只做链接:把多个目标文件合并成可执行文件
g++ main.cpp sum.cpp -o main 不加阶段选项:从源码一次走完预编译 → 编译 → 汇编 → 链接

上面每条命令里的 -o(output)都是指定输出文件名。

(二)查看目标文件内部

命令 含义与参数
objdump -h main.o -h(header):查看段表——文件里有哪些段、各多大、什么标志。Linux 下的等价命令是 readelf -S
nm main.o 查看符号表——每个符号是定义还是引用(UND)、归属哪个段
objdump -d main.o -d(disassemble):反汇编,把机器码翻译回汇编指令
objdump -r main.o -r(relocation):查看重定位表——记录 .text 段里哪些位置”地址待填”
objdump -f main.exe -f(file header):查看文件头,里面有程序入口地址
c++filt _Z3sumii 还原 C++ 修饰过的名字(demangle),输出 sum(int, int)

(三)其他

命令 含义与参数
wc -l inc_test.i wc(word count)统计文本,-l 只输出行数
echo "..." | g++ -x c++ -c - -o x.o 管道写法:把 echo 的输出当源文件喂给 g++。-x c++ 显式指定输入语言是 C++(管道输入没有文件名后缀,不指定会报错);- 表示从标准输入读
ld -e main ld 是链接器本体(g++ 只是调用它的包装);-e(entry)指定程序入口

名词概念对照

这篇文章名词比较密集,而且有几组名字长得很像、实际不是一个东西(符号 / 符号表、重定位 / 重定位表、符号解析 / 符号重定位)。这一节先把它们摆到各自的位置上——后面读到不熟的名词,也可以回到这里查。尤其是”符号解析”和”符号重定位”这对最容易混的。

全景对照表

名词 一句话解释 出现在哪
符号(Symbol) 函数名、全局变量名在编译器和链接器眼里的统一叫法 编译阶段产生,链接阶段使用
符号表(.symtab) 记录所有符号的表:名字、是定义还是引用、归属哪个段 .o 和可执行文件里都有
UND 符号表里的标记:这个符号被引用了,但本文件没有定义它 符号表的一种状态
global / local 符号的链接可见性:普通符号是 global(链接器可见),static 修饰的是 local(链接器看不见) 符号表的一种状态
可重定位 .o 的性质:里面的”地址”还没定下来,要等链接器来定位 .o 的性质
段(Section) 文件里按用途划分的区块:.text 装代码、.data 装已初始化的全局数据等 编译阶段确定归属
指令(Instruction) CPU 执行的机器码;汇编是它的文本形式 编译产出,汇编阶段翻译成机器码
段内偏移 .o 符号表里那一列数字(全是 0),记录符号相对所在段起始的偏移,还不是虚拟地址 .o 文件
虚拟地址 符号和段的最终地址,运行时就按它访问内存 链接阶段(符号解析后)分配
符号解析 动作:把每个引用的符号和它的定义配对 链接第一步
段的合并 动作:所有 .o 的同类段归并到一起 链接第一步
重定位 动作:把指令里占位的 0 改成真实地址 链接第二步
重定位表(.rel.text) 记录”哪些位置待填地址、按什么规则填”的表 .o 里,重定位的依据
program headers 可执行文件特有的结构:告诉系统运行时要加载哪些段 可执行文件
入口地址 文件头里记录的”从哪开始执行” 可执行文件

重点:符号解析 vs 符号重定位

这对概念比较难懂——都带”符号”,都发生在链接阶段。用 main.cpp 调用 sum 的那条指令,看链接器围着它做的两件事。

先交代这条指令的现状(后面”链接”一节会详细展开):编译出 main.o 后,调用处是一条地址为 0 的 call 指令——

1
2a:	e8 00 00 00 00       	call   2f <main+0x2f>

它要调用的目标是 sum(符号名 _Z3sumii),但地址部分填着 0——编译的时候,谁也不知道 sum 会被放到哪。

第一件事:符号解析,回答的问题是——_Z3sumii 这个符号是谁?在哪定义?

  • 查 main.o 的符号表:_Z3sumii 是 U(UND),只引用、没定义
  • 去其他 .o 里找:在 sum.o 的符号表里找到了它的定义(T,在 .text 段)
  • 配对成功——_Z3sumii 的定义找到了

注意:到这一步为止,链接器动的都是符号表——只是弄清了”谁是谁、在哪”。指令里的 0 还原封不动。

解析成功之后、重定位开始之前,链接器还有一小步:给所有符号分配虚拟地址(_Z3sumii 拿到 0x140001710)。地址定下来了,重定位才有”数”可填——这就是顺序不能反的原因。

第二件事:符号重定位,回答的问题是——这条指令里的 0,该填什么数?

  • 查重定位表:main.o 里记着”偏移 2b 处有一条待填记录,目标是 _Z3sumii“
  • 地址已经知道了:按规则算出差值 0x11
  • 把 e8 00 00 00 00 改成 e8 11 00 00 00——指令本身被改了

对照着看:

符号解析 符号重定位
回答的问题 这个符号是谁、在哪定义? 这个位置该填什么数?
操作对象 符号表:把引用和定义配对 指令里的占位:把 0 改成真实地址
依据 所有 .o 的符号表 重定位表
顺序 先做 后做——要等地址分配出来,才有”数”可填
出错时报错 undefined reference、multiple definition 都出在这一步 ——(地址都分配好了,按表填就行)

一句话记住分工:**符号解析负责”配对”,符号重定位负责”填数”**。

第一步:预编译

预编译处理源代码里所有以 # 开头的命令:

指令 处理方式
#define 删除定义,展开所有宏
#include 把被包含的文件插入到指令位置(递归展开)
#if #ifdef #elif #else #endif 条件编译
// 和 /* */ 删除所有注释
—— 添加行号和文件名标识,方便编译报错时定位

预编译后的产物是 .i 文件(C++ 是 .ii),里面不包含任何宏定义——宏都展开完了,头文件也插进来了。

实测一下:

1
2
3
// inc_test.cpp 一共就 2 行
#include <iostream>
int main() { return 0; }
1
2
3
$ g++ -E inc_test.cpp -o inc_test.i
$ wc -l inc_test.i
33092 inc_test.i # 2 行 → 33092 行!

而我们的 main.cpp 没有 #include,预编译后只有 19 行,多出来的内容是行号标记:

1
2
3
4
5
6
7
8
9
10
# 0 "main.cpp"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "main.cpp"

extern int gdata;
int sum(int, int);

int data = 20;
// ……(以下与源码相同)

所以要判断宏定义或头文件包含是否正确时,可以看预编译后的 .i文件。

一个例外:#pragma

#pragma 虽然也以 # 开头,但它不在预编译阶段处理——预编译器会把它原样保留,交给后面的阶段(有的要到链接阶段才用得上)。

比如:

  • 指定当前程序需要链接的库。链接库是链接器干的事,所以这条指令必须”活到”链接阶段
  • 指定程序入口函数程序默认以 main 作为入口函数,如果要修改入口地址,也是链接器关心的事

凡是给链接器提供材料的 #pragma,都不在预编译阶段处理。除此之外,# 开头的都在预编译阶段处理。

第二步:编译

编译阶段对预编译后的文件做词法分析、语法分析、语义分析,再经过代码优化,最后生成汇编代码。

这里有三个值得注意的细节:

1. 优化级别可控:-O0 / -O1 / -O2 / -O3 指定优化级别,代码优化就在这一步做。

2. 汇编代码分两种体系:x86 汇编(Intel 语法)和 AT&T 汇编(Unix 体系)。GCC 默认输出 AT&T 语法。

3. gcc/g++ 命令本身不干活:它只是”包装”,按参数去调用后台程序——预编译和编译是 cc1(C++ 是 cc1plus)、汇编是 as、链接是 ld。

我们实测一下 main.cpp 编译出的汇编(去掉与平台相关的伪指令,保留核心):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
	.globl	data
.data
data: # data 变量定义在 .data 段
.long 20 # 初始值 20 非零 → .data

.text
.globl main
main: # main 函数定义在 .text 段
pushq %rbp # ① 保存调用方栈底
movq %rsp, %rbp # ② 确立自己的栈底
subq $48, %rsp # ③ 开辟 48 字节栈帧
movq .refptr.gdata(%rip), %rax # 访问 gdata(外部符号)
movl (%rax), %eax
movl %eax, -4(%rbp) # a = gdata
movl data(%rip), %eax # 访问 data(本文件符号)
movl %eax, -8(%rbp) # b = data
movl -8(%rbp), %edx # 准备参数 b
movl -4(%rbp), %eax # 准备参数 a
movl %eax, %ecx
call _Z3sumii # 调用 sum(C++ 名字修饰,后面讲)
movl %eax, -12(%rbp) # ret = 返回值
movl $0, %eax # return 0
addq $48, %rsp # 回退栈帧
popq %rbp
ret

让我们重点关注这份汇编里的几个符号:data(本文件定义)和 gdata、sum(外部引用)。它们是后面讲链接的关键。

除此之外,还可以分析出几个有意思的东西:

  • 变量和函数各归各的段:data 在 .data 段(初始化且非零),main 在 .text 段。这就是《进程的虚拟地址空间》里讲的段划分——“变量放哪个段”在编译阶段就定下来了
  • 栈帧三步(pushq %rbp / movq %rsp, %rbp / subq $48, %rsp)和 call / ret,类似于我们在《从汇编指令角度分析函数调用堆栈的详细过程》里逐行分析过的指令。
  • **call _Z3sumii**:C++ 编译器把 sum(int, int) 修饰成了 _Z3sumii(可以近似于函数名 + 形参列表)。可以用 c++filt 还原:
1
2
$ c++filt _Z3sumii
sum(int, int)

第三步:汇编

汇编器(as)把汇编代码逐条翻译成机器码(01 序列),产出 .o 文件(Windows 下叫 .obj)。其实它还有另一个名字:二进制可重定位目标文件

拆开来看:

  • 二进制:内容已经是机器码,不再是文本
  • 可重定位:需要定位的是地址,因为里面的”地址”都还没定下来
  • 目标文件:链接器的输入文件

开头提到的”.o 文件为什么不能运行”这个问题,它的答案就藏在这里——概括地说,它是可重定位的,地址还没确定,CPU 没法执行。

接下来让我们深入分析

.o 文件里有什么

.o 文件的格式:Linux 下是 ELF,Windows 下是 PE(MinGW 编译出的 .o 是 PE 格式)。

简单来说,.o文件的组成结构就是:一个文件头 + 各种各样的段。

目标文件的结构:各段与两张表

我们可以用 objdump -h命令 查看段表(Linux 下也可以用 readelf -S):

1
2
3
4
5
6
7
$ objdump -h main.o
Sections:
Idx Name Size ...
0 .text 00000040 ... CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000010 ... CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 ... ALLOC
...

划重点的段:

段 内容
.text 指令(代码)
.data 已初始化且非零的全局/静态数据
.bss 未初始化或为 0 的全局/静态数据
.symtab 符号表(符号表本身也是一个段)
.rel.text 重定位表——记录 .text 段里哪些位置要修正

这些段就是虚拟地址空间布局图里的那些段。那篇讲的是它们运行时在内存里的位置,这一篇讲的是它们在文件里的形态、以及怎么生成的。

注意段表里 .text 段的标志位里有个 RELOC——这个段里有”待重定位”的记录。这是链接阶段的重要线索,先记住它。

符号表:链接器的”通讯录”

什么是符号

程序里所有函数名、全局变量名,在编译器和链接器眼里都叫符号(Symbol)。

main.o 的符号表(用 nm 查看,去掉了段符号和平台特有项):

U 是 UND 的缩写,写全了就是 undefined,它们是同一个东西。

1
2
3
4
0000000000000000 T main        ← 定义:main 函数在 .text 段
0000000000000000 D data ← 定义:data 变量在 .data 段
U gdata ← 引用:gdata 未定义
U _Z3sumii ← 引用:sum 未定义

sum.o 的符号表:

1
2
0000000000000000 T _Z3sumii    ← 定义:sum 函数在 .text 段
0000000000000000 D gdata ← 定义:gdata 变量在 .data 段

(T = 定义在 .text 段,D = 定义在 .data 段,U = UND 未定义。字母大写表示 global,小写(t/d)表示 local——下一节马上用到)

符号的定义 vs 引用

定义:符号在本文件里”落地”了——函数有指令、变量有存储,符号表里能看到它归属哪个段。

引用:本文件用到它,但它定义在别的文件里,符号表里标 UND(undefined)。

对 UND 的一句话解读是:**”我用到了这个符号,但我不知道它在哪定义”**。

一个容易想错的地方:main.cpp 里对 gdata 只有一句 extern int gdata; 声明,编译后仍然会产生 gdata 的符号(UND 类型)。有人觉得”这只是声明、不是定义,应该不产生符号”——这是不对的,因为如果不产生符号,链接器就不知道这个文件需要 gdata。

local 和 global

符号还分可见性:

类型 哪些 链接器可见性
global 普通全局变量、普通函数 链接器看得见,参与全局匹配
local static 全局变量、static 函数 链接器看不见,只在当前文件内用

举例:

1
2
3
4
5
6
$ echo "int gcount = 1;"        | g++ -x c++ -c - -o global.o
$ echo "static int gcount = 1;" | g++ -x c++ -c - -o static.o

$ nm global.o $ nm static.o
0000000000000000 D gcount 0000000000000000 d _ZL6gcount
↑ 大写 global ↑ 小写 local

写成 static int gcount; 后有两处变化:类型字母从大写 D 变成小写 d;而且符号名被修饰成了 _ZL6gcount——链接器匹配的是名字,名字里带上 L(local)标记,其他文件就找不到它了。

两个推论:

  1. 多个文件里定义同名的 static 变量/函数——没问题,链接器看不见它们
  2. 多个文件里定义同名的普通全局变量/函数——冲突,链接时报重定义错误

符号的地址全是 0

回头看上面符号表的第一列——地址全是 0。

为什么?

  • 对于引用的符号:编译器不知道它定义在哪,无从分配
  • 对于定义的符号:它的最终地址取决于合并后在虚拟地址空间里的位置,而这要等所有 .o 合并完才知道

严格一点说:.o 里这一列记录的其实是符号在段内的偏移(main 是 .text 段的第一项、data 是 .data 段的第一项,所以都是 0),它还不是虚拟地址。虚拟地址要等链接时才分配。

所以:编译阶段(预编译/编译/汇编)不分配虚拟地址。

那什么时候分配呢?——答案是链接阶段。这就回答了开头的第二个问题。

指令怎么使用地址是0的符号

虽然符号的地址还没分配,但指令已经生成好了。指令里要用到符号的地址,怎么办?

实测反汇编 main.o(objdump -d):

1
2
3
4
5
6
7
8
9
10
11
$ objdump -d main.o
Disassembly of section .text:

0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 30 sub $0x30,%rsp
8: e8 00 00 00 00 call d <main+0xd> ← 地址是 0!
d: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax ← 地址是 0!
19: 8b 05 00 00 00 00 mov 0x0(%rip),%eax ← 地址是 0!
2a: e8 00 00 00 00 call 2f <main+0x2f> ← 地址是 0!

对照《从汇编指令角度分析函数调用堆栈的详细过程》看这四条指令:

指令 含义 指令里需要填的地址
call d 调用 __main(MinGW 特有) __main 的地址
mov 0x0(%rip),%rax 取 gdata 引用指针 gdata 相关地址
mov 0x0(%rip),%eax 访问 data data 的地址
call 2f 调用 sum sum 的地址

四条指令的地址部分**全是 00 00 00 00**。

由于编译阶段符号还没分配地址,编译器只能先填个 0 占位。等链接时地址定下来,再回头把这些 0 改掉。

那链接器怎么知道”哪些指令的哪些字节要改”?—— .text 段里那个 RELOC 标志指向的重定位表就是干这个的:它记录了所有”地址待填”的位置。objdump -r 可以看到:

1
2
3
4
5
6
7
$ objdump -r main.o
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000009 IMAGE_REL_AMD64_REL32 __main
0000000000000010 IMAGE_REL_AMD64_REL32 .refptr.gdata
000000000000001b IMAGE_REL_AMD64_REL32 .data
000000000000002b IMAGE_REL_AMD64_REL32 _Z3sumii

OFFSET 就是要重定位的位置——对照上面的反汇编:09 在第一条 call 指令里、10 和 1b 在两条 mov 指令里、2b 在最后一条 call 里。链接器的任务之一,就是把这些地址逐个重定位。

TYPE 一列是 MinGW(PE 格式)的重定位类型名;Linux(ELF 格式)下会显示成 R_X86_64_PC32 这类名字,含义相同——告诉链接器这个位置该怎么填。

到这里,我们大致梳理了一下编译的过程,也分析了.o 文件的具体内容。接下来让我们进入链接。

链接:把一堆 .o 拼成可执行文件

链接的输入:所有编译完成的 .o 文件 + 静态库文件(Linux 下是 .a,Windows 下是 .lib)。

静态库是什么?一堆 .o 文件打包成的集合。程序用到库里哪个符号,链接器就把库里对应的 .o 取出来参与链接。这里不展开,知道它是”链接的原料之一”就行。

链接器做两件核心的事:

步骤一:段的合并 + 符号解析

1. 段的合并

所有 .o 文件的段”按类型归并”:你的 .text 和我的 .text 合并、.data 和 .data 合并、.bss 和 .bss 合并,符号表也合并。

链接第一步:段的合并

2. 符号解析

符号表合并后,链接器做符号解析:所有对符号的引用,都要找到该符号定义的地方。

  • main.o 里 gdata、_Z3sumii 是 UND(引用)→ 去其他 .o 的符号表里找
  • 在 sum.o 里找到了它们的定义 → 配对上

出错的情况有两种。

情况一:找不到定义,比如链接时只链接 main.o,不链接 sum.o:

1
2
3
$ g++ main.o -o test.exe
ld.exe: main.o:main.cpp:(.text+0x2b): undefined reference to `sum(int, int)'
ld.exe: main.o:main.cpp:(.rdata$.refptr.gdata[...]+0x0): undefined reference to `gdata'

情况二:找到多个定义,比如多加了一个重复定义 data 的 bad.cpp(int data = 30;):

1
2
$ g++ main.o sum.o bad.o -o test.exe
ld.exe: bad.o:bad.cpp:(.data+0x0): multiple definition of `data'; main.o:main.cpp:(.data+0x0): first defined here

回到前面 local/global 的推论:普通全局变量重名就冲突;换成 static 修饰的变量,链接器看不见(local),就不会报错。

3. 解析成功后:给所有符号分配虚拟地址

符号解析全部成功、没有报错之后,链接器给所有符号分配虚拟地址。

“分配”这个词容易让人以为链接器在逐个给符号安排位置,实际拆开看是三个动作。

① 段合并时,符号的段内偏移”平移”

合并后的 .text 段里,各部分的排列是:CRT 启动代码等(0x6d0 字节)→ main.o 的 .text(0x40 字节)→ sum.o 的 .text。于是符号的段内偏移按新位置重算:main 从 0 变成 0x6d0;sum 从 0 变成 0x710(前面隔着 main.o 的 0x40 字节)。.data 段同理,data 和 gdata 的偏移分别是 0x10 和 0x20。

② 给段定基址

链接器按链接脚本(默认布局)决定每个段放在虚拟地址空间的哪里。实测段表能看到——第二列 VMA 就是段的虚拟地址:

1
2
3
4
5
$ objdump -h main.exe
Sections:
Idx Name Size VMA ...
0 .text 00002298 0000000140001000 ...
1 .data 00000140 0000000140004000 ...

③ 相加

符号地址 = 段基址 + 段内偏移。到这一步就是纯计算了:

符号 段基址 段内偏移 地址
main 0x140001000 0x6d0 0x1400016d0
_Z3sumii 0x140001000 0x710 0x140001710
data 0x140004000 0x10 0x140004010
gdata 0x140004000 0x20 0x140004020

拿两组数字验证一下:

  • 0x140001710 - 0x1400016d0 = 0x40——正好是 main.o 的 .text 大小
  • 0x140004020 - 0x140004010 = 0x10——正好是 main.o 的 .data 大小

两个 .o 的符号地址之差,正好等于前一个 .o 的段大小——这就是”依次拼接、偏移平移”落实在数字上的样子。

注意:到这里为止,链接器改的只有两张表——段表(每个段分到了虚拟地址)和符号表(每个符号分到了虚拟地址):这两张表里的地址列,从 0 填成了真实值。

而 .text 段里那些指令,机器码中的地址部分还是 00 00 00 00——一处都没改,改它们要等下一步重定位。

把此刻的状态画成对比图:

链接第一步之后:表动了,指令还没动

左边两张表的地址列已经填好,右边指令里的 0 还在原地——重定位要做的,就是把左边的地址填进右边的 0 里。

开头第二个问题的答案就在这里:符号的虚拟地址是在链接阶段分配的,具体说是链接第一步(符号解析)完成之后。

步骤二:符号重定位

地址分配完了,但指令里还填着 0 呢。回到那些指令,把正确的地址填进去——这就是重定位(也叫重定向)。

实测对比。链接前(objdump -d main.o):

1
2
3
4
 8:	e8 00 00 00 00       	call   d <main+0xd>              ← 坑 ①
d: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax ← 坑 ②
19: 8b 05 00 00 00 00 mov 0x0(%rip),%eax ← 坑 ③
2a: e8 00 00 00 00 call 2f <main+0x2f> ← 坑 ④

链接后(objdump -d main.exe):

1
2
3
4
1400016d8:	e8 0a 01 00 00       	call   1400017e7 <__main>               ← ① 填上
1400016dd: 48 8b 05 6c 3e 00 00 mov 0x3e6c(%rip),%rax # 140005550 ← ② 填上
1400016e9: 8b 05 21 29 00 00 mov 0x2921(%rip),%eax # 140004010 <data> ← ③ 填上
1400016fa: e8 11 00 00 00 call 140001710 <_Z3sumii> ← ④ 填上

四个坑全部填上了真实地址。对照表:

指令 链接前 链接后
call __main e8 00 00 00 00(偏移 0) e8 0a 01 00 00 → 跳到 1400017e7
mov 访问 gdata 0x0(%rip) 0x3e6c(%rip) → 指向 140005550
mov 访问 data 0x0(%rip) 0x2921(%rip) → 指向 140004010 <data>
call sum e8 00 00 00 00(偏移 0) e8 11 00 00 00 → 跳到 140001710 <_Z3sumii>

符号表也同步更新了(nm 对比):

1
2
3
4
5
# 链接前(main.o)                # 链接后(main.exe)
0000000000000000 T main 00000001400016d0 T main
0000000000000000 D data 0000000140001710 T _Z3sumii
U gdata 0000000140004010 D data
U _Z3sumii 0000000140004020 D gdata

所有符号都有地址了,UND 也没了——引用和定义全部配上了对。

回到开头的问题

为什么 .o 文件不能运行?

因为 .o 里符号没有虚拟地址、指令里全是 0 占位——它是”半成品”。链接器完成段合并、符号解析、地址分配、重定位之后,才是可执行文件。

符号什么时候分配虚拟地址?

链接第一步(符号解析)完成之后、第二步(重定位)之前。地址先分配好,重定位才有”正确的地址”可填。

可执行文件与 .o 的区别

结构上,可执行文件和 .o 非常像——还是”文件头 + 各种段”:

.o 文件 可执行文件
段的地址 全是 0 有虚拟地址(实测 .text 段在 0x140001000)
文件头 标记”可重定位” 多了入口地址
program headers 没有 有——告诉系统加载什么

program headers(程序头)是可执行文件比 .o 多出来的关键结构。

里面是若干个 LOAD 项,告诉操作系统:运行这个程序时,把哪些内容加载到内存。实测能观察到两个 LOAD:

  • 代码段:可读、可执行(R-X),不能写
  • 数据段:可读、可写(RW)

关键点:不是所有段都要加载。符号表段、段表这些”给链接器用”的信息,运行时不需要,不加载。真正要加载的只有代码段和数据段。

用一张图总结这一节:

可执行文件的结构:各段与加载

入口地址记录在文件头里,实测:

1
2
$ objdump -f main.exe
start address 0x0000000140001125

程序运行:加载与地址映射

可执行文件躺在磁盘上。运行它的时候:

  1. 操作系统读 program headers,知道要加载哪些内容
  2. 把代码段、数据段映射到进程的虚拟地址空间(《进程的虚拟地址空间》里那张布局图)——注意是映射到虚拟地址空间,不是直接塞进物理内存
  3. 把入口地址写进 CPU 的 PC 寄存器,CPU 从入口开始取指令执行
  4. 执行过程中访问某个虚拟地址(取指令或读写数据):
    • 先做地址映射(查页表)
    • 发现这个地址还没有关联物理内存 → 触发页面异常(缺页)
    • 异常处理程序分配物理内存,把映射关系写进页目录/页表
    • 回到刚才的指令继续执行

通览全流程

我们来看一下从源码到程序跑起来的完整链条:

flowchart TB
    A["main.cpp / sum.cpp(源码)"] --> B["预编译 → main.i
(# 指令处理完)"] B --> C["编译 → main.s
(汇编代码,段归属确定)"] C --> D["汇编 → main.o
(符号无地址,指令里占位 0)"] D --> E["链接①:段合并 + 符号解析
(给所有符号分配虚拟地址)"] E --> F["链接②:符号重定位
(把指令里的 0 填成真实地址)"] F --> G["可执行文件 a.out / main.exe"] G --> H["运行:按 program headers 加载到虚拟地址空间
入口地址 → PC 寄存器"]

各阶段一句话总结:

阶段 做什么 产出 关键点
预编译 处理 # 指令(宏展开、头文件插入、条件编译) .i #pragma 除外
编译 词法/语法/语义分析 + 优化 .s 段的归属在这一步确定
汇编 汇编代码 → 机器码 .o 符号无地址、指令地址占位 0
链接① 段合并 + 符号解析 —— 给所有符号分配虚拟地址
链接② 符号重定位 可执行文件 把指令里的 0 填成真实地址
运行 按 program headers 加载 进程 缺页异常分配物理内存

三个问题的答案:

  1. .o 文件里有什么?为什么不能运行?——文件头 + 各种段(.text/.data/.bss)+ 符号表 + 重定位表。符号没有虚拟地址、指令里是 0 占位,是”半成品”
  2. 符号什么时候分配虚拟地址?——链接阶段、符号解析完成之后
  3. 可执行文件和 .o 有什么区别?——段有了虚拟地址、文件头多了入口地址、多了 program headers

即使我们不写编译器,但理解这套原理之后,再看 undefined reference 报错、看内联函数为什么”建议不生效”、看反汇编调 bug,看到的就不再是表面现象,而是知道每一步底层发生了什么。

参考

  • 《程序员的自我修养——链接、装载与库》——第 2 章 编译和链接、第 3 章 目标文件里有什么、第 4 章 静态链接
  • 《深入理解计算机系统》(CSAPP)——第 7 章 链接