当前位置: 首页 > 科技观察

调用函数时堆栈如何变化?

时间:2023-03-20 23:43:56 科技观察

大家都知道函数调用是通过栈实现的,都知道函数的局部变量存放在栈中。但是栈的实现细节可能不太清楚。本文将介绍函数栈在Linux平台下是如何实现的。调用栈帧的结构函数时,会在栈空间上开辟一块空间供函数使用,所以我们先来了解一下一般栈帧的结构。如图所示,栈从高地址向低地址增长,栈有顶有底。入栈和出栈的地方称为栈顶。在x86系统的CPU中,rsp是栈指针寄存器,存放的是栈顶的地址。栈底地址存放在rbp中。函数栈空间主要由这两个寄存器决定。程序运行时,栈指针rsp是可以移动的,而栈指针和帧指针rbp一次只能存放一个地址,所以任何时候,这对指针都指向同一个函数的栈帧结构.帧指针rbp不动,可以用-4(%rbp)或8(%rbp)访问%rbp指针下方或上方的元素来访问栈中的元素。理解了这些,我们来看一个具体的例子:#includeintsum(inta,intb){intc=a+b;returnc;}intmain(){intx=5,y=10,z=0;z=sum(x,y);printf("%d\r\n",z);return0;}反汇编如下,我们根据汇编代码一步步分析函数调用时的栈种类。0000000000000000:0:55push%rbp1:4889e5mov%rsp,%rbp4:897decmov%edi,-0x14(%rbp)#parameterpassing7:8975e8mov%esi,-0x18(%rbp)#parameterpassinga:8b55ecmov-0x14(%rbp),%edxd:8b45e8mov-0x18(%rbp),%eax10:01d0add%edx,%eax12:8945fcmov%eax,-0x4(%rbp)#局部变量15:8b45fcmov-0x4(%rbp),%eax#store结果18:5dpop%rbp19:c3retq000000000000001a

