机制:受限的直接运行(Mechanism: Limited Direct Execution)
为了虚拟化CPU,同时为了运行多个工作(Jobs),OS需要分享物理上存在的CPU。通过CPU的时分,可以达到虚拟化。
有几个挑战(Challenges):
- 第一是性能(Performance):不添加额外的负担(overhead)给系统;
- 第二是控制(Control):在保留CPU的控制的时候,如何把进程运行得高效。
保持控制的同时,获得高性能,是最核心的挑战。
为了达到上述的挑战,需要硬件和软件(OS)的支持。
LDE
当OS希望运行程序的时候,它在进程列表(process list)中创建进程入口(process entry),分配内存,(从硬盘)加载程序代码到内存,分配进入点(entry point)(比如:main()函数),跳转到该点并开始运行。
下图显示了基本的执行协议(当前还没有任何限制),运行完main()之后,跳转回内核(kernel)。
似乎很简单,但是有一些问题:
- OS怎么确保程序不做我们不想它做的事情?同时保持高效运行?
- OS怎么进行进程切换?也即实现时分这个事情?
OS不能控制所有事情,应该“只是一个库”(just a library) :-(
问题1:受限的操作
直接执行显然很快:程序直接运行在CPU硬件上。但是,如果进程想要做一些受限的操作,比如向硬盘发出I/O请求,或者请求更多的资源,比如:CPU或者内存。此时应该怎么办?
重点是怎么在不把控制完全给进程的前提下,OS和硬件如何协调工作。
一种方式是让任意进程做任何它想做的事情,但是,这样会涉及到文件的权限问题。
因此,需要引入新的处理器模式:用户态(user mode)。该模式下,代码是受限的,进程没办法提出I/O请求。与之相对的是:内核态(kernel mode)。该模式下,可以执行任意指令。
好了,问题来了:当用户进程想做需要权限的操作,比如从磁盘读取,怎么办? 如下,可以采用系统调用:
为什么系统调用看起来像过程调用(procedure calls)?
比如open()或者read(),看起来就像普通的过程调用?其实就是一个过程调用,但是在调用里面隐藏了陷阱指令(trap instruction)。
比如在调用open()的时候,库会使用和内核约定俗成的规范。把open()的参数放到大家熟知的位置(比如,栈或者特定的寄存器)。然后执行陷阱指令。陷阱的最后会返回值并将控制交回调用者。
这部分陷阱指令是和OS打交道的,是硬件相关的,需要用汇编写。但是这些库的作者帮我们写好了。
硬件辅助OS来提供不同的执行模式,用户态是受限的,内核态不受限;同时,提供一些特殊的指令,比如:陷入内核、从内核返回、告诉硬件陷入表(trap table)所在内存位置的指令
个人理解的整个调用权限链条:
用户态->系统调用->陷阱(trap)指令->内核态->从陷阱返回(return-from-trap)指令->用户态
硬件在执行陷入的时候需要小心,需要“保留现场”(调用者的寄存器)。在x86,处理器将程序计数器(program counter),标志位(flags)和一些其他的寄存器保存到每个进程各自的内核栈(kernel stack)。从陷阱返回时,会将这些值从栈中推出,然后返回用户态的程序执行方式。而其他硬件系统也相似。
比如, 使用write()的系统调用,用户指定写入的内存地址,如果是坏的地址(内核或者其他进程),OS应该检测出来并拒绝执行。安全的系统应该把用户的输入当成可疑的,不然就很容易被攻击。
陷阱应该执行OS中的哪些代码?不能被调用者指定跳转的地址,不然就会很危险(上方的注释)。内核在引导的时候,设置陷阱表(trap table)。OS应该告诉硬件在发生某些异常事件时应该运行哪些代码。比如硬盘中断、键盘中断等情况。
告诉硬件陷阱表在哪也是一个特权(privileged)操作。
OS告诉硬件这些陷阱处理者(trap handlers)的位置,通常是一些特殊指令。硬件就知道当发生中断或者系统调用时,应该去哪里执行代码了。如果能安装自己的陷阱表,能不能接管机器?
通常给每个系统调用分配一个系统调用号(system-call number)。用户的代码负责将系统调用号放到寄存器或者栈的特定位置。在陷阱处理者里,判断该号是不是有效的,也算是一种保护。
上图是LDE的过程。包括两部分,一部分是初始化,一部分是运行时。
问题2:进程间切换
OS应该结束进程并开始另一个进程。这个时候就会有点triky:如果一个进程在CPU上运行,也即OS不在CPU上运行,那OS怎么做这件事情呢?显然是没办法的,那我们就接触到问题的核心了----怎么重新获得CPU的控制权。(OS怎么获得CPU控制权来进行进程间切换)
一种合作方法:等待系统调用
一种方式是:OS相信系统进程的合理性为。运行过长的进程被认为周期性放弃CPU,这样OS可以决定运行其他任务。
大部分进程,在做系统调用(system calls)的时候,将CPU的控制权给OS,这时候,OS可以运行其他进程。
另外就是,当除0或者访问异常地址的时候,OS会重新获得CPU的控制权。
这种方式有些被动,等待系统调用或者异常操作,如果是一个无限循环但是不带系统调用呢?是不是会一直运行下去?
一种非合作方法:OS来管理
如果不采用这种被动的方式,那怎么才能做到CPU控制权移交回OS?----使用一种计时器中断(timer interrupt)。每隔一段时间(一定ms),当前运行的进程被挂起,OS里预定义的中断处理者(interrupt handler)开始运行:停止当前进程并开始另一个进程。
注意到,当中断发生时,硬件也是有一些责任的。这些处理者和上面的相似,都需要“保留现场”。
保留和恢复上下文
切换进程和调度器(scheduler)有关,后续会提到。如果决定切换进程了,OS会执行一些底层代码,称作上下文切换(context switch)。
保留当前进程的上下文:寄存器,PC,内核栈指针;然后恢复寄存器,PC,并且切换到即将被运行的进程的内核栈。当执行从陷阱返回(return-from-trap)的指令后,新进程就开始运行了。
一个时间线如下图所示。中断的时候,OS决定将控制权从A转到B,并做上下文切换。
包括两个阶段:硬件保存、软件(OS)保存,原文如下:
Note that there are two types of register saves/restores that happen during this protocol. The first is when the timer interrupt occurs; in this case, the user registers of the running process are implicitly saved by the hardware, using the kernel stack of that process. The second is when the OS decides to switch from A to B; in this case, the kernel registers are explicitly saved by the software (i.e., the OS), but this time into memory in the process structure of the process. The latter action moves the system from running as if it just trapped into the kernel from A to as if it just trapped into the kernel from B.
并发问题
当中断在处理的时候,将中断开关关闭(不再接收中断)。是一个简单的解决方法。但是也不能关闭太久,不然有的中断会丢失。
后续会有一些锁(locking)机制来保护内部数据结构的并发访问。并发是相当复杂且会带来许多有趣和难找的bug的问题。
The xv6 Context Switch Code(xv6操作系统的上下文切换的代码)
1 # void swtch(struct context **old, struct context *new);
2 #
3 # Save current register context in old
4 # and then load register context from new.
5 .globl swtch
6 swtch:
7 # Save old registers
8 movl 4(%esp), %eax # put old ptr into eax
9 popl 0(%eax) # save the old IP
10 movl %esp, 4(%eax) # and stack
11 movl %ebx, 8(%eax) # and other registers
12 movl %ecx, 12(%eax)
13 movl %edx, 16(%eax)
14 movl %esi, 20(%eax)
15 movl %edi, 24(%eax)
16 movl %ebp, 28(%eax)
17
18 # Load new registers
19 movl 4(%esp), %eax # put new ptr into eax
20 movl 28(%eax), %ebp # restore other registers
21 movl 24(%eax), %edi
22 movl 20(%eax), %esi
23 movl 16(%eax), %edx
24 movl 12(%eax), %ecx
25 movl 8(%eax), %ebx
26 movl 4(%eax), %esp # stack is switched here
27 pushl 0(%eax) # return addr put in place
28 ret # finally return into new ctxt
上下文切换多久?
有个工具叫lmbench,可以测试上下文切换还有系统调用,还有其他相关的性能指标。
96年1.3.37版本的Linux,200MHz的处理器,上下文切换和系统调用大概4ms和6ms。现代系统在2或者3GHz处理器,是子ms级别。
注意到,不是所有的OS操作跟CPU性能挂钩。许多操作是和内存挂钩的,内存带宽没有像CPU一样增长得这么快。所以升级CPU带来的提速可能没有你希望的那么大。
重要的CPU虚拟化条目(机制):
- CPU应该支持至少两种运行模式:受限的用户态(user mode)和带权限的内核态(kernel mode)。
- 通常用户应用运行在用户态,使用系统调用(system call)来陷(trap)入内核并请求OS的服务。
- 陷阱(trap)指令小心地保存寄存器状态,改变硬件状态到内核态,并且跳转到OS的预指定的目的地:陷阱表(the trap table)
- 当OS结束系统调用 的服务,它通过从陷阱返回(return-from-trap)的指令。减少权限并返还控制权给下一条指令。
- 陷阱表应该在OS启动的时候设定,确保它们不能被用户程序修改。这是LDE的一部分。
- 一旦程序开始运行,OS必须使用硬件机制来确保用户程序不会永远运行。被称为计时器中断(timer interrupt)。这种方法对CPU调度来说,是非合作的(non-cooperative)。
- 有时候OS在计时器中断或者系统调用的时候,可能希望切换当前进程。被大家知道为上下文切换(context switch)。
网友评论