AArch64 学习 (二) 函数调用 (Function Call Convention)
本系列的第一篇 中介绍了 AArch64 的基础指令、进程内存布局以及基础栈操作 等。本文该系列的第二篇,主要聊聊函数调用,涉及到的就是 Function Call Convention. 初衷还是尽可能 “浅入深出” 地 got 到语言背后的本质,这不是一个手册,所以不是完备的.
1. 我们在聊函数调用的时候在聊什么?
至少我们应该把函数调用的几个问题搞清楚:
- 函数在汇编层是怎么调用的,本质是什么?
- 函数的参数怎么传?
- 返回值写到哪里?怎么传给 caller?
- 调用完之后,怎么返回到原来的位置?
Function Call Convention 其实就是回答这些问题的,接下里我们一一找到答案.
1.1. 函数调用本质是什么?
汇编层是没有函数的概念的,我们需要把函数映射到汇编层来,这样我们就知道了它的本质。其实执行一个程序,在汇编层来看就是不断的执行 CPU 指令,都执行完了,进程就结束了。从第一篇的例子其实可以看出,一个函数就是一个 label, 等于代码段中该函数第一条指令的位置。其实本质上函数调用,就是程序从代码段的某一条指令,跳转到另外一个地址上的指令去执行。稍微复杂点的 C 程序都不是从头执行到尾就结束了,会有条件判断,函数调用。函数调用和普通跳转不同的地方在于要处理传参、返回、以及寄存器的 backup 和恢复.
AArch64 提供给我们了一个 bl (branch with link) 指令,用来执行指定的函数。第一篇里,我们介绍了 cmp 以及 b.le/b.ge 等,‘b’ 在这两处都是 branch 跳转的意思.
只不过 bl 是跳转的函数地址上,bl 内部实现是这样的:
- 跳转之前会把函数调用后面地址 (也就是 bl 的下一条指令的地址) 存放到 LR (Link register) 中
- PC 被 bl 的参数替换,就是 PC 指向了 bl 的参数,通常是一个函数 label, 对应着一个地址
- 目标函数开始执行
- 目标函数执行完,调用 ret 指令,ret 会把 LR copy 回 PC
- 程序执行 PC, 也就是执行原来 bl 下一条指令了
1.2. AArch64 Call Convention 约定
- 把需要保存的寄存器值入栈,避免被即将调用的函数修改
- AArch64 中,X0-X7 8 个通用寄存器用来保存函数调用的前 8 个参数,超过 8 个的,通过入栈来传递.
- 返回值默认存入 X0 或者 X0 + X1 寄存器中
- 执行 bl 跳转,跳转到目标函数
- 目标函数如果有返回值,把返回值放入 X0, 然后执行 ret
- 取出返回值,然后出栈,恢复寄存器中的值
ps: 还有一种间接传递返回值的方式,该方式会使用 XR (X8) 进行间接的返回,后文会介绍这种 case.
2. 看一个简单函数调用例子
1 | long add(long x, long y) { |
对应的 AArch64 的汇编代码:
ps: 这里为了方便阅读,我把 add 函数调整到了 main 的后面,下同
1 | main: // @main |
3. 参数超过 8 个参数,通过栈空间传递参数的例子
test 函数共有 10 个参数,为了保持简单,这里都使用 long 类型的.
1 | long test(long n1, long n2, long n3, long n4, long n5, |
我们先看一下函数调用的时候,栈的分配,下面是对应的 AArch64 的汇编代码:
1 | main: // @main |
4. 总结一下函数调用的通用逻辑
- 调用前
- 可能会修改的寄存器先入栈保存
- 准备函数的参数,前 8 个参数参数放入 X0-X8
- 剩余参数入栈
- 使用 bl 调用目标函数
- 执行 bl 之前会把 bl 下一行指令的地址放入 lr 寄存器
- 从 X0-X9 拿到前 8 个参数,然后从上个函数栈的栈中取出剩余的参数
- 目标函数执行完,ret 的时候,会把 lr 寄存器的值 store 到 PC 寄存器
- 执行 pc 寄存器对应的地址,也就是前面 bl 下一行 (step 9 的指令)
- 调用后
- 恢复 1.1 中入栈的寄存器值,恢复调用前的状态