有一些问题相当基础嘛……应该是初学计算机组成原理和操作系统吧,建议首先先集中力量在计算机组成原理上,不过的确单看计算机组成原理也比较枯燥,可以结合起来稍微讲一下。
太长不看的提前总结:
首先从问题最关键的地方开始:归根到底为什么需要保护模式?
从计算机组成原理的最基础的理论开始讲起。说到计算机,从冯诺依曼体系讲起,最重要的就是五部分:运算器、控制器、存储器、输入设备、输出设备。
其中,运算器是无状态的;控制器配合一部分寄存器,但是寄存器数量很少,而且通常都很容易被修改;输入设备、输出设备只有接受指令的时候才动作。归根结底来说,整个计算机的运行状态几乎完全由存储器和少数几个寄存器控制。
也就是说,如果一段程序能够完全控制物理内存,那么它就能做到任意改变计算机的状态,包括干掉整个操作系统然后把自己变成操作系统;把自己变成操作系统的一部分等等。通常来说操作系统肯定是不乐意的了。
早期的 DOS 这样的操作系统,运行在实模式上,就遇到的是这样的情况:它其实将要执行的应用程序加载变成了操作系统的一部分,然后混合起来运行,哪一段是用户程序、哪一段是操作系统并没有很明确的界限:用户程序退出就回到操作系统;用户程序触发软中断就到操作系统,返回又回到用户程序;用户程序自己可以访问大部分的硬件设备;用户程序甚至可以随意修改属于操作系统的数据。于是,当时的许多病毒也毫不客气地把自己直接连接到了操作系统的程序里面,一旦执行就永远驻留成为操作系统的一部分。当时在 DOS 上流行的病毒可谓多种多样、五花八门。
单任务的情况下已经有不少问题了,到了多任务模式下,问题就更严重了:
这还只是考虑到应用程序都是善良的情况下,要对付恶意程序就需要更强的手段。
可我们前面说了,物理内存就是整个计算机状态的全部,如果程序有办法读写所有的物理内存和寄存器,那任何保护手段都无济于事。所以要限制应用程序的行为,必须在应用程序和操作系统执行时有不同的状态,核心问题在于保护关键寄存器和重要的物理内存。
这个目标显然是必须要硬件配合的,否则 CPU 如何区分当前究竟是执行操作系统(开放所有能力)还是应用程序(限制危险功能)呢?那么我们如果不考虑实际结果,只从需求上面分析如何解决这个问题,应该可以得到以下结论:
现在我们回到实际 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,直接访问物理内存,从而获得完整的权限。
从具体设计上来说,最直接的想法就是用户模式和特权模式都使用相同的内存地址,只是在用户模式下设置哪些内存可访问,哪些不可访问。这种方法是否可行呢?实际上是可行的,不过略有一些缺陷:
现代 MMU 通常使用虚拟地址空间的技术来解决这个问题,也就是你说的“用户空间”。在用户模式下,所有访问内存的地址实际上都是虚拟地址,它与实际的物理地址是对应不上的。这样,即便两个应用程序使用了相同的地址,它们也可以做到互不干扰,只需要通过技术手段让它们实际映射到不同的物理地址就行了。MMU 和操作系统通过称作页表的数据结构来实现虚拟地址到物理地址的映射,一般来说在 x86-64 系统中,内存按照 4KB 的大小分成页,每个地址对齐的页可以独立从任意一个虚拟地址段,映射到任意一个物理内存地址段,两个起始地址的低 12 位都是 0(也就是所谓地址对齐,这样任意一个虚拟地址映射到物理地址时,最低 12 位不需要动)。页表的结构在每次进入用户模式之前都可以重新设置,这样切换进程之后,页表发生了变化,同一个虚拟地址就会映射到不同的物理地址上,这就同时实现了多个目标:
最后我们来说一下应用程序怎么访问外部设备的问题。我们说了,用户模式下应用程序无法直接访问硬件设备,但如果完全没法利用硬件设备,那就太不方便了。这两者的权衡是,应用程序通过操作系统使用硬件,也就是说应用程序给操作系统发起请求,操作系统处理请求时将请求转发到硬件,硬件响应后,再将请求转发回应用程序。
许多硬件使用中断和 DMA 来传输信号或数据。这种情况下,操作系统开始操作后,到硬件操作完成前会有一段空闲时间,这时候操作系统可以将当前应用程序挂起,先去执行其他的应用程序。当硬件操作完成时,会触发中断,中断向量表在内存中,是操作系统提前设置好的,指向了操作系统自己的代码;同时,这个中断也会立即强迫 CPU 进入特权模式。这时候操作系统就有机会来处理硬件返回的数据了,同时根据进程优先级,可以将之前挂起的进程重新切换回来重新开始继续执行。
不同硬件往往有不同的接口,但操作系统会希望提供给应用程序统一的接口,这中间就涉及到驱动适配的问题,厂家的驱动程序可以将通用的请求转化为自己家硬件能识别的请求格式。
保护模式不意味着应用程序访问硬件的能力变弱了,实际上,应用程序访问硬件的能力完全取决于操作系统是否允许。别说是 Windows PE,实际上任意版本的 Windows 都是可以允许一个最高权限的用户程序直接读写物理硬盘的(通过 CreateFileEx 的 Windows API 就可以,就跟打开一个普通文件一样),唯一的问题在于 Windows 依赖很多磁盘文件,如果在普通 Windows 执行过程中格式化系统盘,操作系统会崩溃,而 Windows PE 比较小,可以将重要的东西都整个加载到内存里,就可以在保持操作系统正常工作的情况下格式化硬盘了。