阅读 173

重温操作系统——“共享”CPU

操作系统是一门重要的基础知识,了解这门基础知识不仅能帮助我们写出更优秀的程序,还能提高我们的学习能力。当我发现有时候经常看不懂大佬的文章,听不懂大佬间谈话,看不懂项目文档的时候,我想我是时候补充一下基础知识了。本系列篇章内容基于Operating Systems: Three Easy Pieces的读后感,是一份操作系统知识的归纳总结。

“共享”CPU

我们的电脑使用一块CPU“同时”运行着各式各样的应用程序,操作系统通过分时共享的方式,让每个程序轮流使用CPU,就好像每个程序都有自己的CPU一样(就好像共享单车那样)。为了使CPU能够被各进程分时共享,操作系统要掌握分配CPU使用权的权利,同时也要履行服务好各项进程的义务。本篇文章就操作系统如何行使“权利”与履行“义务”做了一些归纳总结——操作系统如何掌握系统的控制权?操作系统如何协调各项进程“共享”CPU?

限制用户进程行为

操作系统是有“被害妄想症”的(事实上,它必须要有被害妄想症...),它不信任用户进程,总想着用户进程充满恶意,会阻碍系统的正常运作。于是乎,只有操作系统才有权限直接访问诸如内存,硬盘,以及其他系统资源。一但有用户进程试图越过操作系统执行这些“危险”的访问操作,该进程就会被杀死。让用户进程直接访问内存,硬盘等资源的确也是很危险的一件事,试想一下如果一个进程可以任意读取和修改其他进程的内存数据,那么基本上所有的进程都不能正常运行了。所以操作系统必须要限制用户进程的行为。

用户态(user mode)与内核态(kernel mode)

操作系统通过划分用户态和内核态来限制用户进程的行为:

  • 用户态:用户进程运行在用户态,只能执行部分低权限的指令。
  • 内核态:操作系统运行在内核态,具备执行所有指令的权限。

CPU硬件支持

为了区分用户态和内核态,操作系统需要硬件的帮助。CPU要提供某种权限机制,来区分用户态和内核态的访问权限。例如,x86 CPU提供4种特权级别(privilege level):0,1,2,3,等级越低权限越高(可以执行的指令种类越多)。在任一时刻,CPU都是在一个特定的特权级下运行,如果进程执行了不符合当前特权级别的指令,CPU会抛出异常,该进程会崩溃退出,从而起到保护作用。

Unix/linux操作系统只用到了Ring0和Ring3:操作系统的特权级别是Ring0,而用户进程的特权级别是Ring3。

系统调用 (System Call)

然而,我们的应用程序总是会需要访问内存以及磁盘的。但在用户态下,用户进程并没有执行直接访问这些硬件资源的指令权限。用户进程需要通过操作系统提供的编程接口——系统调用(system call)与操作系统进行交互,由操作系统在内核态中来执行相应的指令。用户进程通过触发软中断(trap)让CPU陷入内核态,由操作系统在内核态处理用户请求。处理完毕后,操作系统通过执行return-from-trap指令似CPU返回用户态,将结果返回给用户进程,继续用户进程的执行。

中断处理

当某些急切需要CPU处理的事件发生的时候,可以由软件或硬件触发一个中断信号,让CPU停下手头上的事情,立马处理当前触发中断的事件,中断处理完毕后,CPU将继续执行被暂停的程序。每个中断都有一个中断编号,其对应的处理函数入口地址保存在中断向量表(interrupt vector table)中。在操作系统的启动阶段,操作系统会设置中断向量表,当中断产生的时候,CPU根据中断号查询中断向量表,从而得知执行哪一个中断处理函数。可以对中断进行一个简单的分类:

  • 硬中断:由外部硬件触发的中断,比如按下键盘,或者点击鼠标。
  • 软中断:由软件主动触发的中断,比如系统调用,在linux系统中,通常通过执行int 0x80汇编指令来触发系统调用软中断(中断编号为0x80)。

系统调用编号

假设系统调用的中断编号为0x80,CPU能够通过0x80得知这是一个系统调用,执行系统调用中断处理函数。但是我们的系统调用中断处理函数还不知道到底是什么系统调用,是读写文件?还是分配内存?操作系统通过给系统调用编号来解决这个问题。当要执行系统调用的时候,用户进程会将系统调用编号(system-call number),以及调用参数,传给系统调用中断处理函数(通过CPU寄存器传递),后者根据系统调用编号执行相应的代码。

图解系统调用过程

图片修改自 pages.cs.wisc.edu/~remzi/OSTE… Figure 6.2

