这是一段简单的 C++ 的代码
1 | // main.cpp |
1 | // sum.cpp |
这里有两个源文件:main.cpp 用 extern 声明引用了 sum.cpp 里定义的全局变量 gdata 和函数 sum,sum.cpp 负责定义它们。
平时我们只需要在 IDE 里编译运行,或者在命令行敲一句 g++ main.cpp sum.cpp -o main,就得到可执行文件了。不需要关心具体的细节。
但为了深入学习C++,我们需要深入挖掘,打牢基础,修炼“内功”。
接下来,我们从三个问题引入:
- 汇编器产出的**
.o文件里有什么?它为什么不能直接运行?** - 符号什么时候分配虚拟地址?
- 可执行文件和
.o文件有什么区别?
如果不清楚这几个问题的概念,没关系,看完本文,你会有一个比较清晰的认知。
编译链接全景图

整个过程分两大阶段:
- 编译过程:每个源文件独立走完三步——预编译 → 编译 → 汇编,产出
.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 | // inc_test.cpp 一共就 2 行 |
1 | $ g++ -E inc_test.cpp -o inc_test.i |
而我们的 main.cpp 没有 #include,预编译后只有 19 行,多出来的内容是行号标记:
1 | # 0 "main.cpp" |
所以要判断宏定义或头文件包含是否正确时,可以看预编译后的 .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 | .globl data |
让我们重点关注这份汇编里的几个符号: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 | $ c++filt _Z3sumii |
第三步:汇编
汇编器(as)把汇编代码逐条翻译成机器码(01 序列),产出 .o 文件(Windows 下叫 .obj)。其实它还有另一个名字:二进制可重定位目标文件
拆开来看:
- 二进制:内容已经是机器码,不再是文本
- 可重定位:需要定位的是地址,因为里面的”地址”都还没定下来
- 目标文件:链接器的输入文件
开头提到的”.o 文件为什么不能运行”这个问题,它的答案就藏在这里——概括地说,它是可重定位的,地址还没确定,CPU 没法执行。
接下来让我们深入分析
.o 文件里有什么
.o 文件的格式:Linux 下是 ELF,Windows 下是 PE(MinGW 编译出的 .o 是 PE 格式)。
简单来说,.o文件的组成结构就是:一个文件头 + 各种各样的段。

