上章站在山顶画了全景图,这章下山抡锤子:用一个 hello.cpp 亲手走完预处理、编译、汇编、链接四道工序,把 .i / .s / .o 每件中间产物都拿到灯下看仔细
核心方法论:工欲善其事,必先利其器
「别人讲编译原理,喜欢画流程图;我带你抡锤子——把 -E / -S / -c 四道工序逐一上手,亲眼看看 .i、.s、.o 每一件中间产物长什么样。老木匠开工前要先摸一遍家什:刨子快不快、墨斗有没有墨。工具认得准、看得懂,编译器在你眼里就不再是黑盒,而是一条闭着眼都能走完的流水线。这章读完,你手里就有了真家伙。」
第 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 的警告措辞)。先确认工具再动手,能省掉大量"我的输出怎么和教程不一样"的困惑。
主角登场——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 * x,SQUARE(a + b) 会被替换成 a + b * a + b,完全跑偏。以后在宏里看到一堆括号,就知道是前人踩过坑。
注意:别用编辑器直接打开 957KB 的 hello.i——大部分文本编辑器会卡成幻灯片。处理大文本的基本功是 head(看头)、tail(看尾)、grep(按内容找)、wc -l(数行数),按需取用,不要整卷通读。
第二道工序——编译。说人话:这一步翻译官上场,把 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 // 函数返回
| 助记符 | 含义 | 说人话 |
|---|---|---|
sub | subtract(减法) | sub sp, sp, #48 = 栈指针下移 48 字节,给自己"腾地方"(开栈帧) |
stp / ldp | store pair / load pair | 一次把两个寄存器压栈 / 弹栈,保存与还原现场 |
mov | move(搬移) | 把立即数或寄存器值搬进寄存器,mov w0, 3 就是 w0 = 3 |
str / ldr | store / load | 存内存 / 取内存:把寄存器值写进栈上槽位,或读回来 |
mul | multiply(乘法) | mul w0, w0, w0 就是 w0 = w0 × w0,SQUARE 的化身 |
cmp / blt | compare / branch if less | 比较两个数,前者小于后者就跳转——for 循环条件就靠它 |
bl / ret | branch with link / return | 调用函数 / 从函数返回 |
注意:汇编长得像天书是正常的——这一章只要求"会认",不要求"会写"。而且指令集随 CPU 架构而变:本机是 Apple Silicon(arm64),所以是 sub / mul / bl 这套;x86-64(Intel / AMD)Linux 上则是 pushq / movq / callq 这套(第 1 章展示过),名字不同,思路一一对应——别被吓到。
注意夹在指令之间的 lC0、L2、L3、LFB2027 这类标签(label)——它们是"地名牌",让跳转指令(b / blt)知道往哪跳,也让 _printf 这类外部符号有个挂靠点。汇编器在下一道工序会把它们全部换算成具体地址——所以 .s 是"人读的中间稿",.o 才是"机器读的成品"。
第三道工序——汇编。-S 的产物还是文本,CPU 只认二进制。汇编器(as)把 .s 逐条翻译成机器码字节,打包成目标文件 .o。说人话:hello.o 是"半成品零件"——里面有 main 的机器码,但还留着一堆"待补的洞":printf、std::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.i | hello.i | 文本(头文件已展开) | 957,253 B ≈ 935 KB,35,264 行 |
| 编译 | g++ -S hello.i -o hello.s | hello.s | 文本(汇编语言) | 3,904 B,220 行 |
| 汇编 | g++ -c hello.s -o hello.o | hello.o | 二进制(机器指令) | 2,184 B |
| 链接 | g++ hello.o -o hello | hello | 二进制(可执行文件) | 34,168 B |
分步走完这四步,不等于你以后必须这么干——日常开发一条 g++ hello.cpp -o hello 就够了,驱动程序内部会自动按四步跑完。分步的价值在于出问题时知道卡在哪一步。另外注意:中间产物名可以随便起,但扩展名有讲究——.i / .s / .o 是驱动程序判断"该从哪一步开始"的依据,别乱改。
注意:链接成功的 hello 是 34KB,而你亲手写的 main 机器码只有几百字节——多出来的是标准库的"嫁妆"(启动代码、iostream 初始化)。体积暴涨别慌,这是正常现象,2.5 节的 size 会解剖它。
成品做出来了,匠人的习惯是拆开检查。两个趁手工具:size 看身材,objdump 看内脏。size 输出三列:text 是代码字节数,data 是已初始化全局数据,bss 是未初始化数据(不占文件体积,运行时才清零),dec / hex 是合计。对照 hello.o 与 hello:代码从 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 里的机器码字节"翻译回"汇编,给想看内脏的人看)。逐列解读:最左是偏移地址,中间是机器码字节(十六进制),右边是反汇编出的助记符。真实输出如下——注意第 c 行 mov w0, #0x3:MAX_ITEMS 的 3 已经被编进指令的立即数;第 20 行 mul w0, w0, w0 是 SQUARE(i) 的乘法;地址 38 的 bl 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 章讲性能优化时会反复用到这条。
第 1 章结尾埋了伏笔:为什么 C++ 代码要用 g++ 链接?现在现场翻车。同一个 hello.cpp,换成 gcc 编译链接——编译阶段一声不吭,链接阶段炸了:链接器点名 std::cout、std::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 这个名字?用 nm 看 hello.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++。
预处理之后,在 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 行看到它的"尸体"。这就是"宏在编译开始之前就已完成"的铁证。
下面四条命令各产出什么文件、是什么性质?① 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 relocatable 与 ELF pie executable。
在 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)。这说明预处理是编译之前的纯文本加工——宏在编译开始前就已完成,编译阶段再也见不到宏名。
用 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) 编译出的乘法指令。