QOS 开发 4
QOS 开发日记 4: 保护模式
我们需要保护模式!
不是什么时候能用 C 啊我不想再 mov 来 mov 去了(大吵大闹
这篇写了好久…… 代码量也开始有点多了,扔 github 上存着:QOS.git
re: 寄存器
保护模式下的寄存器又有变化。。。
实模式下寄存器只用了 16 位,但是事实上 x86 的通用寄存器有 64 位。
和之前一样,寄存器也是分成通用寄存器(ax
, bx
, cx
, dx
, di
, si
, bp
, sp
)、段寄存器(cs
, ds
, es
, fs
, gs
, ss
)、变址寄存器和指针寄存器(si
, di
, sp
, bp
)以及两个特殊的寄存器 flags
和 ip
。
通用寄存器
1 | 63 31 15 7 0 |
可以把寄存器名字理解为「前缀+名+后缀」的形式(这对除了段寄存器之外的其他寄存器也适用):
前后缀 | 含义 |
---|---|
r** |
64 位 |
e** |
32 位 |
** (直接用名称) |
16 位 |
*h (名称第一个字符加 h ) |
16 位中的高 8 位 |
*l (名称第一个字符加 l ) |
16 位中的低 8 位 |
然后用途的话也是和之前的一样。
段寄存器
直接扔一个表格 qwq
寄存器 | 全称 | 用途 |
---|---|---|
cs |
代码段 code segment | 代码基础位置 |
ds |
数据段 data segment | 变量等数据所在位置 |
es , fs , gs |
附加段 extra segment | 附加段的位置 |
ss |
栈段 stack segment | 栈的位置,栈顶通过 ss:sp 访问 |
实模式里面段寄存器存的是段开头的地址,但是在保护模式中储存的不是段开头地址了,而是一个叫做“选择子”(selector)的东西(这个后面说算了)。不过在写的时候寻址还是按照 _s:_p
的形式(段+地址)寻址。
特殊寄存器
ip
是指令指针,指向下一条指令。也就是说,当前执行完之后的下一条指令的位置是 cs:ip
。
flags
是标志位寄存器,内容参见之前的文章。
保护模式
终于到保护模式了……前置怎么这么多(恼)
那么之前写的 MBR 和内核加载器都是在实模式下运行的。但是实模式有两个很致命的缺点:一个是内存只能用 太小;还有一个就是它里面的内存使用都是通过绝对地址实现的,那这样的话不同的程序跑起来就很容易出现冲突出锅了(悲)。因此 x86 架构提供了保护模式这么一个概念。
为了解决内存太小的问题,寄存器肯定就要扩展了,扩展的位数取决于操作系统的位数(这里就选 32 位啦~ 这样好写一点)。当然寄存器的位数实际上取决于 CPU 的硬件(即硬件上堆了几个 latch),实模式下只是用了硬件寄存器的低 16 位而已。那么要使用 32 位就在寄存器名字前面加个 e
就行了,64 位加 r
。
听说现在有个 AVX 是 256 位的寄存器?有钱人就是可怕.jpg
不过像上面讲的,段寄存器不能再前面加 e
或者加 r
来扩展,它们在保护模式下还是 16 位的。但是理论来说变成 32 位 的地址之后,段寄存器应该也变成 32 位才对啊?因为保护模式为了突出它和实模式的区别(?),维护了一个全局描述符表(global descriptor table, GDT)来管理内存,段寄存器需要在 GDT 上登记一下才能使用。
那么先说一下保护模式的功能:
- 提供了内存分段和分页机制,允许操作系统将内存划分为多个段和页面,并为每个段和页面设置访问权限,从而实现对内存的保护。这样可以防止用户程序越界访问内存、修改操作系统数据结构或者恶意篡改其他程序的数据(然后报
segmentation fault
或者bus error
之类的 RE)。 - 引入了特权级别的概念,限制用户程序对关键资源的访问,确保系统的稳定性和安全性。
- 提供了对 IO 设备的保护机制,可以限制用户程序对设备的直接访问,确保设备的安全和可靠性。
- 更强大的异常和中断处理能力,操作系统可以利用这些机制来响应硬件事件、处理错误情况以及进行多任务调度。
总之就是内存保护、IO 保护、权限控制和其他东西 owo
段描述符
段描述符描述了一个段的属性。 什么废话
它是一个 64 位的值,结构大概是这样的:
1 | 63 56 55 54 53 52 51 48 47 46 45 44 43 40 39 32 |
- 段基址:就是实模式中的那个段开头的地址。因为是 32 位操作系统所以是 32 位的。
- G:等于 0 表示段界限的单位是 B;等于 1 则表示段界限的单位是 4K。
- 段界限:表示段扩展边界的最值,即最大扩展多少(代码段、数据段等)或最小扩展多少(栈),简单地说就是这个段的空间大小。它需要配合 G 决定单位。比如说段界限的值是 ,G = 0 则说明段的大小是 ;G = 1 则说明段的大小是 .
- AVL:保留字段,无特殊用途
- S:描述这个段是系统段(0)还是数据段(1)。
- L:描述这个段是 32 位代码段(0)还是 64 位代码段(1)。因为写的是 32 位的操作系统,所以 L = 0。
- D/B:对于代码段,描述这个段的指令中操作数和指令地址是 16 位(0)还是 32 位(1)。由于相同的原因,D/B = 1,因此表示指令指针时应当用
EIP
。 - DPL:该描述符的特权级别 (Descriptor Privilege Level),指本内存段的特权级,这两位能表示 0~3 共 4 种特权级,数字越小,特权级越大。CPU 由实模式进入保护模式后,特权级自动为 0。用户程序通常处于 3 级。
- P:表示段是否存在于内存中。这个主要是让 CPU 检查的。如果 P=0,则会抛出异常,然后转到异常处理程序。
- TYPE:表示内存段或门的类型。需要根据上面的 S 属性决定定义。
非系统段需要展开讲讲。
X
: 执行(eXcute)R
: 读取(Read)W
: 写入(Write)A
: 访问(Access)。这个是由 CPU 设置的。CPU 每次访问过这一段后都会将它设置为 1。所以新建一个描述符的时候这一位要变成 0。C
: 一致性(Conforming)。一致性代码是指:特权级别设计为允许相同或更低特权等级的代码访问,但不允许提升特权级别进行写入的代码段。可以理解为是内核(高特权级)开放给用户程序(低特权级)的代码。E
: 扩展方向(Extension),这个段向上(0)或向下(1)扩展。
GDT 和选择子
全局描述符表其实就是一个数组…… 里面存的是很多描述内存段的信息(段描述符,后面再说)。
既然是储存了整个内存的段的情况,那它自然很巨大。所以机子专门维护了一个寄存器(全局描述符表寄存器,GDTR)指向这个表的地址。
而 GDTR 是一个 48 位的寄存器。结构:
1 | 47 15 0 |
可以通过指令 lgdt
给 GDTR 赋值(但是不能直接 mov lgdt, xxx
)。
这里你会发现描述 GDT 界限只用了 16 位,因此 GDT 最大的大小就是 . 而一个段描述符需要 的空间,因此这个 GDT 最多能够定义 个段描述符。
那么这样一来,段寄存器里就只用存段描述符的索引就可以了(类比数组下标),也就是选择子。选择子是一个 16 位的数据,结构:
1 | 15 3 2 0 |
这里描述符索引值用了 13 位,因此最多能够索引 个描述符。这和上面算出来 GDT 最多能够存储的描述符数量是相同的 所以没算错。
然后是 TI,也就是表指示器(Table Indicator),表示这个选择子指向的是 GDT 中的描述符(1)还是 LDT 中的描述符(0)。
RPL 则表示了请求特权级(Requested Privilege Level),表示当前这个选择子希望请求的特权级别。
然后呢在进入保护模式之前,需要先构建 GDT,然后先往里面插入 4 个描述符:空描述符(第 0 个)、代码段(给内核用)、数据段和视频段(也就是 VGA 绑定的那块内存,实模式下是 0xb8000
到 0xbffff
的这一块)。
进入保护模式
好了!讲了这么多,可以开始敲键盘咯 >w<
打开 A20 地址线
计算机底层寻址是需要通过地址总线实现的。在实模式下,只有 20 条地址线可以用(A0 ~ A20),因此寻址的范围只有 . 如果强行寻址超过 的位置,系统会自动将其对 取模,然后按余数寻址(实际上是因为只有 20 条线可用,因此丢弃了第 20 位以及它上面的所有位)。
然而现在我们需要进入保护模式,要操作更多的内存了。因此我们需要启用第 21 根(A20)地址线。具体做法是把 0x92
端口的第 1 位设置为 就行了。
1 | mov dx, 0x92 |
8086 使用 20 条地址线,因此仅支持 的内存,即地址范围为
0x00000
到0xfffff
。如果强行求高于0xfffff
的位置,则会直接放弃所有高位,相当于对 取模(其实是高位的地方根本没有地址线,所以就取不到那几位的值了)。这种过程叫做地址回绕。然而到了 80286(第一个具有保护模式的 CPU)之后,地址线增加到 24 条。理论来说,如果直接访问超过
0xffff
的地址(不过也要小于0x10ffef
)并不会发生地址回绕。但是为了兼容性,它的实模式也应当和之前的 20 条地址线表现相同,也就是要求在实模式下发生回绕,保护模式下不回绕。为了解决这个问题,IBM 在键盘上加了一些输出线控制第 21 根地址线(A20)的可用性,也被称为 A20 Gate.显然,A20 Gate = 1,则打开第 21 条地址线;否则,和 8086 一样,采用地址回绕。
因此在进入保护模式之前需要先打开 A20;而由于上面的历史遗留问题,打开 A20 是通过类似硬件操作的方式完成的。
加载 GDT 描述符
于是我们需要加载一下 GDT 里面的描述符。
现在 boot.inc
里给出一堆定义:
1 | ;------- GDT --------; |
然后修改 loader.s
!
有一些前置需要了解。x86 是有一套字(word)系统:
名称 | 大小 | 定义命令 |
---|---|---|
字节 byte | db |
|
字 word | dw |
|
双字 double word | dd |
|
四字 quad word | dq |
首先是建立 GDT 并插入 4 个描述符(实际上是直接分配了这么多的空间):
1 | LOADER vstart=LOADER_ADDR |
然后需要设置 GDTR 寄存器,使它指向 GDT 的表头(也就是 GDT_BASE
)。先得出 GDTR 应该等于多少:
1 | gdt_ptr dw GDT_LIM ; 低 8 位是界限 |
最后再预设一下代码段、数据段和视频段的选择子:
1 | ; 代码段的选择子 |
然后在 jmp
之后的地方加载 GDTR:
1 | lgdt [gdt_ptr] |
开启保护模式
好!内存地址问题解决了之后就可以进入保护模式了!那么要通过哪个开关打开保护模式呢?
实际上 CPU 又搞了一个寄存器让我们控制处理器的工作模式,叫做 CR0
(control register 0)。结构(摘自 CPU register x86 - OSdev wiki):
Bit | Label | Description |
---|---|---|
0 |
PE | 保护模式 Protected Mode Enable |
1 |
MP | 监视协处理器 Monitor co-processor |
2 |
EM | 协处理器硬件仿真 x87 FPU Emulation |
3 |
TS | 经历任务切换 Task switched |
4 |
ET | 支持一些扩展特性 Extension type |
5 |
NE | 启用数字错误等 Numeric error |
16 |
WP | 只读页是否可写入 Write protect |
18 |
AM | 内存对齐检查 Alignment mask |
19 |
NW | 是否禁用换从写透传(write-through)Not-write through |
30 |
CD | 禁用缓存 Cache disable |
31 |
PG | 启用分页机制 Paging |
那么很显然,如果要打开保护模式,就要将 CR0
中的 PE
位设置为 1.
1 | mov eax, cr0 |
然后接下来就可以直接在保护模式下跑代码了吗?还要干一点活…
由于现在在保护模式下,调用任何一块内存都需要经过选择子 GDT 裸机地址实现,而众所周知的是代码也是被加载到内存里的。因此接下来我们需要刷新一下段描述符缓冲寄存器(不然就还是原先实模式下的值,是不正确的)。
以及,由于从 的实模式进入了 保护模式的,我们需要告诉汇编器接下来的代码要汇编成 位的机器码。
1 | jmp dword SEL_CODE:pe_start ; 刷新流水线,后面讲 |
然后就可以在保护模式下写东西了!不过需要注意的是,在保护模式下就不能用 BIOS 中断了 qaq 所以只能用显存输出。
这里用字体颜色区分保护模式和实模式(实模式下用白色 0x0f
,保护模式下用绿色 0x02
)。
1 | mov ax, SEL_VDEO |
然而显存输出是众所周知的麻烦。因此干脆写一个脚本算了。需要注意的是 位下不能 mov qword
!因此只能两个两个字符输出。
1 | s = 'QOS[KL ]: Activated Protected Environment.' |
运行失败?
然而直接用之前的 make.sh
并不能完整的运行:进入 Loader 后完全没有反应……
可以 ls
一下二进制文件夹:
1 | QOS % ls -la bin/loader.bin |
加了 GDT 的 Loader 的二进制文件的大小已经超过了一个扇区()。因此需要修改 mbr.s
中加载的扇区数量。为了方便,这里就直接加载 16 个扇区。
1 | ; ... |
然后还有自动汇编脚本也要改一改(其实就是写入磁盘这里改一下):
1 | nasm -I src/ src/mbr.s -o bin/mbr.bin |
这样子运行就成功了。