Skip to content

第二章 编译 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)

核心操作(无代码编译,仅文本处理) ​

  1. 宏替换:完全替换#define定义的宏(包括宏函数),比如#define PI 3.14会把代码中所有PI替换为3.14,#define MAX(a,b) ((a)>(b)?(a):(b))会展开所有MAX调用。
  2. 头文件包含:处理#include指令,将头文件(如<iostream>、"myheader.h")的内容递归嵌入到当前文件中(这也是头文件重复包含需要#pragma once或头文件保护的原因)。
  3. 条件编译:处理#if/#ifdef/#ifndef/#else/#elif/#endif,保留符合条件的代码,删除不符合的代码块(比如#ifdef DEBUG会只保留调试模式的代码)。
  4. 移除注释:删除所有//(单行注释)和/* */(多行注释),编译器不需要注释信息。
  5. 辅助标记:添加#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个子步骤:

  1. 词法分析:将代码拆分为最小的语法单元(Token),比如int a = 5 + 3;会被拆成int(关键字)、a(标识符)、=(运算符)、5(常量)、+(运算符)、3(常量)、;(分隔符)。
  2. 语法分析:根据C++语法规则,将Token组装成抽象语法树(AST),检查语法错误(比如少分号、括号不匹配、if后少大括号),语法错误会直接终止编译。
  3. 语义分析:检查代码的“逻辑正确性”,比如:
  • 变量未定义就使用(int b = a;但a没定义);
  • 类型不匹配(int c = "hello";);
  • 函数调用参数数量/类型错误(printf("%d", "test"););
  • 构建符号表(记录变量、函数的类型、作用域、存储位置)。
  1. 中间代码优化:将AST转换为“三地址码”(如t1 = 5 + 3; a = t1;),并做基础优化:
  • 常量折叠(int d = 2+3;直接优化为int d = 5;);
  • 死代码删除(删除永远不会执行的代码,如if(false) { ... });
  • 常量传播(int e = 10; int f = e;优化为int f = 10;)。
  1. 汇编代码生成:将优化后的中间代码翻译成对应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),是二进制文件但不可直接执行。

核心操作(汇编指令→机器码) ​

  1. 指令翻译:将汇编指令(如mov eax, 5)翻译成对应CPU的机器码(二进制数,如0xB8 0x05 0x00 0x00 0x00)。
  2. 生成符号表:记录目标文件中:
  • 已定义的符号(比如本文件的全局函数void func()、全局变量int g_val);
  • 引用的外部符号(比如调用了printf、其他文件的extern int val)。
  1. 生成重定位表:记录未确定地址的符号(比如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步:

  1. 符号解析:找到每个外部符号的定义位置,比如:
  • 你的代码调用了printf,链接器会在标准库(libc.so/msvcrt.lib)中找到printf的实现;
  • 你的代码调用了另一个文件utils.cpp中的add()函数,链接器会在utils.o中找到add的定义。
  1. 重定位:根据符号的实际内存地址,修改目标文件中的机器码——把原来的“占位地址”替换为真实地址(比如把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

总结(关键点回顾) ​

  1. 核心四阶段:预处理(文本替换)→ 编译(C++→汇编)→ 汇编(汇编→机器码)→ 链接(合并目标文件+解析符号)。
  2. 核心组件:预处理器(cpp)、编译器前端(cc1plus)、汇编器(as)、链接器(ld)(GCC工具链)。
  3. 关键文件:.i(预处理)→ .s(汇编源码)→ .o/.obj(目标文件)→ 可执行文件/库文件。

理解这个流程能帮你定位编译错误(比如预处理错误是宏/头文件问题,编译错误是语法/语义问题,链接错误是符号未定义/重复定义),也能理解静态/动态库的本质差异。