在操作系统启动阶段初始化中断向量表,并告知CPU硬件中断向量表的访问地址。当用户进程执行系统调用的时候,CPU将部分寄存器信息保存到当前进程的内核栈中,切换至内核态,开始执行系统调用中断处理函数。系统调用中断处理函数根据系统调用编号执行相应系统调用,执行完毕后通过return-from-trap指令,恢复发生中断前的寄存器内容,切换至用户态,继续执行用户进程。

后文有对①②步骤的详细说明。

进程间切换

当用户进程正在CPU上运行时,意味着我们的操作系统并没有运行。既然操作系统没有在运行,那实际上操作系统是没有办法做任何事的。为了让操作系统能够夺回CPU的控制权,我们再一次需要硬件的帮助——借助时钟设备来定期触发时钟中断(timer interrupt)。当时钟中断触发后,当前进程会暂停执行,CPU切换至内核态,执行中断处理函数(interrupt handler),此时,操作系统重新获得了CPU的使用权,根据进程调度算法的选择,它可以暂停当前进程运行,运行另一个进程。

上下文切换(Context Switch)

当进程重新获得CPU使用权的时候,CPU需要知道他上一次执行到哪里才能正确继续执行下去。当操作系统决定要执行进程切换的时候,会执行上下文切换(context switch)——保存当前进程的上下文信息,恢复即将执行的进程的上下文信息。

悠闲的午后,你正在房间里看书(阅读进程)。突然线上出bug了(中断),你夹好书签(保存上下文信息)合上书,火急火燎打开电脑开始排查(切换到修bug进程)。处理完成之后,回到房间继续看书。多亏合书之前夹好了书签,打开书你便可以继续上次的阅读了。

进程控制块(Process Control Block)

进程控制块(PCB)是操作系统用于描述一个进程信息的数据结构。进程的上下文信息保存在PCB中。PCB保存下列信息:

  • 程序计数器(PC):用于标识下一条指令执行的地址。
  • CPU寄存器信息:保存进程用到的寄存器信息,用于进程恢复执行的时候,恢复进程的状态。
  • ...

更多信息可参考:en.wikipedia.org/wiki/Proces…

内核栈(Kernel Stack)

操作系统为每个进程独立分配一段栈空间,当进程因某种原因(中断/上下文切换)暂停运行时,用于保存CPU或进程的上下文信息。当进程需要继续执行的时候,从内核栈中恢复这些信息。

如果仅仅是中断(例如系统调用),而未发生进程切换,其实并不需要保存进程的上下文信息。具体原因在后续的图解中详细说明。

图解进程切换步骤

进程切换步骤如图所示:

图片修改自 pages.cs.wisc.edu/~remzi/OSTE… Figure 6.3

时钟中断的产生暂停了进程A的运行,由硬件将部分寄存器信息保存到内核栈,并切换至内核态。在时钟中断处理函数中,如果操作系统决定要从进程A切换到进程B(根据进程调度算法),开始调用switch()方法执行上下文切换:

  1. 将进程用到的寄存器——例如程序计数器(PC),堆栈指针寄存器(SP),以及其他通用寄存器保存到PCB中。
  2. 根据进程B的PCB信息,恢复进程B的寄存器内容。通过设置堆栈指针寄存器(SP),切换到进程B的内核栈。

上下文切换完成后,操作系统执行return-from-trap指令,恢复进程B的寄存器,切换到用户态,开始执行进程B。

为什么保存/恢复了两次寄存器内容?

图中①②③④步骤都涉及到保存寄存器或恢复寄存器的操作。实际上,只有②跟③是属于上下文切换的范畴——从进程A的上下文切换至进程B的上下文。而①和④步骤由CPU硬件完成,由中断触发,跟进程切换没有直接关系。其目的是——保存CPU进入内核态前的状态,确保调用return-from-trap指令后,能恢复正确的用户态状态(进入内核态前的状态),其实也可以把这个步骤归称之为上下文切换,只不过这是CPU内核态与用户态之间的上下文切换(mode switch)。

小结

操作系统通过区分用户态和内核态,配合CPU硬件提供的权限机制来限制用户进程的执行权限。用户进程只能通过系统调用来访问硬件资源(内存,硬盘等)。操作系统通过时钟中断来保持对操作系统的控制,使用上下文切换的方式来保障进程在CPU上正常切换。执行一次系统调用或上下文切换的代价还是挺高的(CPU做了很多数据搬运的工作),在编码中,我们可以通过减少系统调用的次数来提升程序的性能(比如合并读写操作)。

关注下面的标签,发现更多相似文章
评论