内核态和用户态

太长不看的提前总结:

  1. 内核态,或者说CPU的特权模式,是CPU的一种工作状态,它影响CPU对不同指令的执行结果。操作系统通过跟CPU配合,设置特权模式和用户模式,来防止应用程序进行越权的操作
  2. 防止应用程序越权访问内存时使用了虚拟地址空间映射的技术,这是操作系统软件配合硬件的MMU共同实现的。在用户模式下,应用程序访问的内存地址是虚拟内存地址,会映射到操作系统指定的物理地址上。这个虚拟内存地址空间就是你说的用户空间。
  3. 内核态是个操作系统概念,虽然对应到CPU的特权模式,但一般如果没有操作系统,就不说内核态了,直接说运行在CPU的特权模式应该没毛病。
  4. 应用程序无法自由进入内核态,只能通过操作系统提供的接口调用进入,或者在硬件中断到来时被动进入
  5. 应用程序通过操作系统功能来使用硬件

首先从问题最关键的地方开始:归根到底为什么需要保护模式?

从计算机组成原理的最基础的理论开始讲起。说到计算机,从冯诺依曼体系讲起,最重要的就是五部分:运算器、控制器、存储器、输入设备、输出设备。

其中,运算器是无状态的;控制器配合一部分寄存器,但是寄存器数量很少,而且通常都很容易被修改;输入设备、输出设备只有接受指令的时候才动作。归根结底来说,整个计算机的运行状态几乎完全由存储器和少数几个寄存器控制。

也就是说,如果一段程序能够完全控制物理内存,那么它就能做到任意改变计算机的状态,包括干掉整个操作系统然后把自己变成操作系统;把自己变成操作系统的一部分等等。通常来说操作系统肯定是不乐意的了。

早期的DOS这样的操作系统,运行在实模式上,就遇到的是这样的情况:它其实将要执行的应用程序加载变成了操作系统的一部分,然后混合起来运行,哪一段是用户程序、哪一段是操作系统并没有很明确的界限:用户程序退出就回到操作系统;用户程序触发软中断就到操作系统,返回又回到用户程序;用户程序自己可以访问大部分的硬件设备;用户程序甚至可以随意修改属于操作系统的数据。于是,当时的许多病毒也毫不客气地把自己直接连接到了操作系统的程序里面,一旦执行就永远驻留成为操作系统的一部分。当时在DOS上流行的病毒可谓多种多样、五花八门。

单任务的情况下已经有不少问题了,到了多任务模式下,问题就更严重了:

  1. 因为多个应用程序要独立加载,如果两个应用程序执意要使用同一个内存地址,那就会发生严重的问题,操作系统必须防止这种事情发生
  2. 外部设备一般来说都是很傻的,它并不知道多任务的存在,不管谁操作外部设备它都是一样响应。这样如果多个应用程序自己直接去操纵硬件设备,就会出现相互冲突,有可能一个程序的数据被发送到了另一个程序等等
  3. 操作系统必须自己响应硬件中断,通过硬件中断来切换任务上下文,让合适的任务在合适的时机继续执行。如果应用程序自己把中断响应程序改掉了,整个操作系统都会崩溃
  4. 操作系统必须有能力在单个应用程序崩溃的情况下清理这个应用程序使用的资源,保证不影响其他应用程序;这就要求它必须清楚知道每个应用程序使用了哪些资源

这还只是考虑到应用程序都是善良的情况下,要对付恶意程序就需要更强的手段。

可我们前面说了,物理内存就是整个计算机状态的全部,如果程序有办法读写所有的物理内存和寄存器,那任何保护手段都无济于事。所以要限制应用程序的行为,必须在应用程序和操作系统执行时有不同的状态,核心问题在于保护关键寄存器和重要的物理内存

这个目标显然是必须要硬件配合的,否则CPU如何区分当前究竟是执行操作系统(开放所有能力)还是应用程序(限制危险功能)呢?那么我们如果不考虑实际结果,只从需求上面分析如何解决这个问题,应该可以得到以下结论:

  1. CPU必须至少有两种不同的状态:操作系统状态和应用程序状态。不同状态下,相同指令会产生不同的结果,也就保证某些任务只有操作系统能执行,某些只有应用程序能执行。
  2. 操作系统必须有办法配合CPU,设置哪些内存可以访问,哪些内存不能访问(或者说只有操作系统状态下能访问),不能访问的包括操作系统自己的代码区和数据区、中断向量表等。
  3. 应用程序状态下不能直接访问硬件设备
  4. CPU在触发中断时需要自动切换到操作系统状态(否则无法进行多任务切换)
  5. 操作系统状态可以自由切换到应用程序状态;应用程序状态不能任意切换到操作系统状态,但也需要有触发进入操作系统代码并切换到操作系统状态的能力(否则无法调用操作系统功能)

