栈的定义
栈为先进后出的数据结构
在《数据结构》中有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分别放入了esi
和edi
寄存器。根据x86寄存器使用规定可知这两个寄存器保存第0个和第1个参数。
寄存器名 | 32位 | 参数 | 用途 | 其他 |
---|---|---|---|---|
rsi | esi | c_arg1 | 第二个参数|调用者保存 | 父函数必须手动压栈保存,出栈恢复,否则数据覆盖 |
rdi | edi | c_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位 | 用途 | 其他 |
---|---|---|---|
rax | eax | 返回值|调用者保存 | 父函数必须手动压栈保存,出栈恢复,否则数据覆盖 |
这里对应C语言
return 0;
}
然后是将函数栈进行还原
65b: 5d pop %rbp
65c: c3 retq
retq
指令等价于
pop %rip
即退回到调用main
函数的地方。
对比最初的内存布局可发现,此时在经过函数栈的压栈和弹出操作后,函数又回到了调用main函数之前的状态,注意含有0x0600
值的内存区域可被其他数值覆盖。
add2在接受到main的call之后建栈