第2章:手动走一遍编译:-E / -S / -c

上章站在山顶画了全景图,这章下山抡锤子:用一个 hello.cpp 亲手走完预处理、编译、汇编、链接四道工序,把 .i / .s / .o 每件中间产物都拿到灯下看仔细

🔨

本章导师:鲁班

核心方法论:工欲善其事,必先利其器

「别人讲编译原理,喜欢画流程图;我带你抡锤子——把 -E / -S / -c 四道工序逐一上手,亲眼看看 .i、.s、.o 每一件中间产物长什么样。老木匠开工前要先摸一遍家什:刨子快不快、墨斗有没有墨。工具认得准、看得懂,编译器在你眼里就不再是黑盒,而是一条闭着眼都能走完的流水线。这章读完,你手里就有了真家伙。」

2.1 准备工作区与工具:先认准你的家伙什

第 1 章我们站在山顶画了张全景图:预处理 → 编译 → 汇编 → 链接,四道工序、四件中间产物,一条 g++ -o hello hello.cpp 全部代劳。这章鲁班带你下山,把每条命令亲手敲一遍,把每件产物拿到灯下看仔细。开工前先把工具认清楚——主角是 GNU 的 gcc / g++,Linux 发行版默认的那对编译器。老规矩:先确认工具在不在、什么版本:

# 1) 建一个干净的工作区,别在乱七八糟的目录里练手艺
mkdir -p ~/cpp-internals
cd ~/cpp-internals

# 2) 确认编译器在不在、是什么版本
gcc --version | head -n 1
g++ --version | head -n 1

版本输出里藏着一个重要的平台事实。本章所有产物都是在一台 macOS(Apple Silicon)机器上用 GNU GCC 15 实测跑出来的——注意,macOS 自带的 g++ 其实是 Apple Clang 的"马甲"(同一件外衣,内里是另一家编译器);装 Homebrew 的 gcc 包后才有真正的 GNU GCC,命令带版本号后缀 gcc-15 / g++-15。Linux 上直接敲 g++ 即可,看到的就是下面这种真实输出:

# 本机实测:macOS 自带的 g++ 长这样(其实是 Apple Clang)
$ g++ --version
Apple clang version 21.0.0 (clang-2100.1.1.101)

# 装上 Homebrew 的 gcc 后,真正的 GNU GCC 长这样
$ g++-15 --version
g++-15 (Homebrew GCC 15.2.0_1) 15.2.0

# 顺带确认 CPU 架构——它决定了后面汇编长什么样
$ uname -m
arm64
鲁班提示

命令名一样,不代表编译器一样。macOS 的 g++ 是 Apple Clang 的马甲,Linux 的 g++ 通常是真正的 GNU GCC;想逐字复现本章输出,Linux 用户直接照敲,macOS 用户装 brew install gcc 后用 g++-15。工具认准了,后面每一道工序的产物才靠得住。

注意:版本确认不是走形式。编译器版本不同,警告文案、默认标准、甚至目标文件格式都可能不同(第 1 章展示过 GCC 15 的警告措辞)。先确认工具再动手,能省掉大量"我的输出怎么和教程不一样"的困惑。

2.2 预处理 -E:头文件与宏如何展开

主角登场——14 行的 hello.cpp。它故意把 #include#define 都占了:一个是"头文件灌入",一个是"宏文本替换",正是预处理阶段的两大招牌手艺。说人话:预处理干的活就两件——把 #include 的头文件内容整个"粘贴"进源码,把 #define 定义的宏在代码里"原地替换"。它不检查语法、不生成任何机器码,只是一个文本加工车间:

// hello.cpp —— 本章的"解剖样本",14 行,五脏俱全
#include <iostream>     // 标准输入输出流:std::cout 在这里
#include <cstdio>        // C 风格输出:printf 在这里

#define MAX_ITEMS 3              // 对象宏:给"3"起个名字
#define SQUARE(x) ((x) * (x))   // 函数宏:定义一段"迷你代码"

int main() {
    int count = MAX_ITEMS;      // 用宏给变量赋值
    for (int i = 0; i < count; i++) {
        printf("i = %d, square = %d\n", i, SQUARE(i));
    }
    std::cout << "hello, cpp-internals" << std::endl;
    return 0;
}

