经过一系列的文章,我们通过汇编语言,体验了保护模式下分段、分页、特权级跳转、中断、异常等机制。 那么,事到如今,你是否已经深谙保护模式的设计之道了呢?究竟什么是保护模式,保护模式又在“保护”什么呢?他为了什么诞生,又和实模式有什么区别呢? 本文我们就来详细总结一下。
保护模式是随着 80286 CPU 诞生的,在这之前,CPU 内部只有一系列 16 位寄存器,并且拥有 16 位数据总线和 20 位地址总线。 因此,实地址模式需要通过两个 16 位寄存器来模拟 20 位地址:
基地址 : 偏移地址 = 基地址 * 16 + 偏移地址
这样,CPU 将 1MB 内存空间分成了 64KB 为单位的 16 段空间。 所以实地址模式下,最大寻址范围是 1MB,并且只能访问物理内存,所有经过上述计算得到的地址都是物理地址,直接与物理内存上的空间进行映射。
保护模式是在硬件基础上实现的一系列机制,针对寻址方式来说,保护模式下诞生了分段与分页机制用来进行寻址。 80286 CPU 开始,除了段基址寄存器,CPU 原有的其他 16 位寄存器扩展为了 32 位寄存器,并且新增了一系列新的寄存器,而在 32 位硬件体系中,地址总线也是 32 位的,因此,无需通过段基址:段偏移地址的方式计算物理内存,只需要一个 eip 寄存器就可以存储实际的物理内存了,这样是否可行呢? 从原理上,这当然是可行的,但硬件技术发展到这一时期,人们已经不局限于让整个系统同时只运行一个程序。 在实地址模式下,因为使用的所有地址都是物理内存的实际地址,那么,两个程序在编写过程中就完全无法避免他们所使用的地址发生冲突,所以,想让两个程序通过 CPU 切换来实现同时运行,是很难实现的。 于是,更为抽象与灵活的内存划分方式随着保护模式的诞生而诞生了。
首先,如果要让 CPU 通过切换来实现多个程序的同时运行,就必须在物理内存的基础上,将内存分段,从而实现多个进程所使用内存段的隔离,让两个进程即便使用相同的地址,实际上也访问到不同的物理内存。 与此同时,用来调度进程切换的操作系统的存在,就势必让整个系统中存在可共用的内存,那么,在进程跳转过程中,就必须要考虑内存是否可读写、可访问、权限、类型等等属性。 结合上面两大对内存的限定与保护的诉求,分段模式诞生了。
如图所示,内存通过全局描述符寄存器 GDTR 指向的全局描述符表 GDT 划分为了多个段,每个段对应 GDT 中一个描述符所记录的 32 位段基址 + 16 位段界限的组合,由于每个描述符是 64 位的,因此除了上述段基址与段界限外,还剩余 16 位存储空间,可以用来存储内存属性值,而这些属性,就是保护模式“保护”的精髓所在。 此时,一个进程要访问内存所提供的 cs:eip 已经不再是“段基址:段偏移地址”了,cs 寄存器存储的内容成为了 GDT 内具体某个描述符的偏移,称为“段选择子”,而 eip 则对应该描述符所描述的内存段空间的偏移。 进军保护模式
顾名思义,“全局描述符表”在整个系统中是唯一的,用来描述整个物理内存的分段与属性控制。 那么,如何实现每个进程独有内存的控制呢?这个设计也很容易想到,让 GDT 中每个描述符描述的内存段仅供一个进程使用,不同的进程使用不同的内存段,而在各自的内存段内部,再实现一套类似 GDT 的描述符表,通过让 cs:eip 实现局部描述符表偏移:段内偏移来进行寻址,这样,即使两个进程都指定相同的 cs 寄存器,但由于他们在不同的局部描述符表中进行寻址,最终得到的物理内存上的内存段是截然不同的。 这就是 LDT 的设计思想,进程切换时,通过 lldt 命令,切换 ldtr 寄存器内存储的 LDT 基地址与界限,就实现了进程实际控制的内存段的切换。 此时,cs:eip 存储的地址就被称为“线性地址”,而段内偏移 eip 则被称为“逻辑地址”。 实战局部描述符表 LDT
通过 LDT 进行各进程内存的划分和隔离存在一些问题:
于是,从 80386 开始,新的内存划分和寻址方式诞生了,那就是分页机制,LDT 机制渐渐被大多数操作系统所弃用。
分页机制将内存彻底打散,变成 4KB 为单位的小块,每个小块就被称为“页”,通过上图将 32位地址拆分成 10 位、10 位、12 位三部分,分别在页目录表、页表、页上进行寻址,实现了最大限度的内存离散化。 离散化后的内存可以随时将若干个内存块放入空间足够大的磁盘上,而通过 CPU 中的内存管理单元 MMU,实现将上述分段机制得到的线性地址映射到实际的内存分页虚拟地址上,实现每个进程都可以独立享有完整的内存空间。 而在页目录表与页表中,每个表项都拥有着自己的属性,从而实现了更细粒度 — 4KB 为单位的内存保护。
在实地址模式下,内存被划分为系统保留内存与用户进程可使用的内存,除了用户不能对硬件保留内存进行读写外,并没有过多的访问限制。 保护模式下,对内存和 IO 定义了三个位于不同位置的特权级字段:
同时,内存段分为一致代码段与非一致代码段:
这样,对进程可访问的内存进行了严格的限制,让供不同权限使用的内存得以被保护在特定的特权级下,但有时,限制得过于严格反而让程序实现上处处掣肘,不同特权级之间跳转还是很常见的需求,于是,调用门就这样诞生了。
通过调用门,我们可以实现:
可见,调用门可以让我们通过 call 指令调用高特权级的非一致代码段,但通过栈寻址指针 esp,让我们可以在栈中访问到高特权级程序此前压栈的数据,这显然有着巨大的安全隐患,于是硬件系统同时实现了栈切换机制,为每个特权级的进程单独设置一个栈空间,并将相关的三套 ss:esp 寄存器存储起来用来切换,这就是任务状态段。 利用调用门实现特权级间跳转(上) — 原理篇
同时,调用门有以下四种:
这样,就将中断、陷阱、抢占式任务的切换都实现了上述特权级的保护。
上文已经提到,位于 eflags 的 12、13 位的 IOPL 字段,控制了 IO 敏感指令的执行。
IO 敏感指令包括:in、ins、out、outs、cli、sti
这些指令只有在 CPL <= IOPL 时才能够执行。
而有两个非常特殊的指令:popf和iretf,他们能够改变 eflags 的 IF 位,这显然也是一个很敏感的操作,因此同样限制了只有在 CPL <= IOPL 时,这两个指令才能够实现 eflags IF 位的更改,如果这两个操作没有更改 IF 的意图,则不受特权级的限制。
而上文中提到的任务状态段 TSS 的 102 字节偏移为“IO 位图基地址”(IO Map Base Address),他指向一个以 TSS 地址为基址的偏移地址,在这块内存中,每一位代表一个 IO 端口,0 表示可用 1 表示不可用。 这样,在统一特权级下的不同人物也可以拥有不同的 IO 访问权限。
根据上述详细的解析,我们可以知道,保护模式针对内存的保护主要有以下几方面:
通过 eflags 上的 IOPL 特权级与 TSS 指向的 IO 位图,IO 敏感操作也具有了严格的权限限制。 IO 端口被保护了起来,不同特权级的程序能使用的 IO 端口不同,不同一个特权级内,不同的进程能够使用的 IO 端口也不同,这样就实现了最细粒度的 IO 保护。