汇编中的栈帧理解

一、基本概念

什么是栈帧?根据《深入理解计算机系统》3.7.1节的解释:C语言过程调用(其实就是函数调用)机制的一个关键特性在于使用了栈数据结构提供的后进先出的内存管理原则。(中间省略)。。。当x86-64过程调用需要的存储空间超过寄存器能够存放的大小时,就会在栈上分配空间,这个部分就称为栈帧。

那么,根据这段描述可以比较直观的认为:C语言函数调用的内存申请和释放是通过对栈进行操作来完成的,一个函数在被调用时会在栈内申请一个内存区域(是通过移动%rsp实现的),这个区域被称之为栈帧。在汇编中,栈和栈帧的结构如下图所示:

结合图来理解,栈帧其实就是栈中的一个“块”,这个块存放了一个函数执行时需要的局部变量,参数等信息。块的边界由寄存器rbp和rsp来确定。

 

 

二、栈帧的工作过程

上面这个讲的还比较笼统,现在结合具体的例子讲讲栈帧在函数调用时的工作过程。以下面这段代码为例:

/*
* stackframe.c
*/
int foo(int para1){
    return para1 +10;
}

int main(){
    int var1 = 10;
    int var2 = foo(var1);
    return var1 + var2;
}

这段代码是一个简单的函数调用过程,main()->foo()。

对其使用gcc命令进行反汇编:

gcc -C stackframe.c -S 

得到如下代码:

	.file	"stackframe.c"
	.text
	.globl	foo
	.type	foo, @function
foo:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	%edi, -4(%rbp)    # 约定%edi表示第一个参数,即para1,保存到-4(%rbp)的位置
	movl	-4(%rbp), %eax
	addl	$10, %eax         # 计算para1 + 10
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	foo, .-foo
	.globl	main
	.type	main, @function
main:
.LFB1:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$16, %rsp        # 申请16个字节的栈空间
	movl	$10, -4(%rbp)    # var1存放的位置是-4(%rbp)
	movl	-4(%rbp), %eax
	movl	%eax, %edi       # var1赋值给了%edi
	call	foo
	movl	%eax, -8(%rbp)   # foo的返回值通过eax传回,并存放到
	movl	-4(%rbp), %edx   # 取var1
	movl	-8(%rbp), %eax
	addl	%edx, %eax
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE1:
	.size	main, .-main
	.ident	"GCC: (Debian 9.2.1-19) 9.2.1 20191109"
	.section	.note.GNU-stack,"",@progbits

然后我们通过gdb调试来观察一下这段代码执行时候的内存使用情况

gcc -g stackframe.s -o stackframe.exe

gdb stackframe.exe

由于篇幅有限,我们只展示4个主要过程的内存使用情况。

· main函数调用foo前的情况

main函数调用foo前,做了如下几个关键操作:

pushq %rbp                 将main的父函数的栈帧指针压栈

movq  %rsp, %rbp       将当前栈顶地址,作为当前的栈帧指针 

subq   $16, %rsp         通过减少栈顶的大小,将栈分配16个字节

movl   $10, -4(%rbp)    将变量var1存放在-4(%rbp)的位置上

概括之就是:

1、保存上一个函数的栈帧地址%rbp。

2、将当前的栈顶地址%rsp,作为新的栈帧的起始地址%rbp。

3、移动%rsp,分配内存。

4、以基址加偏移量的方式存储变量var1。

gdb调试看到的内存分布如下图,注意rbp和var1的位置关系。

 

 

· 调用foo()后的情况

foo的调用有如下几个关键步骤

pushq %rbp                    将父函数即main的栈帧指针压栈

movq   %rsp, %rbp         将栈顶地址%rsp作为栈帧地址%rbp

movl   %edi, -4(%rbp)     根据约定,%edi表示第一个 参数,即para1,同样以基址加变址的方式,将其保存。 

概括之就是:

1、保存上main的栈帧地址%rbp。

2、将当前栈的地址%rsp,作为栈帧的地址%rbp。

3、同样以基址加偏移量的方式保存参数。

4、另外还有个比较隐蔽的点是,在进入foo之前,call指令会将call的下一条指令地址压栈,以便函数返回时,能够在函数的下一条指令上继续执行。

这个部分的内存分布如下图,可以看到,在main和foo的栈帧之间,保存了恢复main执行所需要的栈帧地址和程序计数器地址。

这里有个地方比较奇怪,就是foo的栈帧并没有分配空间,且变量para1存在了栈帧之外,这个后面会讲。

 

·foo()退出时的情况

有如下几个关键步骤:

popq   %rbp             将main函数的栈帧地址恢复到%rbp

ret                            将程序计数器恢复到call foo的下一条指令的地址

步骤比较简单,直接看图,可以看到,rbp又恢复到了main执行时的状态。

 

·foo()退出后的情况

movl   %eax, -8(%rbp)          将foo()的结果,即var2,赋值到-8(%rbp)的位置

movl   -4(%rbp), %edx          将var1取出来赋值给%edx

如图所示:对var1和var2的访问仍旧是是通过对rbp的基址加偏移量寻址来实现的,所以说rbp的保存和恢复很重要。

 

由此可见:汇编在函数调用时,会使用rbp和rsp在栈上标定一个范围空间。对于函数中的变量和参数,会通过基址(rbp)加偏移量的方式进行读写。函数的进入和退出时,会保存和恢复父函数的上下文,这个上下文包括两个部分:

1、程序计数器:通过对%rip的压栈和出栈来实现。这个操作包含在call和ret的内部。

2、函数栈帧的基址:通过对%rbp的压栈和出栈实现。

这块最好自己亲自动手调试来验证一遍,印象会更深刻。

 

之前的问题:

foo函数的参数为什么放在了栈帧之外?这个查资料可能是x86-64的“红色区域”特性导致的,因为foo函数是叶子函数,所以可以用栈顶之后的128字节来存放临时的数据。

在%rsp指向的栈顶之后的128字节是被保留的——它不能被信号和终端处理程序使用。因此,函数可以在这个区域放一些临时的数据。特别地,叶子函数可能会将这128字节的区域作为它的整个栈帧,而不是像往常一样在进入函数和离开时靠移动栈指针获取栈帧和释放栈帧。这128字节被称作红色区域。

三、总结:

栈帧是栈的一个部分,它在函数调用过程中,起到一个划定函数所用到的内存区域的作用,函数访问的临时变量和参数就包含在这个区域之间。栈帧是一个虚拟的概念,真正干活的是rbp,rbp指向了当前栈帧的基址,访问函数内的临时变量和参数是通过以rbp作为基址加变址的方式进行的。所以父子函数调用时,内存部分的上下文的切换是通过将rbp压栈和退栈来实现的。指令地址的上下文是通过rip的压栈和退栈来实现的。不得不说:栈这种数据结构真是强大。

  • 5
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值