零.引言

在操作系统中有很多的概念:线程、进程、调度、中断、信号、系统调用……教材分别给它们下出定义:线程是CPU调度的单位,进程是资源分配的基本单位…..但当这些机制同时出现时,一个问题很容易产生:我只知道它们本身是什么,但是它们之间到底是什么关系?为什么要有这些概念?

再具体到实现层面,可能出现下面的问题:为什么线程切换需要保存寄存器和栈?信号处理函数是怎么“插入”到用户执行程序中的?为什么系统调用需要切换到内核栈?为什么调度的核心操作是保存和恢复上下文?

实际上,这些问题都在围绕着同一个事情:暂停一个正在执行的程序,然后在某个时刻恢复它。什么意思?无论是线程切换调度还是中断处理,操作系统都必须完成一个基本的操作:在某个时刻“冻结”当前程序的执行状态,并在未来恢复它。

这就引入了另一个更基础的问题:**“程序的执行状态”到底是由什么状态构成的?**换句话说,如果我们想暂停一个程序,然后在未来继续执行,究竟要保存哪些信息?

从计算机的体系结构来看,这些状态主要包括三部分:

  • 程序计数器(PC):当前/下一步执行哪条指令

  • 寄存器:当前计算的中间结果

  • 调用栈:记录函数的调用链

只要保存并恢复这三部分,程序就可以在任意时刻被暂停,并继续执行。把程序的运行看作是这三种状态在时间上的连续变化,那么这条随时间推进的状态轨迹,就是一条执行流总结一下,执行流就是“程序运行时,CPU做了什么、在做什么以及接下来要做什么”的动态轨迹

而操作系统中的很多机制,本质上都是在创建、暂停、恢复、切换这样的执行流。

此时,可能会有疑问:为什么一个程序的执行状态恰好可以由PC、寄存器和调用栈来描述呢? 其中PC和寄存器来自于处理器自身,前者负责读指令,后者负责存一些计算的中间结果,比较好理解;但是调用栈为什么会存在呢?

  换句话说,为什么程序运行时需要专门一段内存来记录函数的调用历史? 此外,为什么偏偏是栈,而不是队列、数组等等又或是其他的数据结构呢?


一.执行流、函数调用与栈

要回答这两个问题,我们需要暂时离开操作系统,回到计算机最基本的执行模型——冯诺依曼机器。因为函数调用本质上是一种执行流的变化,而执行流的行为最终是由处理器的执行方式决定的

在经典的冯诺依曼体系中,程序被存储为一段连续的指令序列,CPU不断重复以下过程:取指、译码、执行、访存、写回。处理器通过PC指向当前要执行的命令,每执行一条指令,PC就移动到下一条指令:

PC->PC+1->PC+2....

如果程序始终这样执行下去,那么就非常简单了。然而,现实的程序中很少只是简单地顺序执行,为了实现代码复用和模块化,程序往往包含大量的函数调用。

函数调用听起来简单,只要把函数的第一条指令的地址记住,加载到PC就可以跳转过去了。但是,怎么回来呢?所以,在跳转前,最好先把下一条执行的地址保存起来,等函数执行完成后,再根据该地址回到之前的保存的位置继续执行,不就可以了吗?

整体的流程如下:

保存返回位置
    ↓
跳转到函数
    ↓
函数执行
    ↓
跳回返回位置

那把这个返回地址保存在哪比较好呢?可能会想到放在CPU寄存器中,有些体系结构确实会使用一个专门的寄存器保存返回地址,比如ARM。但一旦出现函数嵌套调用的情况,A->B->C->D->…->Z,这么多函数,每个函数都要有返回地址,哪有那么多寄存器呀?如果将所有返回地址保存在同一个寄存器中,则会出现覆盖的问题——A函数先保存自己的返回地址,B也保存,C也保存…因此,程序需要一种结构来保存这些返回地址。此外,还有个要求:后调用函数不会破坏之前函数存储的返回地址,保证一个函数存的返回地址和拿出来的必须是一样的。

而这就要求,**后存的数据必须先拿,先存的数据必须后拿,**而这就是LIFO。因此,用于保存函数调用信息最自然的结构就是栈。

不过,这里还可以再追问一个问题,为什么函数的调用结构恰好符合这种“后进先出”的规律,看起来和栈天生一对?

  换句话说,为什么函数调用天然符合嵌套结构:

[A
   [B
      [C]
   ]
]

而不是下图中的这种结构?

