栈、栈帧、AAPCS的一些粗浅理解(通俗易懂)

ARM过程调用

以前是APCS和TPCS两种,现在都废弃了,统一成了AAPCS(Procedure Call Standard for the ARM Architecture)。
统一了寄存器使用、栈、函数参数传递/返回、回溯的ABI接口规范,主要体现在编译器方面,属于计算机基础,详情可以参考下面的博客:
https://blog.csdn.net/FJDJFKDJFKDJFKD/article/details/102967031
在这里插入图片描述

r0-r15 and R0-R15
a1-a4 (argument, result, or scratch registers,synonyms for r0 to r3)
v1-v8 (variable registers, r4 to r11)
sb and SB(static base, r9)
ip and IP (intra-procedure-call scratch register,r12)
sp and SP (stack pointer, r13)
lr and LR (link register, r14)
pc and PC (program counter, r15).

重点

在不满足寄存器传参(参数个数超过寄存器数量)的场景下,参数将通过栈传递给被调者,ARM 是满减栈, 参数列表从右往左依次从高地址到低地址入栈。

在这里插入图片描述

从下面的一段源码的反汇编代码来浅浅的研究和理解栈帧
源代码:

#include <stdio.h>

static int static_interface_leaf( int x, int y )
{
    int tmp0 = 0x12;
    int tmp1 = 0x34;
    int tmp2 = 0x56;

    tmp0 = x;
    tmp1 = y;

    return (tmp0+tmp1+tmp2);
}

int public_interface_leaf( int x, int y )
{
    int tmp0 = 0x12;
    int tmp1 = 0x34;
    int tmp2 = 0x56;

    tmp0 = x;
    tmp1 = y;

    return (tmp0+tmp1+tmp2);
}

void public_interface( int x )
{
    int tmp0 = 0x12;
    int tmp1 = 0x34;

    tmp0 = x;
    public_interface_leaf( tmp0, tmp1 );
    static_interface_leaf( tmp0, tmp1 );
}

int main(int argc, char **argv)
{
    int tmp0 = 0x12;

    public_interface( tmp0 );

    return 0;
}

反汇编:

static_interface_leaf:
 push   {fp}        ; (str fp, [sp, #-4]!)    //push 压栈 {}中为压入栈中的寄存器
 add    fp, sp, #0                            //确定fp的值,sp+0字节实际上就是栈顶的sp,此时的栈顶就是栈帧的开始
 sub    sp, sp, #28                           #sp减去28字节,分配28字节栈空间,栈是向下增长的,也就是说,栈指针的值减小表示栈向上扩展。
                                              #因此,通过将栈指针减去一个正数,可以为栈分配一段连续的内存空间。
                                              #对于栈来说,减去一个值可以将栈指针向下移动,向栈的高地址方向分配空间。
 str    r0, [fp, #-24]  ; 0xffffffe8          //第一个参数,用r0寄存器暂存,r0指向fp-24字节处的地址(r0中的值填入[fp-24]地址,即栈中)
 str    r1, [fp, #-28]  ; 0xffffffe4          //第二个参数,用r1寄存器暂存,r1指向fp-28字节处的地址(r1中的值填入[fp-28]地址,即栈中)
 mov    r3, #18                               //将第一个局部变量,tmp0,立即数18即0x12,移动到r3寄存器(tmp0)
 str    r3, [fp, #-8]                         //再将r3中的值填入[fp, -8]的地址,即fp之后的8个字节的地址
 mov    r3, #52 ; 0x34                        //将第二个局部变量,tmp1,立即数52即0x34,移动到r3寄存器
 str    r3, [fp, #-12]                        //再将r3中的值填入[fp, -12]的地址,即fp之后的12个字节的地址
 mov    r3, #86 ; 0x56                        //将第三个局部变量,tmp2,立即数86即0x56,移动到r3寄存器
 str    r3, [fp, #-16]                        //再将r3中的值填入[fp, -16]的地址,即fp之后的16个字节的地址
 ldr    r3, [fp, #-24]  ; 0xffffffe8          //从[fp, -24]地址(栈)的内存中加载数据填入r3寄存器,也就是开头r0存入的参数(int x)
 str    r3, [fp, #-8]                         //再把r3的值填回上面r3指向的原地址,完成赋值操作(tmp0 = x;)
 ldr    r3, [fp, #-28]  ; 0xffffffe4          //和上面的操作一样,只不过这次是将传参的y值赋值给tmp1
 str    r3, [fp, #-12]
 ldr    r2, [fp, #-8]                         //r2 = tmp0
 ldr    r3, [fp, #-12]                        //r3 = tmp1
 add    r2, r2, r3                            #r2 = r2 + r3 = tmp0 + tmp1
 ldr    r3, [fp, #-16]                        //r3 = tmp2
 add    r3, r2, r3                            #r3 = r2 + r3 = tmp0 + tmp1 + tmp2
 mov    r0, r3                                #将r3的值移动到r0寄存器,这里用来保存函数返回值
 add    sp, fp, #0                            //更新sp(清理栈空间)
 pop    {fp}        ; (ldr fp, [sp], #4)      //fp出栈,恢复fp
 bx lr                                        #函数返回(返回到之前调用的地方)
public_interface_leaf:
 push   {fp}        ; (str fp, [sp, #-4]!)    //这个叶子函数和上面的没啥区别
 add    fp, sp, #0
 sub    sp, sp, #28
 str    r0, [fp, #-24]  ; 0xffffffe8
 str    r1, [fp, #-28]  ; 0xffffffe4
 mov    r3, #18
 str    r3, [fp, #-8]
 mov    r3, #52 ; 0x34
 str    r3, [fp, #-12]
 mov    r3, #86 ; 0x56
 str    r3, [fp, #-16]
 ldr    r3, [fp, #-24]  ; 0xffffffe8
 str    r3, [fp, #-8]
 ldr    r3, [fp, #-28]  ; 0xffffffe4
 str    r3, [fp, #-12]
 ldr    r2, [fp, #-8]
 ldr    r3, [fp, #-12]
 add    r2, r2, r3
 ldr    r3, [fp, #-16]
 add    r3, r2, r3
 mov    r0, r3
 add    sp, fp, #0
 pop    {fp}        ; (ldr fp, [sp], #4)
 bx lr
public_interface:
 push   {fp, lr}                        #这里是和叶子函数不一样的地方,这一级会把LR寄存器压栈,保存返回到main函数的地址
 add    fp, sp, #4
 sub    sp, sp, #16
 str    r0, [fp, #-16]
 mov    r3, #18
 str    r3, [fp, #-8]
 mov    r3, #52 ; 0x34
 str    r3, [fp, #-12]
 ldr    r3, [fp, #-16]
 str    r3, [fp, #-8]
 ldr    r1, [fp, #-12]
 ldr    r0, [fp, #-8]
 bl 60 <public_interface_leaf>            #跳转到指定执行体,并更新LR,即改成当前函数的返回(调用)地址
    R_ARM_CALL public_interface_leaf
 ldr    r1, [fp, #-12]
 ldr    r0, [fp, #-8]
 bl 0 <static_interface_leaf>
 nop            ; (mov r0, r0)
 sub    sp, fp, #4
 pop    {fp, pc}                          #PC不用关注,一般会自动更新,如果要压栈出栈,注意一下是否是上下文切换或异常&中断即可
main:
 push   {fp, lr}
 add    fp, sp, #4
 sub    sp, sp, #16
 str    r0, [fp, #-16]
 str    r1, [fp, #-20]  ; 0xffffffec
 mov    r3, #18
 str    r3, [fp, #-8]
 ldr    r0, [fp, #-8]
 bl c0 <public_interface>
    R_ARM_CALL public_interface
 mov    r3, #0
 mov    r0, r3
 sub    sp, fp, #4
 pop    {fp, pc}

汇编指令说明
add(加法)指令:用于将两个值相加并将结果存储在目标寄存器中。例如,“add r0, r1, r2” 将把寄存器 r1 和 r2 中的值相加,并将结果存储在寄存器 r0 中。
sub(减法)指令:用于将两个值相减并将结果存储在目标寄存器中。例如,“sub r0, r1, r2” 将从寄存器 r1 中减去寄存器 r2 中的值,并将结果存储在寄存器 r0 中。
str(存储)指令:用于将数据存储到内存中的指定位置。例如,“str r0, [r1]” 将把寄存器 r0 中的值存储到以寄存器 r1 中的地址为基准的内存位置。
mov(移动)指令:用于将一个值从一个位置复制到另一个位置,可以是寄存器之间的复制或者寄存器和内存之间的复制。例如,“mov r0, #10” 将立即数 10 移动到寄存器 r0 中。
ldr(加载)指令:用于从内存中加载数据到寄存器中。例如,“ldr r0, [r1]” 将从以寄存器 r1 中的地址为基准的内存位置加载数据到寄存器 r0 中。
bx(分支和交换)指令:用于实现无条件跳转到目标地址的分支指令。例如,“bx r0” 将跳转到寄存器 r0 中存储的地址处执行代码。
bl(分支和链接)指令:用于实现函数调用和跳转,并将返回地址存储在链接寄存器中。例如,“bl func” 将跳转到标记为 “func” 的函数,并将返回地址存储在链接寄存器中,以便函数执行完毕后返回到正确的位置。
nop(空操作)指令:用于插入一个空操作指令,不执行任何操作,只是占据一个指令周期。通常用于调试或填充内存。

从上面的汇编代码中可以看到,数据在函数执行体中的工作方式实际上就是不断地将数据存入通用寄存器,再将寄存器中的值存入内存(栈)中,当赋值的时候,再将内存中的数据拿回放到寄存器中,再存到别的内存地址,每一段代码都会像这样重复无数遍。
寄存器的运算机制暂且不表,接下来看sp、fp寄存器,目的是为了搞懂栈帧以及栈的区别,还有原理。

首先要理解栈和栈帧这两个概念。

简单理解为,从main函数开始,到最后一个函数调用结束之间所用到的空间,由sp指定范围。
还可以从两种角度来理解
从数据结构的角度:一种描述先入后出的数据结构。(特性)
从进程的内存空间的角度:一种特殊的内存段,用于存放局部变量、函数参数、返回值等。(实际应用)
栈帧
属于各个函数执行体的内部空间,可以认为是函数的栈空间,由fp指定范围。
其实就类似于“子集”的概念。
举个简单的例子,在大厦中,有一部电梯,fp就相当于电梯中的楼层按钮,而sp就相当于这部电梯。(这个例子只是简单的用来理解栈和栈帧,与先入后出的概念无关)
电梯(sp)会随着按钮(fp)到达指定的楼层。比如当按下28楼,电梯会移动到28楼(入栈),此时就是28楼的入口(开始/栈顶),fp就只能走进28楼。
此时的28楼有一个空间,这个空间的大小取决于里面的有效空间,比如整个28楼被规划出8个办公室(局部变量),那么fp只能是在这8个办公室的空间中做自己想做的事,探索完成(到达栈底)后,重新召唤电梯,前往下一个更高的楼层。
想下楼的时候,首先要调整的就是电梯的方向(出栈),毕竟想到高楼层就要往上,想下楼就要往下嘛,下楼的时候,就要把上一层的入口关闭,然后一层一层的移动,直到电梯移动到最开始的地方。

接下来看看代码中的栈和栈帧的处理。
SP栈指针
从main函数开始,把sp相关的代码按照调用顺序排列(暂且不管FP指针)

main:
    add    fp, sp, #4
    sub    sp, sp, #16    //设置栈帧,16字节
        public_interface:
            add    fp, sp, #4
            sub    sp, sp, #16    //设置栈帧,16字节
                static_interface_leaf:    
                    add    fp, sp, #0
                    sub    sp, sp, #28    //设置栈帧,28字节
                    ...
                    add    sp, fp, #0
                public_interface_leaf:
                    add    fp, sp, #0
                    sub    sp, sp, #28    //设置栈帧,28字节
                    ...
                    add    sp, fp, #0
            ...
            sub    sp, fp, #4
    ...
    sub    sp, fp, #4

从上面的设置栈帧大小的sub减法指令可以看出栈的一个特性,就是栈的增长方向是向下的,fp和sp都是向低地址增长。
高地址(栈顶)的值大,低地址(栈底)的值小,所以减去一个整数就相当于指向低地址,也就相当于创建了更多的栈空间。此时sp加上一个负偏移量,增加了栈的大小的同时也增加了栈帧的大小。从指向上看是向下,从栈空间增长上看是向上,但不管是哪个角度去看,栈的空间都是变大了,所以一般说的栈是向下增长也就不难理解。
而add加法指令和最后面的sub减法指令则可以看到栈和栈帧的关系是如何实现的。
在函数的开头,add fp, sp, #0 或 add fp, sp, #4 指令将栈指针sp的值赋给帧指针fp。帧指针fp用于引用当前函数的栈帧,通过它可以访问局部变量和参数。
如下图所示,在函数的结尾,sub sp, fp, #0 或 sub sp, fp, #4 指令用于恢复栈指针sp的值,将其指向上一级函数的栈帧。这是为了清除当前函数的栈帧,释放栈空间,并回到上一级函数继续执行。
在这里插入图片描述

这样做的效果就是将栈指针恢复到调用函数的位置,相当于弹出了被调用函数的栈帧,释放了栈空间。
要是还不理解,我换个说法,将栈指针恢复到上一级函数调用的地方,被调用的函数的栈就相当于无效了,因为sp指示的是栈的位置和范围,所以之前压栈的数据就会从低地址向高地址弹出,将占用过的栈空间恢复成未使用(其他人可以用了)的状态,同时将栈恢复到了函数调用前的状态,介就是先进后出(LIFO)。
这里就会有一个很经典很重要的问题需要注意,就是通常所说的函数返回后不能访问被返回函数的局部变量,这其中的局部指针变量尤其致命,有可能会导致很多不好的事情发生(自行了解)。这就是因为,上面的栈帧的清空,只是恢复了栈的指向,其中的局部变量和其他数据是依旧存在内存中的,但是栈指针已经不再指向它们,之前的局部变量和其他数据的偏移量已经失效,所以后面的函数调用中,这些数据要么被覆盖要么就直接无效,这时候再去访问上一个栈帧中的局部变量(同一个偏移量)就会访问到其它函数的栈帧或者无效的内存区域。
这其实涉及到的就是设计之初,对计算机程序生命周期以及CPU资源的思考,如果将函数返回了,那么就代表你已经不需要这个函数里面的东西,CPU就不应该浪费一个或者几个指令周期的资源在这上面,这也是要让开发者去思考,什么东西存在价值,这样可以避免浪费宝贵的资源。
如果需要在函数调用结束后访问被调用函数的局部变量和其他数据,通常需要采取其他方式,例如将这些数据保存到全局变量、堆内存或者通过函数的返回值传递回调用函数。
这里可以顺便说说还有哪些清空栈的方法:

使用add 或 sub 指令(上面的汇编代码就是这样用的):
add sp, sp, #n:通过将栈指针(SP)增加一个偏移量 n的方式来清空栈。这将使栈指针向高地址方向移动,跳过之前被压入栈的数据。
sub sp, sp,#n:通过将栈指针(SP)减少一个偏移量 n 的方式来清空栈。这将使栈指针向低地址方向移动,回到调用函数的栈帧位置。
使用mov 和 ldr 指令
mov sp,fp:将帧指针(FP)的值直接赋给栈指针(SP)。这将使栈指针恢复到调用函数的栈帧位置,达到清空栈的效果。
ldr sp,[fp]:通过加载帧指针(FP)所指向的内存位置的值来恢复栈指针(SP)。这个内存位置存储了调用函数的栈帧位置,恢复栈指针将清空栈。
使用函数调用指令
一些架构和汇编语言提供特定的函数调用指令,如 ret 或return。当函数调用结束时,执行这些指令将自动清空栈,将栈指针恢复到调用函数的位置。

实际上用哪些方法更好,这要对使用的编译器,程序的整体结构,内存的优化等,要很有考究的去选择。
程序中展开栈帧的时候,还需要确定实际使用到的空间,并在这的基础上做一些优化和预留,这主要体现在编译器对程序主体的分析判断上,总结一下这里编译器会对各个部分的一些分析。

编译器通过静态分析和语义解析来确定每个函数所需的栈空间大小。具体的过程可能会因编译器实现和目标平台而有所不同,但通常包括以下几个步骤:
局部变量分析:编译器会分析函数内部的局部变量声明和使用情况。它会确定每个局部变量的类型、大小和作用域,并计算出每个局部变量所需的存储空间。
函数调用分析:编译器会分析函数的参数传递方式和返回值处理方式。它会确定函数参数的个数、类型和大小,并考虑函数调用过程中的栈空间需求,比如参数传递、返回地址保存等。
控制流分析:编译器会分析函数内的控制流语句,比如条件语句、循环语句等。它会考虑控制流路径上局部变量的生命周期和作用域,以确定在每个控制流路径上所需的栈空间大小。
运算和表达式分析:编译器会分析函数内的运算和表达式,比如算术运算、函数调用等。它会考虑运算过程中临时变量的使用情况,并计算出临时变量所需的栈空间大小。
综合以上分析,编译器会计算出每个函数所需的栈空间大小,并在生成的汇编代码中进行相应的指令和偏移量的设置,以确保函数在运行时有足够的栈空间可用。

需要注意的是,编译器可能会进行一些优化,比如寄存器分配、尾调用优化等,以减少对栈空间的需求。这些优化可能会影响最终分配给函数的栈空间大小。
FP栈帧指针
fp会随着局部变量或者其他数据的使用而变化。
在上面的汇编代码中,可以看到在最开始fp都会以sp为基准来展开栈帧,而在展开栈帧获得栈帧空间后,栈帧基本上不会再有改动,代码中看到的一些fp的运算,实际上只是以栈帧的顶部开始,并确定数据准确的存入地址而已,栈帧是没有变化的,直到最后栈帧的空间被确定不再需要的时候,才以sp为界限结束此栈帧的使用,也就是函数执行体结束、到达栈底,开始返回。

以上的篇幅只是浅浅浅浅浅的对栈帧和栈的一些研究和理解,实际上,这里面的情况远远要复杂得多,三天三夜都说不完,前人的智慧总是值得后人去深入发掘,这是一条朗朗大道,走不到尽头。

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值