当前位置: 首页 > news >正文

《操作系统真象还原》第4章 保护模式入门

目录

引用与说明

4.1、保护模式概述

1、为什么要有保护模式

4.2、初见保护模式

1、保护模式之寄存器扩展

2、保护模式之寻址扩展

3、保护模式之运行模式反转

4、保护模式之指令扩展【汇编】

4.3、全局描述符表

1、段描述符

2、全局描述符表 GDT、局部描述符表 LDT 及选择子

3、打开 A20 地址线

4、保护模式的开关,CR0 寄存器的 PE 位

5、让我们进入保护模式【代码】

4.5、使用远跳转指令清空流水线,更新段描述符缓冲存储器

4.6、保护模式之内存段的保护

1、向段寄存器加载选择子时的保护

2、代码段和数据段的保护

3、栈段的保护


引用与说明

  • 《操作系统真相还原》,作者:郑钢
  • kanshanxd的博客:​​​​​​《操作系统真象还原》第四章 保护模式入门_保护模式下显存地址-CSDN博客​​​​​​
  • 除原书外其他引用均在段落末首尾标记“【引】”,再次感谢郑钢与kanshanxd的开源!
建议略读章节
4.1.2、实模式不是 32 位 CPU,变成了 16 位
4.4、处理器微架构简介
代码实现章节
4.3.5、让我们进保护模式


4.1、保护模式概述

1、为什么要有保护模式

实模式的问题

  • 安全缺陷
    • 实模式下操作系统和用户程序属于同一特权级
    • 用户程序所引用的地址都是指向真实的物理地址,也就是说逻辑地址等于物理地址
    • 用户程序可以自由修改段基址,可以访问所有内存
  • 使用缺陷
    • 访问超过 “64KB“ 的内存区域时要切换段基址,转来转去容易晕乎
    • 一次只能运行一个程序,无法充分利用计算机资源
  • 硬伤
    • 共 20 条地址线,最大可用内存为 1MB,不够用


4.2、初见保护模式

1、保护模式之寄存器扩展

CPU 发展到 32 位后,地址总线和数据总线也发展到 32 位, 寻址空间达到 2 的 32 次方,4GB 。内存寻址方式还得兼容 “段基址:段内偏移地址” 的方式,寄存器还是 16 位宽度的话,无法做到 4GB 寻址,所以要扩展。

经过 extend 后的寄存器,统一在名字前加了 e 表示扩展

【注】:寄存器中低 16 位的部分是为了兼容实模式,可以单独使用。高 16 位没办法单独使用,只能在用 32 位寄存器时才有机会用到它们。

由图 4-1 可以看出,除段寄存器外,通用寄存器、指令指针寄存器、标志寄存器都由原来的 16 位扩展到了 32 位。

【Q】:为什么段寄存器不用扩展?

段寄存器的改变:偏移地址还和实模式下的一样。为了更加安全,段基址添加了约束条件,它们是对内存段的描述信息。信息很多,用一个寄存器是放不下的,所以专门找了个数据结构一一全局描述符表。全局描述符表很大,所以放在了内存中,由 GDTR 寄存器指向它就行。段寄存器中保存的再也不是段基址了,里面保存的内容叫 “选择子“, 选择子是个数,用这个数来索引全局描述符表中的段描述符,把全局描述符表当成数组,选择子就是数组下标。存 “数组下标” 是完全够存的,所以不需要扩展段寄存器。

【注】:全局描述符表在 4.3 节会讲到。

段描述符缓冲寄存器

段描述符的两个问题:

  • 段描述符在内存中,访问内存对 CPU 来说是比较慢。
  • 段描述符的格式很奇怪,一个数据要分三个地方存,所以 CPU 要把这些七零八落的数拼合成一个完整数据也是要花时间的。

针对上述问题引出了缓存技术,将段信息用一个 ”段描述符缓冲寄存器“ 来缓存,是不可见寄存器。 CPU 每次获取到的内存段信息,整理成 “完整的、通顺、不鳖脚” 的形式后,存入段描述符缓冲寄存器,以后每次访问相同的段时,就直接读取该段寄存器对应的段描述符缓冲寄存器。

缓存有个失效时间。只要往段寄存器中赋值, CPU 就会更新段描述符缓冲寄存器。例如,在保护模式下加载选择子(即使新选择子的值和之前段寄存器中老的选择子相同),CPU 就会重新访问全局描述符表,再将获取的段信息重新放回段描述符缓冲寄存器,或在实模式下为段寄存器赋予段基址,无论是否与之前段基址相同,段基址左移 4 位后的结果就被送入段描述符缓冲寄存器。

CPU 有三种模式: 实模式、虚拟 8086 模式、保护模式。虚拟 8086 模式也称 “过搜模式”

2、保护模式之寻址扩展

实模式下,对于内存寻址来说,其中的基址寻址、变址寻址、基址变址寻址,这三种形式中的基址寄存器只能是 bx、bp,变址寄存器只能是 si 、 di,也就是说,只能用这 4 个寄存器。其中 bx 默认的段寄存器是 ds,它经常用于访问数据段, bp 默认的段寄存器是 ss,它经常用于访问栈。