跑第一道工序。-E 的意思是"只做预处理,干完就收工",-o 指定产物文件名。产物 hello.i 是纯文本,但它不再是你那 14 行小诗了——iostream 连同它 include 的一串兄弟头文件全被灌了进来:14 行源码变成 35,264 行、约 957KB 的文本。这就是为什么平时没人直接看 .i——近 1MB 的"头文件税",不是给人读的。

# 只做预处理:展开 #include 与 #define,产物 hello.i 仍是纯文本
$ g++-15 -E hello.cpp -o hello.i

# 数一数行数:14 行的源码,展开后变成了多少行?
$ wc -l hello.cpp hello.i
    14 hello.cpp
 35264 hello.i

打开 hello.i 瞄一眼开头。这些以 # 开头、格式像 # 行号 "文件名" 的行叫行标记(linemarker)——它们不是注释,是预处理器留下的路标,告诉后续阶段"这段代码来自哪个文件的第几行",报错时编译器靠它把位置指回你的源文件。那些 /opt/homebrew/... 绝对路径,就是 iostream 在系统里的真实藏身之处(随系统而异):

# hello.i 开头(真实输出,节选)——以 # 开头的是行标记,不是注释
# 0 "hello.cpp"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "hello.cpp"
# 1 "/opt/homebrew/Cellar/gcc/15.2.0_1/include/c++/15/iostream" 1 3
# 40 "/opt/homebrew/Cellar/gcc/15.2.0_1/include/c++/15/iostream" 3
# 1 "/opt/homebrew/Cellar/gcc/15.2.0_1/include/c++/15/bits/requires_hosted.h" 1 3
# ...(中间是 iostream 展开出的数万行声明,直接快进到我们的代码)...
# 2 "hello.cpp" 2
int main() {
    int count = 3;              // 注意:MAX_ITEMS 已经变成 3 了!
    for (int i = 0; i < count; i++) {

宏呢?grep 一下,证据就在眼前——宏名 SQUARE 在 .i 里彻底消失了grep -c 输出 0),但展开后的"尸体"还躺在第 35,260 行:SQUARE(i) 变成了 ((i) * (i))。这就是"宏在编译开始之前就已完成"的铁证——编译器拿到的永远是替换后的文本:

# 证据一:SQUARE 这个名字在 .i 里消失了(grep -c 输出 0)
$ grep -c SQUARE hello.i
0

# 证据二:展开后的文本还躺在第 35260 行
$ grep -n '((i) \* (i))' hello.i
35260:       printf("i = %d, square = %d\n", i, ((i) * (i)));
鲁班提示

宏替换是纯文本替换,不是函数调用。SQUARE(x) 定义里里外外全是括号,这是防坑的手艺:若写成 x * xSQUARE(a + b) 会被替换成 a + b * a + b,完全跑偏。以后在宏里看到一堆括号,就知道是前人踩过坑。

注意:别用编辑器直接打开 957KB 的 hello.i——大部分文本编辑器会卡成幻灯片。处理大文本的基本功是 head(看头)、tail(看尾)、grep(按内容找)、wc -l(数行数),按需取用,不要整卷通读。

2.3 编译 -S:汇编输出初识

第二道工序——编译。说人话:这一步翻译官上场,把 C++ 翻译成汇编语言。汇编语言是"机器码的助记符版":CPU 的指令本质是二进制位,汇编给每条指令起了个简短英文名(sub 减法、mul 乘法、bl 调用函数……),这些名字叫助记符(mnemonic)。汇编仍是文本、人能读懂,是观察"编译器把你的代码变成了什么"的最佳窗口。命令就一条,-S 表示"编译到汇编就停":

# 编译到汇编就停:把 .i 翻译成 .s(汇编语言,仍然是文本)
$ g++-15 -S hello.i -o hello.s

# 也可以直接吃源码——编译器会自动先跑预处理,产物与上面完全一致(已实测比对)
$ g++-15 -S hello.cpp -o hello.s

打开 hello.s 先看骨架(真实输出,节选)。前几行全是"环境说明":.arch 声明指令集是 armv8.5-a(Apple Silicon);.text 宣布"以下是代码节";.zerofill 在 .bss 节预留 1 字节——iostream 的全局初始化哨兵(2.5 节还会见到);.cstring 里躺着两串"文案":lC0 是格式串,lC1 是那句问候。字符串在编译时就被收进只读区,运行时按地址引用:

.arch armv8.5-a
.build_version macos,  26, 0
.text
.zerofill __DATA,__bss,__ZStL8__ioinit,1,0
.cstring
.align	3
lC0:
	.ascii "i = %d, square = %d\12\0"
.align	3
lC1:
	.ascii "hello, cpp-internals\0"
.text
.align	2
.globl _main
_main:

再看 main 的本体(同一段真实输出,这次加上中文注释)。第 1 章讲的故事全都在:函数开头用 sub / stp / add 搭建栈帧(说人话:函数在栈上给自己划一块"工作台"存局部变量,退出时还原现场);mov w0, 3——注意这个 3!它就是 MAX_ITEMS,宏名早已被换成字面量;mul w0, w0, w0——SQUARE(i) 的平方运算;bl _printf——调用函数;cmp / b.lt——for 循环的条件判断与回跳:

_main:
	sub	sp, sp, #48      // 开栈:划出 48 字节"工作台"
	stp	x29, x30, [sp, 16] // 保存调用者现场(旧栈帧指针与返回地址)
	add	x29, sp, 16       // 建立新栈帧基址
	mov	w0, 3            // ← 注意这个 3!它就是 MAX_ITEMS
	str	w0, [x29, 24]      // count = 3,存入局部变量槽位
	str	wzr, [x29, 28]     // i = 0(wzr 是"恒为零"寄存器)
	b	L2                 // 跳到循环条件判断
L3:                            // —— 循环体 ——
	ldr	w0, [x29, 28]      // 读 i
	mul	w0, w0, w0       // ← SQUARE(i):乘法指令,i * i
	str	w0, [sp, 8]        // 临时保存计算结果
	ldr	w0, [x29, 28]      // 再读一次 i(printf 的实参)
	str	w0, [sp]           // 实参入栈
	adrp	x0, lC0@PAGE       // 取格式串 "i = %d, ..." 的地址
	add	x0, x0, lC0@PAGEOFF
	bl	_printf             // 调用 printf
L2:                            // —— 循环条件:i < count ? ——
	ldr	w1, [x29, 28]      // 读 i
	ldr	w0, [x29, 24]      // 读 count
	cmp	w1, w0            // 比较 i 和 count
	blt	L3                 // 若 i < count,跳回循环体
	mov	w0, 0            // return 0 的 0
	ret                      // 函数返回
助记符含义说人话
subsubtract(减法)sub sp, sp, #48 = 栈指针下移 48 字节,给自己"腾地方"(开栈帧)
stp / ldpstore pair / load pair一次把两个寄存器压栈 / 弹栈,保存与还原现场
movmove(搬移)把立即数或寄存器值搬进寄存器,mov w0, 3 就是 w0 = 3
str / ldrstore / load存内存 / 取内存:把寄存器值写进栈上槽位,或读回来
mulmultiply(乘法)mul w0, w0, w0 就是 w0 = w0 × w0,SQUARE 的化身
cmp / bltcompare / branch if less比较两个数,前者小于后者就跳转——for 循环条件就靠它
bl / retbranch with link / return调用函数 / 从函数返回

注意:汇编长得像天书是正常的——这一章只要求"会认",不要求"会写"。而且指令集随 CPU 架构而变:本机是 Apple Silicon(arm64),所以是 sub / mul / bl 这套;x86-64(Intel / AMD)Linux 上则是 pushq / movq / callq 这套(第 1 章展示过),名字不同,思路一一对应——别被吓到。

鲁班提示

注意夹在指令之间的 lC0L2L3LFB2027 这类标签(label)——它们是"地名牌",让跳转指令(b / blt)知道往哪跳,也让 _printf 这类外部符号有个挂靠点。汇编器在下一道工序会把它们全部换算成具体地址——所以 .s 是"人读的中间稿",.o 才是"机器读的成品"。

2.4 汇编 -c 与链接:生成可执行文件

第三道工序——汇编。-S 的产物还是文本,CPU 只认二进制。汇编器(as)把 .s 逐条翻译成机器码字节,打包成目标文件 .o。说人话:hello.o 是"半成品零件"——里面有 main 的机器码,但还留着一堆"待补的洞":printfstd::cout 的实现都在标准库里,还没接上。命令是 -c,意为"只编译、不链接":

# 汇编:把 .s 翻译成机器码,打包成目标文件 .o(二进制!)
$ g++-15 -c hello.s -o hello.o
$ file hello.o
hello.o: Mach-O 64-bit object arm64

怎么证明 .o 是二进制?file 一眼看穿格式,xxd 把字节摆上台面。Mach-O 是 macOS 的可执行文件格式(Linux 上叫 ELF,格式不同、道理一样);文件开头 4 字节 cf fa ed fe 是 Mach-O 的魔数(magic number,文件格式的"身份证";ELF 的身份证是 7f 45 4c 46,即 ASCII ".ELF")。右侧 ASCII 栏全是乱码——它压根不是文本:

# 十六进制查看器:hello.o 的头 4 字节 cffa edfe 就是 Mach-O 的魔数
$ xxd hello.o | head -3
00000000: cffa edfe 0c00 0001 0000 0000 0100 0000  ................
00000010: 0400 0000 a802 0000 0020 0000 0000 0000  ......... ......
00000020: 1900 0000 2802 0000 0000 0000 0000 0000  ....(...........

第四道工序——链接。链接器(ld)把 hello.o 与标准库预编译好的 printf、iostream 实现"拼装"起来,填上所有"待补的洞",产出可执行文件 hello。跑一下:三行平方数加一句问候,四步走完,一块木头变成成品。每步产物与体积本机实测如下——.i 暴涨又骤降,故事全在数字里:

# 链接:把 hello.o 和标准库拼装成可执行文件
$ g++-15 hello.o -o hello
$ file hello
hello: Mach-O 64-bit executable arm64

# 跑起来!
$ ./hello
i = 0, square = 0
i = 1, square = 1
i = 2, square = 4
hello, cpp-internals
步骤命令产物性质本机实测体积
预处理g++ -E hello.cpp -o hello.ihello.i文本(头文件已展开)957,253 B ≈ 935 KB,35,264 行
编译g++ -S hello.i -o hello.shello.s文本(汇编语言)3,904 B,220 行
汇编g++ -c hello.s -o hello.ohello.o二进制(机器指令)2,184 B
链接g++ hello.o -o hellohello二进制(可执行文件)34,168 B
鲁班提示

分步走完这四步,不等于你以后必须这么干——日常开发一条 g++ hello.cpp -o hello 就够了,驱动程序内部会自动按四步跑完。分步的价值在于出问题时知道卡在哪一步。另外注意:中间产物名可以随便起,但扩展名有讲究——.i / .s / .o 是驱动程序判断"该从哪一步开始"的依据,别乱改。

注意:链接成功的 hello 是 34KB,而你亲手写的 main 机器码只有几百字节——多出来的是标准库的"嫁妆"(启动代码、iostream 初始化)。体积暴涨别慌,这是正常现象,2.5 节的 size 会解剖它。

2.5 解剖产物:objdump 与 size

成品做出来了,匠人的习惯是拆开检查。两个趁手工具:size 看身材,objdump 看内脏。size 输出三列:text 是代码字节数,data 是已初始化全局数据,bss 是未初始化数据(不占文件体积,运行时才清零),dec / hex 是合计。对照 hello.ohello:代码从 461 涨到 525 字节,多出来的是链接器塞进的启动代码:

# size:text 是代码,data 是已初始化的数据,bss 是未初始化数据
$ size hello.o hello
   text	   data	    bss	    dec	    hex	filename
    461	      8	      1	    470	    1d6	hello.o
    525	     64	      1	    590	    24e	hello

objdump -d反汇编(说人话:把 .o 里的机器码字节"翻译回"汇编,给想看内脏的人看)。逐列解读:最左是偏移地址,中间是机器码字节(十六进制),右边是反汇编出的助记符。真实输出如下——注意第 cmov w0, #0x3MAX_ITEMS 的 3 已经被编进指令的立即数;第 20mul w0, w0, w0 是 SQUARE(i) 的乘法;地址 38bl 0 <_printf> 是函数调用指令,链接后那个 0 会被填成 printf 的真实地址——这就是我们说的"待补的洞"之一:

# objdump -d:把机器码"反汇编"回汇编,逐条翻译
$ objdump -d hello.o

hello.o:     file format mach-o-arm64

Disassembly of section .text:

0000000000000000 <_main>:
   0:	d100c3ff 	sub	sp, sp, #0x30
   4:	a9017bfd 	stp	x29, x30, [sp, #16]
   8:	910043fd 	add	x29, sp, #0x10
   c:	52800060 	mov	w0, #0x3                  	// #3
  10:	b9001ba0 	str	w0, [x29, #24]
  14:	b9001fbf 	str	wzr, [x29, #28]
  18:	1400000c 	b	48 <_main+0x48>
  1c:	b9401fa0 	ldr	w0, [x29, #28]
  20:	1b007c00 	mul	w0, w0, w0
  24:	b9000be0 	str	w0, [sp, #8]
  28:	b9401fa0 	ldr	w0, [x29, #28]
  2c:	b90003e0 	str	w0, [sp]
  30:	90000000 	adrp	x0, 88 <lC0>
  34:	91000000 	add	x0, x0, #0x0
  38:	94000000 	bl	0 <_printf>
  3c:	b9401fa0 	ldr	w0, [x29, #28]
  40:	11000400 	add	w0, w0, #0x1
  44:	b9001fa0 	str	w0, [x29, #28]
  48:	b9401fa1 	ldr	w1, [x29, #28]
  4c:	b9401ba0 	ldr	w0, [x29, #24]
  50:	6b00003f 	cmp	w1, w0
  54:	54fffe4b 	b.lt	1c <_main+0x1c>  // b.tstop
  58:	90000000 	adrp	x0, a0 <lC1>
  5c:	91000001 	add	x1, x0, #0x0
  60:	90000000 	adrp	x0, 0 <__ZSt4cout>
  64:	f9400000 	ldr	x0, [x0]
  68:	94000000 	bl	0 <__ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc>
  6c:	90000001 	adrp	x1, 0 <__ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_>
  70:	f9400021 	ldr	x1, [x1]
  74:	94000000 	bl	0 <__ZNSolsEPFRSoS_E>
  78:	52800000 	mov	w0, #0x0                  	// #0
  7c:	a9417bfd 	ldp	x29, x30, [sp, #16]
  80:	9100c3ff 	add	sp, sp, #0x30
  84:	d65f03c0 	ret

objdump -h 则列出目标文件的节表——每个节装什么、占多大,一目了然。真实输出里,.text 是代码节(0x88 = 136 字节),.cstring 是只读字符串节,.bss 是未初始化数据节,还有两个"幕后工作者":__TEXT.__StaticInit.mod_init_func——它们负责 std::cout 的"点火仪式"(用 cout 前要先构造它的全局对象,即 2.3 节那个 1 字节哨兵的来历):

# objdump -h:列出节表——每个节装什么、占多大
$ objdump -h hello.o

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000088  0000000000000000  0000000000000000  000002c8  2**2
  1 .bss          00000001  00000000000001d8  00000000000001d8  00000000  2**0
  2 .cstring      0000002d  0000000000000088  0000000000000088  00000350  2**3
  3 __TEXT.__StaticInit 00000050  00000000000000b8  00000000000000b8  00000380  2**2
  4 .eh_frame     000000c8  0000000000000108  0000000000000108  000003d0  2**3
  5 .mod_init_func 00000008  00000000000001d0  00000000000001d0  00000498  2**3
节名装什么说人话
.text机器指令你的 main 编译出的代码本体(136 字节)
.cstring只读字符串"i = %d, square = %d" 和 "hello, cpp-internals" 两句文案
.bss未初始化数据iostream 的全局初始化哨兵,只有 1 字节
__TEXT.__StaticInit静态初始化代码cout 用之前要先"点火"的构造逻辑
.eh_frame异常处理 / 栈回溯信息程序崩溃时用来打印调用栈的"地图"
.mod_init_func构造器函数指针表程序启动时自动调用的初始化函数清单
鲁班提示

在 Linux 上你更习惯用 readelf 看节表、nm 看符号——macOS 没有 readelf,但 objdump -h / size / nm 全都有,信息等价。本章命令在 Linux 上照敲不误,只是节名风格略有差异(Linux 直接叫 .text / .rodata / .bss,没有 __TEXT.__StaticInit 这种双段名)。命令不同,道理同一。

注意:反汇编出来的是"编译器优化后的成品",不是源码的逐句翻译。没开优化时它比较"老实";一旦开了 -O2,编译器会重排指令、合并计算、删掉它认为"没用"的代码,看起来像在"乱改你的代码"。看反汇编永远要带着"这是优化后的产物"这个觉悟——第 15 章讲性能优化时会反复用到这条。

2.6 gcc 与 g++ 的分工:为什么 C++ 代码要用 g++ 链接

第 1 章结尾埋了伏笔:为什么 C++ 代码要用 g++ 链接?现在现场翻车。同一个 hello.cpp,换成 gcc 编译链接——编译阶段一声不吭,链接阶段炸了:链接器点名 std::coutstd::endl 一串"未定义符号"(真实输出,节选):

# 用 gcc 编译并链接 C++ 源码:编译过了,链接炸了(本机真实输出,节选)
$ gcc-15 hello.cpp -o hello
Undefined symbols for architecture arm64:
  "std::ostream::operator<<(std::ostream& (*)(std::ostream&))", referenced from:
      _main in ccKI9cT8.o
  "std::ios_base::Init::Init()", referenced from:
      __static_initialization_and_destruction_0() in ccKI9cT8.o
  "std::cout", referenced from:
      _main in ccKI9cT8.o
# ...(中间还省略了 std::endl 等一串未定义符号)...
ld: symbol(s) not found for architecture arm64
collect2: error: ld returned 1 exit status

说人话:gcc 是 C 语言的编译器,前端恰好也认得 C++ 语法(所以编译能过),但链接时它只连 C 标准库(libc);std::cout 所在的 C++ 标准库(libstdc++)没人帮它连,链接器只能喊"找不着"。Linux 上同样的错,文案是 undefined reference to 'std::cout'——同一件事,两种方言。

关键点:错误只出现在链接阶段。分步验证给你看——gcc -c 编译好好的,gcc 链接才炸;同一个 .o 交给 g++ 链接,一次通过。两种修复都实测可用:

# 分步看:编译阶段 gcc 一声不吭,问题只出在链接
$ gcc-15 -c hello.cpp -o hello_c.o     # -c 只编译不链接:成功!
$ gcc-15 hello_c.o -o hello            # 链接:失败
Undefined symbols for architecture arm64:
  "std::cout", referenced from:
      _main in hello_c.o
ld: symbol(s) not found for architecture arm64
collect2: error: ld returned 1 exit status

# 修复 1(推荐):用 g++ 链接,它会自动带上 C++ 标准库
$ g++-15 hello_c.o -o hello
$ ./hello
i = 0, square = 0
i = 1, square = 1
i = 2, square = 4
hello, cpp-internals

# 修复 2:坚持用 gcc,但手动把 C++ 标准库补上(-l 是"链接某个库")
$ gcc-15 hello_c.o -o hello -lstdc++
$ ./hello
hello, cpp-internals

再往深挖一层:为什么链接器认不出 std::cout 这个名字?用 nmhello.o 的符号表(说人话:符号表就是目标文件里的"花名册",记录它用到哪些名字、提供哪些名字):

# nm:U 表示 Undefined(未定义)——这些符号等着链接阶段来"认领"
$ nm hello.o | grep -E 'cout|printf'
                 U __ZSt4cout
                 U __ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
                 U _printf

注意那些怪名字——这就是名字修饰(name mangling)。C++ 允许函数重载:两个同名、不同参数列表的函数可以共存,链接器必须能区分它们,于是编译器把"函数名 + 参数类型"编码成独特符号。__ZSt4cout 就是 std::cout__ZSt4endl<...>std::endl 的模板实例;而 printf 没有重载,符号朴素(macOS 惯例加下划线前缀 _printf,Linux 直接是 printf)。链接器看到 U __ZSt4cout 却没人在花名册里"认领"——因为 libstdc++ 没被链接进来——于是当场报"未定义符号"。

鲁班提示

判断用哪个:源文件是 .c 用 gcc,是 .cpp / .cc 用 g++。g++ 链接时会顺带把 C 标准库也连上(C++ 标准库依赖它),是"超集"。C++ 工程一律用 g++,省心、少踩坑。

注意:有个隐蔽的坑:如果 C++ 程序碰巧只用 printf 不用 cout,用 gcc 链接居然也能成功(printf 在 libc 里)!这让新人误以为"gcc 可以编译 C++",直到加了第一行 std::cout,链接突然爆炸。记住:能跑 ≠ 配置正确,从第一天起就用 g++。

章末练习

练习 1:宏去哪了 入门

预处理之后,在 hello.i 里用 grep 还能找到宏名 SQUARE 吗?为什么?如果找不到,那 SQUARE(i) 去哪了?

提示

回想 2.2 节的两条 grep 命令:grep -c SQUARE hello.i 的输出是什么?

参考答案

找不到,grep -c SQUARE hello.i 输出 0。宏在预处理阶段就被"原地文本替换":SQUARE(i) 变成了 ((i) * (i)),.i 里只留下展开后的文本,宏名本身彻底消失。用 grep -n '((i) \* (i))' hello.i 能在第 35,260 行看到它的"尸体"。这就是"宏在编译开始之前就已完成"的铁证。

练习 2:命令与产物连线 进阶

下面四条命令各产出什么文件、是什么性质?① g++ -E hello.cpp -o hello.i;② g++ -S hello.i -o hello.s;③ g++ -c hello.s -o hello.o;④ g++ hello.o -o hello。再用 file 命令检查 hello.o 与 hello,两者的输出有何不同?

提示

回看 2.4 节的四步表格;file 的输出里 object 与 executable 是两个关键词。

参考答案

① hello.i——文本,头文件已展开;② hello.s——文本,汇编语言;③ hello.o——二进制,机器码目标文件;④ hello——二进制,可执行文件。file 输出:hello.o: Mach-O 64-bit object arm64 vs hello: Mach-O 64-bit executable arm64——前者是"半成品零件"(object),后者是"成品"(executable);Linux 上对应 ELF relocatableELF pie executable

练习 3:一个数字的旅程 进阶

hello.s 里有一行 mov w0, 3,这个 3 是哪来的?源码里明明写的是 MAX_ITEMS,为什么汇编里变成了 3?这说明了预处理阶段的什么特性?

提示

想想 #define MAX_ITEMS 3 在哪个阶段起作用,编译器拿到的是哪份文本。

参考答案

3 来自 #define MAX_ITEMS 3。预处理阶段把标识符 MAX_ITEMS 替换成字面量 3,编译器拿到的是替换之后的文本,于是把 3 直接编进了指令的立即数字段(arm64 的 mov w0, #3,机器码 52800060)。这说明预处理是编译之前的纯文本加工——宏在编译开始前就已完成,编译阶段再也见不到宏名。

练习 4:一次链接事故的完整排障 挑战

gcc 编译链接一个用了 std::cout 的 .cpp 文件,链接时报 Undefined symbols for architecture arm64: "std::cout"(Linux 上是 undefined reference to 'std::cout')。① 解释原因;② 给出两种修复命令;③ 更进一步:用 objdump -d 检查编译出的 hello.o,你能在 .text 节里找到哪条指令佐证宏 MAX_ITEMS 确实被替换了?

提示

想想 2.6 节的 gcc/g++ 分工;宏替换会体现在指令的立即数上——回看 2.5 节反汇编输出第 c 行。

参考答案

① gcc 是 C 编译器,链接时只带 C 标准库 libc,而 std::cout 的实现躺在 C++ 标准库 libstdc++ 里,没人帮它链接,于是符号未定义。注意编译阶段 gcc 不报错(语法语义都正确),问题只出在链接阶段。② 修复 1:g++ hello_c.o -o hello(g++ 自动带 libstdc++);修复 2:gcc hello_c.o -o hello -lstdc++(-l 手动链接库)。③ 在反汇编中找到 mov w0, #0x3(机器码 52800060,位于偏移 0xc)——MAX_ITEMS 已被替换成立即数 3 编进指令;顺带还能看到偏移 0x20 的 mul w0, w0, w0,正是 SQUARE(i) 编译出的乘法指令。