真正理解操作系统栈的本质(上)

151 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 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

至于这个结果,我这里就不再展示了,我想让你自己动手试一下,然后在留言区和我们分享。

到这里,我们已经从人的大脑的理解角度和机器指令的角度,让你加深了对栈和栈帧的理解。现在,我们就从理论转向实操,举一个通过缓冲区溢出来破坏栈的例子。通过这个例子,你就知道在平时的工作中,应该如何避免写出被黑客攻击的不安全代码。