对于寻址中的偏移量,只能是 1 个字以内的立即数,即不能超过 16 位。

在保护模式下,内存寻址中,基址寄存器不再只是 bx 、bp,是所有 32 位的通用寄存器,变址寄存器也不再只是 si 、 di,是除 esp 之外的所有 32 位通用寄存器,偏移量由实模式的 16 位变成了 32 位。并且,还可以对变址寄存器乘以一个比例因子,注意比例因子,只能是 1 、 2、 4、 8。

具体形式如下代码:

mov eax, [eax + edx * 8 + 0x12345678]
mov eax, [eax + edx * 2 + 0x8]
mov eax, [ecx * 4 + 0x1234]

esp 无法用作变址寄存器,但其可用于基址寄存器

mov eax, [esp]
mov eax, [esp + 2]

3、保护模式之运行模式反转

16 位环境下可以用 32 位环境的资源,而 32 位下也可以用 16 位的资源。

bits 指令:用于指定运行模式。向编译器传达下面的指令都要编译成 xx 位。

使用 bits 指令的情况是:清楚所写的代码是运行在哪种模式下,需要向编译器明确指出将其编译成哪种模式的机器码。

 指令格式:[bits 16]或 [bits 32]。"[ ]" 可省略。

bits 指令的范围是从当前 bits 标签直到下一个 bits 标签的范围,这个范围中的代码将被编译成相应宇长的机器码。在未使用 bits 指令的地方,默认是 [bits 16]。
 

操作数反转前缀 0x66:临时将当前运行模式下的操作数大小转变成另外一种模式下的操作数大小。比如,在指令中添加了 0x66 反转前缀之后:假设当前运行模式是 16 位实模式,操作数大小将变为 32 位。假设当前运行模式是 32 位保护模式,操作数大小将变为 16 位。这个转换只在当前指令有效。与第二章的段跨越前缀一样,都是指令前缀。

寻址方式反转前缀 0x67:临时将当前运行模式下的寻址方式转变成另外一种模式下的寻址方式。

【注】:添加指令前缀是编译器的工作,不需要我们动手,所以不用了解的那么详细。代码样例见书【P143、P144】

4、保护模式之指令扩展【汇编】

 【注】:本章作者有大量例子验证,初读容易读不下去,实际上不看这些例子也没什么影响。

涉及到操作数变化的指令也要跟着扩展,既要兼容 16 的操作数,也要支持 32 位的操作数。

操作数在一个寄存器中

对于大部分双操作数指令,操作数在一个寄存器中,只需要改变选用的寄存器,如:

add al, cl     ;支持 8 位操作数
add ax, cx     ;支持 16 位操作数
add eax, ecx   ;支持 32 位操作数sub al, cl     ;支持 8 位操作数
sub ax, cx     ;支持 16 位操作数
sub eax, ecx   ;支持 32 位操作数

对于一些单操作数指令,如 inc、 dec 等,也是同时支持 8 位、 16 位、32 位寄存器。

并不是所有的指令都要支持以上 3 种宽度的操作数,比如对于 loop 指令,实模式下要用 cx 寄存器来存储循环次数,在保护模式下,要用 ecx。

操作数不仅在一个寄存器中

mul、push 等操作数不在一个寄存器中 ,为了支持 32 位操作数,不得不增加了额外的寄存器。

mul:无符号数相乘指令。

指令格式: mul 寄存器/内存。其中 “寄存器/内存” 是乘数

  • 如果乘数是 8 位,则把寄存器 al 当作另一个乘数,结果便是 16 位,存入寄存器 ax。
  • 如果乘数是 16 位,则把寄存器 ax 当作另一个乘数,结果便是 32 位,存入寄存器 eax。
  • 如果乘数是 32 位,则把寄存器 eax 当作另一个乘数,结果便是 64 位,存入 edx: eax,其中 edx 是积的高 32 位, eax 是积的低 32 位。

有符号数相乘指令 imul 也是一样。

div:无符号数除法指令。

指令格式: div 寄存器/内存。其中 “寄存器/内存” 是除数

  • 如果除数是 8 位, 被除数就是 16 位,位于寄存器 ax。所得的结果,商在寄存器 al,余数在寄存器 ah。
  • 如果除数是 16 位,被除数就是 32 位,被除数的高 16 位则位于寄存器 dx,被除数的低 16 位则位于寄存器 ax。所得的结果,商在寄存器 ax,余数在寄存器 dx 。
  • 如果除数是 32 位,被除数就是 64 位,被除数的高 32 位则位于寄存器 edx,被除数的低 32 位则位于寄存器 eax,所得的结果,商在寄存器 eax,余数在寄存器 edx 。

push:操作数是 立即数 / 寄存器 / 内存,需要根据其操作数的类型分别讨论。