现在我们回到实际CPU的设计上,显然实际CPU的设计者的思路跟我们是差不多的。这里我们叫做操作系统状态的,在实际操作系统概念中就叫做内核态,在CPU设计上则叫做特权模式;我们叫做应用程序状态的,在实际操作系统概念中叫做用户态,CPU设计上叫做用户模式。

注意到,内核态并不是一个东西,没有处于什么地方一说,它是CPU的两种状态之一。如果不是说进入内核态,而是说切换到内核态,可能你就没有这种误解了。都怪intel将系统调用的指令起名字叫sysenter,所以大家都比较习惯说“进入”内核态。

实际上CPU可能被细分为更多的运行模式,而不仅仅是特权和用户两种模式,不过操作系统至少需要这两种。有的时候特权和用户模式也指的并不是一种真正的模式,而是一类模式,比如好几种类似的但略有区别的运行模式都合成特权模式之类。

这种 特权+用户 的多模式切换的运行方式,就叫做(x86)CPU的保护模式功能。保护模式之所以也是一个模式,有一定的历史原因,因为intel CPU每一代产品都会尽量兼容之前的产品,早期的CPU启动时是实模式,没有这种模式切换的功能,后来的CPU为了兼容早期的CPU,启动时也处于实模式,需要引导程序主动进入保护模式,然后才拥有多模式切换的能力。这些是历史原因和一些细节问题。

对于CPU本身来说,CPU是不知道究竟哪一段代码属于应用程序、哪一段代码属于操作系统的,它没有能力识别当前执行的代码究竟应不应该有权限,因此它只负责按照程序逻辑来执行:如果指令自己要求自己进入用户模式,CPU就进入用户模式,但进去之后,就只有特定的方法才能再回到特权模式。所以并不是说进入特权模式就一定是操作系统代码了,CPU并没有这个保证。但是,我们说了,保护模式设计的目标就是为了让应用程序代码受到限制,如果应用程序的代码进入了特权模式,这个限制就完全失效了,所以操作系统设计上会使用各种各样的巧妙手段,配合CPU的功能,保障应用程序只能通过跳转到操作系统代码的方式来切换到内核态上,这样也就间接保障了内核态下执行的都是操作系统(包括驱动)的代码。

接下来我们讨论如何限制内存访问的问题,这也是这个设计中最困难的一部分。相比来说,在用户模式下禁用一部分指令功能比较简单,无非是控制器里加入相应的组合逻辑,判断当前状态,如果状态为用户模式则拒绝执行特权指令而已。而内存读写则不一样,指令是相同的,只是访问的内存地址不同,这时候有些地址是可以访问的,有些地址则不能访问,能不能访问的区别仅仅在内存地址上。要知道,CPU是支持利用寄存器间接寻址的,因此这个非法的指令不可能在译码的阶段就发现,而是必须在执行期间发现;同时,哪些地址可以访问,哪些地址不能访问,必须完全是可配置的,操作系统有极大的自由。最后,这个系统还必须对应用程序有最基础的友好性,不能让应用程序太难写。

既然内存里每一个单元是否允许访问都需要能够设置,而内存的大小是不确定的,那这个设置的数量也不确定,而且会较为庞大,在寸土寸金(?)的CPU里放这么多、这么复杂的设置是很不合适的,唯一可行的方案就是通过内存自己来管理内存——使用一部分内存用来存储其他内存应该如何使用的配置。这样,实际访问内存时,就需要——

先访问内存中的内存配置,根据内存配置判断要访问的内存是否允许访问,如果不允许访问需要触发非法操作的中断,而如果允许访问则正常访问;同时,内存中的内存配置也是内存的一部分,所以内存中的内存配置也会受到内存中的内存配置的管理。

仅仅从这个拗口程度上也能知道这是一件多么复杂的事情,使用内存自己来管理内存,这就好比左脚踩着右脚上天梯,一个不小心玩脱了就出大事了。而且为了让带配置的内存使用起来有效率,还需要大量使用缓存技术。

CPU中引入了一种称为MMU的单元,它可能是现代CPU最复杂的组件之一了。它能从内存中以指定格式加载配置,从而影响用户模式下访问内存的特性。为了方便进程切换,这个格式往往有复杂的数据结构,还要支持多种多样的配置功能。在用户模式下,所有内存访问经过MMU,从而对内存的访问受到了保护;在特权模式下,内存访问绕过MMU,直接访问物理内存,从而获得完整的权限。

