点我查看操作系统秘籍连载


栈空间:用户栈和内核栈

程序的执行流程

进程其实都是在执行任务,而任务其实就是函数定义的(函数也称为方法、子程序等,本质都一样),所以进程的作用就是不断的执行函数。程序启动时,第一个要执行的函数是main()函数(有些语言隐藏了这个函数,但任何程序一定会有一个程序入口函数),然后在main()函数中调用其它函数,每当调用其它函数时,都会先进行函数跳转,转而让进程去执行被调用的函数,当被调函数执行完成后又回到调用函数的位置继续向下运行。

程序执行的基本流程如下图所示。右边是程序的伪代码,左边是程序运行过程。首先进程跳转到main函数处开始执行,然后执行一个赋值语句a=1,继续往下发现是调用一个函数func1(),于是跳转到func1(),同时还会保存好main中是从这个位置(假设称为位置1)处跳转的,以便执行完func1()后可以跳回到main()。然后开始执行func1()中的代码,在CPU执行func1()执行的时候,main()函数就无法继续向下执行了,它必须等待func1()执行完成后的返回,当func1()执行完后根据跳回到位置1,于是main函数继续向下执行,也就是赋值语句x=2,然后又以同样的流程调用func2()函数并返回,最终main()函数执行完成,进程终止,程序退出。

用户栈和内核栈

用户栈

每当进程调用一次函数,都会在用户栈中为该函数分配一个栈帧(stack frame),也称为调用栈(call stack),当该函数返回时又会释放该栈帧。释放的栈帧不会从虚拟内存中移除,它可以被之后调用的函数重新使用,所以栈空间的大小是不会减小的。

根据这个特性并结合上图所描述的程序执行过程,可以推断出一个重要的结论。由于函数内部调用函数时,外部函数的栈帧不会释放,只有内部函数全部退出了才会继续执行外部函数并在执行完成的时候释放外部函数的栈帧,所以,递归函数(即函数内部调用函数自身)如果递归调用的层次太多(比如无限递归),会分配大量的栈帧,并且不会释放,直到栈空间不足,无法再分配新的栈帧,这时会报栈溢出(stack overflows)错误。所以,必须要合理编写递归函数,使得递归函数能够在达到某些条件时返回,从而释放栈帧,避免无限递归。

栈帧中保存了传递给该函数的参数、该函数中定义的局部变量、函数的返回值、调用该函数的程序计数器副本,以及一些其它重要信息。这里有必要解释下栈帧中的程序计数器副本。

什么是程序计数器(Program Counter,PC)?这是CPU中的一个寄存器,在这个寄存器中保存了下一个要执行指令的指针。所以,CPU每执行一个指令的时候,就会设置这个寄存器使它指向下一个指令。

前面描述程序执行流程的时候说过,当main()函数调用func1()函数的时候,需要保存main()函数中调用func1()的位置,以便func1()返回时可以跳转回main()函数继续向下执行。其实,main()函数在开始调用func1()函数的时候,PC寄存器就已经指向了这个指令,CPU可以将这个指令的指针的值(也就是PC的副本)保存在func1()函数的栈帧中,这样func1()执行完成后就能将这个指针重新放回到CPU的PC寄存器中,使得CPU重新回到main()函数调用func1()的位置处,从而调用者main可以取得函数func1()栈帧中的返回值(这时候func1()的栈帧被释放),并继续执行下面的代码。

内核栈

操作系统还为每个进程维护另一个栈:内核栈。这个栈的位置在内核的内存区域中,只有内核能够访问,用户进程无法访问。

内核栈的作用是存放上下文切换时的进程信息。

当进程A要切换到进程B时,首先要陷入内核,然后内核将CPU中关于进程A的进程信息(即某些寄存器中的值)保存在进程A的内核栈中,然后从进程B的内核栈中恢复进程B的信息到CPU的某些寄存器中,再退出内核模式回到进程B,这样CPU就开始执行进程B了。