Legacy Mode #
内存管理主要分为以下几种模式:
- 实模式
- 保护模式
- 虚拟 8086 模式
- 管理模式
实模式 #
我们不妨先从 8086 讲起,众所周知,8086 的内存地址线有 20 位,而 CPU 寄存器只有 16 位,那么要怎么设计才能让 CPU 访问全部的内存呢?答案是分段。将内存每 16Byte 分为一段,一共 65536 段,这下可以用两个 16 位的寄存器来表示地址了,于是最终的物理地址就是 段基址 << 4 + 段偏移
。
为了程序上的方便,专门设计了多个段寄存器用来保存段基址:
- CS: 代码段寄存器,存放代码段的段基址
- DS: 数据段寄存器,存放数据段的段基址
- ES: 是扩展段寄存器,存放当前程序使用附加数据段的段基址,该段是串操作指令中目的串所在的段
- SS: 是堆栈段寄存器,存放堆栈段的段基址
而段偏移值则由 IP 寄存器存储。
直到现在,所有的 CPU 上电时都是实模式,在系统内核加载之后由内核切换为保护模式。
那保护模式又是什么呢?别急,下面会讲。
除此之外,在实模式下给出超过 1M(20bit) 的地址时,系统不会认为是异常,而是将地址对 1M 取模,这种技术叫做 warp-around。
但是这种方式有很多缺点:
- 程序之间内存完全暴露,无法防止错误或恶意的程序访问其他程序甚至硬件的内存[注1]
- 只能访问 1M 以内的内存
- 不能多任务
虽然也有很多优点: - 可以直接访问 BIOS 的低级 API
- 访问的是物理地址,无需转换,性能更高
- ……
当然在讲保护模式之前,我们还是继续扩展一下实模式的中断。
中断,即停止当前程序的运行,跳转到特定的地址上去运行程序。这是 cpu 响应外部事件的一种方式。比如我们在键盘上敲击,就会对 cpu 产生中断,让它停下当前程序的运行去看看我们输入了什么。中断分为两种,硬件中断和软件中断。字如其名,一个主要是由外围设备产生的,另一个是由 cpu 上跑的程序产生的。
在内存中会存储中断向量表,在 cpu 的 IDTR 寄存器会存储这张表在内存的位置和长度。当中断产生时,cpu 会去中断向量表寻找对应的条目,也就是将表起始地址和中断号相加得到条目地址。然后存储当前的 CS 和 IP 寄存器并把这两个寄存器设置为条目里对应的值。
讲了那么多,终于可以开始保护模式了。
保护模式 #
平坦模型 #
前面我们讲到,8086 的寄存器只有 16 位,即使使用分段技术也只能访问 1M 的内存,所以最关键的还是要给寄存器上点强度。从 80836 开始,cpu 开始使用 32 位寄存器。
另外,之前每次都要设置段基址,写程序的时候也过于麻烦了。既然我们现在有了 32 位的寄存器,干脆就扔掉段基址了可以吗?并不可以,为了保持兼容性,以后的 cpu 依然保留了段基址,只是段基址一律都为 0 了。
但是这样就解决了所有问题吗?并没有,之前所说的程序之间内存暴露、多任务等问题还是没有解决。所以人们借用了原来的分段技术,对 cpu 进行了虚拟抽象化处理。简单来说,就是个每个程序都划分出一块专用区域,而这种专用区域就是新意义上的段。原先简单的位移计算也肯定无法满足这种新的分段方式的要求了,于是描述符表也就应运而生。表中的每一项都是对内存一段的描述,包括起始地址,长度,权限等等。当然寄存器肯定是存不下这么多信息的,描述符表像前面所讲的中断向量表一样,也是放在内存上,由 GDTR 寄存器指示位置和长度。
下图就是描述符的结构:
- Base: 段基址
- SegLimit:段的限制,你也许会好奇为什么不直接翻译成长度,那是因为这个数就不一定代表了长度。我们都知道,栈是从高到低增长的,而其他内存数据一般从低到高增长,因此段也被分为两种,一种是 expand-down,一种是 expand-up,分别对应上述两种内存使用方式。在 expand-down 中,段的偏移值是 \[SegLimit + 1, 0xFFFFFFFF/0xFFFF\] ,而在 expand-up 中,段的偏移值是 \[0, Seglimit\]。
- G(Granularity):粒度,控制 SegLimit,当值为 0 时,粒度为 1Byte,为 1 时,粒度为 4KByte。具体来说,当值为 1 时,SegLimit 的实际值为
SegLimit << 12 + 0xFFF
- D/B(Default Operation Size … flag) 控制默认的操作数大小,比如对 64 位代码段设置为 1 就可以让 cpu 执行时对指令默认使用 64 位操作数
- L(64-bit code segment) cpu 使用,区分代码类型
- AVL(Available Bits) 保留给操作系统使用
- P(Segment Present) 标记是否在内存中存在,当设置为 0 时加载进段寄存器会抛出错误。内存管理器可以利用这一特性,比如提前分配段但是不分配段在物理内存上的位置,当段被访问到再去分配。
- DPL(Descriptor Privilege Level) 段特权级,可以为 0-3,下面会着重讲解
- Type(Type Field):段类型,可以为 X(执行),W(读写),R(读出),A(已访问),具体不在此展开
我们继续来讲讲 DPL,之前提到,原来的模式中程序会访问其他程序甚至系统/硬件的内存,这肯定是不安全的,于是新的段机制中加入了权限位,权限从高到低分别是 ring0 到 ring3,低权限代码想要访问高权限段会触发异常。
然而,ring0 到 ring3 看起来很高级,但是现在 x86 系统(包括 linux 和 windows),都只使用 ring0 和 ring3,称为用户态和内核态。至于为什么呢?还是因为给的权限级别不够颗粒度,另外太多的内存权限级别使得程序之间数据共享效率大大降低,同时在安全性上改善不大。下文的分页会详细展开。
顺带的,在新的 cpu 中,设计者多增加了亿些寄存器,它们分别是
寄存器 | 作用 |
---|---|
eax,ebx,ecx,edx,edi,esi,ebp | 32 位通用寄存器 |
eip | 32 位程序指针寄存器,始终指向下一条指令的地址 |
esp | 32 位栈指针寄存器,始终指向当前栈顶 |
cs,ds,es,ss,fs,gs | 16 位段寄存器,存放内存段的描述符索引 |
EFLAGS | 32 位 cpu 标志寄存器,存放 cpu 执行运算指令产生的状态位 |
CR0-3 | 32 位 cpu 控制寄存器,控制 cpu 的功能控制特性,如开启保护模式 |
为了兼容实模式,段寄存器仍然是 16 位,那么自然没法存放整个段描述符,于是它被设计成存放描述符在表中的索引。
但是我们都知道,内存的速度远远比不上寄存器,如果每次涉及地址都需要去描述符表里找段,无疑会拖慢速度。于是 cpu 会给段寄存器分配描述符寄存器,这被称为影子寄存器。
在这里,我们又看到了一个 Privilege Level。需要指出的是,CS(代码段寄存器)和 SS(栈段寄存器)中的 RPL 称为 CPL(Current Priviledge Level)。这也很好理解,比较指令基本上都在这两者中间被加载。
那既然段描述符已经有了 DPL,那为什么这里又要来个 RPL 呢?别急,让我们先看下权限的比较规则。
CPL ≤ DPL && RPL ≤ DPL
第一个条件我们很好理解,但第二个着实有点烧脑。举个例子,我们平时在进行系统操作时会进行系统调用,那此时 ring3 的程序调用了 ring0 的程序,CPL 就变为了 0,如果这个系统调用还有一个参数是内存地址,那么 ring3 的程序就有机会访问所有内存。为了对其进行限制,就在 ring0 程序加载段时将 RPL 设置为 ring3,这样 ring3 的程序就没办法传入一个高权限的地址来逃脱检查了。
而中间 TI 则是 GDT 和 LDT 的选择器。可以简单地理解,GDT 用来存放全局的段信息,而 LDT 则用来存放普通程序的信息。一般来说,GDT 只有一个,而 LDT 可以有很多。当程序切换时,通过 LDT 就可以切换访问的段。当 TI 设置为 1时,cpu 会去 LDT 里寻找段的信息。但是 cpu 又是怎么知道 LDT 的位置的呢?就像 GDT 一样,LDT 也有一个寄存器 LDTR 用来存放描述符的位置、限制等信息。但是正如我们前面所说的,可能有很多 LDT,自然也需要很多空间来存放他们的信息(基址,限制等等),一个寄存器肯定是放不下的。所以 LDT 的信息放在 GDT 中,当 LDT 被用到的时候再从 GDT 取出存进 LDTR 中。
第一篇就先写到这吧……
再写要累死了