我们可以用 objdump -h命令 查看段表(Linux 下也可以用 readelf -S):
1 | $ objdump -h main.o |
划重点的段:
| 段 | 内容 |
|---|---|
.text |
指令(代码) |
.data |
已初始化且非零的全局/静态数据 |
.bss |
未初始化或为 0 的全局/静态数据 |
.symtab |
符号表(符号表本身也是一个段) |
.rel.text |
重定位表——记录 .text 段里哪些位置要修正 |
这些段就是虚拟地址空间布局图里的那些段。那篇讲的是它们运行时在内存里的位置,这一篇讲的是它们在文件里的形态、以及怎么生成的。
注意段表里 .text 段的标志位里有个 RELOC——这个段里有”待重定位”的记录。这是链接阶段的重要线索,先记住它。
符号表:链接器的”通讯录”
什么是符号
程序里所有函数名、全局变量名,在编译器和链接器眼里都叫符号(Symbol)。
main.o 的符号表(用 nm 查看,去掉了段符号和平台特有项):
U是UND的缩写,写全了就是 undefined,它们是同一个东西。
1 | 0000000000000000 T main ← 定义:main 函数在 .text 段 |
sum.o 的符号表:
1 | 0000000000000000 T _Z3sumii ← 定义:sum 函数在 .text 段 |
(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 | $ echo "int gcount = 1;" | g++ -x c++ -c - -o global.o |
写成 static int gcount; 后有两处变化:类型字母从大写 D 变成小写 d;而且符号名被修饰成了 _ZL6gcount——链接器匹配的是名字,名字里带上 L(local)标记,其他文件就找不到它了。
两个推论:
- 多个文件里定义同名的 static 变量/函数——没问题,链接器看不见它们
- 多个文件里定义同名的普通全局变量/函数——冲突,链接时报重定义错误
符号的地址全是 0
回头看上面符号表的第一列——地址全是 0。
为什么?
- 对于引用的符号:编译器不知道它定义在哪,无从分配
- 对于定义的符号:它的最终地址取决于合并后在虚拟地址空间里的位置,而这要等所有
.o合并完才知道
严格一点说:
.o里这一列记录的其实是符号在段内的偏移(main是.text段的第一项、data是.data段的第一项,所以都是 0),它还不是虚拟地址。虚拟地址要等链接时才分配。
所以:编译阶段(预编译/编译/汇编)不分配虚拟地址。
那什么时候分配呢?——答案是链接阶段。这就回答了开头的第二个问题。
指令怎么使用地址是0的符号
虽然符号的地址还没分配,但指令已经生成好了。指令里要用到符号的地址,怎么办?
实测反汇编 main.o(objdump -d):
1 | $ objdump -d main.o |
对照《从汇编指令角度分析函数调用堆栈的详细过程》看这四条指令:
| 指令 | 含义 | 指令里需要填的地址 |
|---|---|---|
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 | $ objdump -r main.o |
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 | $ g++ main.o -o test.exe |
情况二:找到多个定义,比如多加了一个重复定义 data 的 bad.cpp(int data = 30;):
1 | $ g++ main.o sum.o bad.o -o test.exe |
回到前面 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 | $ objdump -h main.exe |
③ 相加
符号地址 = 段基址 + 段内偏移。到这一步就是纯计算了:
| 符号 | 段基址 | 段内偏移 | 地址 |
|---|---|---|---|
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 | 8: e8 00 00 00 00 call d <main+0xd> ← 坑 ① |
链接后(objdump -d main.exe):
1 | 1400016d8: e8 0a 01 00 00 call 1400017e7 <__main> ← ① 填上 |
四个坑全部填上了真实地址。对照表:
| 指令 | 链接前 | 链接后 |
|---|---|---|
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 | # 链接前(main.o) # 链接后(main.exe) |
所有符号都有地址了,UND 也没了——引用和定义全部配上了对。
回到开头的问题
为什么 .o 文件不能运行?
因为 .o 里符号没有虚拟地址、指令里全是 0 占位——它是”半成品”。链接器完成段合并、符号解析、地址分配、重定位之后,才是可执行文件。
符号什么时候分配虚拟地址?
链接第一步(符号解析)完成之后、第二步(重定位)之前。地址先分配好,重定位才有”正确的地址”可填。
可执行文件与 .o 的区别
结构上,可执行文件和 .o 非常像——还是”文件头 + 各种段”:
.o 文件 |
可执行文件 | |
|---|---|---|
| 段的地址 | 全是 0 | 有虚拟地址(实测 .text 段在 0x140001000) |
| 文件头 | 标记”可重定位” | 多了入口地址 |
| program headers | 没有 | 有——告诉系统加载什么 |
program headers(程序头)是可执行文件比 .o 多出来的关键结构。
里面是若干个 LOAD 项,告诉操作系统:运行这个程序时,把哪些内容加载到内存。实测能观察到两个 LOAD:
- 代码段:可读、可执行(R-X),不能写
- 数据段:可读、可写(RW)
关键点:不是所有段都要加载。符号表段、段表这些”给链接器用”的信息,运行时不需要,不加载。真正要加载的只有代码段和数据段。
用一张图总结这一节:

入口地址记录在文件头里,实测:
1 | $ objdump -f main.exe |
程序运行:加载与地址映射
可执行文件躺在磁盘上。运行它的时候:
- 操作系统读 program headers,知道要加载哪些内容
- 把代码段、数据段映射到进程的虚拟地址空间(《进程的虚拟地址空间》里那张布局图)——注意是映射到虚拟地址空间,不是直接塞进物理内存
- 把入口地址写进 CPU 的 PC 寄存器,CPU 从入口开始取指令执行
- 执行过程中访问某个虚拟地址(取指令或读写数据):
- 先做地址映射(查页表)
- 发现这个地址还没有关联物理内存 → 触发页面异常(缺页)
- 异常处理程序分配物理内存,把映射关系写进页目录/页表
- 回到刚才的指令继续执行
通览全流程
我们来看一下从源码到程序跑起来的完整链条:
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 加载 | 进程 | 缺页异常分配物理内存 |
三个问题的答案:
.o文件里有什么?为什么不能运行?——文件头 + 各种段(.text/.data/.bss)+ 符号表 + 重定位表。符号没有虚拟地址、指令里是 0 占位,是”半成品”- 符号什么时候分配虚拟地址?——链接阶段、符号解析完成之后
- 可执行文件和
.o有什么区别?——段有了虚拟地址、文件头多了入口地址、多了 program headers
即使我们不写编译器,但理解这套原理之后,再看 undefined reference 报错、看内联函数为什么”建议不生效”、看反汇编调 bug,看到的就不再是表面现象,而是知道每一步底层发生了什么。
参考
- 《程序员的自我修养——链接、装载与库》——第 2 章 编译和链接、第 3 章 目标文件里有什么、第 4 章 静态链接
- 《深入理解计算机系统》(CSAPP)——第 7 章 链接