:1a:55push%rbp#save%rbp。rbp,栈底地址1b:4889e5mov%rsp,%rbp#设置新的栈指针。rsp栈指针,指向栈顶地址1e:4883ec10sub$0x10,%rsp#分配16字节的栈空间。%rsp=%rsp-1622:c745f405000000movl$0x5,-0xc(%rbp)#Assignment29:c745f80a000000movl$0xa,-0x8(%rbp)#Assignment30:c745fc00000000movl$0x0,-0x4(%rbp:)#Assignment378b55f8mov-0x8(%rbp),%edx3a:8b45f4mov-0xc(%rbp),%eax3d:89d6mov%edx,%esi#参数传递,从右到左3f:89c7mov%eax,%edi#参数传递41:e800000000callq46#callsum46:8945fcmov%eax,-0x4(%rbp)49:8b45fcmov-0x4(%rbp),%eax#store计算结果4c:89c6mov%eax,%esi4e:488d3d00000000lea0x0(%rip),%rdi#5555:b800000000mov$0x0,%eax5a:e800000000callq5f5f:b800000000mov$0x0,%eax64:c9leaveq65:c3retq在调用函数之前,调用者会调用函数做准备。首先在函数栈上开辟一个16字节的空间存放定义的3个int变量,并建立main函数的栈。接下来,给三个变量赋值。下面4行代码是传参的。我们可以看到函数参数是倒序传入的:先传入第N个参数,然后再传入第N-1个参数(CDECL约定)。mov-0x8(%rbp),%edxmov-0xc(%rbp),%eaxmov%edx,%esi#参数传递,从右到左mov%eax,%edi#参数最后传递,会执行到call指令,调用sum函数。callq46#调用sumCALL指令实际上隐含了一个将返回地址(即CALL指令的下一条指令的地址)压栈的动作(由硬件完成)。具体来说,当call指令执行时,先将下一条指令的地址压栈,然后跳转到对应函数的开始处执行。函数调用时进入sum函数后,我们看到函数的前两行:push%rbpmov%rsp,%rbp这两条汇编指令的意思是:先将rbp寄存器压入栈,然后赋值顶部指针rsp指向rbp。“movrbprsp”指令看似是用rsp覆盖了rbp原来的值,其实不然。因为在给rbp赋值之前,原来的rbp值已经被压入栈中(栈顶),新的rbp刚好指向栈顶。此时rbp寄存器已经处于非常重要的位置。这个寄存器存储了一个栈中的地址(原来的rbp入栈后的栈顶)。以此地址为参照,向上(栈底方向)可以得到返回地址和参数值,向下(栈顶方向)可以得到返回地址和参数值。函数的局部变量值,以及调用上一个函数时的rbp值都存放在这个地址。一般来说,%rbp+4是返回地址,%rbp+8是第一个参数值(最后一个入栈的参数值,这里假设占4字节内存),%rbp-4是第一个局部变量,%rbp是上一层的rbp值。由于rbp中的地址永远是“调用上一个函数时的rbp值”,而在每次函数调用中,都可以将当时的%rbp值“向上(到栈底)”得到返回地址,参数值,“向下(栈顶方向)”可以获得函数局部变量值。接下来的四个指令被执行。mov%edi,-0x14(%rbp)#传参mov%esi,-0x18(%rbp)#传参mov-0x14(%rbp),%edxmov-0x18(%rbp),%eaxadd%edx,%eaxmov%eax,-0x4(%rbp)上面的指令通过给rbp加上offset,把main传给sum的两个参数保存在当前栈帧的合适位置,然后取出来放到寄存器中,貌似有点多余,这是因为gcc在编译时没有指定优化级别,gcc在编译程序时,默认不做任何优化,所以看起来比较冗长。需要注意的是,sum的两个参数和返回值都是int类型,在内存中只占4字节,图中每个栈内存单元都是按照8字节地址边界对齐的,所以就是如下图这样。请看接下来的三个说明。add%edx,%eaxmov%eax,-0x4(%rbp)#局部变量mov-0x4(%rbp),%eax#存储结果上面第一条指令负责执行加法运算,并将结果存储在eax中,第二条指令将eax中的值存入局部变量c所在的内存中,第三条指令将局部变量c的值读入eax中。可以看出局部变量c被编译器安排到这个地址对应的%rbp-0x4内存中。接下来继续执行pop%rbpretq这两条指令。这两条指令的作用相当于下面的指令:mov%rbp,%rsppop%rbppop%rip即在操作以上两条指令时,先给rsp赋值,其值存放在调用的地址函数rbp的值,因此您可以通过弹出堆栈来检索调用函数的rbp来为rbp赋值。通过栈的结构我们可以知道rbp是调用函数调用被调用函数的下一条指令的执行地址,所以需要将其赋值给rip来获取调用函数中指令的执行地址。当整个函数跳回到main时,它的rsp和rbp会变回原main函数的栈指针。C语言程序就是通过这种方式来保证函数调用后原程序能够继续执行。当函数调用后函数最终返回时,继续执行以下指令:mov%eax,-0x4(%rbp)#将求和函数的返回值赋给变量z上面的指令将eax中的结果放入rbp中-0x4指的是内存,main的局部变量z所在的地方。下面指令如下:mov%eax,-0x4(%rbp)mov-0x4(%rbp),%eax#计算结果mov%eax,%esimov%eax,%esilea0x0(%rip),%rdimov$0x0,%eaxcallq5f上述指令首先为printf准备参数,然后调用printf。具体过程类似于调用sum的过程,让CPU直接执行到main的倒数第二条leave指令。mov$0x0,%eax指令的作用是将main的返回值0放入寄存器eax,main返回后调用main获取这个值。执行leave指令相当于执行下面两条指令:mov%rbp,%rsppop%rbpleave指令先将rbp的值复制到rsp中,rsp指向rbp指向的栈单元。然后leave指令将栈单元的值弹出到rbp,使rsp和rbp恢复到刚进入main时的状态。