[A
   [B
   ]
      [C]
]

要理解这一点,就需要回到计算机最基本的执行模型——冯诺依曼机器。在冯诺依曼体系中,CPU在任意时刻只有一个程序计数器,因此机器一次只有一条执行流,程序执行的流程是:取指、执行、更新PC、再取指….也就是说,程序本质上一条单线推进的执行流程。所以,当函数A调用B,执行流就必须从A跳转到B,当B调用C,执行流就又要跳转到C。并且,再C返回前,A和B都不会继续执行,处于“暂停”状态。于是,这就形成了一个典型的结构:

A
 └─ B
     └─ C
         └─ D

从拓扑结构来看,这实际上是一颗函数调用树,而程序的实际执行顺序,本质上就是调用这颗树的深度优先遍历(DFS),而DFS的经典实现方式,正是栈。

所以,从更深层的角度来看,栈并不是某种偶然的设计,不是一拍脑门觉得正好符合函数调用结构所以选的栈,而是在冯诺依曼的这种顺序执行机器在面对嵌套控制调用流程的必然选择

而一旦从这个角度来看,栈的意义就不仅仅是“保存返回地址了”,栈实际上记录了程序执行流的结构

从执行流的角度来看:执行流本身是一个抽象的概念,而栈则是执行流在内存中的一种具体编码,是记录执行流历史的物理容器

一层函数调用——一层函数栈帧

返回地址作为执行流未来的回退地址,对应存储在栈帧的顶部

局部变量作为当前执行阶段的状态,参数作为传递给下一层的执行信息,都对应存储在栈帧中

而PC描述了当前执行流的位置,寄存器则记录了当前的计算状态,其中,RSP栈指针则记录了执行流当前的调用深度

而这再次回答了上一节提到的问题:为什么程序的执行状态可以由PC、寄存器和调用栈来描述。因为在冯诺依曼机器中,程序的运行本质是:一条执行流沿着指令的执行不断向前推进(PC),并在推进的过程中不断产生新的状态(寄存器),并在函数调用时形成嵌套的控制结构(调用栈)。 而操作系统所谓的暂停程序、恢复程序、切换程序,实际上也正是对这三部分状态进行保存切换和恢复。换句话说,实际上操作系统真正的管理对象,并不是”程序“本身,而是程序所对应的执行流状态


二.多条执行流:线程与调度

上一节我们看到,程序运行可以理解为一条执行流沿着执行的执行不断地前进,而其状态可以通过PC、寄存器和调用栈来表示。

从计算机的视角来看,一个”正在运行的程序“其实只是一组状态的集合而已。只要可以做到保存并恢复这三者,程序就可以在任意时刻被暂停,并在任意时刻继续执行。那如果系统中保存多组这样的状态,并在不同的时刻运行不同组的状态,是不是就相当于存在多条执行流了? 在CPU运行某一组状态时,其对应的执行流就处于”运行“状态,当CPU切换去运行另外一组状态时,原先的执行流就被暂停,而新的执行流恢复,开始继续向前推进。

在外部看来,好像是多个程序同时在运行一样,这种运行方式被称为并发,而这种被操作系统管理、可以被暂停与恢复的执行流,就是我们熟悉的线程

因此,从执行流的角度来看,线程就是一条被操作系统管理的执行流。

既然系统中存在多条执行流,单核处理器下在任意时刻下只能选其中的一条来执行,那么操作系统就必须决定下一刻让哪条执行流继续运行,这个选择的过程,就称为调度。在选择完成后,操作系统需要真正进行执行流的切换,而这就是线程切换


三.执行流的保存与切换

上一小节说到,既然线程可以被理解为一条被操作系统管理的执行流,那么线程之间的切换,本质上就是执行流之间的切换。

此时就会出现一个问题:为了能够让执行流在未来时被恢复,究竟需要保存什么?还记得第一节中的结论吗?程序在任意时刻的运行状态都可以由三部分描述:

  • 程序计数器(PC):当前执行流正在执行的位置

  • 寄存器:当前计算过程中的中间状态

  • 调用栈:记录函数调用链

因此,当操作系统准备暂停一条执行流时,为了之后能够重新将这些状态恢复到处理器中,必须将这些状态保存起来。只有这样,执行流才能从直接被打断的地方继续执行,就好像没被打断过一样

实际上,这组用于描述一条执行流当前状态的信息,就被称为上下文。而在当前执行流在被切换时,可以分解为三步:保存当前执行流的上下文->选择要运行的下一条执行流->恢复该执行流的上下文,而这个过程就被称为上下文切换

