持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第21天,点击查看活动详情
栈被操作系统安排在进程的高地址处,向下增长。
StackOverflow错误
写递归函数时,当漏掉退出条件者退出条件写错,就会栈溢出错误。
1 函数与栈帧
调用一个函数时,CPU会在栈空间(线性空间的一部分)开辟一小块区域,这函数的局部变量都在这一小块区域存活。当函数调用结束,这一小块区域里的局部变量就会被回收。
这一小块区域像个框子,所以命名它为stack frame。frame本意框子,译为帧,即栈帧。栈帧本质上是个函数的活动记录。当某函数正在执行,它的活动记录就会存在,当函数执行结束时,活动记录也被销毁。
函数执行时,它可调用其他函数,这时它的栈帧还存在。如A函数调用B函数,A栈帧不会被销毁,而是会在A栈帧下方,再创建B函数的栈帧。当B函数执行完,B的栈帧也被销毁,CPU才会回到A的栈帧里继续执行。
1.1 案例
#include <stdio.h>
void swap(int a, int b) {
int t = a;
a = b;
b = t;
}
void main() {
int a = 2;
int b = 3;
swap(a, b);
printf("a is %d, b is %d\n", a, b);
}
swap函数a/b值交换,但main函数里,打印a和b的值,a还是2,b还是3,why?栈帧角度:
main执行时,main栈帧里存在变量a、b。main调用swap时,会在main帧下新建swap栈帧。swap帧里也有局部变量a、b,但这a、b与main里的a、b无任何关系,不管对swap帧的a、b变量做何操作都不影响main栈帧。
接下来,我们再通过一个递归的例子来加深对栈的理解。由于递归执行的过程会出现函数自己调用自己的情况,也就是说,一个函数会对应多个同时活跃的记录(即栈帧)。所以,理解了递归函数的执行过程,我们就能更加深刻地理解栈帧与函数的关系。
2 递归的本质
2.1 汉诺塔
三根柱A、B、C,A柱有n个盘,从上到下的编号1~n,上面盘子一定比下面盘子小。
要求:一次只能移动一只盘子,且大盘子不能压在小盘上,将所有盘从A移到C需几步?
2.1.1 求解程序:
#include <stdio.h>
void move(char src, char dst, int n) {
printf("move plate %d form %c to %c\n", n, src, dst);
}
void hanoi(char src, char dst, char aux, int n) {
if (n == 1) {
move(src, dst, 1);
return;
}
hanoi(src, aux, dst, n-1);
move(src, dst, n);
hanoi(aux, dst, src, n-1);
}
int main() {
hanoi('A', 'C', 'B', 5);
}
打印出借由B柱,将5盘从A移到C的所有步骤。hanoi四个参数:
- src,要搬的起始柱子(A)
- dst,目标柱(C)
- aux,可借用的中间的那个柱子(B)
- 共要搬的盘子总数(5)
从A搬5个盘子到C,先将4个盘子搬到B上,然后第14行代表将第5个盘子从A搬到C,第15行代表把B上面的4个盘子搬到C上去。第8行的判断是说当只搬一个盘子的时候,就可以直接调用move方法。
假设n=3:
可见,执行hanoi(A, C, B, 3)时,CPU为其创建一个栈帧,记录变量src、dst、aux和n。
此时n为3,所以,代码可执行到第13行,然后就会调用执行hanoi(A, B, C, 2)。这代表着将2个盘子从A搬到B,同样CPU也会为这次调用创建一个栈帧;当这一次调用执行到第13行时,会再调用执行hanoi(A, C, B, 1),代表把一个盘子从A搬到C。不过,由于这一次调用n为1,所以会直接调用move函数,打印第一个步骤“把盘子1从A搬到C”。
接下来,程序就会回到hanoi(A, B, C, 2)的栈帧,继续执行第14行,打印第二个步骤”把盘子2从A搬到B”。然后再执行第15行,也就是执行hanoi(C, B, A, 1)。这一步的栈帧变化:
调用hanoi(C, B, A, 1)的时候,由于n等于1,所以就会打印第三个步骤“把盘子1从C搬到B”,此时hanoi(C, B, A, 1)就执行完。
那么接下来,程序就退回到hanoi(A, B, C, 2)的第15行的下一行继续执行,也就是函数的结束,这就意味着hanoi(A, B, C, 2)也执行完了。这个时候,程序就会回退到最高的一层hanoi(A, C, B, 3)的第14行继续执行。这一次就打印了第四个步骤“把盘子3从A搬到C”,此时的栈帧如上图(b)所示。
然后,程序会执行第15行,再次进入递归调用,创建hanoi(B, C, A, 2)的栈帧。当它执行到第13行时,就会再创建hanoi(B, A, C, 1)的栈帧,此时栈的结构如上图(c)所示。由于n等于1,这一次调用就会打印第五个步骤“把盘子1从B搬到A”。
再接着就开始退栈了,回到hanoi(B, C, A, 2)的栈帧,继续执行第14行,打印第六个步骤“把盘子2从B搬到C”。然后执行第15行,也就是hanoi(A, C, B, 1),此时n等于1,直接打印第七个步骤“把盘子1从A搬到C”。接下来就执行退栈,这一次每一个栈帧都执行到了最后一行,所以会一直退到main函数的栈帧中去。退栈的过程比较简单,你自己思考一下就好了。
这样我们就完成了一次汉诺塔的求解过程。在这个过程中呢,我们观察到,先创建的帧最后才销毁,后创建的帧最先被销毁,这就是先入后出的规律,也是程序执行时的活跃记录要被叫做栈的原因。
那么在这里呢,我还想让你做一个小练习。我想让你试着用我们上面分析栈变化的方法,来分析使用深度优先算法打印全排列的程序,这会让你更加深入地理解栈的运行规律,同时掌握深度优先算法的递归写法。
res = []
def make(n, level):
if n == level:
print(res)
return
for i in range(1, n+1):
if i not in res:
res.append(i)
make(n, level+1)
res.pop()
make(3, 0)
指令角度理解栈
CPU层的机器指令如何理解栈的?
案例
// 递归求阶乘
int fac(int n) {
return n == 1 ? 1 : n * fac(n-1);
}
编译,objdump反编译,观察编译后机器码:
# gcc -o fac fac.c
# objdump -d fac
40052d: 55 push %rbp
40052e: 48 89 e5 mov %rsp,%rbp
400531: 48 83 ec 10 sub $0x10,%rsp
400535: 89 7d fc mov %edi,-0x4(%rbp)
400538: 83 7d fc 01 cmpl $0x1,-0x4(%rbp)
40053c: 74 13 je 400551 <fac+0x24>
40053e: 8b 45 fc mov -0x4(%rbp),%eax
400541: 83 e8 01 sub $0x1,%eax
400544: 89 c7 mov %eax,%edi
400546: e8 e2 ff ff ff callq 40052d <fac>
40054b: 0f af 45 fc imul -0x4(%rbp),%eax
40054f: eb 05 jmp 400556 <fac+0x29>
400551: b8 01 00 00 00 mov $0x1,%eax
400556: c9 leaveq
400557: c3 retq
第1行是将当前栈基址指针存到栈顶,第2行是把栈指针保存到栈基址寄存器,这两行的作用是把当前函数的栈帧创建在调用者的栈帧之下。保存调用者的栈基址是为了在return时可以恢复这个寄存器。
第3行的作用呢,是把栈向下增长0x10,这是为了给局部变量预留空间。从这里,你可以看出来运行fac函数要是消耗栈空间的。
试想一下,如果我们不加n==1的判断,那么fac函数将无法正常返回,会出现一直递归调用回不来的情况,这样栈上就会出现很多fac的帧栈,会造成栈空间耗尽,出现StackOverflow。这里的原理是,操作系统会在栈空间的尾部设置一个禁止读写的页,一旦栈增长到尾部,操作系统就可以通过中断探知程序在访问栈末端。
第4行是把变量n存到栈上。其中变量n一开始是存储在寄存器edi中的,存储的目标地址是栈基址加上0x4的位置,也就是这个函数栈帧的第一个局部变量的位置。变量n在寄存器edi中是X86的ABI决定的,第一个整型参数一定要使用edi来传递。
第5行将变量n与常量0x1进行比较。在第6行,如果比较的结果是相等的,那么程序就会跳转到0x400551位置继续执行。我们看到,在这块代码里,0x400551是第13行,它把0x1送到寄存器eax中,然后返回,就是说当n==1时,返回值为1。
如果第5行的比较结果是不相等的,又会怎么办呢?那第6行就不会跳转,而是继续执行第7行。7、8、9这三行的作用,就是把n-1送到edi寄存器中,也就是说以n-1为参数调用fac函数。这个时候,调用的返回值在eax中,第11行会把返回值与变量n相乘,结果仍然存储在eax中。然后程序就可以跳转到0x400556处结束这次调用。
callq指令
执行callq指令时,CPU会把rip寄存器中的内容,也就是call的下一条指令的地址放到栈上(在这个例子中就是0x40054b),然后跳转到目标函数处执行。当目标函数执行完成后,会执行ret指令,这个指令会从栈上找到刚才存的那条指令,然后继续恢复执行。
栈空间中的rbp、rsp,以及返回时所用的指令都是非常敏感的数据,一旦被破坏就会造成不可估量的损失。
不过,你在重现这个例子一定要注意,我们使用不同的优化等级,产生的汇编代码也是不同的。比如如果你用以下命令进行编译,得到的二进制文件中将不再使用rbp寄存器。
# gcc -O1 -o fac fac.c
至于这个结果,我这里就不再展示了,我想让你自己动手试一下,然后在留言区和我们分享。
到这里,我们已经从人的大脑的理解角度和机器指令的角度,让你加深了对栈和栈帧的理解。现在,我们就从理论转向实操,举一个通过缓冲区溢出来破坏栈的例子。通过这个例子,你就知道在平时的工作中,应该如何避免写出被黑客攻击的不安全代码。