当 CPU 以 16 位的模式运行时,不是说变成纯粹的 16 位的 CPU 了(纯粹 16 位 CPU 是无法进入到 32 位保护模式的), 32 位 CPU 在 16 位的实模式下运行时,可以理解为 16 位 CPU 的加强版。但其本身是 32 位 CPU,它天生就具备处理 32 位数据的能力。所以,在 16 位的实模式下, CPU 照样可以处理 32 位的数据。例子就是 push,这是往栈中添加数据的指令,在实模式和保护模式下都可以同时处理 16 位和 32 位的数据。

  • 操作数是 立即数

指令格式是:push  8 位立即数 / 16 位立即数 / 32 位立即数

虽说可以压入 8 位立即数,但实际上,对于 CPU 来说,出于对齐的考虑,操作数要么是 16 位,要么是 32 位,所以 8 位立即数会被扩展成各模式下的默认操作数宽度,即实模式下 8 位立即数扩展成为 16 位后再入栈,保护模式下扩展成为 32 位后再入栈。

  • 操作数是 段寄存器

对于段寄存器的入栈,即 cs、 ds 、 es 、 fs、 gs 、 ss,无论在哪种模式下,都是按当前模式的默认操作数大小压入的。例如,在 16 位模式下, CPU 直接压入 2 字节,栈指针 sp 减 2。在 32 位模式下, CPU 直接压入 4 字节,栈指针 esp 减 4。

  • 操作数是 通用寄存器、内存
    • 如果压入的是 16 位数据,栈指针减 2
    • 如果压入的是 32 位数据,栈指针减 4


4.3、全局描述符表

保护模式下,内存段(如数据段、代码段等)不再是简单地用段寄存器加载一下段基址就能用啦,段的信息增加了很多,需要提前把段定义好才能使用。

全局描述符表(Global Descriptor Table, GDT)是保护模式下内存段的登记表,这是不同于实模式的显著特征之一。

进入保护模式三步骤(无先后顺序,可以不连续)

  • 打开 A20
  • 加载 gdt
  • 将 cr0 的 pe 位置 1

1、段描述符

16 位模式下,访问内存时将段基址加载到段寄存器中,再结合偏移地址就行了,段寄存器太小了,只能存储 16 位的信息,甚至连 20 位地址都要借助左移 4 位来实现。现在为了安全性,为内存段添加一些额外的安全属性。这些用于安全方面的属性用寄存器肯定装不下,只能放在内存里。

段描述符:用来描述内存段的属性,被放到了一个称为段描述符的结构中。该结构专门用来描述一个内存段,该结构是 8 字节大小。

【注】:

20 位的段界限属性,被拆分成两部分。段界限的低 16 位(0~15 位)存放在段描述符的低 32 位,段界限的高 4 位 (16~19 位)存放在段描述符的高 32 位。32 位的段基址被分拆成三份存放。这些都是历史遗留问题。4.2.1 节介绍过,避免影响 CPU 获取段信息的效率,段信息会被 CPU 缓存到段描述符缓冲寄存器中,寄存器中的内容便是段描述符中的内容,它是经过 CPU 整理后的,段界限和段基址已经被拼合到一起, CPU 下次会自动到段描述符缓冲寄存器中取段数据。

段界限:表示段边界的扩展最值,即最大扩展到多少或最小扩展到多少。

  • 数据段和代码段的扩展方向向上,即地址越来越高,此时段界限用来表示段内偏移的最大值。
  • 栈段的扩展方向是向下,即地址越来越低, 此时的段界限用来表示段内偏移的最小值。

【Q】:如何计算 段界限边界值?如何限制段内偏移地址的范围?

段界限用 20 个二进制位来表示。此段界限只是个单位量,它的单位要么是字节,要么是 4KB,这是由描述符中的 G 位来指定的。最终段的边界是此段界限值*单位,故段的大小要么是 2 的 20 次方等于 1MB,要么是 2 的 32 次方( 4KB 等于 2 的 12 次方, 12+20=32 ) 等于 4GB 。

这里的 1MB 和 4GB 是个范围,不是具体的边界值。段界限是个偏移量,是从 0 算起的,所以:

实际的段界限边界值=(描述符中段界限+1)*(段界限的粒度大小: 4KB 或者 1)  - 1

这个公式实质上就是表示有多少个 4KB 或 1 。 由于描述符中的段界限是从 0 起的,所以(描述符中段界限+1),表示 4KB 或 1 的实际数量。它与第二个括号中的段粒度大小相乘后得到的乘积是以 1 为起始的段的实际大小。 由于地址是以 0 为起始的,所以公式的最后又减了 1 。

  • 如果 G 位为 0,表示段界限粒度大小为 1 字节,根据公式,段界限实际大小就等于描述符中的段界限值
  • 如果 G 位为 1 ,表示段界限粒度大小为 4KB 字节,实际段界限=(描述符中段界限+1) * 4k - 1 。 举个例子,如果是平坦模型,段界限为 0xFFFFF,G 位为 1 ,套用上面公式,段界限边界值 = 0x100000 * 0x1000 - 1 = 0xFFFFFFFF

