实验目的:通过分析 C 语言源程序经过 gcc 12.2 生成的 ARM64 汇编代码,理解存储程序计算机与函数调用框架。
这里推荐一个网站:https://godbolt.org,可以在线地将常见的高级语言源程序编译成对应指令集的汇编,支持 x86、ARM、MIPS、RISC-V 甚至龙芯的 Loongarch。本文以 ARM64 gcc 12.2 为例,解析汇编代码。
示例 C 语言源程序如下:
int g(int x) { return x + 3; } int f(int x) { return g(x); } int main(void) { return f(8) + 1; }
在线生成的 ARM64 汇编如下:
g: sub sp, sp, #16 str w0, [sp, 12] ldr w0, [sp, 12] add w0, w0, 3 add sp, sp, 16 ret f: stp x29, x30, [sp, -32]! mov x29, sp str w0, [sp, 28] ldr w0, [sp, 28] bl g ldp x29, x30, [sp], 32 ret main: stp x29, x30, [sp, -16]! mov x29, sp mov w0, 8 bl f add w0, w0, 1 ldp x29, x30, [sp], 16 ret
下面我们从 main 函数开始,逐行解析汇编代码的执行过程。
stp x29, x30, [sp, -16]!
main 第 1 条指令(第 17 行)将 x29(调用 main 函数之前的函数栈基址)、x30 (main 函数返回后执行的下一条指令的内存地址)分别压入栈中,即 sp - 16 的内存地址所在的 16 个字节的存储空间。这里用到了回写前变址寻址方式,sp 被回写为 sp - 16。这条指令备份了 main 函数调用者的栈基址和跳转指令的下一条指令的内存地址,以便 main 函数返回后继续执行 main 函数调用者的指令。
mov x29, sp
main 第 2 条指令(第 18 行)将栈顶地址赋给栈基址,形成了逻辑上的空栈,即构造了 main 函数的初始栈空间。
mov w0, 8
main 第 3 条指令(第 19 行)将 f 函数的参数 8 赋给寄存器 w0 进行传参。
bl f
main 第 4 条指令(第 20 行)调用函数 f,先将下一指令地址(即函数返回地址)保存到 x30 寄存器中,再进行跳转。
stp x29, x30, [sp, -32]!
f 第 1 条指令(第 9 行)同样是将 x29 和 x30 压栈,只不过这里 sp 被回写成了 sp - 32,是为了多生成一些栈空间存放函数调用的参数,这会在后面的指令中体现。
mov x29, sp
f 第 2 条指令(第 10 行)构造了 f 函数的初始栈空间。
str w0, [sp, 28] ldr w0, [sp, 28]
f 第 3、4 条指令(第 11、12 行)先将上 main 函数传递过来的参数从 w0 存入栈中(第 9 行提前预留好了栈空间),再从栈中取回 w0(疑问:为何不能直接把这个步骤优化掉呢,不是很明白)。
bl g
f 第 5 条指令(第 13 行)调用函数 g。
sub sp, sp, #16
g 第 1 条指令(第 2 行)为 g 开辟了 16 个字节的栈空间,以存放 g 函数传来的参数。由于 g 函数不需要再执行函数调用,所以就不需要再构建逻辑上的空栈,只要留出栈空间即可。
str w0, [sp, 12] ldr w0, [sp, 12] add w0, w0, 3
g 第 2~4 条指令(第 3~5 行)先将参数从 w0 存入栈中再取回,接着执行 +3 操作,准备作为返回值返回。
add sp, sp, 16 ret
g 第 5~6 条指令(第 6~7 行)将栈指针上移(销毁形参和临时变量),并执行返回操作。ret 指令将 x30 寄存器的值赋给指令指针寄存器 PC。后续汇编代码将从第 14 行开始执行。
ldp x29, x30, [sp], 32 ret
f 第 6~7 条指令(第 14~15 行)将 x29 和 x30 寄存器的内容退栈、销毁 f 的栈空间(x29 和 x30 恢复成 main 函数的状态),并将 x30 寄存器的值赋给指令指针寄存器 PC。后续汇编代码将从第 21 行开始执行。
add w0, w0, 1
main 第 5 条指令(第 21 行)将 f 函数的返回值 w0 加 1。
ldp x29, x30, [sp], 16 ret
main 第 6~7 条指令(第 22~23 行)与第 14~15 行指令的过程类似,区别只是销毁的栈空间大小不同。