第二章 编译 v1.0
一、编译阶段
C++源代码(.cpp/.h) → 预处理(.i文件) → 编译(.s汇编文件) → 汇编(.o/.obj目标文件) → 链接(可执行文件/库文件)
整体概览
C++编译的核心流程分为4个关键阶段,每个阶段由不同的编译工具链组件处理,输入输出的文件格式也不同:
C++源代码(.cpp/.h) → 预处理(.i文件) → 编译(.s汇编文件) → 汇编(.o/.obj目标文件) → 链接(可执行文件/库文件)以GCC工具链(Linux/macOS)为例,对应的核心组件分别是:预处理器(cpp)、编译器前端(cc1plus)、汇编器(as)、链接器(ld);Windows下的MSVC工具链逻辑一致,仅组件名称(如cl.exe、link.exe)和文件后缀略有差异。
阶段1:预处理(Preprocessing)
负责组件
预处理器(Preprocessor),GCC中对应cpp程序(执行gcc -E时调用),MSVC中是cl.exe的预处理阶段。
输入/输出
- 输入:
.cpp/.h/.hpp等C++源代码文件 - 输出:预处理后的纯C++代码文件(后缀通常为
.i,Windows下为.ii)
核心操作(无代码编译,仅文本处理)
- 宏替换:完全替换
#define定义的宏(包括宏函数),比如#define PI 3.14会把代码中所有PI替换为3.14,#define MAX(a,b) ((a)>(b)?(a):(b))会展开所有MAX调用。 - 头文件包含:处理
#include指令,将头文件(如<iostream>、"myheader.h")的内容递归嵌入到当前文件中(这也是头文件重复包含需要#pragma once或头文件保护的原因)。 - 条件编译:处理
#if/#ifdef/#ifndef/#else/#elif/#endif,保留符合条件的代码,删除不符合的代码块(比如#ifdef DEBUG会只保留调试模式的代码)。 - 移除注释:删除所有
//(单行注释)和/* */(多行注释),编译器不需要注释信息。 - 辅助标记:添加
#line指令记录原始文件的行号和文件名(方便后续报错时定位到原代码)。
示例命令(仅执行预处理)
bash
# GCC:将test.cpp预处理为test.i,不执行后续步骤
gcc -E test.cpp -o test.i阶段2:编译(Compilation)
负责组件
编译器前端(Compiler Frontend),GCC中对应cc1plus(专门处理C++的编译器核心),Clang/LLVM中是AST生成与优化组件。
输入/输出
- 输入:预处理后的
.i文件 - 输出:汇编语言源代码文件(后缀为
.s,跨平台但指令集相关,如x86/ARM汇编)
核心操作(高级语言→汇编语言,最复杂的阶段)
这个阶段是把人类可读的C++代码翻译成CPU能理解的汇编指令,分为5个子步骤:
- 词法分析:将代码拆分为最小的语法单元(Token),比如
int a = 5 + 3;会被拆成int(关键字)、a(标识符)、=(运算符)、5(常量)、+(运算符)、3(常量)、;(分隔符)。 - 语法分析:根据C++语法规则,将Token组装成抽象语法树(AST),检查语法错误(比如少分号、括号不匹配、if后少大括号),语法错误会直接终止编译。
- 语义分析:检查代码的“逻辑正确性”,比如:
- 变量未定义就使用(
int b = a;但a没定义); - 类型不匹配(
int c = "hello";); - 函数调用参数数量/类型错误(
printf("%d", "test");); - 构建符号表(记录变量、函数的类型、作用域、存储位置)。
- 中间代码优化:将AST转换为“三地址码”(如
t1 = 5 + 3; a = t1;),并做基础优化:
- 常量折叠(
int d = 2+3;直接优化为int d = 5;); - 死代码删除(删除永远不会执行的代码,如
if(false) { ... }); - 常量传播(
int e = 10; int f = e;优化为int f = 10;)。
- 汇编代码生成:将优化后的中间代码翻译成对应CPU架构的汇编指令(比如x86的
mov、add、call指令)。
示例命令(仅执行预处理+编译)
bash
# GCC:生成test.s汇编文件,不执行汇编和链接
gcc -S test.i -o test.s阶段3:汇编(Assembly)
负责组件
汇编器(Assembler),GCC中对应as程序,Windows下是masm/nasm。
输入/输出
- 输入:
.s汇编语言文件 - 输出:目标文件(Object File),后缀为
.o(Linux/macOS)或.obj(Windows),是二进制文件但不可直接执行。
核心操作(汇编指令→机器码)
- 指令翻译:将汇编指令(如
mov eax, 5)翻译成对应CPU的机器码(二进制数,如0xB8 0x05 0x00 0x00 0x00)。 - 生成符号表:记录目标文件中:
- 已定义的符号(比如本文件的全局函数
void func()、全局变量int g_val); - 引用的外部符号(比如调用了
printf、其他文件的extern int val)。
- 生成重定位表:记录未确定地址的符号(比如
printf的地址在汇编阶段还不知道),为后续链接阶段做准备。
示例命令(仅执行汇编)
bash
# GCC:将test.s汇编为test.o目标文件
gcc -c test.s -o test.o
# 也可以直接从.cpp生成.o(预处理+编译+汇编)
gcc -c test.cpp -o test.o阶段4:链接(Linking)
负责组件
链接器(Linker),GCC中对应ld程序,Windows下是link.exe,macOS下是ld64。
输入/输出
- 输入:一个/多个
.o/.obj目标文件 + 依赖的库文件(静态库.a/.lib、动态库.so/.dll) - 输出:可执行文件(Linux:ELF格式,Windows:PE格式
.exe,macOS:Mach-O格式)或库文件。
核心操作(合并目标文件+解析依赖)
链接是解决“跨文件依赖”的关键阶段,核心分2步:
- 符号解析:找到每个外部符号的定义位置,比如:
- 你的代码调用了
printf,链接器会在标准库(libc.so/msvcrt.lib)中找到printf的实现; - 你的代码调用了另一个文件
utils.cpp中的add()函数,链接器会在utils.o中找到add的定义。
- 重定位:根据符号的实际内存地址,修改目标文件中的机器码——把原来的“占位地址”替换为真实地址(比如把
call printf的占位地址替换为printf在内存中的实际地址)。
链接的两种方式
- 静态链接:将用到的静态库(
.a/.lib)中的代码直接复制到可执行文件中。优点:运行时不依赖外部库;缺点:可执行文件体积大,库更新后需要重新编译。 - 动态链接:不复制库代码,仅在可执行文件中记录动态库(
.so/.dll)的名称和符号地址。运行时由动态链接器(Linux:ld-linux.so,Windows:ntdll.dll)加载动态库。优点:可执行文件小,多个程序共享同一个库;缺点:运行时必须有对应的动态库。
示例命令(链接生成可执行文件)
bash
# GCC:将test.o链接为可执行文件test(默认动态链接libc)
gcc test.o -o test
# 静态链接libc(生成独立可执行文件)
gcc test.o -static -o test_static
# 链接数学库(libm.so)
gcc test.o -lm -o test_with_math总结(关键点回顾)
- 核心四阶段:预处理(文本替换)→ 编译(C++→汇编)→ 汇编(汇编→机器码)→ 链接(合并目标文件+解析符号)。
- 核心组件:预处理器(
cpp)、编译器前端(cc1plus)、汇编器(as)、链接器(ld)(GCC工具链)。 - 关键文件:
.i(预处理)→.s(汇编源码)→.o/.obj(目标文件)→ 可执行文件/库文件。
理解这个流程能帮你定位编译错误(比如预处理错误是宏/头文件问题,编译错误是语法/语义问题,链接错误是符号未定义/重复定义),也能理解静态/动态库的本质差异。