栈是如此重要——C函数栈

栈的定义

栈为先进后出的数据结构

在《数据结构》中有C的栈实现算法,然而很少看到对于栈有什么作用的描述。

考虑一个场景,当CPU在计算时需要将某些值临时保存,待当前需要处理的事情处理完毕之后,重新把这些数据还原。此时应该怎样做?典型的应用就是函数调用,比如

int add3(int a, int b, int c){
	return a + b + c;
}

int add2(int a, int b){
	int result = a+b;
	return add3(result, a, b);
}

int main(){
	add2(2,3);
	return 0;
}

在上述程序中,add2会调用add3函数,当调用add3函数前,计算机必须保留下add2函数的位置,以便在add3结束之后可以返回到add2处理的位置。

在这里插入图片描述

此时,栈就起到了记录现场的作用

C语言函数栈

上述的函数调用过程可用下图实现

在这里插入图片描述

在程序进入main函数之后,在内存中划出一片区域,该区域用于保存main的信息。

当main函数调用add2函数时,在main函数的底部处划出一片区域用于add2函数使用,此时可以看出,main函数的信息保存在add2的顶部,当add2执行完毕之后,只需要退回到main函数的起始处即可。

同理,add2调用add3函数时,也是在add2的底部划出一片区域用于add3函数使用,当add3执行完毕,将这部分区域抛弃,回到add2起始处即可。

很明显,上图实现的是一个先进后出结构。在计算机中,栈在许多情况下都用于保存现场信息,理解了栈,就理解了很多计算机的执行过程。典型应用栈除了函数栈之外,还有线程栈,撤销操作,系统调用等。

x86函数栈的汇编实现

首先考虑到栈的定位问题,如何确保不会在栈元素的弹出(pop)过程中不会丢失压栈(push)的元素信息。这里以x86为例进行说明。

在x86中有2个寄存器专门用于记录栈的位置信息。一个是rbp(帧基地址寄存器),一个是rsp(栈顶指针寄存器)。rbp指向栈的高地址,rsp指向栈顶地址。当需要压栈某个元素的时候,只需要将rsp的值做减法,并将值放入rsp的地址中即可。

例如,将int 5压栈。

sub $0x4, %rsp
mov $0x5, (%rsp) 

在这里插入图片描述

由于在实际程序运行的过程中,压栈操作频繁,因此x86指令将上述2条指令缩减为1条push指令,执行效果与上述2条指令一样。

push $5

相似地,如果要将int 5从栈中弹出并放到rax寄存器,那么需要将rsp所指内存地址中的值放入rax寄存器,并将rsp寄存器的值加上4字节。

mov (%rsp), %rax
add $0x4, %rsp

同理,x86将上述两条指令缩减为一条pop指令,执行效果与上述2条指令一样。

pop $5

现在将上述C语言的进行反汇编,查看其汇编代码。

gcc -O0 main.c -o main

O0选项指示gcc不执行任何优化,这样就可以看出函数栈是如何实现的。使用下列指令查看反汇编结果。

objdump -d main

反汇编

00000000000005fa <add3>:
 5fa:   55                      push   %rbp
 5fb:   48 89 e5                mov    %rsp,%rbp
 5fe:   89 7d fc                mov    %edi,-0x4(%rbp)
 601:   89 75 f8                mov    %esi,-0x8(%rbp)
 604:   89 55 f4                mov    %edx,-0xc(%rbp)
 607:   8b 55 fc                mov    -0x4(%rbp),%edx
 60a:   8b 45 f8                mov    -0x8(%rbp),%eax
 60d:   01 c2                   add    %eax,%edx
 60f:   8b 45 f4                mov    -0xc(%rbp),%eax
 612:   01 d0                   add    %edx,%eax
 614:   5d                      pop    %rbp
 615:   c3                      retq   

