本节摘要:源代码变成处理器动作要经过预处理、编译、汇编、链接、装载、执行六个站点,ARM 汇编恰好住在"编译"与"汇编"两站之间——它既是编译器的输出语言,又是处理器的输入语言。本节带你沿这条链亲手走一遍,在每一站停下来查看产物的模样,为全书建立一张总地图。
上一章全景图里我们说过,这本册子的每个结论都要在调试器里走一遍。走的前提是知道路——本节就是那条路本身。读完这里,你再看第 2 章的寄存器、第 3 章的编码,就知道它们分别挂在旅程的哪个位置上。
设想一个再普通不过的场景:你在调试器里按下单步键,屏幕上的汇编窗口跳了一行,寄存器面板里几个数字变了,底部状态栏的 PC 值悄悄挪了四位。这一跳之间其实发生了什么?那行汇编是从哪个文件里来的?它又是谁写进去的?如果你答不上来,不是因为你不懂 C,而是因为从 C 到这一跳之间的六站你从来没走过。本节就把这六站走完。
一个 C 源文件到达处理器之前,要经历六次形态变化。用 GCC 的驱动程序可以把任意两站之间单独跑出来,这正是我们观察全程的抓手:
# 从源码一路走到可执行文件,再单步执行 aarch64-linux-gnu-gcc -E main.c -o main.i # 第一站:预处理,产出展开后的纯 C 文本 aarch64-linux-gnu-gcc -S main.c -o main.s # 第二站:编译,产出 ARM 汇编文本 aarch64-linux-gnu-gcc -c main.s -o main.o # 第三站:汇编,产出目标文件(机器码+符号表) aarch64-linux-gnu-gcc main.o -o main # 第四站:链接,合并重定位成可执行镜像 qemu-aarch64 ./main # 第五站装载、第六站执行,交给模拟器完成
六个产物形态各不相同:main.i 还是 C 但宏已展开;main.s 是人类可读的 ARM 汇编助记符;main.o 是带元数据的机器码;main 是地址已定的可执行镜像;装载之后它变成内存里的代码段与数据段;执行时它退化成流水线上的一条条指令流。下面的流程图把六站与产物形态对应起来:
注意高亮的两站:汇编助记符是编译器的输出、汇编器的输入,它是一种"中间文本";而目标文件里的机器码是处理器唯一认识的东西。ARM 汇编教程教的既包括怎么读左边的助记符(第 3 章),也包括助记符背后的比特契约(第 3 章 3.2 节)。
空谈流程没意思,我们拿一个十行的函数把六站走全。这是我们的实验对象:
// sum.c :把数组前 n 个元素累加 long sum(const long *a, long n) { long s = 0; for (long i = 0; i < n; i++) s += a[i]; return s; }
操作:用前面第二站的开关生成汇编,只看函数体主干(伪指令与无关段略去):
sum: mov x2, xzr // 累加器 s 清零,注意用了零寄存器 xzr 而不是立即数 cbz x1, .Lreturn // n 为 0 直接跳到返回,编译器替我们做了空判断 .Lloop: ldr x3, [x0], 8 // 取 a[i] 到 x3,随后 x0 自增 8 字节(后索引寻址) add x2, x2, x3 // s 累加 sub x1, x1, 1 // 循环变量递减——编译器把递增比较反转成了递减到零 cbnz x1, .Lloop // 非零则继续循环 .Lreturn: mov x0, x2 // 返回值放进 x0:AAPCS 约定的位置 ret // 跳回 x30 里存的返回地址
结果:五行 C 变成了九行 AArch64 汇编。继续走第三站,把目标文件反汇编,看到的就不是助记符而是"地址 + 编码 + 助记符"三列对照:
0000000000000000 <sum>: 0: aa0103e2 mov x2, x1 // 这里 -O0 版本与上面 -O2 版本不同,见下文 4: d1000809 sub x9, x0, #0 8: f9400123 ldr x3, [x9, #0] ...
解读:三列对照正是全书反复使用的基本功。第一列是链接前的相对地址;第二列 f9400123 是真实的 32 位机器码——十六进制只是比特的缩写,第 3 章会教你把这类编码逐位拆开;第三列才是助记符。你会发现 -O2 版本和 -O0 版本的指令序列差别很大:-O0 老老实实为每个 C 变量在栈上安排槽位, loads 与 stores 成对出现;-O2 则把变量全部提升到寄存器、反转循环方向、用 cbz 处理空输入。这解释了开篇那个调试困惑——高级语言断点处"看着正常"的变量,在优化版本里可能根本不存在于内存中,只活在寄存器里。
变式:把循环改成累加浮点数,同一条链会走出一副新面孔——循环体换用 d0、d1 这样的浮点寄存器,累加指令换成 fadd d0, d0, d3;如果数组长度是编译期常量,编译器甚至会干脆展开循环或直接算出结果。变式的意义在于验证:走完一条链,你就获得了"提问—改代码—看输出"的实验回路,这个回路比任何结论都值钱。
装载与执行两站离得远,看个模拟器的会话就懂。把 -O0 版本的 sum 放进 QEMU 配 gdb 远程调试,在循环体第一行设断点后单步:
(gdb) b sum (gdb) c Breakpoint 1, 0x0000000000400780 in sum () (gdb) info registers x0 x1 x30 pc x0 0x4000a0 // 第一个参数:数组首地址 x1 0x5 // 第二个参数:n = 5 x30 0x4007c4 // 返回地址,ret 会跳到这里 pc 0x400780 // 当前执行位置,正是 sum 的第一条指令 (gdb) si (gdb) si (gdb) p/d $x2 $1 = 2 // 走过两次循环体后累加器已经是前两项之和
三个观察值得记下:参数确实按 AAPCS 约定从 x0、x1 进来(第 5 章细讲);返回地址安静地躺在 x30,ret 只是它的搬运工;pc 每次单步前进四字节——因为 AArch64 指令定长 32 位,这一点与 x86 的变长指令截然不同,也是第 3 章编码格式讨论的伏笔。
新手在这条链上最容易栽三个地方。其一,把 -S 产出的助记符直接当机器码去数长度——助记符文本一行不代表四个字节,AT&T 风格与 Intel 风格的 x86 反汇编还会把操作数顺序写反,ARM 这边统一用"目的寄存器在前"的惯例。其二,混淆 -O0 与 -O2 的产物:背指令序列时若不确定优化等级,先看函数有没有栈帧读写,再对照源码,别把优化器的重排当成编译器写错了。其三,在宿主机(通常是 x86)上直接运行交叉编译出的 ARM 可执行文件——没有 QEMU 就会收到"无法执行二进制文件"的报错,这不是编译失败,是架构不匹配。
-S、对比输出——这是比结论更值得带走的工具。下一节我们把镜头拉远,看看同样这条链在 x86 与 RISC-V 上会走出一副什么样的不同面孔。