Skip to content

18.1 编译过程详解

在 IDE 中点下一次“构建 (Build) / 运行”按钮,你以为只是瞬间发生了一件事。但在底层,你的 C++ 代码(主要是 .cpp 文本文件)却要经历一条极为漫长严谨的工业流水线,才能最终变为计算机 CPU 能够认识并执行的二进制机器码程序。

整个过程被称为构建(Building),它主要分为四大阶段:预处理(Preprocessing) -> 编译(Compilation) -> 汇编(Assembly) -> 链接(Linking)。

编译器在真正去分析你的 C++ 语法之前,会先派出一个叫预处理器的小弟,让他去对所有的源代码文本文件进行“纯文本替换”。

在这个阶段,预处理器只认带有 # 号开头的指令(宏指令):

  1. 展开包含#include):预处理器会立刻去找到你包含的那个文件(比如 <iostream> 或者 "my_header.h"),然后直接把那整个文件里的全部文本代码死里白赖地死死复制+粘贴到这行指令所在的地方!
  2. 宏替换#define MAX 100):把文件中后续出现的所有 MAX 字眼无脑替换为字符串 100
  3. 条件编译#if, #ifdef):如果条件不满足,预处理器会直接把对应的代码块全部删除剥离,不让它们进入下一站。

经过预处理后,会生成一个极其庞大(因为合并了太多头文件)的中间 .i 或纯净版 C++ 代码文件。在这个文件里,你彻底找不到任何一个 #

这是耗时最长的一站。编译器会接过预处理好的巨大文本文件,开始做真正的苦力活:

  1. 词法与语法分析:检查你有没有漏写分号、括号配不配对。
  2. 语义分析与优化:检查类型是否匹配,并对可以进行的逻辑进行激进的底层性能优化(所以 C++ 运行极快)。
  3. 汇编代码生成:将 C++ 语言转换为低级的、人类依然勉强能看懂的指令集语言——汇编语言(Assembly Language)。输出为 .s 文件。

汇编器(Assembler)上场。它非常单纯,直接把上一站生成的汇编代码文本,一对一地机械翻译成 0 和 1 的机器码(Machine Code)

这一步的产出被称为目标文件(Object File)(在 Windows 下后缀是 .obj,Linux 下是 .o)。此时的目标文件里已经装满了供 CPU 执行的二进制机器流。

{% hint style=“warning” %} 重点来了:为什么到了现在依然不能运行? 因为在这个 .o 文件里,如果你调用了 std::cout 或者你在别的文件里写好的 add() 函数。在这个目标文件眼里,它并没有这段函数的真正机器码。它只在这里留下了一个空洞(占位符),里面贴了张条子说:“我也不知道 add 的机器码在哪,等会谁来帮我填一下吧。” {% endhint %}

你的大型项目可能由 10 个 .cpp 文件组成。经过前面三站的独立作业,此刻你的硬盘上躺着 10 个充满着“断头路和空洞”的 .o 目标文件。

**链接器(Linker)**是闭环的最后一位超级大佬。它负责:

  1. 把这 10 个孤立的 .o 目标文件,连同标准库或者别的第三方库提供进来的库文件捆到一起。
  2. 填补空洞(符号决议,Symbol Resolution):比如在 a.o 里发现了一个条子是要找 add() 的地址,链接器就会在海量的文件中搜索,它在 b.o 里找到了 add() 的真实机器码具体所在!于是链接器把 b.o 里实际地址抽出,回填补进 a.o 的那个空洞里。

如果在全服搜索后依然找不到实现(比如你只写了函数声明却忘了写大括号里的实现,或者你忘了把存放实现的那个 .cpp 加入编译队列),链接器就会怒吼出每个 C++ 程序员最害怕的梦魇报错: Undefined Reference (LNK2019: 无法解析的外部符号)

当一切空洞都被完美对接相连,链接器最终将它们紧紧拍压融合成一体,加上操作系统的运行头部,最终输出伟大的产物:可执行文件(.exe / a.out)!