0000000000000616 <add2>:
 616:   55                      push   %rbp
 617:   48 89 e5                mov    %rsp,%rbp
 61a:   48 83 ec 18             sub    $0x18,%rsp
 61e:   89 7d ec                mov    %edi,-0x14(%rbp)
 621:   89 75 e8                mov    %esi,-0x18(%rbp)
 624:   8b 55 ec                mov    -0x14(%rbp),%edx
 627:   8b 45 e8                mov    -0x18(%rbp),%eax
 62a:   01 d0                   add    %edx,%eax
 62c:   89 45 fc                mov    %eax,-0x4(%rbp)
 62f:   8b 55 e8                mov    -0x18(%rbp),%edx
 632:   8b 4d ec                mov    -0x14(%rbp),%ecx
 635:   8b 45 fc                mov    -0x4(%rbp),%eax
 638:   89 ce                   mov    %ecx,%esi
 63a:   89 c7                   mov    %eax,%edi
 63c:   e8 b9 ff ff ff          callq  5fa <add3>
 641:   c9                      leaveq 
 642:   c3                      retq   

0000000000000643 <main>:
 643:   55                      push   %rbp
 644:   48 89 e5                mov    %rsp,%rbp
 647:   be 03 00 00 00          mov    $0x3,%esi
 64c:   bf 02 00 00 00          mov    $0x2,%edi
 651:   e8 c0 ff ff ff          callq  616 <add2>
 656:   b8 00 00 00 00          mov    $0x0,%eax
 65b:   5d                      pop    %rbp
 65c:   c3                      retq   
 65d:   0f 1f 00                nopl   (%rax)

这里看<main>

0000000000000643 <main>:
 643:   55                      push   %rbp
 644:   48 89 e5                mov    %rsp,%rbp
 647:   be 03 00 00 00          mov    $0x3,%esi
 64c:   bf 02 00 00 00          mov    $0x2,%edi

对应C语言实现

int main(){
	add2(2,3);

尽管汇编是按顺序执行,我们这里可以先观察到2和3分别放入了esiedi寄存器。根据x86寄存器使用规定可知这两个寄存器保存第0个和第1个参数。

寄存器名32位参数用途其他
rsiesic_arg1第二个参数|调用者保存父函数必须手动压栈保存,出栈恢复,否则数据覆盖
rdiedic_arg0第一个参数|调用者保存父函数必须手动压栈保存,出栈恢复,否则数据覆盖

上述汇编的过程可用下图显示。其中粉色的RA表示调用main函数的上一级函数的地址。

在这里插入图片描述

然后执行call指令

651:   e8 c0 ff ff ff          callq  616 <add2>

call指令的执行包含2个步骤。第一个步骤是将rip寄存器的地址压栈(rip寄存器内是相对于当前指令的下一条指令地址,也被称为返回地址,简写为RA)。第二步为跳到执行add2函数的区域,即程序计数器PC里的值更新为0x616。以上述汇编为例。

0000000000000643 <main>:
 643:   55                      push   %rbp
 644:   48 89 e5                mov    %rsp,%rbp
 647:   be 03 00 00 00          mov    $0x3,%esi
 64c:   bf 02 00 00 00          mov    $0x2,%edi
 651:   e8 c0 ff ff ff          callq  616 <add2>
 656:   b8 00 00 00 00          mov    $0x0,%eax

假设现在程序执行到了callq指令。则当前指令地址为0x651,那么rip寄存器里的值是0x656

call指令执行结果如下

在这里插入图片描述

现在假设add2程序执行完毕,则执行main汇编的最后3条指令。首先是返回值。

 656:   b8 00 00 00 00          mov    $0x0,%eax

首先是将0赋值给eax寄存器。根据x86寄存器使用规范。

寄存器名32位用途其他
raxeax返回值|调用者保存父函数必须手动压栈保存,出栈恢复,否则数据覆盖

这里对应C语言

return 0;
}

然后是将函数栈进行还原

 65b:   5d                      pop    %rbp
 65c:   c3                      retq   

retq指令等价于

pop %rip

即退回到调用main函数的地方。

在这里插入图片描述
对比最初的内存布局可发现,此时在经过函数栈的压栈和弹出操作后,函数又回到了调用main函数之前的状态,注意含有0x0600值的内存区域可被其他数值覆盖。
在这里插入图片描述
add2在接受到main的call之后建栈

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值