2. GCC/G++编译器
说实话,很多C++开发者写了几年代码,对编译器的理解还停留在「点一下运行按钮」的阶段。我个人觉得,这挺可惜的。因为搞懂GCC/G++的编译流程,就像医生看懂CT片子——你才能真正诊断问题。
今天我们就来聊聊GCC/G++的四个编译阶段,以及那些你每天都在用、但可能没完全吃透的编译选项。
2.1 编译的四个阶段:从源码到可执行文件
一个C++源文件变成可执行程序,中间经历了四次「变身」。我当年刚入行时,以为就是一步到位,直到有一次遇到链接错误,折腾了两天才明白——哦,原来问题出在汇编阶段。
这四个阶段分别是:预处理 → 编译 → 汇编 → 链接。我们逐个来看。
2.1.1 预处理(Preprocessing)
这个阶段干的事,说白了就是「文本替换」。GCC会处理所有以 # 开头的指令:
#include:把头文件内容原封不动插入进来#define:宏定义替换#if / #ifdef / #endif:条件编译- 删除注释
你可以用 -E 选项单独看预处理结果:
g++ -E main.cpp -o main.i
打开 main.i 文件,你会发现——好家伙,几百行代码变成了几千行。这就是头文件展开的效果。
-E 看看宏展开后的样子。有一次一个同事写的宏嵌套了三层,展开后完全不是他想要的效果,用 -E 一看就明白了。
2.1.2 编译(Compilation)
这是最核心的阶段。编译器把预处理后的代码,翻译成汇编语言。注意,还不是机器码,是汇编。
这个阶段做的事情包括:词法分析、语法分析、语义分析、中间代码生成、优化等。说白了,就是检查你的代码有没有语法错误,然后生成对应的汇编指令。
用 -S 选项可以查看编译结果:
g++ -S main.i -o main.s
打开 main.s,你会看到一堆 mov、push、call 之类的汇编指令。嗯,看着挺亲切的——如果你学过汇编的话。
2.1.3 汇编(Assembly)
汇编器把汇编代码转换成机器码,生成目标文件(.o 文件)。这个阶段相对简单,基本上就是一一对应翻译。
g++ -c main.s -o main.o
或者一步到位:
g++ -c main.cpp -o main.o
目标文件里已经是二进制了,但还不能直接运行。为什么?因为里面还有未解析的符号——比如你调用了 std::cout,但 cout 的实现并不在这个 .o 文件里。
2.1.4 链接(Linking)
链接器把多个目标文件和库文件「粘」在一起,生成最终的可执行文件。它主要做两件事:
- 符号解析:找到每个符号(函数、变量)的定义
- 重定位:把符号的引用地址修正为实际地址
g++ main.o utils.o -o my_program
链接错误是我见过最多的编译错误之一。最常见的提示就是 undefined reference to ...。遇到这种错误,别慌,八成是忘记链接某个库了,或者函数声明了但没实现。
-lpthread 放在源文件前面,结果一直报链接错误。后来才发现,GCC的链接器是从右向左解析符号的——库要放在引用它的目标文件后面。
2.2 常用编译选项详解
GCC/G++的选项少说也有上百个,但常用的其实就那么几个。我把它们分成四类,咱们一个一个说。
2.2.1 -g:生成调试信息
这个选项告诉编译器在目标文件中包含调试信息。没有它,GDB就没法告诉你源代码行号、变量名这些信息。
g++ -g main.cpp -o main
调试信息会增大可执行文件的体积,但这是值得的。我个人的习惯是:开发阶段永远加 -g,发布时再考虑去掉。
-g 编译后发现GDB还是看不到变量值,试试 -g3。它包含更详细的宏定义信息,调试宏的时候特别有用。
2.2.2 -O:优化选项
优化等级从低到高分别是:
| 选项 | 含义 | 编译时间 | 执行速度 |
|---|---|---|---|
-O0 |
不优化(默认) | 快 | 慢 |
-O1 |
轻度优化 | 中等 | 较快 |
-O2 |
中度优化(推荐) | 较慢 | 快 |
-O3 |
激进优化 | 慢 | 最快 |
-Os |
优化体积 | 中等 | 较快 |
你想想看,-O2 是生产环境最常用的选项。它做了函数内联、循环展开、常量传播等优化,但不会像 -O3 那样激进到可能引入bug。
我曾经在某个项目中用了 -O3,结果程序跑起来结果不对。查了两天,最后发现是编译器过度优化导致一个volatile变量被优化掉了。从那以后,我对 -O3 就多了一份敬畏。
2.2.3 -Wall:开启警告
这个选项其实不是「开启所有警告」,而是开启「大多数常见警告」。它的名字有点误导人,但确实很有用。
g++ -Wall main.cpp -o main
我建议你再加两个:
g++ -Wall -Wextra -Werror main.cpp -o main
-Wextra:开启更多警告-Werror:把警告当作错误处理
为什么要这么干?因为很多bug在早期就是以一个警告的形式出现的。比如未初始化的变量、有符号和无符号比较、函数参数顺序不对……这些警告你如果忽略,后面可能就是段错误。
2.2.4 -std:指定C++标准
C++标准一直在演进,不同版本支持的特性差别很大。用 -std 选项告诉编译器用哪个标准:
g++ -std=c++11 main.cpp -o main
g++ -std=c++14 main.cpp -o main
g++ -std=c++17 main.cpp -o main
g++ -std=c++20 main.cpp -o main
我个人建议新项目直接用 -std=c++17 或 -std=c++20。C++17 带来了 std::optional、std::variant、结构化绑定等好东西,写起来舒服多了。
但要注意,有些老旧编译器不支持新标准。比如GCC 4.8对C++11的支持就不完整。如果你在维护老项目,先确认一下编译器版本。
2.3 综合示例:一个完整的编译命令
把上面这些选项组合起来,一个典型的开发阶段编译命令长这样:
g++ -g -O2 -Wall -Wextra -Werror -std=c++17 \
main.cpp utils.cpp \
-o my_app \
-lpthread
这个命令的意思是:
- 生成调试信息(
-g) - 中度优化(
-O2) - 开启严格警告(
-Wall -Wextra -Werror) - 使用C++17标准(
-std=c++17) - 链接pthread库(
-lpthread)
嗯,这就是我日常开发的标准配置。发布时去掉 -g,其他保持不变。
2.4 知识体系总览
为了让你更直观地理解整个编译流程和选项的关系,我画了一张图:
这张图把整个流程串起来了。你从左上角开始,沿着箭头走一遍,就是编译器做的工作。下面四个选项框,是你日常开发中最常用的武器。
2.5 避坑指南
最后分享几个我踩过的坑:
- 忘记加
-g:有一次我调试一个段错误,GDB 一直报??,折腾了半小时才发现是编译时没加-g。嗯,从那以后我把它写进了 Makefile 的默认选项。 - 优化等级太高:前面说过
-O3的教训。如果你发现调试时变量值「不对」,先检查是不是优化等级太高了。 - 忽略警告:我曾经接手过一个项目,编译时几百个警告没人管。结果线上出了个偶发崩溃,查了三天,最后发现是一个未初始化变量导致的。如果当初开了
-Werror,这个bug根本活不过编译。 - 标准版本不匹配:用 C++17 的
std::filesystem写了个文件遍历工具,放到客户的 CentOS 7 上编译不过——GCC 4.8 不支持。所以,跨平台项目一定要确认目标平台的编译器版本。
好了,关于GCC/G++的编译流程和常用选项,就聊这么多。记住这四个阶段和四个选项,你的C++编译之路就稳了一大半。
可可精品资料库