我们已经学习了进程这个抽象概念,下面要接触到具体的机制了,之前说过,机制是某个功能的实现细节,而策略则类似于对机制的调度。
要学习的第一个机制被称为受限直接执行(Limited Direct Execution)。
作为一个优秀的操作系统,应该小心地提供那些会导致危险的指令(例如对硬盘的 IO 操作,称特权指令),这些操作被操作系统封装为系统调用(system call)。
但是这里还有一个问题,系统调用终究也是一长串指令序列,看起来它与用户的程序并无不同。毕竟 CPU 执行指令时也不会考虑这个指令是谁的。况且这个"谁"也只是我们的抽象罢了,进程(或者说程序)并无生命,在硬件层面根本不存在进程这一说,它们仅仅是内存中待执行的指令数据而已。
那么操作系统该怎么保护特权指令呢?当用户程序调用了特权指令会如何呢?
现代 CPU 拥有多级指令执行级别,即在不同级别下可以执行特定级别的指令,这些级别可以分为两种模式:内核模式(kernel mode)和用户模式(user mode)。
这些执行级别是通过硬件实现的,CPU 会检查特定寄存器中的值来确定当前的执行级别,这些逻辑都被设计在硬件中。
当机器启动时,引导程序会加载操作系统,此时操作系统运行在内核模式,从此往后,任何用户程序在什么执行级别下运行,完全由操作系统说了算。
就像人类社会的统治阶层,若他们不让民众使用特权,民众就永远没有机会成为统治阶层。换言之,任何在内核模式运行的程序都有机会将自己变成统治阶层。这让我想起了联合国五常的一票否决权,只有在五常自身同意的情况下,其他国家才能废除五常的一票否决权,否则其他国家就没有任何机会,因为五常可以直接一票否决"废除一票否决权"的提案。
这就是所谓"受限直接执行"的"受限"的含义。
回答第二个问题,当用户程序调用了特权指令会如何呢?
一般来说,当某进程的某个指令并不匹配目前的执行级别时,CPU 会异常,这时候操作系统可能会选择终止该进程。
这里的理解可以参考知乎上的这个问题:计算机怎么知道用户态和内核态?
说到这里就很清晰了,用户程序无法直接执行特权指令,而它们所需的功能由操作系统封装为系统调用,并对外开放。用户程序只需要调用这些系统调用就能实现对应的功能了。
不过,how it works?当一个用户进程调用系统调用时会发生什么?
首先我们应该弄清楚一件事,这些系统调用的二进制代码放在哪里?
有一种被称为陷阱表(trap table)的东西,陷阱表告诉硬件,当发生异常、中断、或系统调用时应该运行哪些代码。陷阱表在机器启动时由操作系统(以一些特殊的特权指令)指定。这些代码被称为陷阱处理程序。
有了陷阱表,硬件就知道发生系统调用时该去哪个地方运行代码了。那么我们如何触发这种调用呢?
要执行系统调用,用户程序必须执行陷阱指令(trapped instruction),同时,根据操作系统的约定,程序将一些必要的参数和调用号(指定调用哪个系统调用)放在约定好的内存空间(或寄存器)中。(这里被称为陷入操作系统)
指令执行后,由于系统调用(或称陷阱处理程序)是一种过程调用,硬件会负责保存进程状态(例如保存到**内核栈(kernel stack)**中),然后将进入内核模式,跳到特定的陷阱处理程序处开始执行。
陷阱处理程序结束后,操作系统负责执行一个从陷阱返回指令(return-from-trap instruction),恢复进程状态,降低指令执行级别,进入用户模式,执行接下来用户进程的代码。
以上就是一次完整的系统调用。
上下文切换(context switch)
操作系统可以调度进程,决定某个进程是否该在当下执行。完成这一策略的程序被称为调度程序(scheduler)。
但应该注意到,当一个用户程序正在运行时,操作系统是无法运行的,试想一下,CPU 里跑的全是用户程序的指令,这显然跟操作系统半毛钱关系没有,这也就意味着操作系统没有控制权,那么 CPU 也就无法执行操作系统的调度程序,一个没有执行能力的统治阶层是没有任何意义的。
操作系统会在什么情况下运行呢?
假设一下把,通过我们刚才学到的东西,可以这样说,当机器启动时,操作系统被加载,它会做一些初始化工作,包括我们刚才说的初始化陷阱表,这时候操作系统肯定是运行着的。
然后,当用户程序陷入操作系统(例如引发异常、或发生系统调用)时,硬件触发了陷阱处理程序,这些陷阱处理程序的代码都是操作系统的一部分,所以这时候操作系统也是运行的。
但是,如果用户程序表现非常良好,没有引发异常,而且也不做一些特权操作。
举个最简单的例子,陷入死循环:
int main(){
while(1);
return 0;
}
按照我们刚才的假设,操作系统将永远无法拿到控制权,这就意味着操作系统将无法控制陷入死循环的、或者是其他表现良好且不发生系统调用的机器。
这被称为协作(cooperative)方式,即操作系统假定每个进程都是友好的,而且会定期让出 CPU,一般这种系统会提供一个用于让出 CPU 的系统调用,这个调用什么也不干,仅会陷入操作系统。
这种夺回控制权的方式表现并不好,如果进程拒绝进行系统调用,而且也不会出错,那么操作系统将永远无法运行,也无法做任何事情。
于是我们有了非协作方式,用额外的硬件时钟设备来定期发出时钟中断(timer interrupt),以陷入操作系统。令 CPU 执行特定的操作系统的代码,让操作系统拿到控制权来做它想做的事情。这里需要注意的是,当发生中断时,硬件需要保存目前的进程状态,以便在返回时恢复。
这里 CPU 因为时钟中断而执行的"特定的操作系统的代码"就是陷阱处理程序,这种非协作方式其实就相当于让一个"程序"定时调用上面说的那个用于让出 CPU 的系统调用。
接下来我们将完整地描述一次上下文切换。
当时钟中断发生时,硬件将保存当前进程 A 的进程状态至内核栈,随后操作系统将夺回控制权。
操作系统将做一些事情,例如,根据调度程序的策略,我们已经决定要切换到进程 B。
下面会执行一段用来切换上下文的代码段,例如称其为switch()
。
switch()
会将进程 A 的进程状态作为一个结构保存到一个列表中进行维护(以便下次切换回来时进行恢复),然后从这个列表中找到进程 B 的结构恢复到寄存器。
然后就是从陷阱返回,进程 B 将从他的内核栈恢复寄存器,然后开始执行他的代码,此时就好像刚刚陷入内核的是进程 B 一样。
这就是一次完整的上下文切换过程。
特别注意上面两次保存进程状态,第一次是为了执行陷阱处理程序而进行的,将进程状态保存到内核栈的过程。第二次是操作系统为了进行上下文切换,将进程状态作为结构保存到操作系统自己维护的列表中的过程。第二次操作直接改变了下一步执行的进程在实际内存中的位置。
# void swtch(struct context **old, struct context *new);
#
# Save current register context in old
# and then load register context from new.
.globl swtch
swtch:
# Save old registers
movl 4(%esp), %eax # put old ptr into eax
popl 0(%eax) # save the old IP
movl %esp, 4(%eax) # and stack
movl %ebx, 8(%eax) # and other registers
movl %ecx, 12(%eax)
movl %edx, 16(%eax)
movl %esi, 20(%eax)
movl %edi, 24(%eax)
movl %ebp, 28(%eax)
# Load new registers
movl 4(%esp), %eax # put new ptr into eax
movl 28(%eax), %ebp # restore other registers
movl 24(%eax), %edi
movl 20(%eax), %esi
movl 16(%eax), %edx
movl 12(%eax), %ecx
movl 8(%eax), %ebx
movl 4(%eax), %esp # stack is switched here
pushl 0(%eax) # return addr put in place
ret # finally return into new ctxt