编译四步:你的代码是怎么变成 exe 的

87 字节的源文件,预处理后变成 102 万字节——中间发生了什么?

商用

◎学完本节你会

本页所有数字和产物都是真实的:用本机 g++ 15.2(MSYS2 MinGW64) 对下面这个 87 字节的 hello.cpp 逐步编译,逐个产物量出来的。不是示意图里的假数据。
// hello.cpp —— 本页全程用这个文件,87 字节 #include <iostream> int main() { std::cout << "Hello" << std::endl; return 0; }

1四步流水线:单步走一遍

点「单步 ▶」逐步推进,看每一步敲什么命令、产出什么文件、文件有多大、内容变成什么样。 每步的数字都是本机真实量出来的。

点「单步 ▶」开始,或直接点上面任意一步。

2体积反差:87 字节怎么变成 102 万字节

同样一段代码,四个阶段的产物大小差得离谱。注意横轴是对数刻度—— 因为线性画的话,除了 .ii 其他几根都看不见。

三个反差,各自说明一件事:
① 87 B → 1,025,036 B(约 1.18 万倍):#include <iostream> 把整个标准库头文件原样复制进来了。 你以为只写了一行 include,实际编译器要读一百万字节的文本。这就是为什么"头文件别乱 include"。
② 1,025,036 B → 1,388 B(缩到 1/738):那 100 万字节里绝大部分是你没用到的声明, 真正翻译成汇编的只有 main 那几行。
③ 1,647 B → 129,034 B(约 78 倍):链接时把 C++ 运行时库的启动代码、 std::cout 的实现等一并塞进来了。所以最小的 exe 也有 100 KB 出头。

3做个实验:预处理阶段不检查语法

很多人以为"编译第一步就会告诉我语法错了"。其实预处理只做文本替换, 它连 C++ 语法都不看。我写了个满是语法错误的文件实测:

bad_syntax.cpp(故意写错)

#include <iostream> int main() { int x = ; // 缺表达式 this is not c++ // 裸文字 return 0 // 缺分号 }

两步的真实结果

# 只做预处理: $ g++ -E bad_syntax.cpp -o bad.ii (无任何报错,退出码 0) # 产物 bad.ii = 1,025,051 字节 # 完整编译: $ g++ bad_syntax.cpp -o bad.exe bad_syntax.cpp:3:13: error: expected primary-expression before ';' token bad_syntax.cpp:4:5: error: invalid use of 'this' in non-member function
结论(本机实测):-E 面对三处语法错误照样成功, 还老老实实产出了 100 万字节的 .ii。语法错误是在第二步「编译」才被发现的。
这解释了一个现象:为什么 #include 写错文件名会在预处理阶段报错(找不到文件是文本层面的事), 而变量名写错要到编译阶段才报(那是语法/语义层面的事)。

4为什么必须有「链接」这一步?

hello.o 已经是机器码了,为什么不能直接双击运行? 用 nm -C 看看它里面缺什么:

$ nm -C hello.o U __main U std::cout U std::ostream::operator<<(std::ostream& (*)(std::ostream&)) U std::basic_ostream<char,...>& std::endl<...>(...) U std::basic_ostream<char,...>& std::operator<< <...>(..., char const*) U = Undefined:这个符号我【用到】了,但我这里【没有】它的实现。
5 个未定义符号——你写的 std::cout << "Hello" 这一行, 背后要调用标准库的 5 个东西,而它们的实现不在你的 .o 里,在 C++ 运行时库里。 链接这一步干的就是这件事:把散落在各个 .o 和库文件里的实现,按符号名对上号、拼成一个完整可执行文件。

对上了 → 生成 hello.exe;对不上 → 就是你在 92.2 里见过的 undefined reference(找不到实现)和 multiple definition(找到两份实现)。

顺带看看:机器码里能认出 "Hello" 吗?

# objdump -s -j .rdata hello.o (实测输出) 0000 48656c6c 6f000000 00000000 00000000 Hello........... ↑ 48='H' 65='e' 6c='l' 6c='l' 6f='o' 00=字符串结束符
汇编阶段能看到 .LC0: .ascii "Hello\0";汇编成机器码后, 字符串就以 48 65 6c 6c 6f 00 这串字节躺在 .rdata(只读数据段)里。 源码里的每个字面量,最后都变成了内存里的一段字节——这是 2.3 节"计算机里数是怎么存的"的延续。