从具体设计上来说,最直接的想法就是用户模式和特权模式都使用相同的内存地址,只是在用户模式下设置哪些内存可访问,哪些不可访问。这种方法是否可行呢?实际上是可行的,不过略有一些缺陷:

  1. 在保护模式出现之前,编译器都是针对实模式设计的,在编译过程中,使用哪些内存地址范围、内存的什么位置放什么数据,都完全是编译器可以自己决定的。即使是保护模式出现之后,操作系统的部分也需要相同的编译方式。如果应用程序的编译需要放弃这一套逻辑,改成所有地址都由操作系统分配,那现有的汇编程序和编译器都需要重写,这个代价难以接受。
  2. 应用程序经常会需要使用一大片连续的内存空间,比如说涉及数组的一系列算法。如果内存空间全部都是动态分配的,那有些程序可能会不断地申请小块小块的空间,从而让内存空间碎片化,没有连续成片的内存。等这些程序退出之后,释放出来的内存都是小块、不连续的,操作系统就没法让其他应用程序使用连续成片的内存了。
  3. 安全上有隐患,虽然应用程序没法读取其他内存,但是应用程序可以知道哪些内存已经被其他应用程序用了,于是可以从内存地址的分配上分析出一些信息,例如当前操作系统可能执行了哪些其他应用程序,这些应用程序可能处于什么状态等等。还有可能因为CPU实现的bug导致应用程序能以意想不到的方式读取到不应当能读取的数据。
  4. 现代操作系统希望支持一些高级的内存管理方式,例如虚拟内存——将一部分不使用的内存暂时放在磁盘上,这样可以用较少的内存支撑更多的应用程序;写时复制——两个应用程序使用相同的内存块,希望能暂时使用同一个物理内存地址,但是其中一个需要修改的时候再将它复制成两份独立的内存块,从而节约内存。

现代MMU通常使用虚拟地址空间的技术来解决这个问题,也就是你说的“用户空间”。在用户模式下,所有访问内存的地址实际上都是虚拟地址,它与实际的物理地址是对应不上的。这样,即便两个应用程序使用了相同的地址,它们也可以做到互不干扰,只需要通过技术手段让它们实际映射到不同的物理地址就行了。MMU和操作系统通过称作页表的数据结构来实现虚拟地址到物理地址的映射,一般来说在x86-64系统中,内存按照4KB的大小分成页,每个地址对齐的页可以独立从任意一个虚拟地址段,映射到任意一个物理内存地址段,两个起始地址的低12位都是0(也就是所谓地址对齐,这样任意一个虚拟地址映射到物理地址时,最低12位不需要动)。页表的结构在每次进入用户模式之前都可以重新设置,这样切换进程之后,页表发生了变化,同一个虚拟地址就会映射到不同的物理地址上,这就同时实现了多个目标:

  1. 应用程序有独立的虚拟地址空间
  2. 应用程序只能访问已经映射了的虚拟地址空间,未映射的物理地址无法访问(实现了保护内存)
  3. 页表和中断向量表,理所当然不会被映射出来
  4. 部分RISC(x86是CISC)的架构上,内存和外部设备有统一的地址空间,不映射外设的地址,也就阻止了对外设的访问
  5. 应用程序看来连续的内存,在物理内存上不需要是连续的,内存使用的效率很高
  6. 以某些方式访问某些页面时可以触发操作系统的中断,操作系统可以趁这个机会修改页表,这就给操作系统实现高级内存管理功能打下了基础

最后我们来说一下应用程序怎么访问外部设备的问题。我们说了,用户模式下应用程序无法直接访问硬件设备,但如果完全没法利用硬件设备,那就太不方便了。这两者的权衡是,应用程序通过操作系统使用硬件,也就是说应用程序给操作系统发起请求,操作系统处理请求时将请求转发到硬件,硬件响应后,再将请求转发回应用程序。

许多硬件使用中断和DMA来传输信号或数据。这种情况下,操作系统开始操作后,到硬件操作完成前会有一段空闲时间,这时候操作系统可以将当前应用程序挂起,先去执行其他的应用程序。当硬件操作完成时,会触发中断,中断向量表在内存中,是操作系统提前设置好的,指向了操作系统自己的代码;同时,这个中断也会立即强迫CPU进入特权模式。这时候操作系统就有机会来处理硬件返回的数据了,同时根据进程优先级,可以将之前挂起的进程重新切换回来重新开始继续执行。

不同硬件往往有不同的接口,但操作系统会希望提供给应用程序统一的接口,这中间就涉及到驱动适配的问题,厂家的驱动程序可以将通用的请求转化为自己家硬件能识别的请求格式。

保护模式不意味着应用程序访问硬件的能力变弱了,实际上,应用程序访问硬件的能力完全取决于操作系统是否允许。别说是Windows PE,实际上任意版本的Windows都是可以允许一个最高权限的用户程序直接读写物理硬盘的(通过CreateFileEx的Windows API就可以,就跟打开一个普通文件一样),唯一的问题在于Windows依赖很多磁盘文件,如果在普通Windows执行过程中格式化系统盘,操作系统会崩溃,而Windows PE比较小,可以将重要的东西都整个加载到内存里,就可以在保持操作系统正常工作的情况下格式化硬盘了。


内核态和用户态
https://flepeng.github.io/001-计算机组成原理-内核态和用户态/
作者
Lepeng
发布于
2021年5月19日
许可协议