-
-
[原创] AArch64函数栈的分配,指令生成与GCC实现(上)
-
发表于: 2022-4-5 08:50 19503
-
本篇主要介绍原理,源码分析见 AArch64函数栈的分配,指令生成与GCC实现(下)
为便于理解,本文中后续术语含义解释如下:
aarch64下函数栈是向低地址方向增长, 按照由高=>低的顺序其函数栈可以包含如下区域:
虽然函数栈整体是由高地址=>低地址增长的,但每个区域内部有自己的变量分配顺序。这些区域的主要作用为:
即传入栈参数区域, 内部元素分配方向: 低地址 => 高地址。
严格来讲这段区域是caller分配的, 并不属于callee栈帧的一部分, 当caller调用callee时, callee 通过incoming 区域接收caller的参数。
对于caller来说这段区域称为outgoing stack argument(传出栈参数区域), 一个函数的 incoming 区域首地址等于其父函数的outgoing 区域的首地址。
即匿名寄存器参数区域, 内部元素分配方向: 低地址 => 高地址。
对于可变参函数,此区域用来保存函数中可能用到的所有匿名寄存器参数,va_xxx系列函数的参数解析依赖于此区域。
从此区域开始(以及向低地址方向的内存)均为callee分配, 此区域按照高地址=>低地址实际上分为GPRs/FPRs两个子区域; 每个区域内部元素分配方向均是低地址=>高地址;
即局部变量区域, 内部元素分配方向: 高地址 => 低地址。
函数中显示定义的变量,编译器内部生成的临时变量均存放在此区域中。
需要注意的是,编译优化可能导致变量分配顺序与源码中定义顺序不同,在不考虑优化的情况下其内部元素分配方向默认是高地址=>低地址。
即callee-saved寄存器区, 内部元素分配方向: 低地址 => 高地址。
在AAPCS64标准中[3]:
即动态栈分配区, 内部元素分配方向: 高地址 => 低地址。
当函数中调用alloca时会在栈中dynamic allocation区域分配需内存,这部分区域在编译期间不存在, 在运行期间大小可以随执行变化。
即栈参数传出区域, 内部元素分配方向: 低地址=> 高地址;
当caller需要通过栈向子函数传递参数(如大于8个GPR参数)时, 栈参数保存在caller的outgoing区域。
一个函数(作为caller)其outgoing区域的大小是固定的,取决于其调用的callees中使用了最多栈参数的那个函数。当函数中存在动态分配时,其outging区域的首地址是可能动态变化的,但总是等于当前硬件寄存器sp的值, caller调用callee时也是通过此sp将其outgoing区域首地址传递给callee(作为callee的incoming区域首地址)。
按照AAPCS64标准,以通用寄存器(GPRs)传参为例(不考虑浮点参数和结构体参数等) :
如以下函数:
其中:
1. func1/func2的栈参数来自其caller(main1)的函数栈
2. 两次函数调用的前两个寄存器参数在栈中的地址是相同的, 其中:
在incoming/outgoing区域, 栈参数是基于callee入口时sp向高地址方向寻址的, 正是由于参数首地址相同且ABI规则相同(AAPCS64), caller和callee之间才能正确的传递栈参数.
以上代码的函数栈如图:
这里需要注意的是:
通过汇编代码可以更好的理解outgoing/incoming区域:
可变参函数指的是一个可以接受可变个参数的函数, 调用此函数时只有caller知道为此函数传入了多少个参数, 可变参函数callee只知道caller最少传入了多少个参数:
对于可变参函数(callee), 其需要:
在运行时通常需要通过已知的某个命名参数来确定caller到底传入了多少个参数(caller在调用可变参数函数时通常需要显式或隐式的告知callee自己传入了多少个参数), 如:
可变参函数的参数分为四类, 即寄存器命名参数, 寄存器匿名参数, 栈命名参数, 栈匿名参数, 举例如下:
可变参函数的参数传递同样满足AAPCS64:
* 对caller来说, 编译期间即可确定其向可变参callee传入了多少个参数,故其直接按照AAPCS64标准布局参数(寄存器参数直接赋值给硬件寄存器,栈参数保存到outgoing栈对应位置).
* 对callee来说, 编译期间只能确定有多少个命名参数:
* 对callee来说, 匿名参数的个数需直接或间接通过某个命名参数得知:
举例如下:
输出结果:
其函数栈分布如图:
由此函数可以看到:
若func有大于8个整型, 8个浮点型参数,则其函数栈中不再需要为匿名寄存器参数预留空间,如:
在不开启优化时, 局部变量通常是按照其定义顺序依次分配到局部变量区域的(高地址=>低地址), 但开启优化时局部变量的分配可能经过延迟展开,重排等优化, 此时源码中定义顺序与分配顺序可能不同。
局部变量区的尾地址(即第一个局部变量的尾地址):
故对于定参函数,通常会看到如下布局:
局部变量区除了用来保存源码中定义的局部变量外,还用来存储gcc编译过程中生成的临时变量以及硬件寄存器参数的原始值(若需要), 以三.2中代码为例,由其局部变量区可见:
1. 局部变量区域的起始地址为变量var0的尾地址
2. 源码中的局部变量var0/var1/vl按照高地址=>低地址的顺序依次分布(但并非总是如此,编译优化可能改变分配顺序)
3. 源码中存在对参数p0/p1/p2的解引用, 在局部变量区依次为其分配存储空间(参数的存储位置总是在源码中局部变量之后),实际上:
callee-saved regs区域用来保存 callee-saved register的, 按照AAPCS64标准 [r19, r28] 是callee-saved register,这些寄存器在子函数返回时要保持不变,也就是说如果子函数中使用了这些寄存器,则子函数需要保存并在返回前恢复其原始值。 函数栈中callee-saved register区域就是用来保存这些寄存器原始值的。callee-saved regs区域内部也是由低地址=>高地址增长的, 但区别在于此区域按照寄存器的编号顺序保存,而不是其在源码中被使用的顺序,如:
需要注意的是,当当前函数需要保存frame chain时, x29,x30总是放在callee-saved regs最低端,同样是如上代码:
当源码中调用alloca/__builtin_alloca时会在当前函数栈中为此变量分配存储空间, alloca函数转换成伪c代码可以表示为:
动态栈分配和outgoing区域并不冲突,当前函数栈中:
以下面的函数为例:
函数栈帧是在其prologue中构建,在其epilogue中释放的。prologue的主要工作包括两点: 1) 通过减小sp预留栈空间 2) 保存所有callee-saved 寄存器; epilogue则相反。gcc内部主要依靠4个变量决定如何生成prologue中的指令,分别是:
如下:
在汇编代码中我们通常可以看到4种不同的指令生成方式, 按照gcc中的判断顺序:
以如下函数为例:
prologue的主要作用是调整sp以及保存callee-saved regs,优先选用stp/str Rm, Rn, [sp, -frame_size]! 模板的原因是因为此指令可以在调整栈帧的同时直接在栈顶 save两个寄存器,但同时此模板也受到两个限制:
1. frame_size 不能过大:
frame_size多少算过大取决于当前函数保存的callee-saved reg 的数量:
2. 当前函数不能存在outgoing区域:
若当前函数存在outgoing区域,则 stp/str Rm, Rn, [sp, -frame_size]! 会直接将Rm/Rn 保存到outgoing栈,故不能实用此模板。
若1不成立则优先考虑此模板,以如下函数为例:
若1不成立,且此时 outgoing + callee_saved reg区域也较大时,则只能使用最麻烦的方法,即:
但这实际上又分为两种情况:
若此时hard_fp_offset区域较小,则上面1)/2)有部分可以合并处理, 即通过stp reg1, reg2, [sp, -hard_fp_offset]! 一条指令即将sp先指向callee-saved regs区域,又同时保存了两个寄存器,如:
那只能使用最慢的方式,如:
[1] GCC源码分析(十五) — gimple转RTL(pass_expand)(上)_ashimida@的博客-CSDN博客_gcc gimple
[2] GCC源码分析(八) — 语法/语义分析之声明与函数定义的解析_ashimida@的博客-CSDN博客_gcc源码分析
[3] 《Procedure Call Standard for the ARM 64-bit Architecturn》
[4] GCC源码分析—shrink-wrapping_ashimida@的博客-CSDN博客
[5] https://blog.csdn.net/lidan113lidan/article/details/123962416
版权声明:本文为笔者本人「ashimida@」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/lidan113lidan/article/details/123961152
更多内容可关注微信公众号
/* 从高地址=>低地址, 这些区域包括: * incoming stack: 传入栈参数区域 (这段区域实际上属于caller函数, 在callee中作为栈参数使用) * varargs: 匿名寄存器参数区域 * local variables: 局部变量区域 * callee-saved regs: callee-saved 寄存器存储区域 * dynamic allocation: 动态栈分配区域 * outgoing stack: 传出栈参数区域 */ +-------------------------------+ <- highmem | | | incoming stack arguments | | | +-------------------------------+ <-- incoming stack (aligned)/caller sp | | | callee-allocated save area | | for register varargs | | | +-------------------------------+ | local variables | | | +-------------------------------+ | padding | \ +-------------------------------+ | | callee-saved registers | | frame.saved_regs_size +-------------------------------+ | | LR' | | +-------------------------------+ | | FP' | / +-------------------------------+ <-- 硬件寄存器fp(x29)指向这里(若有) | dynamic allocation | +-------------------------------+ | padding | +-------------------------------+ | outgoing stack arguments | | | +-------------------------------+ <-- 硬件寄存器sp指向这里/callee sp | | | <- lowmem
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!
赞赏
- [原创] AArch64中va_list/va_start/va_arg/...的实现 19803
- [原创] AArch64函数栈的分配,指令生成与GCC实现(上) 19504
- [原创] 内核模块的加载流程 55123
- [原创] linux中的信号处理与SROP 26163
- [原创] AARCH64平台的栈回溯 30315