5四步产物对照表(本机实测)

步骤命令产物大小文件头还是文本吗
0 源码—hello.cpp87 B#include✅ 文本
1 预处理g++ -Ehello.ii1,025,036 B# 0 "hel✅ 文本(仍是 C++)
2 编译g++ -Shello.s1,388 B⇥.file "✅ 文本(汇编)
3 汇编g++ -chello.o1,647 B64 86(COFF)❌ 二进制
4 链接g++ hello.ohello.exe129,034 B4d 5a(MZ/PE)❌ 二进制
看"文件头"这一列——它是判断"这文件到底是什么"的最快方法: MZ 开头才是 Windows 可执行文件;64 86(COFF)是目标文件,双击跑不起来; 前三个都还是能用记事本打开的文本。
Linux 下不一样:可执行文件头是 7f 45 4c 46(.ELF),目标文件也是 ELF 但类型不同。

!易错点清单(三条都本机实测过)

① 以为 -c 出来的 .o 能直接运行:不能。 它的文件头是 64 86(COFF)而不是 MZ,而且里面还挂着 5 个未定义符号。 .o 是半成品,必须经过链接。
② Windows 下敲 ./hello 报"找不到文件":因为链接产物叫 hello.exe,磁盘上根本不存在名为 hello 的文件(本机实测目录里只有 hello.exe)。 要写 .\hello.exe。Linux 下产物就叫 hello,所以要写 ./hello——照抄教程常在这里翻车。
③ 以为预处理阶段会报语法错:不会(见第 3 节实验,三处语法错误 -E 照样通过)。 语法错误在第二步编译才报。所以看到 error: expected ';' 这类, 说明已经过了预处理、正在编译——报错行号指的是展开后的位置,有时和你想的不一样。
💡 还有一条实用推论:正因为 #include 是"把整个文件原样贴进来", 所以头文件里写变量定义会导致重复定义(贴几次就有几份)——这就是 26.2 和 92.2 的 E2 要讲的事。

✎练习题

