一、引言
随着计算机技术的飞速发展,人们对计算机的性能要求越来越高,为了突破32位架构的4GB地址空间限制,并实现更好的性能提升。ARM公司推出了一种64位处理器架构,也就是我们今天所要讨论的ARM64。ARM64(也称ARMv8)面世以来,在移动设备、嵌入式系统以及服务器领域得到了广泛的应用。对于我们开发人员来说,深入了解ARM64架构下的函数调用标准和栈布局可以帮助我们更好的进行开发以及优化我们的代码。
本文我们将深入探讨ARM64架构下的函数调用约定,包括寄存器约定、参数传递、返回值处理以及调用者/被调用者寄存器保存规则等。同时,我们也将分析ARM64体系结构下的栈布局,包括栈指针、帧指针以及栈帧对齐这些方面。下面我们就一起来看一下ARM64的函数调用标准与栈布局吧。
二、ARM64函数调用标准
2.1 定义
函数调用标准(Procedure Call Standard, PCS)是指在函数调用过程中,对父/子函数如何编译以及链接做一些规范和约定。如参数的传递,返回值处理、寄存器的使用和栈的布局等。函数调用标准定义了函数调用的具体实现机制。
注意:
对于不同的处理器架构都有不同的函数调用标准,本文我们将讲解关于ARM64的函数调用标准。
Procedure Call Standard for the Arm 64-bit Architecture.pdf
是一份描述ARM64架构函数调用的标准和规范文档。该文档网址地址点这里。
2.2 寄存器的使用约定
在深入探讨ARM64函数调用标准和栈布局之前,我们先简要了解一下ARM64架构的通用寄存器以及SP寄存器,它们在ARM64栈布局的实现中起着关键作用。
ARM64架构提供了31个64位通用寄存器,编号从X0到X30.这些通用寄存器可以用于存储整数、地址和指针等数据。
SP寄存器用来管理函数调用期间的栈空间。SP寄存器指向当前栈的栈顶位置。
ARM64架构寄存器的描述我在前面的文章有详细介绍,大家感兴趣可以点击这里。
下面我们来简单了解一下ARM64架构的通用寄存器以及SP寄存器(特殊寄存器)。
寄存器 | 特殊寄存器 | 功能描述 |
---|---|---|
sp | 存放栈指针 | |
X30 | LR | 链接寄存器,在函数调用时存储函数的返回地址 |
X29 | FP | 栈帧指针寄存器,指向当前函数的栈帧 |
X19 ~X28 | 被调用函数保存的寄存器。在子函数中使用时需要保存到栈中 | |
X18 | 通常被用作平台相关的寄存器 | |
X17 | IP2 | 临时寄存器或者第二个IPC(Intra-Procedure-Call)临时寄存器 |
X16 | IP1 | 临时寄存器或者第一个IPC(Intra-Procedure-Call)临时寄存器 |
X9~X15 | 调用者保存的寄存器,临时寄存器 | |
X8 | 间接结果位置寄存器,用于保存子程序的返回地址 | |
X0 ~X7 | 用于传递子程序参数和结果,若参数个数大于8,就采用栈来传递。64位的返回结果采用X0寄存器,128位的返回结果采用X0和X1两个寄存器 |
三、 ARM64栈布局
3.1 栈的概念
栈是一种特殊的数据结构,它遵循后进先出(LIFO,Last-In-First-Out)的原则。栈只允许在栈顶进行添加(push)或删除(pop)元素的操作。
在 ARM64 架构中,栈的方向是从高地址往低地址生长。栈的起始地址称为栈底,栈从高地址延伸到栈顶(低地址)。
注意: A64指令集使用加载和存储指令来实现入栈和出栈操作。A32指令集提供了PUSH和POP指令来实现入栈和出栈操作,但是A64指令集已经去掉了PUSH和POP指令集。
栈在函数调用过程中起到非常重要的作用,包括存储函数使用的局部变量、传递参数等。
3.2 栈帧的概念
在函数调用过程中,栈是逐步生成的。当一个函数被调用时,CPU 会在内存栈上为该函数创建一个新的栈空间。即从该函数栈底(高地址)到栈顶(低地址)的这段空间我们把它称为栈帧(stack frame)。栈帧是函数调用时在内存栈上分配的一块区域,用于存储函数的局部变量、参数等信息。
栈帧通常包含以下几个部分:
返回地址: 当前函数返回时需要跳转的地址,通常是调用函数的下一条指令。返回地址通常是由调用指令自动压入栈的。
函数参数: 传递给当前函数的参数值,它们需要被保存在栈帧中。当函数返回时,这些参数值需要被恢复。
局部变量: 函数内部声明的局部变量,它们的生命周期仅限于函数的执行期间。这些变量需要被保存在栈帧中,以免被其他函数覆盖。
寄存器备份: 在函数调用过程中,需要保存一些重要的寄存器值,以便在返回时恢复。这些寄存器可能包括函数返回地址寄存器、帧指针寄存器等。
动态分配的内存: 函数在执行过程中动态分配的内存空间,需要在栈帧中保存相关信息。这些动态分配的内存会在函数返回时被自动释放。
四、代码分析
4.1 代码示例
下面我们将以具体的代码为例对ARM64的函数调用标准与栈布局进行详细分析。我们先来看一下这段代码。
文件名:main.c
#include <stdio.h>
int function2(int c, int d) {
int ret = c + d;
return ret;
}
int function1(int a, int b) {
int ret = a - b;
ret = function2(a, ret);
return ret;
}
int main(void) {
int i = 3, j = 1;
int ret = function1(i, j);
return 0;
}
这段代码的函数调用关系为main()---->function1()---->function2()。
4.2 反汇编
通过反汇编可以帮助我们深入地分析函数的的调用过程,观察寄存和栈的使用情况,从而全面地理解ARM64函数调用的实现细节。下面我们就来看一下上面C
代码所对应的汇编代码。
4.2.1 反汇编的方式
4.2.1.1 在线编译平台
在线编译平台网址:Compiler Explorer。
编译器: ARM64 gcc trunk
编译器flag: -O0
如下图所示
4.2.1.2 使用交叉编译
在Linux中使用交叉编译工具
$ aarch64-linux-gnu-gcc main.c -O0 -o main.out
$ aarch64-linux-gnu-objdump -j .text -ld -C -S main.out
4.2.1.3 在ARM64架构上直接反汇编
gcc main.c -O0 -o main.out
objdump -j .text -ld -C -S main.out
4.2.2 反汇编后的代码
000000000000074c <function2>:
function2():
74c: d10083ff sub sp, sp, #0x20
750: b9000fe0 str w0, [sp, #12]
754: b9000be1 str w1, [sp, #8]
758: b9400fe1 ldr w1, [sp, #12]
75c: b9400be0 ldr w0, [sp, #8]
760: 0b000020 add w0, w1, w0
764: b9001fe0 str w0, [sp, #28]
768: b9401fe0 ldr w0, [sp, #28]
76c: 910083ff add sp, sp, #0x20
770: d65f03c0 ret
0000000000000774 <function1>:
function1():
774: a9bd7bfd stp x29, x30, [sp, #-48]!
778: 910003fd mov x29, sp
77c: b9001fe0 str w0, [sp, #28]
780: b9001be1 str w1, [sp, #24]
784: b9401fe1 ldr w1, [sp, #28]
788: b9401be0 ldr w0, [sp, #24]
78c: 4b000020 sub w0, w1, w0
790: b9002fe0 str w0, [sp, #44]
794: b9402fe1 ldr w1, [sp, #44]
798: b9401fe0 ldr w0, [sp, #28]
79c: 97ffffec bl 74c <function2>
7a0: b9002fe0 str w0, [sp, #44]
7a4: b9402fe0 ldr w0, [sp, #44]
7a8: a8c37bfd ldp x29, x30, [sp], #48
7ac: d65f03c0 ret
00000000000007b0 <main>:
main():
7b0: a9be7bfd stp x29, x30, [sp, #-32]!
7b4: 910003fd mov x29, sp
7b8: 52800060 mov w0, #0x3 // #3
7bc: b9001fe0 str w0, [sp, #28]
7c0: 52800020 mov w0, #0x1 // #1
7c4: b9001be0 str w0, [sp, #24]
7c8: b9401be1 ldr w1, [sp, #24]
7cc: b9401fe0 ldr w0, [sp, #28]
7d0: 97ffffe9 bl 774 <function1>
7d4: b90017e0 str w0, [sp, #20]
7d8: 52800000 mov w0, #0x0 // #0
7dc: a8c27bfd ldp x29, x30, [sp], #32
7e0: d65f03c0 ret
7e4: d503201f nop
7e8: d503201f nop
7ec: d503201f nop
这部分汇编代码对应的函数调用关系为main()---->function_1()---->function_2()。
注意: 在C语言程序的执行过程中,程序的入口并不是从main()函数开始的。因为在程序执行之前,底层操作系统需要完成一些初始化工作。具体来说,在程序开始执行之前,操作系统会加载程序的可执行映像到内存中,在这个过程中,操作系统会调用一些特殊的启动例程(startup routine)来执行一些必要的初始化工作,如设置栈指针、处理命令行参数等。这些启动例程通常定义在编译器提供的运行时库(runtime library)中,并在main()函数之前被调用执行。因此,在main()函数被调用之前,这些启动例程就已经完成了必要的初始化工作。
4.3 汇编代码分析
4.3.1 函数调用标准的注意点
在分析函数调用栈帧变化之前,ARM64的函数调用标准我们要确认以下几点:
-
寄存器是唯一被所有过程(函数调用)共享的资源,虽然在给定时刻只有一个函数调用只在执行,但是我们仍然要确保当一个过程(caller - 调用者)调用另一个过程(callee - 被调用者)时,callee不会覆盖caller稍后会使用的寄存器值,callee必须保存这些寄存器的值,保证他们的值在 callee返回到caller 与 caller调用callee 的值是一样的。 对于ARM64架构 X18 - X28,X29(FP),x30(LR) 被划分为被调用者保存寄存器。
-
callee要想保证一个寄存器不变,要么就是根本不改变它,要么就是把原始值压入栈中,callee把原始值压入栈中就可以使用该寄存器了,返回到caller时,将其从栈中弹出,恢复该寄存器的值。
-
函数的前8个参数用X0 - X7 寄存器传递,如果参数类型长度小于等于int,那么使用W0 - W7。
-
函数的返回值保存在X0寄存器中,如果返回值类型长度小于等于int,那么使用W0。
-
一个函数在分配自己的栈帧时,sp指针只扩大一次,刚进入函数的时候,就会一次性把所有需要的栈空间都申请出来。
-
栈指针为16字节对齐,分配栈空间的时候以16字节为单位对齐。
-
所有的函数调用栈都会组成一个单链表。
-
每个栈由两个地址来构成这个链表,这两个地址都是64位宽的,并且它们都位于栈的底部。
低地址存放: 指向上一个栈帧(父函数的栈帧)的栈基地址FP,类似于链表的prev指针。我们把这个地址称为P_FP(Previous FP),以区别于处理器内部的FP寄存器。
-
高地址存放: 当前函数的返回地址,也就是进入该函数时LR的值,我们把这个地址称为P_LR(Previous LR)。
-
处理器的FP和SP寄存器相同。在函数执行时,FP和SP寄存器会指向该函数栈空间的FP处。即栈底。
-
函数返回时,ARM64处理器先把栈中的P_LR的值载入当前LR寄存器,然后再执行ret指令。
4.3.2 相关汇编指令说明
ARMv8系统架构是基于指令加载和存储的体系架构。在这种体系架构中,所有的数据处理都需要在通用寄存器中完成,而不能直接在内存中完成。因此需要把待处理的数据从内存加载到通用寄存器,然后进行数据处理,最后把结果写入内存中。
LDR //把存储器地址中的数据加载到目标寄存器中
STR //把源寄存器的数据存储到存储器地址中
LDP //加载寄存器对根据基寄存器值和立即数偏移量计算地址,从内存加载两个32位字或两个64位双字,并将它们写入两个寄存器。
STP //存储寄存器对根据基寄存器值和立即数偏移量计算地址,并从两个寄存器将两个32位字或两个64位双字存储到计算的地址。
BL //实现函数跳转操作,带返回地址的调跳转指令。BL指令将返回地址设置到LR(X30寄存器)中,保存的值为调用BL指令的当前PC值加上4。
RET //从子程序中返回到调用该子程序的主程序
SUB //将两个操作数相减,并将结果存储在目的操作数中
ADD //将两个操作数相加,并将结果存储在目的操作数中
NOP //不执行任何操作。作用为占位,延时或无操作
注意: 上面汇编代码第一列代表汇编代码在内存中的地址,第二列表示的是具体的 ARM 汇编指令的16进制。
例:a9be7bfd 等同于 stp x29, x30, [sp, #-32]!
4.3.3 main函数栈变化过程
main函数开辟自己的栈空间,同时保存caller的FP和LR
当代码执行到main函数时,由于main函数不是叶子函数,而main函数要使用x29(FP), x30(LR)寄存器,需要将这两个寄存器的值保存到当前栈,以便返回时恢复。
7b0: a9be7bfd stp x29, x30, [sp, #-32]!
# 注释
#stp: 将两个寄存器的值一起存储到内存中。
# x29,x30 要存储的寄存器
# [sp, #-32] 将数据存储到以sp为基地址、偏移量为-32的内存位置。
# sp:栈指针寄存器,用于指示当前栈顶的位置
# #-32: 表示偏移量为-32,即向下偏移32个字节
# !表示在执行存储操作后,将sp寄存器更新为新的地址。
这条指令的作用是将x29和x30寄存器的值压入栈中,存储在sp寄存器指向的位置减32个字节的地址处。同时将sp寄存器的值减少32,使其指向新的栈顶位置。
重点:
为什么是给main函数的栈帧分配 32 字节的空间?
上文我们说了一个函数在分配自己的栈帧时,sp指针只扩大一次,刚进入函数的时候,就会一次性把所有需要的栈空间都申请出来。现在我们来解释一下。
返回地址(x30寄存器): 在函数调用过程中,需要保存被调用函数的返回地址,以便函数执行完后能正确返回到调用点。x30 寄存器通常用于存储返回地址,占用 8 字节。
函数的栈帧指针 (x29 寄存器): x29 寄存器通常用作帧基址指针(frame base pointer),用于访问函数的局部变量和参数。保存 x29 寄存器也需要 8 字节空间。
函数的局部变量: 函数内部定义的局部变量需要在栈上分配空间,main函数中有i,j,ret三个4字节的局部变量,需要12字节的空间。
函数调用时传递的参数: 在mian函数中,我们没有使用传参,所以不使用栈空间。main函数的栈空间: x30 + x29 + (3 * int)= 8+8+12 = 28;因为栈指针为16字节对齐,分配栈空间的时候以16字节为单位对齐。所以申请的栈空间为16的倍数,也就是需要申请32字节大小的栈空间。
程序在内存中分布区域如下图:
我们主要对stack区进行分析,下面我们用图来理解stp x29, x30, [sp, #-32]!
这个指令执行的操作。
SP是当前函数栈指针,指向栈顶,栈顶存储的FP寄存器的值为当前函数(mian)的栈帧基地址,同时也相当于main函数的caller函数的栈顶。FP寄存器是当前函数栈帧指针,指向当前函数(main)的栈帧基地址。
更新栈帧寄存器FP
7b4: 910003fd mov x29, sp
#注释
#将CPU栈指针寄存器sp的值复制到x29中。
对当前函数(main)来说,执行FP = SP。将当前SP的值赋给FP寄存器,使FP指向当前函数(main)的栈帧基地址。每个有caller和callee的函数都要执行该操作。
main函数的局部变量依次入栈保存
7b8: 52800060 mov w0, #0x3 // #3
7bc: b9001fe0 str w0, [sp, #28]
7c0: 52800020 mov w0, #0x1 // #1
7c4: b9001be0 str w0, [sp, #24]
#注释
# mov w0, #0x3: 将立即数 0x3(十进制 3)加载到 w0 寄存器中。w0 是 64位 x0 寄存器的低32位。
# str w0, [sp, #28]:指令将 w0 寄存器的值(3)存储到以 sp 为基地址、偏移量为 28 的内存地址处。
# mov w0, #0x1:将立即数 0x1(十进制 1)加载到 w0 寄存器中
# str w0, [sp, #24]:将 w0 寄存器的值(1)存储到以 sp 为基地址、偏移量为 24 的内存地址处。
将当前函数的局部变量,依次入栈保存。
将局部变量的值读取到寄存器中准备传参,并跳转到子函数
7c8: b9401be1 ldr w1, [sp, #24]
7cc: b9401fe0 ldr w0, [sp, #28]
7d0: 97ffffe9 bl 774 <function1>
#注释
# ldr w1, [sp, #24]: 当前栈帧的偏移量为 24 的位置读取一个 32 位的值,并将其加载到 w1 寄存器中。把j = 1加载到寄存器w1
# ldr w0, [sp, #28]: 从当前栈帧的偏移量为 28 的位置读取一个 32 位的值,并将其加载到 w0 寄存器中。把i = 3加载到寄存器w0
# bl 774 <function1>:调用function1函数,并保存好当前函数的返回地址,使得function1执行完毕后能够正确的返回到调用点。
在函数调用过程中(bl),CPU会自动将返回地址保存到x30寄存器中,供函数返回时使用。
将子函数返回值赋值给局部变量
7d4: b90017e0 str w0, [sp, #20]
# 注释
# r w0, [sp, #20]:将 w0 寄存器中的值写入到当前栈帧的偏移量为 20 的位置。
因为函数返回值默认保存在W0寄存器,所以w0的值就是子函数的返回值,将子函数返回值赋值给局部变量ret并保存在当前栈帧的偏移量为 20 的位置。
main函数准备返回值
7d8: 52800000 mov w0, #0x0 // #0
#注释
# 这条指令将立即数 0 加载到寄存器 w0 中。用于将函数的返回值设置为 0。
main函数将返回值0放在寄存器w0,在ret返回时给caller。
main函数释放栈,从栈上恢复FP和LR寄存器,并返回
7dc: a8c27bfd ldp x29, x30, [sp], #32
7e0: d65f03c0 ret
7e4: d503201f nop
7e8: d503201f nop
7ec: d503201f nop
#注释
# ldp x29, x30, [sp], #32: 从栈中加载 x29 和 x30 寄存器的值,并将 sp 寄存器增加 32。x29是帧指针,x30 是链接寄存器(存储返回地址)。这条指令用于恢复函数调用之前的栈帧状态。
# 7e0: d65f03c0 ret: 用于函数返回,它从 x30 寄存器中取出返回地址,并跳转到该地址继续执行。
# nop: 不执行任何操作,只是占用一些指令执行周期。它们通常用于对齐代码
当从main()返回时,栈指针会调整回父函数的栈顶,于是main()的栈空间就被释放了。
4.3.4 function1函数栈变化过程
由于function1函数不是叶子函数,所以function1函数要使用x29,x30寄存器,而此时x29,x30的值是caller者函数main()的值,因此要保存起来。function1函数为自己分配栈空间,在其caller main函数的底部,栈向下生长。
函数function1为自己分配栈空间,并保存LR和FP到栈顶
774: a9bd7bfd stp x29, x30, [sp, #-48]!
# 注释
# stp: 将两个寄存器的值一起存储到内存中。
# x29,x30 要存储的寄存器
# [sp, #-48] 将数据存储到以sp为基地址、偏移量为-48的内存位置。
# sp:栈指针寄存器,用于指示当前栈顶的位置
# #-48: 表示偏移量为-48,即向下偏移48个字节
# !表示在执行存储操作后,将sp寄存器更新为新的地址。
这条指令的作用是将x29和x30寄存器的值压入栈中,存储在sp寄存器指向的位置减32个字节的地址处。同时将sp寄存器的值减少48,使其指向新的栈顶位置。
函数function1更新栈帧FP寄存器
778: 910003fd mov x29, sp
#注释
#将CPU栈指针寄存器sp的值复制到x29中。
对当前函数(function1)来说,执行FP = SP。将当前SP的值赋给FP寄存器,使FP指向当前函数(function1)的栈顶。每个有caller和callee的函数都要执行该操作。
function1函数保存寄存器传参的值
从caller(main)传过来的参数保存在w0和w1,接下来需要用到这两个寄存器。因此,先将传参保存到栈上。
77c: b9001fe0 str w0, [sp, #28]
780: b9001be1 str w1, [sp, #24]
#注释
#str w0, [sp, #28]:将w0 寄存器中的值写入到当前栈帧的偏移量为 28 的位置。
#str w1, [sp, #24]:将 w1 寄存器中的值写入到当前栈帧的偏移量为 24 的位置。
先保存w0,后保存w1。先存高地址,后存低地址。压栈按照从高到低顺序(栈底->栈顶)
function1函数将运算结果赋值给局部变量
784: b9401fe1 ldr w1, [sp, #28]
788: b9401be0 ldr w0, [sp, #24]
78c: 4b000020 sub w0, w1, w0
790: b9002fe0 str w0, [sp, #44]
#注释
# ldr w1, [sp, #28]:从栈指针 (sp) 偏移 28 字节的地址处加载一个 32 位的值到寄存器 w1 中。也就是将3加载到w1。
# ldr w0, [sp, #24]:从栈指针 (sp) 偏移 24 字节的地址处加载一个 32 位的值到寄存器 w0 中。也就是将1加载到w0。
# sub w0, w1, w0:从寄存器 w1 中减去寄存器 w0 中的值,并将结果存储到寄存器 w0 中。
# str w0, [sp, #44]: 将寄存器 w0 中的值存储到栈指针 (sp) 偏移 44 字节的地址处。
为局部变量ret分配空间,存放计算结果。ret作为funb函数的第一个局部变量,放在栈底的位置。
function1函数为function2准备传参,并跳转到function2
794: b9402fe1 ldr w1, [sp, #44]
798: b9401fe0 ldr w0, [sp, #28]
79c: 97ffffec bl 74c <function2>
#注释
# ldr w1, [sp, #44]:从栈指针 (sp) 偏移 44 字节的地址处加载一个 32 位的值到寄存器 w1 中。即把2加载到寄存器w1中。
# ldr w0, [sp, #28]: 从栈指针 (sp) 偏移 28 字节的地址处加载一个 32 位的值到寄存器 w0 中。即把3加载到寄存器w0中。
# bl 74c <function2>: 调用function1函数,并保存好当前函数的返回地址,使得function1执行完毕后能够正确的返回到调用点。
在函数调用过程中(bl),CPU会自动将返回地址保存到x30寄存器中,供函数返回时使用。
function1函数处理function2返回结果
7a0: b9002fe0 str w0, [sp, #44]
#注释
#str w0, [sp, #44]:将寄存器 w0 中的数据存储到栈指针 sp 偏移 44 字节的地址处。
子函数function2执行结束后,将返回值保存到寄存器W0。function1函数将返回结果保存到ret处。
function1函数准备返回结果
7a4: b9402fe0 ldr w0, [sp, #44]
#注释
# ldr w0, [sp, #44]:从栈指针 sp 偏移 44 字节的地址处将数据加载到寄存器 w0 中,
function1函数在返回前,将返回结果存放在w0寄存器。
function1在返回前,恢复mian的FP和LR
7a8: a8c37bfd ldp x29, x30, [sp], #48
7ac: d65f03c0 ret
#注释
# ldp x29, x30, [sp], #48:从内存地址 [sp] 开始,加载 64 位的值到寄存器 x29 和 x30 中,在加载完成后,栈指针 sp 会自动增加 48 字节。
# ret:将程序的执行流程返回到链接寄存器 (x30) 所保存的地址处。
4.3.5 function2函数栈变化过程
function2函数为自己分配栈
74c: d10083ff sub sp, sp, #0x20
#注释
# sub sp, sp, #0x20:将栈指针 sp 寄存器的值减小 32 个字节,将减小后的值重新存储回 sp 寄存器。
由于function2函数为叶子函数,没有callee。不需要修改FP和LR寄存器的值,因此无需保存这两个寄存器。
function2函数保存寄存器传参的值
750: b9000fe0 str w0, [sp, #12]
754: b9000be1 str w1, [sp, #8]
#注释
# str w0, [sp, #12]: 将 w0 寄存器的值存储到栈上偏移量为 12 字节的位置。也就是将3存储到栈上偏移量为 12 字节的位置
# str w1, [sp, #8]: 将 w1 寄存器的值存储到栈上偏移量为 8 字节的位置。也就是将2存储到栈上偏移量为 8 字节的位置
function2中给局部变量ret分配空间
758: b9400fe1 ldr w1, [sp, #12]
75c: b9400be0 ldr w0, [sp, #8]
760: 0b000020 add w0, w1, w0
764: b9001fe0 str w0, [sp, #28]
# 注释
# ldr w1, [sp, #12]:从内存地址 sp + 12 处加载数据到寄存器 w1。
# ldr w0, [sp, #8]: 从内存地址 sp + 8 处加载数据到寄存器 w0。
# add w0, w1, w0: 将 w1 和 w0 的值相加,结果存储到 w0 寄存器中。
# str w0, [sp, #28]: 将 w0 的值存储到内存地址 sp + 28 处,也就是ret的值。
function2函数准备返回值到w0,释放自己的栈
768: b9401fe0 ldr w0, [sp, #28]
76c: 910083ff add sp, sp, #0x20
770: d65f03c0 ret
# 注释
# ldr w0, [sp, #28]: 将之前存储在栈上偏移量为 28 字节的位置的值加载到 w0 寄存器中。
# add sp, sp, #0x20: 将 sp 加上 0x20,相当于释放掉之前在函数开始时分配的 32 字节栈空间。
# ret:用于从函数中返回,这条指令将控制权返回到调用该函数的代码处
将返回值保存到w0寄存器,并释放到function2函数的栈空间。
5. 总结
5.1 ARM64典型栈结构
-
每个函数(同时具有caller和callee)的栈结构格式
-
栈顶保存的是自己的栈帧基地址,即指向上一个栈帧的栈顶
-
栈顶+8处保存的是LR寄存器的值,也就是自己retun后要从哪里开始执行
-
然后保存的是传参的值,从传参n到1,从低往高保存
-
然后保存局部变量的值
-
到上一个函数的FP为止,函数的栈结束
-
这样构成了一个完整的栈帧
-
每个函数(同时具有caller和callee)进入后典型的栈操作
-
首先会将caller的FP(也就是当前函数的栈帧基地址)保存到栈的顶部(SP+0)
-
然后,将LR寄存器(返回地址)保存在自己的栈(SP+8)
-
函数总会执行FP=SP操作。因此,对arm64来说,当前函数的FP=SP
-
注意: 如果函数不具有caller和callee,不会执行FP=SP操作
-
每个函数(假设同时具有caller和callee)返回前典型的栈操作
-
将当前栈中保存的LR赋值给LR寄存器,然后ret
5.2 应用场景
FP和LR是两个很重要的寄存器,根据这两个寄存器就可以反推出所有函数的调用栈。
5.2.1 根据当前函数的FP,推断函数调用栈
- 关键寄存器
-
FP寄存器和SP寄存器
-
关键规则
-
所有函数调用栈都会组成一个链表。
-
每个栈有两个地址来构成这个链表。两个64位宽的地址。
-
低地址(栈顶)存放了当前函数的栈帧基地址,也就是caller函数的栈顶,该函数栈顶存放了该函数的栈帧基地址,这种操作类似于链表的prev指针。
-
高地址存放了LR寄存器,当前函数的返回地址。
- 示例
已知如下图所示的function2执行时寄存器的值,我们如何知道函数调用关系以及调用栈?
执行步骤
gcc -g main.c -o a.out
gdb a.out
b function2
run
info registers
-
规律分析
-
由于function2函数为叶子函数,没有callee。不需要修改FP和LR寄存器的值,因此无需保存这两个寄存器。根据当前function2函数执行时的FP(x29)值,我们可以知道function2函数的栈帧基地址,根据当前function2函数执行时的SP值,我们可以知道function2函数的栈顶地址。
-
根据当前function2函数执行时的FP值,我们可以知道function1函数的栈顶地址。
-
从function1函数的栈顶地址位置获取上一级的栈帧指针FP。通过读取该地址的值,我们就知道了function1函数的栈帧基地址,同时也就知道了mian函数的栈顶地址。
- 这样,层层往上递推我们就可以找到所有caller的栈帧基地址,即确定函数的调用关系。
如下图所示:
function2函数的栈顶为SP的值:0xfffffffffa60
function2函数的栈帧基地址为FP的值:0xfffffffffa80
function1函数的栈顶为function2函数执行时的栈帧基地址FP(x29)的值:0xfffffffffa80
function1函数的栈帧基地址为function2函数执行时FP(x29)指向的值:0xfffffffffab0
(gdb) x/gx 0xfffffffffa80
0xfffffffffa80: 0x0000fffffffffab0
main函数的栈顶为function1函数执行时的栈帧基地址FP(x29)的值:0xfffffffffab0
main函数的栈帧基地址为function1函数执行时FP(x29)指向的值:0xfffffffffad0
(gdb) x/gx 0xfffffffffab0
0xfffffffffab0: 0x0000fffffffffad0
验证
function2函数栈帧的基地址为 :0xfffffffffa80
当前栈帧是由位于地址 0xfffffffffab0 的上一个栈帧调用的(即function1函数的栈帧基地址)
上一个栈帧的Stack Pointer值为 0xfffffffffa80(即function1函数的栈顶地址)
Breakpoint 1, function2 (c=3, d=2) at main.c:4
4 int ret = c + d;
(gdb) info frame
Stack level 0, frame at 0xfffffffffa80:
pc = 0xaaaaaaaa0758 in function2 (main.c:4); saved pc = 0xaaaaaaaa07a0
called by frame at 0xfffffffffab0
source language c.
Arglist at 0xfffffffffa60, args: c=3, d=2
Locals at 0xfffffffffa60, Previous frame's sp is 0xfffffffffa80
function1函数栈帧的基地址为 :0xfffffffffab0
当前栈帧是由位于地址 0xfffffffffad0 的上一个栈帧调用的(即main函数的栈帧基地址)
上一个栈帧的Stack Pointer值为 0xfffffffffab0(即main函数的栈顶地址)
Breakpoint 1, function1 (a=3, b=1) at main.c:9
9 int ret = a - b;
(gdb) info frame
Stack level 0, frame at 0xfffffffffab0:
pc = 0xaaaaaaaa0784 in function1 (main.c:9); saved pc = 0xaaaaaaaa07d4
called by frame at 0xfffffffffad0
source language c.
Arglist at 0xfffffffffa80, args: a=3, b=1
Locals at 0xfffffffffa80, Previous frame's sp is 0xfffffffffab0
Saved registers:
x29 at 0xfffffffffa80, x30 at 0xfffffffffa88
main函数栈帧的基地址为 :0xfffffffffad0
上一个栈帧的Stack Pointer值为 0xfffffffffad0
(gdb) info frame
Stack level 0, frame at 0xfffffffffad0:
pc = 0xaaaaaaaa07b8 in main (main.c:15); saved pc = 0xfffff7e798b8
source language c.
Arglist at 0xfffffffffab0, args:
Locals at 0xfffffffffab0, Previous frame's sp is 0xfffffffffad0
Saved registers:
x29 at 0xfffffffffab0, x30 at 0xfffffffffab8
函数调用关系图
5.2.2 根据当前函数栈帧,获取所有caller的函数名,和函数Entry地址
- 关键寄存器
FP寄存器和LR寄存器 -
从5.2.1我们知道,通过FP寄存器就能得到每个函数的栈帧基地址。而知道每个函数的栈帧基地址的条件下,可通过当前函数栈帧保存的LR获得当前函数Entry地址和函数名。
- 理论分析
通过当前函数栈帧所保存的LR间接获取到caller调用callee时的PC,从而根据符号表得到具体的函数名。在caller(调用者)调用callee(被调函数)时,LR指向被调函数callee的返回的下一条指令,通过LR指向的地址-4字节偏移就得到了跳转时的指令,包括被调用函数callee的入口地址。再通过符号表即可得到此入口函数对应的函数名。
-
caller跳转时的pc= *LR - 4 = *(FP + 8 )- 4
- 示例
函数调用关系为:mian()->function1()->function2()
function2函数执行时寄存器的值如下图
根据X30(LR)寄存器的值,得到function2的返回地址(0xaaaaaaaa07a0)。
(gdb) x/gx 0xaaaaaaaa07a0
0xaaaaaaaa07a0 <function1+44>: 0xb9402fe0b9002fe0
function2返回地址-4 (0x0000aaaaaaaa079c)就是calller(function1)跳转到callee时的pc. 配合符号表,对该地址进行反汇编,就得到该地址处的跳转指令。跳转指令中就有callee function2函数的函数名。
第一列: 0x0000aaaaaaaa079c <+40>。表示PC地址 0x0000aaaaaaaa0774指向main函数入口地址+40个字节偏移地址。 0x0000aaaaaaaa079c地址减去偏移40 ,就得到了caller函数function1的入口地址。
第二列: bl 0xaaaaaaaa074c <function2> ,这一列对应的是PC地址处的指令,caller跳转到callee。这里我们可以看到跳转指令(bl) callee的函数名(function2)和callee的入口地址(0xaaaaaaaa074c )。
根据FP寄存器得到function2函数的栈帧基地址为0xfffffffffa80,即function1函数的栈顶地址为0xfffffffffa80
x29 0xfffffffffa80
得到栈帧上保存的LR,0xfffffffffa80 + 8 = 0xfffffffffa88,得到function1的返回地址。
(gdb) x/gx 0xfffffffffa88
0xfffffffffa88: 0x0000aaaaaaaa07d4
function1返回地址-4 (0x0000aaaaaaaa07d0)就是calller跳转到callee时的pc. 配合符号表, 对该地址进行反汇编,就得到该地址处的跳转指令。跳转指令中就有callee function1函数的函数名。
第一列: 0x0000aaaaaaaa07d0 <+32>。表示PC地址 0x0000aaaaaaaa07d0,指向main函数入口地址+24个字节偏移地址。> 0x0000aaaaaaaa07d0地址减去偏移32 ,就得到了caller函数main的入口地址。
第二列: bl 0xaaaaaaaa0774 <function1> ,这一列对应的是PC地址处的指令,caller跳转到callee。这里我们可以看到跳转指令(bl),callee的函数名(function1)和callee的入口地址(0xaaaaaaaa0774 )。