在具体实现层面上,可以概括为:保存寄存器、切换栈指针、更新计数器,之后CPU就可以继续执行新执行流了。但在更抽象的角度上来看,CPU完成了一次执行流的切换。

我们已经清楚了什么是切换,但是,什么时候需要切换呢?有可能是当前执行流因为阻塞主动让出CPU,也有可能是被迫让出CPU并切换到其他执行流,比如因为定时器中断。

所以,从执行流的角度,操作系统中的许多机制——调度、阻塞、中断处理等,本质上都是围绕着“改变当前正在运行的执行流”这件事展开的

进一步思考,如果当前执行流主动让出了CPU,比如等待IO或者调用了sleep(),那么操作系统可以在合适的时机保存它的上下文并切换到其他执行流。但是,像定时器中断这样的情况就不同了——执行流完全不知道什么时候会被打断,其是异步的,那么问题就变成了:操作系统是如何在一个程序运行的过程中,突然”插入“自己的代码的?

要理解这一点,就需要引入另一个非常关键的机制——中断。


四.中断与内核栈

上一节中,提到了需要通过中断机制,在程序运行过程中“插入”内核自己的代码。在中断发生时,CPU会暂停当前正在执行的程序,并跳转到操作系统预先设置好的中断处理代码。

从执行流的角度来看,当前执行流被暂时打断,并进入了一段新的执行路径。不过,在开始执行这段新的路径之前,CPU必须先保存当前执行流的上下文信息,否则之后就无法恢复到原来的执行位置。问题是,当执行流从用户程序进入操作系统时,是否应该继续使用原先的栈?

在执行用户代码时,执行流一直运行在用户栈上,函数返回地址、局部变量以及寄存器保存等信息,都会被压入到其中。如果CPU要执行中断处理代码,即执行内核代码时依然将这些信息压入到用户栈中,就会导致一个问题——用户执行流的状态和内核执行流的状态被混在了同一个栈上。 此外,更重要的是会导致安全问题,但由于本文重点在于”栈与执行流”,所以就不展开了。

因此,在中断发生并进入操作系统代码前,CPU必须先切换到一块由内核自己管理的栈空间下,而这块专门用于执行内核代码的栈,就被称为内核栈

此时,CPU 实际上仍然在继续同一条执行流,只不过这条执行流的运行环境已经从用户空间切换到了内核空间。当中断处理完成后,操作系统再恢复之前保存的执行状态,并切换回用户栈,执行流便可以继续向前推进。

从执行流的视角来看,内核栈的作用其实非常简单:它为执行流进入内核之后提供了一个新的运行栈环境,使得可以独立、安全地继续下去

到目前为止,我们讨论的主要是执行流被动进入内核的情况,比如定时器中断或设备中断,统称为硬件中断,这些事件都是异步发生的。但在程序运行过程中,程序往往还需要主动请求操作系统提供某些服务,比如读取文件、分配内存、创建进程等等,其都涉及到了硬件资源或者内存资源,不能由用户程序完成,必须交给操作系统来实现。

于是,程序需要一种机制,能够让当前执行流主动进入内核并执行一段内核代码,而这种机制就是系统调用

从执行流的角度来看,系统调用和中断的行为非常类似,只不过前者是程序主动发起的,后者是硬件事件被动触发的。二者本质上都完成了一件事:让操作系统接管当前执行流。

注意,无论是系统调用还是中断,执行流本身都没有被真正被改变。 具体来说,在系统调用和中断发生时,CPU只是“暂停”执行当前的用户代码,并切换到内核栈上执行一段内核代码,当执行完成后,会恢复之前保存的上下文,并返回到原来的用户代码,从上次“暂停”的地方继续执行。

但是在某些情况下,操作系统做的可不仅仅是接管执行流,而是会在程序运行过程中改变执行流本身。

比如,当进程受到某些事件通知时,操作系统可能需要让程序执行一段用户提前定义好的代码,用于处理该通知,而不是立刻继续原先被“暂停”的执行路径,在将用户定义好的代码执行完成后,再“继续”原先的执行路径。

要实现这种效果,操作系统就必须在从内核空间返回到用户空间前,修改用户执行流的状态——特别是用户栈上的内容。

而这种能够在内核运行过程中,动态修改用户栈的机制,就是信号


五.信号:操作系统如何修改执行流