Q1. 想把编译停在"汇编代码"这一步,看 C++ 被翻译成了什么指令,该敲哪条命令?
-S = 编译到汇编为止,产出 .s(文本,能用记事本打开,本机 1388 字节)。-E 只到预处理(.ii);-c 会多做一步汇编、产出二进制 .o;D 是四步全做完。
Q2. hello.o(1647 字节)为什么不能直接运行?
本机实测:nm -C hello.o 列出 5 个 U(未定义)符号,包括 std::cout、__main——这些实现都在 C++ 运行时库里,得靠链接补上。而且它的文件头是 64 86(COFF 目标文件),不是 MZ(PE 可执行)。D 说反了:.o 是二进制,.ii/.s 才是文本。
Q3. 一个满是语法错误的 .cpp,只跑 g++ -E(预处理),结果是?
本机实测:三处语法错误(缺表达式、裸文字、缺分号)的文件,-E 退出码 0,正常产出 1,025,051 字节的 bad.ii,一个错都不报。预处理只做文本替换(展开 include、替换宏、按 #ifdef 裁剪),它根本不解析 C++ 语法。语法错误要到第二步「编译」才报。

Q4. 你的 hello.cpp 只有 87 字节,#include <iostream> 之后预处理产物 hello.ii 却有 1,025,036 字节。多出来的一百万字节是什么? 由此说明"头文件"在使用上应该注意什么?

答案:多出来的是 <iostream> 以及它层层 include 的其他头文件的全部内容—— 标准库把 cout、ostream、string 等一整套声明都写在里头, 预处理把它们原样复制到你的 .ii 里(本机实测:展开后 #include 剩余 0 行,证明全被替换成了正文)。
实践注意:① 只 include 你真需要的头文件(用了 cout 才要 <iostream>, 只用 vector 就 include <vector>); ② 头文件只放声明、不放定义——因为每个 include 它的 .cpp 都会得到一份副本, 放定义就会在链接期撞上 multiple definition(92.2 的 E2); ③ 编译慢很多时候就是 include 太多,这也是"预编译头 / 模块"想解决的问题。

Q5. 下面是 hello.s 里 main 的真实汇编片段(本机 g++ 15.2 输出):

main: pushq %rbp movq %rsp, %rbp subq $32, %rsp call __main leaq .LC0(%rip), %rdx call _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc movl $0, %eax
请回答:① .LC0 里放的是什么?② _ZStlsISt11char_traitsIcEE... 这一长串是什么,为什么长这样?③ call 的那几个东西,在 .o 里是什么状态?

答案:
① .LC0 是编译器给字符串字面量起的标号,里面就是 .ascii "Hello\0" (本机在 .s 里实测到这行)。汇编成机器码后,它以字节 48 65 6c 6c 6f 00 躺在 .rdata 段。 leaq .LC0(%rip), %rdx 就是"把这个字符串的地址装进 rdx 寄存器",准备当参数传出去。
② 那是 C++ 名字改编(name mangling)后的符号名,真身是 std::operator<<<std::char_traits<char>>(std::basic_ostream<char,...>&, char const*) ——也就是你写的 cout << "Hello" 实际调用的函数。 因为 C++ 支持重载和命名空间,同一个函数名可能对应多个不同版本, 编译器必须把参数类型、命名空间全编码进符号名才能唯一区分,所以名字这么长。 nm -C 的 -C 就是把它还原成人能读的样子(demangle)。
③ 在 .o 里它们是 U(Undefined,未定义)状态——本机 nm -C hello.o 实测有 5 个 U 符号, 包括 __main、std::cout 和上面那个 operator<<。 实现都在 C++ 运行时库里,要等链接阶段才补上。补不上就是你熟悉的 undefined reference。

Q6. 同事的工程有两个现象,请用四步模型分别解释根因:
现象一:只改了一个 .cpp,构建系统却把全部 200 个文件重编一遍,每次要 10 分钟。
现象二:代码在 Linux 上编译运行都正常,换到 Windows(MinGW) 上,编译也过了, 但敲 ./app 报"系统找不到指定的文件"。

现象一根因:大概率是头文件依赖失控。四步模型告诉我们:预处理会把 include 的头文件全文复制进每个 .ii。 如果有个"万能头"(比如 common.h)被 200 个 .cpp 都 include, 那么改这个头文件 = 200 个 .cpp 的预处理产物全变 → 全部要重编。
排查/修法:① 用 g++ -M main.cpp 或 g++ -MM 列出真实依赖,看是谁被过度依赖; ② 拆分大头文件,别搞"万能头";③ 能在头文件里用前置声明(class Foo;)就别 include; ④ 把实现从头文件挪到 .cpp(模板除外,模板必须在头文件,见 30.4); ⑤ 构建系统要用 -MD 生成依赖文件,否则连"该重编谁"都算不准。

现象二根因:这是平台产物命名差异,不是代码问题。链接这一步在 Windows 上产出的是 app.exe,磁盘上不存在名为 app 的文件(本机实测:目录里只有 hello.exe), 所以 ./app 当然找不到。Linux 下产物就叫 app,写 ./app 才对。
修法:Windows 下写 .\app.exe(或 app.exe); Makefile 里的目标名也要跟着平台走——目标名必须等于真实产物名, 写成 app: 却产出 app.exe,会让 make 每次都以为目标没生成、从而永远重编(这个坑本机也实测复现过,见 92.1)。

◎跨学科小贴士:为什么要分成四步,而不是一步到位

编译器完全可以"读进去、吐出来"一步搞定,但工程上分成四步是有硬道理的——为了增量。

想想盖楼:如果每次都从"烧砖"开始做到"装修完毕",改一扇窗就得整栋重盖。 实际做法是预制构件(砖、梁、墙板各自单独生产)+ 现场组装。 改一扇窗,只重做那一扇窗的构件,其他照用。

编译四步就是这套思路:每个 .cpp 独立走完前三步,各自产出一个 .o(预制构件); 最后链接把它们组装起来。改了一个 .cpp,只需重编它一个 .o,再重新链接—— 这就是 Makefile / CMake 存在的意义(26.4 / 26.5),也是为什么"编译 200 个文件的大工程"改一行不用等 10 分钟。

代价是:错误也被分成了两类——构件自己造错了(编译期,指着某个 .cpp 的某一行), 和构件之间对不上(链接期,说某个符号找不到或找到两份)。 分清这两类,是排错的第一课——92.2《编译链接错误对照表》专门讲这个。

顺带说一句:这个"分开编译、最后链接"的模型是 C/C++ 从 1970 年代继承下来的。 现代语言(Java/C#/Go/Rust)大多改成了别的模型,而 C++20 的模块(modules) 就是想解决"头文件全文复制"这个老大难——第 27 章会碰到它。