本系列的第一篇 中介绍了 AArch64 的基础指令、进程内存布局以及基础栈操作 等. 本文该系列的第二篇, 主要聊聊函数调用, 涉及到的就是 Function Call Convention. 初衷还是尽可能 “浅入深出” 地 got 到语言背后的本质, 这不是一个手册, 所以不是完备的.
至少我们应该把函数调用的几个问题搞清楚:
Function Call Convention 其实就是回答这些问题的, 接下里我们一一找到答案.
汇编层是没有函数的概念的, 我们需要把函数映射到汇编层来, 这样我们就知道了它的本质. 其实执行一个程序, 在汇编层来看就是不断的执行 CPU 指令, 都执行完了, 进程就结束了. 从第一篇的例子其实可以看出, 一个函数就是一个label, 等于代码段中该函数第一条指令的位置. 其实本质上函数调用, 就是程序从代码段的某一条指令, 跳转到另外一个地址上的指令去执行. 稍微复杂点的 C 程序都不是从头执行到尾就结束了, 会有条件判断, 函数调用. 函数调用和普通跳转不同的地方在于要处理传参、返回、以及寄存器的 backup 和恢复.
AArch64 提供给我们了一个 bl (branch with link) 指令, 用来执行指定的函数. 第一篇里, 我们介绍了 cmp 以及 b.le/b.ge 等, ‘b’ 在这两处都是 branch 跳转的意思.
只不过 bl 是跳转的函数地址上, bl 内部实现是这样的:
long add(long x, long y) {
return x + y;
}
int main() {
long z = add(1, 2);
return 0;
}
对应的 AArch64 的汇编代码:
ps: 这里为了方便阅读, 我把 add 函数调整到了 main 的后面, 下同
main: // @main
// 1. 分配 48 字节的栈空间, 使用情况见 step 11
sub sp, sp, #48 // =48
// 2. stp 和 str 类似, 区别是 stp 一次保存多个
// 这里等于把 x29/FP => [sp + 32], x30/LR => [sp + 40]
stp x29, x30, [sp, #32] // 16-byte Folded Spill
// 3. x29 = sp + 32
add x29, sp, #32 // =32
// 4. w8 = 0, 然后存入后面能用到
mov w8, wzr
// 5. x29-4 = sp+32-4 = sp + 28
stur wzr, [x29, #-4]
// 6. 把字面量 1 和 2 放入 X0, X1, 作为入参传给 add
mov x0, #1
mov x1, #2
// 7. 前面把 w8 置为 0, 这里相当于在 sp+12 位置保存了一个 0
str w8, [sp, #12] // 4-byte Folded Spill
// 8. 函数调用
bl add(long, long)
// 9. 把 X0 也就是返回值, 放入 sp + 16 中
str x0, [sp, #16]
// 10. 因为 main 的返回值是 int, 4 字节, 所以用的是 w0, sp+12 前面我们知道保存的是 0
// 所以这里相当于把 0 放入了 w0, 作为 main 函数的返回值
ldr w0, [sp, #12] // 4-byte Folded Reload
// 11. 回顾一下分配的 48 字节栈空间的使用情况
| sp + 40 | LR (8 bytes)
| sp + 32 | FP (8 bytes)
| sp + 24 | 0 (8 bytes, 低四位(sp + 28) 存放 0)
| sp + 16 | X0 (8 bytes)
| sp + 8 | 0 (8 bytes, 低四位(sp + 28) 存放 0)
| sp | (8 bytes, 为了16对齐, 多分配出来的)
// 和 step2 操作相反, 恢复 X29, X30, 也就是 FP 和 LR 寄存器
// 类似 ldr, ldp load 多个: X29 <= [sp + 32], X30 <= [sp + 40]
ldp x29, x30, [sp, #32] // 16-byte Folded Reload
// 释放栈空间
add sp, sp, #48 // =48
ret
add(long, long): // @add(long, long)
// add 函数有两个 long 参数, 会占用栈空间, 分配 16 字节
sub sp, sp, #16 // =16
// X0 是第一个参数 x, 保存到 sp + 8
str x0, [sp, #8]
// X1 是第二个参数 y, 保存到 sp 中
str x1, [sp]
// 取出 x 和 y
ldr x8, [sp, #8]
ldr x9, [sp]
// 相加, 把和放入 X0 中, 也是约定的返回值存放位置
add x0, x8, x9
// 释放栈空间
add sp, sp, #16 // =16
// 返回
ret
test 函数共有 10 个参数, 为了保持简单, 这里都使用 long 类型的.
long test(long n1, long n2, long n3, long n4, long n5,
long n6, long n7, long n8, long n9, long n10) {
return n1 + n2;
}
int main() {
long z = test(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
return 0;
}
我们先看一下函数调用的时候, 栈的分配, 下面是对应的 AArch64 的汇编代码:
main: // @main
// 1. 这部分和上面例子非常类似, 不赘述了
sub sp, sp, #64 // =64
stp x29, x30, [sp, #48] // 16-byte Folded Spill
add x29, sp, #48 // =48
mov w8, wzr
stur wzr, [x29, #-4]
// 2. 前 8 个参数通过通用寄存器 X0-X8 传递
mov x0, #1
mov x1, #2
mov x2, #3
mov x3, #4
mov x4, #5
mov x5, #6
mov x6, #7
mov x7, #8
// 3. 这三条指令相当于把第 9 个参数 #9 放入 [sp], 也就是栈顶的位置
mov x9, sp
mov x10, #9
str x10, [x9]
// 4. 把第 10 个参数 #10 放到 [sp + 8], 也即是栈顶的下一个位置
mov x10, #10
str x10, [x9, #8]
// 5. 此时栈的情况是这样的:
| sp + 40 |
| sp + 32 |
| sp + 24 |
| sp + 16 | 其他值
| sp + 8 | #10, 第 10 个参数
| sp | #9, 第 9 个参数
stur w8, [x29, #-20] // 4-byte Folded Spill
// 6. 执行函数调用
bl test(long, long, long, long, long, long, long, long, long, long)
// 7. 也和前面例子非常类似, 不赘述
stur x0, [x29, #-16]
ldur w0, [x29, #-20] // 4-byte Folded Reload
ldp x29, x30, [sp, #48] // 16-byte Folded Reload
add sp, sp, #64 // =64
ret
test(long, long, long, long, long, long, long, long, long, long): // @test(long, long, long, long, long, long, long, long, long, long)
// 10个参数, 分配 80 字节的栈空间, 也是 16 的倍数
sub sp, sp, #80 // =80
// 结合上面第5步, 我们可以知道当前栈是这样的:
// 前面 sp = sp - 80, 所以这里 main 函数栈相当于离栈顶 sp 又远了80, 需要 + 80
----main func----
| sp + 40 + 80 |
| sp + 32 + 80 |
| sp + 24 + 80 |
| sp + 16 + 80 | 其他值
| sp + 8 + 80 | #10, 第 10 个参数
| sp + 80 | #9, 第 9 个参数
----test func----
| sp + 72 |
| sp + 64 |
| sp + 56 |
| sp + 48 |
| sp + 40 |
| sp + 32 |
| sp + 24 |
| sp + 16 |
| sp + 8 |
| sp |
-----------------
// 这个初看有些奇怪, 一共分配了 80 自己的空间, 那这里的 sp + 80, 岂不是访问出界了啊?
// 实际上是特意的, 根据前图, sp + 80 相当于访问到了 #9 所在的位置, 所以 x8 = #9
// 同理 x9 实际访问到了 [sp, #88], 也就是 #10 所在的位置, 所以 x9 = #10
// 这样就拿到了最后两个参数
ldr x8, [sp, #80]
ldr x9, [sp, #88]
// 前 8 个参数, 逐个压入到栈中. 空余了 sp 和 sp + 8
str x0, [sp, #72]
str x1, [sp, #64]
str x2, [sp, #56]
str x3, [sp, #48]
str x4, [sp, #40]
str x5, [sp, #32]
str x6, [sp, #24]
str x7, [sp, #16]
// 再把从前面函数栈中拿到的第 9、10 个参数入栈
str x8, [sp, #8]
str x9, [sp]
// 此时 函数栈中的值是这样的:
----main func----
| sp + 40 + 80 |
| sp + 32 + 80 |
| sp + 24 + 80 |
| sp + 16 + 80 |
| sp + 8 + 80 | #10, 第 10 个参数
| sp + 80 | #9, 第 9 个参数
----test func----
| sp + 72 | #1
| sp + 64 | #2
| sp + 56 | #3
| sp + 48 | #4
| sp + 40 | #5
| sp + 32 | #6
| sp + 24 | #7
| sp + 16 | #8
| sp + 8 | #9
| sp | #10
-----------------
// 拿出 #1 和 #2, 相加的结果 3 放入 X0 作为返回值
ldr x8, [sp, #72]
ldr x9, [sp, #64]
add x0, x8, x9
// 释放栈空间
add sp, sp, #80 // =80
ret