内存访问需要用到 “段基址:段内偏移地址“,段界限其实是用来限制段内偏移地址的,段内偏移地
址必须位于段的范围之内,否则 CPU 会抛异常。根据段的扩展方向,此 “段界限*单位” 便是段内偏移地址的最大值(向上扩展)或最小值(向下扩展〉,任何超过此值的偏移地址都被认为是非法访问, CPU 会将此错误捕获。
顺便提一句,是 CPU 硬件负责检测,这块没咱们啥事,但检测到错误后就有咱们的事啦,CPU 会触发相应的异常,咱们负责写相应的异常处理程序。


段描述符中的属性

【留意】:以后想扩展成 64 位操作系统需要留意一下这里的 L 字段。

字段作用
段基址用于寻址
段界限限制段内偏移地址的范围
S

CPU 把段描述符分为两类,描述的是系统段,描述的是数据段。凡是硬件运行需要用到的东西都可称之为系统,凡是软件需要的东西都称为数据,无论是代码、数据、包括栈,都作为硬件的输入,都是给硬件的数据,所以代码段在段描述符中也属于数据段(非系统段)。 

S 置 1 表示 非系统段

TYPE指定本描述符的类型,用于表示内存段或门的子类型。S 字段的值确定后, type 字段的值才有具体意义
DPL

指定描述符特权级,由于段描述符用来描述一个内存段或一段代码的情况,所以描述符中的 DPL 是指所代表的内存段的特权级。

这两位能表示 4 种特权级,分别是 0 、 1 、 2 、 3 级特权,数字越小,特权级越大。

CPU 由实模式进入保护模式后,特权级自动为 0。操作系统应该处于最高的 0 特权级。用户程序通常处于 3 特权级,权限最小。某些指令只能在 0 特权级下执行,从而保证了安全。

P

表示段是否存在。如果段存在于内存中, P 为 1 ,否则 P 为 0 。

对于 P 字段, CPU 只负责检查,咱们负责赋值。

AVLAVaiLable,可用的。不过这 “可用的” 是对用户来说的,也就是操作系统可以随意用此位。
L设置是否是 64 位代码段。 L 为 1 表示 64 位代码段,否则表示 32 位代码段。
D/B

指示有效地址及操作数的大小,兼容 286 操作系统的保护模式。
对于代码段,此位是 D 位。为 0,表示指令中的有效地址和操作数是 16 位,指令有效地址用 IP 寄存器;为 1 ,表示指令中的有效地址及操作数是 32 位,指令有效地址用 EIP 寄存器。

对于栈段,此位是 B 位,用来指定操作数大小,此操作数涉及到战指针寄存器的选择及栈的地址上限。为 0,使用的是 sp 寄存器,也就是栈的起始地址是 16 位寄存器的最大寻址范围, 0xFFFF;为 1 ,使用的是 esp 寄存器,也就是栈的起始地址是 32 位寄存器的最大寻址范围, 0xFFFFFFFF。

G粒度,用来指定段界限的单位大小,它与段界限一起来决定段的大小。若 G 为 0,表示段界限的单位是 1 字节,这样段最大是 2的 20 次方* 1 字节,即 1MB。若 G 为 1,表示段界限的单位是 4KB,这样段最大是 2 的 20 次方*4阻字节,即 4GB 。

TYPE 的 位作用
A,Accessed 位

根据此位能判断该描述符是否可用。

每当该段被 CPU 访问过后, CPU 就将此位置 1。

创建一个新段描述符时,应该将此位置 0。

C,Conforming,一致性代码段,也称为依从代码段

如果自己是转移的目标段,并且自己是一致性代码段,自己的特权级一定要高于当前特权级,转移后的特权级不与自己的 DPL 为主,而是与转移前的低特权级一致,也就是听从、依从转移前的低特权级。

C 置 1 表示 表示该段是一致性代码段。

R

用来限制代码指令对代码段的访问。

R 为 1 表示可读, R 为 0 表示不可读。

X,EXecutable

在 CPU 眼中指令和数据都是二进制。要用 type 中的 X 位来标识出是否是可执行的代码。

R 为 1 表示代码段是可执行的。

E,Extend用来标识段的扩展方向。 E 为 0 表示向上扩展,地址越来越高,用于代码段和数据段。 E 为 1 表示向下扩展,地址越来越低,用于栈段。
W,Writable指段是否可写。 W 为 1 表示可写,通常用于数据段。 W 为 0 表示不可写入,通常用于代码段。

2、全局描述符表 GDT、局部描述符表 LDT 及选择子

一个段描述符只用来定义一个内存段。代码段要占用一个段描述符、数据段和栈段等,多个内存段也要各自占用一个段描述符,这些描述符放在全局描述符表。

全局描述符表

GDT 相当于是描述符的数组,数组中的每个元素都是 8 字节的描述符。用选择子中提供的下标在 GDT 中索引描述符。“全局” 体现在多个程序都可以在里面定义自己的段描述符,是公用的。表位于内存中,需要用专门的寄存器 GDTR 指向它后, CPU 才知道它在哪里。GDTR 专门用来存储 GDT 的内存地址及大小,是个 48 位的寄存器。

lgdt 指令

指令格式:lgdt48 位内存数据

访问 gdtr 寄存器的专用指令。这 48 位内存数据划分为两部分,其中前 16 位是 GDT 以字节为单位的界限值,所以这 16 位相当于 GDT 的字节大小减 1 。后 32 位是 GDT 的起始地址。由于 GDT 的大小是 16 位二进制,其表示的范围是 2 的 16 次方等于 65536 字节。每个描述符大小是 8 字节,GDT 中最多可容纳的描述符数量是 65536 / 8 = 8192 个,即 GDT 中可容纳 8192 个段或门

指令运用时机:此指令在实模式与保护模式下都能运行,言外之意便是进入保护模式需要有GDT,但进入保护模式后,还可以再重新换个 GDT 加载。在保护模式下重新换个 GDT 的原因是实模式下只能访问低端 1MB 空间,所以 GDT 只能位于 1MB 之内。根据操作系统的实际情况,有可能需要把 GDT 放在其他的内存位置,所以在进入保护模式后,访问的内存空间突破了 1MB,可以将 GDT 放在合适的位置后再重新加载进来。

GDT 中的第 0 个段描述符是不可用的,原因是定义在 GDT 中的段描述符是要用选择子来访问的,如果使用的选择子忘记初始化,选择子的值便会是 0,这便会访问到第 0 个段描述符。为了避免出现这种因忘记初始化选择子而选择到第 0 个段描述符的情况, GDT 中的第 0 个段描述符不可用。也就是说,若选择到了 GDT 中的第 0 个描述符,处理器将发出异常。 

选择子

保护模式下,段基址已经存入了段描述符中,所以段寄存器中再存放段基址是没有意义的,在段寄存器中存入的是选择子,selector。选择子 “基本上” 是个索引值,用此索引值在段描述符表中索引相应的段描述符。

段基址在段描述符中,用给出的选择子索引到描述符后, CPU 自动从段描述符中取出段基址,由于已经是 32 位地址线和寄存器,任意寄存器都能够提供 32 位地址,故不需要将段基址乘以 16 后再与段内偏移地址相加,直接加上段内偏移地址,便凑成了 “段基址:段内偏移地址” 的形式。

由于段寄存器是 16 位,所以选择子也是 16 位。

名称功能
0~ 1RPL存储请求特权级,可以表示 0、 1 、 2、 3 四种特权级。
2TI指示选择子是在 GDT 中,还是 LDT 中索引描述符。TI 为 0 表示在 GDT 中索引描述符, TI 为 1 表示在 LDT 中索引描述符。
3~15描述符的索引值用此值在 GDT 中索引描述符。索引值部分是 13 位,2 的 13 次方是 8192,故最多可以索引 8192 个段,这和 GDT 中最多定义 8192 个描述符是吻合的。


【了解】:在现代操作系统中很少有用 LDT 的,我们系统中也未用 LDT。

局部描述符表

LDT 是 CPU 厂商为在硬件一级原生支持多任务而创造的表。CPU 厂商建议每个任务的私有内存段都应该放到自己的段描述符表中,该表就是 LDT,即每个任务都有自己的 LDT,随着任务切换,也要切换相应任务的 LDT。LDT 也位于内存中,其地址需要先被加载到某个寄存器后,CPU 才能使用 LDT,该寄存器是 LDTR。同样也有专门的指令用于加载 LDT,即 lldt。以后每切换任务时,都要用 lldt 指令重新加载任务的私有内存段。

段描述符中的 type 字段,其中 LDT 为系统段,换句话说, LDT 虽然是个表,但其也是一片内存区域,所以也需要用个描述符在 GDT 中先注册。段描述符是需要用选择子去访问的。

lldt 指令

指令格式为:lldt 16 位寄存器 / 16 位内存

无论是寄存器,还是内存,其内容一定是个选择子,选择子在 GDT 中索引 LDT 的段描述符。 

在 LDT 被加载到 ldtr 寄存器后,之后再访问某个段时,选择子的 TI 位若为 1 ,就会用该选择子中的高 13 位在 ldtr 寄存器所指向的 LDT 中去索引相应段描述符。

LDT 中的段描述符和 GDT 中的一样,与 GDT 不同的是 LDT 中的第 0 个段描述符是可用的,因为提交的选择子中的 TI 位, TI 位用于指定是 GDT,还是 LDT,TI 为 1 则表示在 LDT 中索引段描述符,即 TI 为 1 必然是经过显式初始化的结果,完全排除了忘记初始化的可能。


3、打开 A20 地址线

地址回绕:实模式下寄存器都是 16 位的,如果段基址和段内偏移地址都为 16 位的最大值,即 0xFFFF: 0xFFFF,最大地址是 0xFFFF0 + 0xFFFF,即 0x10FFEF。由于实模式下的地址线是 20 位,最大寻址空间是 1MB,即 0x00000~0xFFFFF。显然,0x10FFEF > 1MB。 超出 1MB 内存的部分在逻辑上也是正常的,但物理内存中却没有与之对应的部分。为了让 “段基址:段内偏移地址” 策略继续可用, CPU 采取的做法是将超过 1MB 的部分自动回绕到 0 地址,继续从 0 地址开始映射。 相当于把地址对 1MB 求模。超过 1MB 但多余出来的内存被称为高端内存区 HMA。

这种地址回绕是如何做到的呢?

对于只有 20 位地址线的 CPU,不需要任何额外操作便能自动实现地址回绕。

在 8086/8088 中,20 位地址线,A0~A19。 20 位地址线表示的内存是 2 的 20 次方,最大是 1MB,0x0~0xFFFFF。内存若超过 1MB,是需要第 21 条地址钱支持的。 所以说,若地址进位到 1 MB 以上,如 0x100000,由于没有第 21 位地址线,相当于丢掉了进位 1,变成了 0x00000。

CPU 发展到了 80286 后,地址总线从原来的 20 位发展到了 24 位,从而能够访问的内存范围可达到 2 的 24 次方,等于 16MB。 为了兼容,80286 以及后续 CPU 的实模式都应该与 8086/8088 完全一样,即仍然只使用 20 条地址线。 但 80286 有 24 条地址线,A0~A23,也就是说 A20 地址线是开启的。如果访问 0x100000~0x10FFEF 之间的内存,系统将直接访问这块物理内存,并不会像 8086/8088 那样回绕到 0 。

为了解决此问题, IBM 在键盘控制器上的一些输出线来控制第 21 根地址线(A20)的有效性,故被称为 A20Gate 。

  • 如果 A20Gate 被打开,当访问 0xl00000~0x10FFEF 之间的地址时, CPU 将真正访问这块物理内存。
  • 如果 A20Gate 被禁止,当访问 0xl00000~0x10FFEF 之间的地址时, CPU 将采用 8086/8088 的地址回绕。

地址回绕是为了兼容 8086/8088 的实模式。如今在保护模式下,需要突破第 20 条地址线(A20)去访问更大的内存空间。 需要关闭地址回绕。而关闭地址回绕,就是上面所说的打开 A20Gate

打开 A20 地址线将端口 0x92 的第 1 位置 1 就可以了。

in al, 0x92
or al, 0000_0010B
out 0x92, al

4、保护模式的开关,CR0 寄存器的 PE 位

CRx 系列是控制寄存器,3.2.2 节提到过一点。控制寄存器是 CPU 的窗口,既可以用来展示 CPU 的内部状态,也可用于控制 CPU 的运行机制。我们要用到 CR0 寄存器的第 0 位,即 PE 位,此位用于启用保护模式,是保护模式的开关。当打开此位后, CPU 才真正进入保护模式,所以这是进入保护模式三步中的最后一步。我们将突破 1MB 内存的束缚,踏入广阔  4G 的天空。

将 cr0 的 pe 位置 1PE 为 0 表示在实模式下运行, PE 为 1 表示在保护模式下运行。

mov eax, cr0          ; 将 cr0 写入 eax
or eax, 0x00000001    ; 通过或运算 or 指令将 eax 的第 0 位置 1
mov cr0, eax          ; 将 eax 写回 cr0,这样 cr0 的 PE 位便为 1 了

5、让我们进入保护模式【代码】



4.5、使用远跳转指令清空流水线,更新段描述符缓冲存储器

代码 4-3 中的第 81 行(原书 78 行)的无条件跳转指令:

jmp dword SELECTOR_CODE: p mode_start

为什么要用 jmp 远转移,是因为我们有两个问题要解决。

  • 段描述符缓冲寄存器未更新,它还是实模式下的值,进入保护模式后需要填入正确的信息。

段描述符缓冲寄存器在 CPU 的实模式和保护模式中都同时使用,在不重新引用一个段时,段描述符缓冲寄存器中的内容是不会更新的,无论是在实模式,还是保护模式下, CPU 都以段描述符缓冲寄存器中的内容为主。实模式进入保护模式时,由于段描述符缓冲寄存器中的内容仅仅是实模式下的 20 位的段基址,很多属性位都是错误的值,这对保护模式来说必然会造成错误,所以需要马上更新段描述符缓冲寄存器,也就是要想办法往相应段寄存器中加载选择子。

  • 流水线中指令译码错误。
    mov eax,cr0or eax,0x00000001mov cr0,eax;jmp dword SELECTOR_CODE:p_mode_start	    jmp  SELECTOR_CODE:p_mode_start	                    ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,; 这将导致之前做的预测失效,从而起到了刷新的作用。[bits 32]
p_mode_start:

CPU 为了提高效率而采用了流水线,这样,指令间是重叠执行的。“mov cr0,eax” 之前的指令都是 16 位指令,该行执行之后 CPU 便进入了保护模式,“jmp SELECTOR_CODE:p_mode_start” 已经是在保护模式下了,但它依然还是 16 位的指令,相当于处于 16 位保护模式下。为了让其使用 32 位偏移地址,所以添加了伪指令 dword,故其机器码前会加 0x66 反转前缀。 [bits 32] 后的代码全是 32 位指令。

流水线的工作是这样的:“mov cr0,eax” 执行的同时,“jmp SELECTOR_CODE:p_mode_start” 和之后的部分指令己经被送上流水线了,但是,段描述符缓冲寄存器在实模式下时已经在使用了,其低 20 位是段基址,但其他位默认为 0,也就是描述符中的 D 位为 0,表示当前的操作数大小是 16 位。流水线上的指令全是按照 16 位操作数来译码的,而 [bits 32] 开始的指令明明是 32 位指令, 16 位和 32 位的指令都有各自不同的意义,所以就出错了。

  • 解决方法

对于第一个问题,不重新引用一个段时,段描述符缓冲寄存器中的内容是不会更新的,所以需要重新引用一个段;对于第二个问题,需要清空流水线

代码段寄存器 cs,只有用远过程调用指令 call、远转移指令 jmp、远返回指令 retf 等指令间接改变,没有直接改变 cs 的方法;CPU 遇到 jmp 指令时,之前已经送上流水线上的指令只有清空。观察一下,舍 jmp 其谁。



4.6、保护模式之内存段的保护

保护模式中的 ”保护“ 主要体现在段描述符的属性字段中。这些属性只是用来描述一块内存的性质,是用来给 CPU 做参考的,当有实际动作在这片内存上发生时, CPU 用这些属性来检查动作的合法性,从而起到了保护的作用。

本节围绕内存段来做个基本的阐述,有些方面的保护放在之后重点介绍。

1、向段寄存器加载选择子时的保护
  •  根据选择子的值验证段描述符是否超越界限

选择子的索引值一定要小于等于描述符表(GDT 或 LDT) 中描述符的个数。像数组下标,绝对不能越界。也就是说,段描述符的最后 1 字节一定要在描述符表(GDT 或 LDT)的界限地址之内。

判断公式:描述符表基地址 + 选择子中的索引值 * 8 + 7 <= 描述符表基地址 + 描述符表界限值

选择子检查过程:处理器检查 TI,如果 TI 是 0,从全局描述符表寄存器 gdtr 中拿到 GDT 基地址和 GDT 界限值。如果 TI 是 1,从局部描述符表寄存器 ldtr 中拿到 LDT 基地址和 LDT 界限值。有了描述符表基地址和描述符表界限值后,把选择子的高 13 位代入判断公式,若不成立, 处理器抛出异常。GDT 中的第 0 个描述符是空描述符,如果选择子的索引值为 0 则会引用到它。不允许往 CS 和 SS 段寄存器中加载索引值为 0 的选择子。可以往 DS 、 ES、FS、 GS 寄存器中加载值为 0 的选择子,但真正在使用时 CPU 将会抛出异常。

  • 检查段的类型。段描述符中还有个 type 字段,这用来表示段的类型,也就是不同的段有不同的作用。

这里主要是检查段寄存器的用途和段类型是否匹配。大的原则如下。CPU 发现有任意规则不符,检查就不会通过。

  • 只有具备可执行属性的段(代码段)才能加载到 CS 段寄存器中
  • 只具备执行属性的段(代码段)不允许加载到除 CS 外的段寄存器中
  • 只有具备可写属性的段(数据段)才能加载到 SS 栈段寄存器中
  • 至少具备可读属性的段才能加载到 DS 、 ES、FS、GS 段寄存器中

  • 检查段是否存在。CPU 通过段描述符中的 P 位来确认内存段是否存在。
    • P 为 1 表示存在,这时候就可以将选择子载入段寄存器了,同时段描述符缓冲寄存器也会更新为选择子对应的段描述符的内容,随后处理器将段描述符中的 A 位置为 1 ,表示己经访问过了。
    • P 位为 0 表示该内存段不存在,不存在的原因可能是由于内存不足,操作系统将该段移出内存转储到硬盘上了。这时候处理器会抛出异常,自动转去执行相应的异常处理程序,异常处理程序将段从硬盘加载到内存后井将 P 位置为 1 ,随后返回。 CPU 继续执行刚才的操作,判断 P 位。
    • 以上所涉及到的 P 值由软件(通常是操作系统)来设置,由 CPU 来检查。 A 位由 CPU来设置。

2、代码段和数据段的保护
  • 对于代码段和数据段, CPU 每访问一个地址,都要确认地址不能超过其所在内存段的范围。

当 G 置 1,单位是 4k 粒度大小,4KB 等于 2 的 12 次方,即 0x1000

实际段界限的值 =(描述符中段界限 + 1)  *(段界限的粒度大小:4k 或者 1)  - 1

                         =   描述符中段界限 * 4k + 4k - 1

                         =   描述符中段界限 * 0x1000 + 0xFFF

0xFFF 是 4k 中以 0 为起始的最后一字节。公式意义是以 0 为起始的段偏移量,即段界限。

  • CPU 要检查地址的有效性

对于代码段,段中的 “数据” 是各种机器指令。CS: EIP 只是指令的起始地址,指令本身是有长度的。CPU 得确保指令任意一部分都在当前的代码段内,就是要满足:

EIP 中的偏移地址 + 指令长度 - 1 <= 实际段界限大小

对于数据段,要满足:

偏移地址 + 数据长度 - 1 <= 实际段界限大小

3、栈段的保护

虽然段描述符 type 中的 e 位用来表示段的扩展方向,但它和别的描述符属性一样,仅仅是用来描述段的性质,即使 e 等于 1 向下扩展,依然可以引用不断向上递增的内存地址,即使 e 等于 0 向上扩展,也依然可以引用不断向下递减的内存地址。栈顶指针 [e]sp 的值逐渐降低,这是 push  指令的作用,与描述符是否向下扩展无关,也就是说,是数据段就可以用作栈。

CPU 对数据段的检查,其中一项就是看地址是否超越段界限。如果将向上扩展的数据段用作栈,那 CPU 将按照上一节提到的数据段的方式检查该段。如果用向下扩展的段做栈的话,情况有点复杂,这体现在段界限的意义上。

  • 对于向上扩展的段,实际的段界限是段内可以访问的最后一字节。
  • 对于向下扩展的段,实际的段界限是段内不可以访问的第一个字节。

为了避免碰撞,将段界限地址+1 视为栈可以访问的下限。段界限+1 ,才是栈指针可达的下边界,如图 4-18 所示。

以 32 位保护模式下:

栈顶指针是 esp 寄存器,栈的操作数大小是由 B 位决定的,我们这里假设 B 为 1 ,即操作数是 32 位。栈段也是位于内存中,所以它也要受控于段描述符中的 G 位。

  • 如果 G 为 0,实际的段界限大小 = 描述符中的段界限。
  • 如果 G 为 1,实际的段界限大小 = 描述符中段界限 * 0x1000 + 0xFFF 。

push 指令每向栈中压入操作数时,将 esp 指针减去操作数的大小(2 字节或 4 字节)后,再将操作数复制到 esp 减 4 后的新地址。

栈指针可访问的最低地址是由实际段界限决定的,但栈段最大可访问的地址是由 B 位决定的,我们这里 B 位为 1。表示 32 位操作数,所以栈指针最大可访问地址是 0xFFFFFFFF。综上所述,每次向栈中压入数据时就是 CPU 检查栈段的时机,它要求必须满足以下条件:

实际段界限 + 1  <=  esp - 操作数大小 <= 0xFFFFFFFF

假设现在 esp 指针为 0xFFFFE002,段描述符的 G 位为 1 ,描述符中的段界限为 0xFFFFD。故实际段界限为 0x1000 * FFFFD + 0xFFF = 0xFFFFDFFF。当执行 push ax,压入 2 字节的操作数,即 esp - 2 = 0xFFFFE000,新的 esp 值注实际段界限 0xFFFFDFFF + 1 。如果执行 push eax,压入 4 字节的数据, esp - 4 = 0xFFFFDFFE,小于实际段界限 0xFFFFDFFF,故 CPU 会抛出异常。

由于 esp 只是栈段内的偏移地址,其真正物理地址还要加上段基址。假设段基址为 0,故该栈段:

最大可访问地址为 0 + 0xFFFFFFFF = 0xFFFFFFFF。

最小可访问地址为 0 + 0xFFFFDFFF + 1 = 0xFFFFE000。

栈段空间大小为 0xFFFFFFFF - 0xFFFFE000 = 8KB 。

【Q】:我在学这里的时候总感觉对这个问题不太理解:用向上扩展的数据段作为栈段,也依然可以引用不断向下递减的内存地址。你是否也有同样的疑问,先按下不表,之后将给出详细说明。


http://www.mrgr.cn/news/61534.html

相关文章:

  • 【Rust学习笔记】Rust 的所有权介绍
  • [每周一更]-(第131期):Go并发协程总结篇
  • docker中jenkins流水线式部署GitLab中springboot项目
  • Java 如何传参xml调用接口获取数据
  • 基于考研概率论知识解读 Transformer:为何自注意力机制要除以根号 dk
  • 【Redis源码】 RedisObject结构体
  • 使用Node.js内置的http模块创建简单的HTTP服务器,并根据请求的路径返回不同的文本响应。
  • LeetCode 3211.生成不含相邻零的二进制字符串:二进制枚举+位运算优化
  • 计算机毕业设计——ssm基于HTML5的互动游戏新闻网站的设计与实现录像演示2021
  • modelsim命令:add atv
  • 【Java数据结构】树】
  • 基于SSM积分商城管理系统的设计与实现(源码+lw+部署文档+讲解等)
  • 小红书图文无水印下载
  • 进一步认识ICMP协议
  • MySQL用户权限管理属于SQL语句中的DCL语句
  • 深入理解阻塞队列
  • 鸿蒙生态崛起:开发者的机遇与挑战
  • 数据结构————map,set详解
  • Rust实现Kafka - 前言
  • 18 Docker容器集群网络架构:一、etcd 概述
  • windows 驱动实例分析系列: NDIS 6.0的Filter 驱动改造(一)
  • Ubuntu下搭建自己的Docker镜像仓库
  • svg + canvas + 烟花 + 0.0
  • 记录一次更新idea
  • 记录工作上一次计算的优化
  • 基于JSP的篮球系列网上商城系统【附源码】