美文网首页
函数调用 (Function Call Convention)

函数调用 (Function Call Convention)

作者: superme_ | 来源:发表于2022-12-01 11:15 被阅读0次

https://joey.blue/2022/06/03/AArch64-02-function-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. 看一个简单函数调用例子

longadd(longx,longy){

returnx + y;

}

intmain(){

longz =add(1,2);

return0;

}

对应的 AArch64 的汇编代码:

ps: 这里为了方便阅读, 我把 add 函数调整到了 main 的后面, 下同

main:                                  // @main

//1.分配48字节的栈空间, 使用情况见 step11

sub    sp, sp, #48// =48

//2.stp 和 str 类似, 区别是 stp 一次保存多个

// 这里等于把 x29/FP=> [sp +32], x30/LR=> [sp +40]

stp    x29, x30, [sp, #32]            //16-byteFoldedSpill

//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-byteFoldedSpill

//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-byteFoldedReload

//11.回顾一下分配的48字节栈空间的使用情况

| sp +40|LR(8bytes)

| sp +32|FP(8bytes)

| sp +24|0(8bytes, 低四位(sp +28) 存放0)

| sp +16|X0(8bytes)

| sp +8|0(8bytes, 低四位(sp +28) 存放0)

| sp      |    (8bytes, 为了16对齐, 多分配出来的)

// 和 step2 操作相反, 恢复X29,X30, 也就是FP和LR寄存器

// 类似 ldr, ldp load 多个:X29<= [sp +32],X30<= [sp +40]

ldp    x29, x30, [sp, #32]            //16-byteFoldedReload

  // 释放栈空间

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

3. 参数超过 8 个参数, 通过栈空间传递参数的例子

test 函数共有 10 个参数, 为了保持简单, 这里都使用 long 类型的.

longtest(longn1,longn2,longn3,longn4,longn5,

longn6,longn7,longn8,longn9,longn10){

returnn1 + n2;

}

intmain(){

longz =test(1,2,3,4,5,6,7,8,9,10);

return0;

}

我们先看一下函数调用的时候, 栈的分配, 下面是对应的 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

4. 总结一下函数调用的通用逻辑

调用前

可能会修改的寄存器先入栈保存

准备函数的参数, 前8个参数参数放入 X0-X8

剩余参数入栈

使用 bl 调用目标函数

执行 bl 之前会把 bl 下一行指令的地址放入 lr 寄存器

从 X0-X9 拿到前 8 个参数, 然后从上个函数栈的栈中取出剩余的参数

目标函数执行完, ret 的时候, 会把 lr 寄存器的值 store 到 PC 寄存器

执行 pc 寄存器对应的地址, 也就是前面 bl 下一行 (step 9 的指令)

调用后

恢复 1.1 中入栈的寄存器值, 恢复调用前的状态

相关文章

网友评论

      本文标题:函数调用 (Function Call Convention)

      本文链接:https://www.haomeiwen.com/subject/dayefdtx.html