上一节中最后提到了信号,此时可能会有这样的疑问:如果操作系统修改了用户栈,在用户栈上插入了新的执行路径,那么原本的调用栈结构不会被破坏吗?还能从最开始“暂停”的地方继续执行吗?

例如,假设程序正在执行某个函数 func(),此时用户栈上已经存在一系列函数调用形成的栈帧。此时因为中断/系统调用进入内核空间,并在从内核空间返回到用户空间前,发现需要处理信号,那么需要让程序跳转到用户定义的信号处理函数 handler(),而 handler() 也需要使用用户栈来执行。这样一来,它似乎就会覆盖原有的栈内容。

如果真的发生这种情况,那么当信号处理函数返回时,程序显然已经无法回到原来的执行位置。

然而事实并非如此。信号处理函数执行完成后,程序依然可以准确地回到被打断的位置继续运行,就像什么都没有发生过一样。

这是怎么做到的?在从内核空间准备返回用户空间时,发现需要处理信号。此时,内核是已经保存好了被打断时的上下文信息的。接下来,内核会在用户栈上额外开辟一小段内存空间,称为single frame,并会向其中写入被打断时的上下文信息。

 注意,不是在收到信号时就立刻处理,而是因为系统调用/中断进入内核空间后,在从内核空间准备返回到用户空间时才主动检查是否有需要处理的信号

这些准备工作完成后,内核会对执行流的状态做两处关键调整:

1.内核修改程序计数器,使得当前执行流重新回到用户空间时,不再回到原先被打断的位置,而是跳转到用户注册的信号处理函数。

2.内核调整RSP,使信号处理函数执行时,使用刚刚构造好的那段栈空间。这样一来,从用户程序的角度来看,执行流就像是“突然”调用了一个新的函数

随后,信号处理函数就像普通函数一样开始运行,它也会在single frame中继续建立自己的函数栈帧,并处理对应的逻辑。在处理完成后,执行流不会直接回到原先被打断的位置,而是进入一个由系统提供的特殊恢复过程。这个过程会重新进入内核,读取之间保存在single frame中的上下文信息并重新加载到内核栈中。此时,执行流才会从内核空间返回到用户空间,并将上下文信息加载到寄存器中。于是,执行流就可以继续从原来被打断的位置继续运行。

从执行流的角度,整个过程发生了下面这样的一次“插入式调用”:

信号处理前:

用户栈 (高地址 ↓)

+------------------+
| foo() frame      |
+------------------+
| bar() frame      |  ← 当前正在执行
+------------------+
| ...              |
+------------------+

内核插入single frame:

用户栈 (高地址 ↓)
+------------------+
| foo() frame      |
+------------------+
| bar() frame      |
+------------------+
| signal frame     |  ← 保存原寄存器/返回信息
+------------------+
| handler frame    |  ← 信号处理函数开始执行
+------------------+

最后返回:

用户栈 (高地址 ↓)

+------------------+
| foo() frame      |
+------------------+
| bar() frame      |  ← 恢复
+------------------+
| ...              |
+------------------+

可以看到,信号处理函数并不会破坏原有的栈结构

而信号机制的本质就是,操作系统在用户栈上通过single frame,构造了一次“伪函数调用”,并在处理完成后恢复原先的上下文,从而实现对用户执行流的动态修改

原来的执行流
    ↓
foo()
    ↓
bar()   ← 信号在这里发生
    ↓
handler()   ← 临时插入的执行路径
    ↓
bar()       ← 恢复原来的上下文
    ↓
foo()

六.总结

本文从最开始的函数调用,到线程与线程调度,再到中断与信号,这些看似分散的机制,实际上都可以从一个统一的视角来理解——执行流。

在程序运行过程中,执行流描述了当前代码的执行路径,而栈则记录了这条路径的结构。函数调用通过在栈上创建新的栈帧,使执行流能够进入新的函数并在结束后返回原来的位置。线程出现后,其作为基本调度单位,操作系统通过保存和恢复上下文,使得CPU可以在不同的执行流之间切换,从而实现了并发。

而中断和系统调用则进一步扩展,允许操作系统接管当前执行流,进入内核空间执行内核代码。信号则更进一步,不仅能让操作系统接管执行流,还可以在返回用户空间前修改用户执行流本身。实际上,这其中还有很多没有展开的细节,考虑到篇幅有限以及复杂性,就不细说了

所以,从执行流的视角来看,操作系统中调度很多机制,本质上都在围绕着一个问题展开:如何保存、切换和恢复执行流