自制操作系统_03_启动操作系统进入保护模式
原文地址¶
[org 0x1000]
; 校验用的, 这里会被读到 0x1000处
dw 0xa0a0
mov si, loading_log
call print
call detect_memory
call prepare_protect_mode
jmp $
detect_memory:
; 设置检测内存的buffer位置
mov ax, 0
mov es, ax
mov edi, mem_task_buffer
; 固定签名
mov edx, 0x534d4150
; 置为0, 每次系统调用会修改这个寄存器
xor ebx, ebx
; 保存的目的地
mov di, mem_task_buffer
.next:
; 子功能号
mov eax, 0xe820
; ards 结构的大小 (字节)
mov cx, 20
; 调用中断
int 0x15
; 如果cf置位, 表示出错了
jc error
; 计算下一个内存结构体保存的首地址
add di, cx
inc word [mem_task_count]
; 不为0 说明检查未完成
cmp ebx, 0
jnz .next
mov si, detect_memory_log
call print
; ; 循环结构体内的值(我们只读取低32位相关的信息, 高32位的暂时不需要)
; mov cx, [mem_task_count]
; ; 初始偏移量
; mov si, 0
; .show
; mov eax, [mem_task_buffer + si] ; 基地址 低32位
; mov ebx, [mem_task_buffer + si + 8] ; 内存长度的低32位
; mov edx, [mem_task_buffer + si + 16] ; 本段内存类型 1: 可以使用, 2: 内存使用或者被保留中, 其他: 未定义
; add si, 20
; ; xchg bx, bx ; bochs 的魔数, 代码执行到这里会停下
; loop .show
print:
push ax
mov ah, 0x0e
.show
mov al,[si]
cmp al, 0
jz .end
int 0x10
inc si
jmp .show
.end
pop ax
ret
loading_log:
db 'Loader Start', 13, 10, 0
detect_memory_log:
db 'detect_memory end', 13, 10, 0
prepare_protect_mode:
cli ; 关闭中断
; 打开 A20线
mov al, 0xdd
out 0x64, al
; 进入保护模式
mov eax, cr0
or eax, 0b1
mov cr0, eax
; 加载 gdt
lgdt [gdt_ptr]
; 长跳转, 刷新缓存, 跳进保护模式
jmp word code_selecter:protect_mode
ret
[bits 32]
protect_mode:
; 这里已经进入了保护模式
; 初始化段寄存器(将代码段之外的寄存器都设置为 数据段)
mov ax, data_selecter
mov ds, ax
mov ss, ax
mov es, ax
mov gs, ax
mov fs, ax
; 修改自己设置的栈顶
mov esp, 0x10000
; 进入保护模式之后, 实模式的print就不能用了
mov byte [0xb8000], 'P'
jmp $
error:
mov si, .error_msg
call print
hlt ; 让 CPU 停止
jmp $
ret
.error_msg db "Loader Error!!!", 10, 13, 0
mem_task_count:
dw 0
; 用来存放检测内存结果的结构体
mem_task_buffer:
times 20*10 db 0
; 定义gdt
base equ 0 ; 段地址
limit equ 0xfffff ; 段界限数量, 和粒度搭配 如果粒度是4k, 那么 0xfffff*4096 最大的大小
; 段选择子
code_selecter equ 1 << 3 ; 选择子 前13位 代表索引, 后面3位暂时不需要
data_selecter equ 2 << 3 ;
; gdt表的指针
gdt_ptr:
dw gdt_end - gdt_start - 1 ; 16位表示gdt的总大小, 每个段描述符8字节, 2**16/8=8192(2的13次方个刚好是段选择子的最大索引)
dd gdt_start ; gdt表起始位置
; gdt 表的详情
gdt_start:
gdt_base:
times 8 db 0 ; 第0个段描述符 不能被选择也不可使用和访问, 否则会cpu发出异常
gdt_code:
dw limit ; 段界限 dw 0~15
dw base ; 段基址 dw 0~15
db base >> 16 ; 段基址16~23
db 0b_1_00_1_1010 ; P_DPL_S_TYPE(代码段, 非依从, 可读, 没有被cpu访问过)
db 0b_1_1_0_0_0000 | limit >> 16 ; G_D/B_L_AVL | 段界限16~19
db base >> 24 ; 段基址24~31
gdt_data:
dw limit ; 段界限 dw 0~15
dw base ; 段基址 dw 0~15
db base >> 16 ; 段基址16~23
db 0b_1_00_1_0010 ; P_DPL_S_TYPE(0010 表示"数据段,数据可写, 数据向上拓展")
db 0b_1_1_0_0_0000 | limit >> 16 ; G_D/B_L_AVL | 段界限16~19
db base >> 24 ; 段基址24~31
gdt_end:
本文主要介绍 loader
和保护模式,后面将会接着了解分页机制和怎么运行并初始化内核。
回到接力赛的场景,现在对于 loader
同样有两个问题:
- 谁给它的接力棒:由我们自己编写的
MBR
读取磁盘中的二进制文件到内存当中,并且跳转过去执行,完成了交棒 - 把接力棒交给谁:
loader
在执行完自己的本职工作之后,就可以将接力棒交给内核了!
回顾一下 MBR
的过程,我们要遵守 BIOS
的规则,只能使用一个扇区512字节的代码,然后加载到 0x7c00,并且让程序跳转到 0x7c00完成交棒。所以 MBR
只是一个中转站,由于它的局限性,注定是完成不了多少工作。但是 loader
就不同了,loader
能够有多少字节都是我们编写的程序 MBR
决定的,所以理论上它可以占据多个扇区、完成很多功能,然后再将控制权交给操作系统。
比如我们可以在 loader
里面完成分页的设置、切换到保护模式等等。这里在 loader
中设置分页机制并不是强制的,因此本文的 loader
只简单的切换到了保护模式,在os初始化时才完成对分页的设置。
重申一下 loader
要实现的两个功能:
- 切换到保护模式
- 传递接力棒给内核
本文主要介绍怎么进入保护模式,主要使用 OS-elephant/loader.s 的代码
1. 保护模式的引入¶
由于我们使用的是 IA32 CPU,首先运行 BIOS
和 MBR
时是16位实模式,在运行 loader
时通过执行对应的一组指令切换到 32位保护模式,才开始加载内核进入内存,并且开始执行用户程序。
保护模式的引入是一个历史发展问题,需要知道古老的CPU有哪些缺点,才知道保护模式解决了哪些问题。
1.1 实模式¶
第一位选手是16位 8086CPU,使用的是20位地址线。它存在下面的问题:
- 实模式下操作系统和用户程序属于同一特权级
- 用户程序所引用的地址都是指向真实的物理地址
- 用户程序可以自由修改段基址,不受阻碍地访问所有内存
- 访问超过 64KB 的内存区域时要切换段基址,转来转去容易晕乎
- 一次只能运行一个程序,无法充分利用计算机资源(由于直接就是物理地址,那么就不能很好的保证并发)
- 共 20 条地址线,最大可用内存为 1MB,这即使在 20 年前也不够用
前三点是关于安全的缺陷,后三点是关于性能和程序方便的问题。后面的cpu将运行 8086 程序的模式称为实模式。16位实模式相当于 plus 版的 16位8086CPU,在模拟16位CPU运行的同时,可以使用32位寄存器、额外段寄存器等 32位CPU 的资源。
1.2 解决实模式的问题¶
为了解决实模式的问题,intel 后面又相继推出了 80286CPU 和 80386CPU,80286CPU 有24位的地址总线和引进了保护模式,80386 在拥有保护模式的基础上变成了 32位CPU 和 32位地址线。
保护模式是如何解决实模式的问题的呢?它拥有一些特点
- 地址转换:在开启分页机制后,程序引用的地址、CPU执行机器指令时处理的地址都变成了虚拟地址,需要转化为物理地址后再去访问,地址转换由 处理器 和 操作系统 共同完成。处理器提供硬件,操作系统提供页表。
- 兼容实模式:实模式是 32位CPU 运行在 16位模式 下的状态,此时 CPU 相当于更加强大的16位CPU,可以处理32位的操作数。
- 段保护:在实模式下,程序访问内存段时,CPU 会完成对用户的权限检查,防止出现错误
地址转换涉及到分页机制,在后面介绍。下面主要介绍:
- 保护模式相比于实模式的一些功能拓展
- 保护模式最重要特征:段描述符 & 全局描述符表
- 保护模式的段保护机制
- 怎么进入保护模式
2. 功能拓展¶
2.1 寄存器拓展¶
由于是32位CPU,拥有32位地址线,因此为了方便寻址,在原本 8086CPU 的16位寄存器的基础上又引入了 32位寄存器。低16位寄存器是为了兼容实模式,可以单独使用。
可以发现段寄存器除了原本的 CS
、SS
、DS
、ES
之外还引入其他的额外段寄存器,增加了段寄存器就能使得程序运行时更新段寄存器次数减小,效率提升。16位实模式下也可以使用 32位寄存器和额外的段寄存器。
这里可能带来一个问题:实模式在16位下运行,16位CPU如何处理32位CPU的指令。这个问题要到 2.2 解决
2.2 运行模式反转¶
首先我们要知道,CPU 在16位模式和32位模式处理同一条机器码,转化后的汇编指令是不同的。
在编写 MBR
和 loader
的时候,都是使用汇编语言进行编写,几乎没有直接编写机器码。要将一个汇编指令编写为 16位模式的机器码 或者 32位模式下的机器码是由编译器决定的,CPU 只负责处理机器码。如果是 16位模式下,就将当前的机器码按 16位模式执行。如果是 32位模式下,就将当前的机器码按 32位模式执行。
因此,一个很重要的事情是:知道 CPU 当前运行在什么模式下,并且让编译器编译对应模式的代码。我们想要,在实模式下,编译的代码都是16位,在32位保护模式下,编译的代码都是32位。也就是说,同一段程序要经历两种模式,一段程序有两种模式的机器码。
这现实吗?其实这很难让编译器做到,因为进入保护模式(5.进入保护模式)是由很多条指令构成的,编译器很难知道是不是进入了保护模式(但执行这些指令后,CPU自己是会知道他进入了保护模式的),所以我们在进入保护模式之后,需要通过一些方法告诉编译器:我们进入保护模式了,下面的代码请帮我翻译成 32位 的机器码吧!
不同编译器对应的指令不同,例如有 bits 指令。指令格式是:[bits 16]
或 [bits 32]
。bits 指令的范围是从当前 bits 标签直到下一个 bits 标签的访问,这个范围中的代码将被编译成相应字长的机器码。bits 外面的中括号是可以省略的,另外,在未使用 bits 指令的地方,默认是 [bits 16]
,也就是将汇编语言翻译成 16位模式。
下面是一个例子
[bits 32]
mov ax, SELECTOR_DATA
mov ds, ax
mov es, ax
mov ss, ax ; 上面全部翻译成了 32位代码
[bits 16]
mov esp,LOADER_STACK_TOP
mov ax, SELECTOR_VIDEO
mov gs, ax ; 这边全部翻译成了 16位代码
由于32位和16位下相互兼容,所以如果未使用 bits
,编译器不会报错,而是全部生成 16位模式机器码,但是执行的结果可能就不同了(比如 mov ax
变成了 mov eax
)。因此我们在切换为保护模式之后,需要记得用 bits
指出运行模式。
还有一种方法可以使用另一模式的资源,也就是
- 前面运行 bits 16,想让操作数大小或者寻址方式变为32位。
- 前面运行 bits 32,想让操作数大小或者寻址方式变为16位
我们只要编写与当前模式不符的代码,编译器就会将这些代码自动加上一些前缀。
操作数大小反转
如上,假如在一个模式下,使用了另一个模式的资源,也就是16位下使用 eax
,32位下使用ax
。编译器会在在机器码前面加上 0x66
,就能够让使用的操作数大小变为另一个模式下的大小。
寻址方式反转
如上,假如在一个模式下,使用了另一个模式的资源,也就是16位下内存寻址使用 eax
,32位下内存寻址使用bx
。编译器会在机器码前面加上 0x67
,就能够让使用的寄存器大小变为另一个模式下的大小。
回到16位模式运行32位模式代码的问题 CPU在16位下,其实主要还是要合理的用
bits
指令指定编译模式,那么编译器才会加上反转前缀,假如让16位模式直接执行C70034120000
(没有反转前缀) 应该是会出错的。 所以兼容实际上是编译器加上了反转前缀,和CPU协同完成的兼容,假如没有反转前缀,是谈不上什么兼容的,因此可以看出合理的使用bits指令很重要。
3. 全局描述符表¶
在实模式下,访问内存地址是通过 段基址:段内偏移地址
的方式寻址。保护模式也不例外,但是对于保护模式,每个内存段都需要有约束条件,比如对内存段的描述信息、读写执行权限等等。由于信息太多,通过寄存器是很难存储所有信息的,所以需要把这些信息存放在内存当中。
全局描述符表(Global Descriptor Table,GDT)是保护模式下内存段的登记表,这是保护模式不同于实模式的显著特征之一。
3.1 段缓冲符寄存器¶
那么原本实模式访存是 段基址:段内偏移地址
。但是保护模式能和实模式一样,段寄存器存储段基址,然后去得到内存段吗?当然不行啊,这样就没有获得段的描述符信息,也就不能起到保护的作用了。
所以32位保护模式下,段寄存器存储的不是段基址,而是一个段选择子。而一个段的所有描述信息称为段描述符,大小为64字节。所有的段描述符构成一个表,称为全局描述符表。整个表很大,存放在内存中,由 GDTR寄存器 存储它的地址。
本质上保护模式访问地址还是通过段基址:段内偏移访问的,但是在汇编程序中,用户跨段访问、跳转就需要使用 段选择子: 段内偏移,而不能使用 段基址:段内偏移。
前面说到一个段描述符是64字节,那么一个寄存器肯定是放不下来的,现在遇到了两个问题。每次访问都要访问内存获取段描述符,并且看过 3.2 段描述符 的话,可以看到段描述符的格式相当古怪,每次读取段基址、段界限的时候都很复杂。
为此,CPU 引入了一个段描述符缓冲寄存器,对于程序员不可见,不可以用 MOV
指令对他进行操作。
可以看到,这个寄存器将段基址、段界限连到一起,不再像段描述符那样分为3个地方存储。并且加上了一些必要的信息。那么,每次程序访问相同段时,CPU直接访问段缓冲符寄存器,就不需要费劲的访问全局描述符表并且组合段基址、段界限。
前面说过实模式可以使用 32位CPU 的资源,为了加速,实模式下 CPU 也使用这个寄存器,也就是缓存 段基址左移4位的结果。每次访问段内都是访问这个寄存器。不过这是 CPU 为我们隐藏的细节,编写汇编程序的时候不需要考虑这个。
这个寄存器什么时候更新呢?
等到段寄存器被重新赋值,即使和原来的段选择子相同,也会重新访问全局描述符表,然后将组合好的信息放到段描述符缓冲寄存器中。
实模式下就是每次更新段寄存器时,更新这个段描述符缓冲寄存器。
段缓冲符寄存器是 80286CPU 引入的,80286CPU 是第一个拥有保护模式的CPU,但是它只有24位地址总线,它同样有段描述符来保证保护模式的运行。段寄存器就是存储段基址,每次更新段寄存器时更新段缓冲符寄存器。但是由于有24位地址线,通用寄存器还是16位,每次偏移地址只能是16位寄存器,只能访问到段内64kb偏移量,一个寄存器无法访问到全部内存空间,所以80286很鸡肋。 80386 进化成了 IA32,32位地址总线,任意一个段都可以访问到全部的 4GB 空间(其实这也不准确,如果超过段界限还是会报错,但是如果段界限就是4GB,任何一个段都能访问全部的空间),那么就不需要变化段基址,也不需要段基址+偏移地址这种访问方式,这就是开始了平坦模式,使用平坦模式的一个好处就在于不需要经常切换段选择子。
3.2 段描述符¶
IA32 仍然还是通过 段基址:段内偏移 的方式,即使是保护模式也是这样,保护模式的意义在于保证保护模式下的内存访问依然是 “段基址:段内偏移” 的形式,又要有效提高了安全性。
当然,前面已经提到了,段基址不再是由程序给出的,保护模式的程序应该给出的是一个段选择子,段选择子存储段描述符表的索引值和一些其他信息,首先程序先获得段描述符,然后获得在段描述符当中段基址。最后组合一下就成为了一个地址:段基址 + 偏移地址。所以说,每次跳转都要获得一次段描述符,段描述符很重要。
段描述符需要有哪些信息呢?
首先,需要解决实模式下的问题:
- 实模式用户程序访问时,需要知道代码段、数据段。因此我们需要给段描述符增加 类型属性。
- 实模式的用户程序和操作系统是一个级别的,因此我们需要给段描述符增加 特权级属性。
其次,方式内存段必要属性条件:
- 保护模式不应该是像实模式一样,每个起始地址都是有规律的。所以我们需要一个 段基址。
- 保护模式不应该是像实模式一样,每个段的大小都是一致的。所以我们需要一个 段界限。
这些内存段的属性最后都被放到了段描述符的结构中。可以看成一个数组元素,每个元素都是8字节大小。
下面介绍一下段描述符的组成结构:
- 段基址:保护模式下,一个地址由32位组成,为了表示一个段基址,我们就需要在段描述符中使用32位来存储段基址。之所以分为多份存储是因为历史遗留问题,可以看到第一份段基址8位,第二位 = 24位,因为80286是第一个使用保护模式的cpu,地址总线为24位,支持16位下的保护模式,因此刚开始需要有24位来支持保护模式的段基址,后面为了兼容 80286,就不能重新设计段描述符,而是另开一份存储额外的段基址8位。
- 段界限:段界限符 = 该段的大小,这里段界限符需要16 + 4 = 20位来存储,刚开始的16位是因为80286只支持16位的保护模式,因此内存寻址 = 16位,后面四位是一个拓展,可以看看 G位的解释。
- G位:G表示段界限粒度大小,假如 G = 1,粒度 = 4kb,G = 0,粒度 = 1字节。因此这里段界限符只有20位时,也能够表示出 4GB = 2^32 大小的段界限,假如G=1,最大段界限 = 2^20 * 4kb = 2^32,假如G=0,最大段界限 = 2 ^ 20 * 1 = 2^20。超过段界限,就会触发内存段保护。
- 段类型:需要看两个字段:type和S位。
- S位:S位的作用在于,指示这个段是不是系统段。只有知道了S位,type段才有意义。系统段就是说,这个段由硬件CPU运行,非系统段是由软件(OS/用户)运行。
- TYPE:这里只介绍非系统段:
-
- A:access 位,由cpu设置,这个段被CPU访问过后,就会被设置为1,新创的段的段描述符为0,调试时根据这个位来判断描述符是否可用。
- C:表示一致性代码,特权级高的进程无法访问特权级的进程,也就是内核无法访问用户代码。
- R:当CPU访问不可读的段,回抛出异常
- X:可执行
- E:拓展方向,E = 1表示向下拓展,用于栈段
-
W:w只用于数据段,表示不可以写
-
DPL:表示2位、4种特权级
-
P:表示段是否存在,用于内存段保护
-
AVL:对硬件而言没有专门用途,也就是cpu留给操作系统随便用的东西
-
L:用来设置是不是64位代码段,属于保留位,在32位保护模式,这个都是0。
-
D/B:实模式可以使用32位的地址线和操作数(bit 32),对于栈段和代码段,这个位表示不同的功能。
-
- 栈段:表示B,如果是0,就为16位,栈的起始地址位 2^16-1。如果是1,32位,栈的起始地址为 2^32 - 1。
- 代码段:表示D,D为0,指令的有效地址和操作数都是16位,用于IP寄存器。
3.3 全局描述符表GDT¶
GDT 存放在内存中。从实模式进入保护模式需要做的一个事情就是:初始化GDT表。之后为了访问这个GDT,需要知道一个 GDT表在内存起始地址和GDT表的界限。
这个信息存放在哪里呢?CPU 提供了一个寄存器存储。
段界限符只有16位,也就是表明GDT表最多占 2^16字节,那么每个描述符 = 8字节,GDT总共只能有 2^13 个表项
GDTR是一个48位的寄存器,存储的内容结构如上所示。这个寄存器是程序员不可见的,我们不能直接使用mov gdtr xxx,有两个特定的指令来访问这个 GDT
表
- 通过
lgdt xxxx
将xxxx存储到 GDTR 寄存器,xxxx包含GDT内存起始地址和GDT界限。 - 通过
sgdt xxxx
获得当前 GDTR 寄存器的内容存储到 xxxx 当中。
虽然实模式只有执行这个指令才能够进入保护模式,但是保护模式下也能够再次执行这个指令,进入保护模式后,可以重新换一个GDT加载,这样是因为实模式下,cpu只能够访问16位,而且
lgdt
也没有对应的0x66 前缀指令,那么GDT只能存储在低1mb,进入保护模式后,可能我们想要将gdt加载到其他地方。
3.4 选择子¶
ok!那现在怎么访问gdt的一个元素呢??首先我们需要知道段描述符,就要知道它在gdt当中的下标,然后和实模式一样,需要使用 段基址:段内偏移 的方式,因此我们还要知道段的偏移量。
前面提到了 段选择子,存储在 段寄存器 当中。段选择子的结构如下
由于需要获得段基址和段描述信息,我们的段选择子前13位是描述符在 gdt 当中的下标。后面的 TI 指示当前要访问的描述符表是 GDT 还是 LDT,如果TI 是1,要访问的表就是 LDT。RPL 表示访问这个段时程序的特权级。
例如选择子是 0x8,将其加载到 ds 寄存器后,访问 ds:0x9 这样的内存:
- 0x8 的低 2 位是RPL,其值为 00。
- 第 2 是 TI,其值 0,表示是在 GDT 中索引段描述符
- 用 0x8 的高 13 位 0x1 在 GDT 中索引,也就是 GDT 中的第 1 个段描述符(GDT 中第 0 个段描述符不可用)
- 假设第 1 个段描述符中的 3个段基址部分,其值为 0x1234。CPU 将 0x1234 作为段基址,与段内偏移地址 0x9 相加,0x1234+0x9=0x123d。
- 用所得的和 0x123d 作为访存地址。
gdt 的第0个描述符是不可用的!因为选择子未初始化时值 = 0,会去访问第0个选择子,干脆就让第0个不可以用算了。访问gdt表第0个描述符时,会直接cpu发出异常 有趣的是,正好索引值就是13位 = GDT表的最大大小
使用选择子的代码
;定义了几个选择子,通过编译器变量存储
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0 ; 同上
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0 ; 同上
;执行这条指令时, jmp 会使用代码段选择子,加载到cs段寄存器当中, cs 加载代码段选择子不会报错
;enter_kernel 是汇编的一个函数, 等价于一个地址
;保护模式jmp长跳转的格式就是 段选择子:段内偏移
;而不是段基址:段内偏移
jmp SELECTOR_CODE:enter_kernel ;强制刷新流水线,更新gdt
3.5 局部描述符表LDT¶
ldt在现代操作系统中很少使用到。这里简单介绍。
根据cpu的设想,一个任务对应一个 ldt,每个人物的私有内存段都应该有自己的段描述符表,这个表就是ldt,也就是每个任务都是由自己的ldt,随着任务切换,也切换自己的ldt。ldt 存储在内存中,拥有对应的存储器 ldtr,通过指令 lldt xxx
进行访问。
ldt 属于一个系统段,不被软件所访问到,所以也需要存储一个表项到 GDT 当中。lldt 寄存器只有16位,存储一个选择子,根据选择子去查看 GDT 就能够知道 LDT表的起始地址和表界限。LDT 的段描述符和 GDT 一样,但是第0个段描述符可用,因为 ti为1表示已经初始化过了,所以访问ldt一定都是已初始化的选择子。
访问 LDT表项的流程:
- 首先根据 ldtr 寄存器中存储的段选择子访问 GDT 表,获得 LDT 表的起始地址和界限
- 根据这个 GDT 表项,加上段寄存器中存储的另一个选择子访问 LDT
- 获得 LDT 中的一个段描述符,获得其中的段基址,段基址 + 偏移地址 = 要访问的地址
3.6 创建 GDT¶
介绍完GDT的概念,现在要看看怎么创建一个 GDT。
GDT 是由 loader
创建好,然后存储在内存当中。CPU 只需要通过 lgdt
这条指令,知道 GDT
在哪里就可以了。因此,我们 loader
在内存中的副本不能被其他程序覆盖,否则 GDT 就会受到破坏,CPU使用的时候就会发生错误,loader
被加载的地址其他程序不要使用,这个在后面会加以讲述。
下面看看怎么创建 GDT
吧!
section loader vstart=LOADER_BASE_ADDR ;这边给定一个编译的段基址,
;构建gdt及其内部的描述符,gdt有四个表项,其中 GDT_BASE 也就是第0个表项,是全部为0的,表示无效(前面有说过)
;后面依次是代码段、栈段, 这两个开启了平坦模式,所以段基址为0,段界限为4GB
;其实可以发现,代码段和栈段指向的范围都是相同的,这样不会出错,只不过访问一个地址的使用,使用不同的段描述符
;这样的保护效果是不同的
;最后一个 VIEDO段表示显存,没有开启平坦模式,访问的时候可以更好地保护
GDT_BASE: dd 0x00000000
dd 0x00000000
CODE_DESC: dd 0x0000FFFF
dd DESC_CODE_HIGH4
DATA_STACK_DESC: dd 0x0000FFFF
dd DESC_DATA_HIGH4
VIDEO_DESC: dd 0x80000007 ; limit=(0xbffff-0xb8000)/4k=0x7
dd DESC_VIDEO_HIGH4 ; 此时dpl为0
GDT_SIZE equ $ - GDT_BASE ;这里标注一下,GDT表的大小,一个编译器的变量,没有存储到内存中
GDT_LIMIT equ GDT_SIZE - 1 ;获得GDT界限
times 60 dq 0 ; 此处预留60个描述符的空位(slot),times 表示让编译器重复执行 dq 命令60次
; 最后会有60个 dq在可执行文件中,一个dq表示8字节,对应一个描述符
4. 内存段保护¶
既然称为保护模式,访问内存段就需要一些保护措施:
- 首先在加载选择子的时候,就需要一些保护措施,比如越界、这个段是否存在
- 其次需要检查对应的段类型,查看是否执行相关的操作
4.1 段寄存器加载选择子的保护¶
在加载选择子到寄存器之前,需要先根据选择子去访问描述符表,查看是否能够加载。
首先判断描述符索引值,确保没有超出描述符表(gdt or ldt)中描述符的个数,具体流程为:
- 先检查 TI,查看要访问 gdt 还是 ldt
- 获得 GDT、LDT 界限值,如果索引值超过了描述符表的大小,CPU触发异常,跳转到异常处理程序
- 没有超过的话,进入下一步
不允许往 CS 和 SS 加载gdt中索引值为0的选择子,但是其他的可以加载,CPU运行时才会抛出异常。
选择子检查段类型
第二步是根据选择子来检查段类型,查看选择子和要加载的段寄存器是否匹配。
检查段是否存在
最后一个检查是检查段是否存在于内存中,运用到了描述符中的 P 位。检查完就能够将选择子载入段寄存器,然后更新段描述符的 A = 1。如果 P = 0就需要抛出异常,跳到异常处理程序。
保护模式下允许段不存在于物理内存中,这里抛出的异常算是一个缺段异常,如果段不在内存中,需要将整个段都拷贝到内存中。其实可以看到是很不合理的,后面提及分页机制时会解决这个问题。
4.2 代码段和数据段的保护¶
对于代码段和数据段来说,CPU 每访问一个地址,都要确认该地址不能超过其所在内存段的范围,也就是段界限。如果要访问的数据或者代码超出了段界限,就会抛出异常。
4.3 栈段保护¶
在段描述符的属性中,E 位表示当前段是向上拓展还是向下拓展
- 对于向上扩展的段,实际的段界限是段内可以访问的最后一字节。
- 对于向下扩展的段,实际的段界限是段内不可以访问的第一个字节
同样,栈段也不能超出段界限,否则就会出错。
5. 如何进入保护模式¶
进入保护模式的代码却是千奇百怪的,形式可不统一。比如进入保护模式需要三个步骤。
- 打开 A20
- 加载 gdt
- 将 cr0 的 pe 位置 1
这三个步骤并没有什么强制顺序,只要执行完这三个步骤时,CPU 就进入了保护模式,访问地址需要通过段描述符。
5.1 打开 A20¶
前面已经提到过了 8086、80286、80386 三种CPU的区别,这里不再赘述。旧的 8086 CPU 只有20位地址总线,80386CPU 为了保证对程序的兼容,在地址的第20位上制造了一个逻辑 OR 门,以便可以开启或关闭超过20位的地址总线。这样,为了兼容旧的处理器,在机器开启时A20被禁止的。
假如禁止了 A20,访问的地址超过 20 位会出现地址回绕的问题,也就是超过 20位的数字全部丢弃,相当于对 2^20 取模。
BIOS 在计算和测试可用内存时实际上是开启了A20线的,然后在进入 MBR 之前又关闭了它以保持兼容旧的处理器。
A20线是一个OR逻辑电路门,被放置在第20位的地址总线上,而且可以开启或关闭。这是通过键盘控制器的P21线来完成的,这样,通过键盘控制器可以开启开关闭A20线。
也就是说,实模式下 A20 线是默认关闭的,想要进入32位保护模式就要开启 A20 线,否则我们只能访问16位内的地址。
开启A20线有三种方法:
- 通过键盘控制器
- 调用BIOS功能
- 使用系统端口
磁盘控制器开启A20:
这是开启A20线通常的作法。键盘的微控制器提供了一个关闭或开启A20线的功能。在开启A20线之前需要关闭中断以防止我们的内核陷入混乱。命令字节是通过键盘控制器的IO端口0x64来发送的。
命令字节有两种:
- 0xDD 可以开启A20线
- 0xDF 关闭A20线
通过将命令字节发送到 0x64端口,就能够开启A20
cli ;Disables interrupts #关中断
push ax ;Saves AX #保存AX寄存器
mov al, 0xdd ;Look at the command list #开启命令
out 0x64, al ;Command Register #将命令发送到0x64端口
pop ax ;Restore's AX #恢复AX寄存器
sti ;Enables interrupts #开中断
BIOS开启A20
int 15
的2400,2401,2402命令被用来关闭,开启和返回A20线状态。
- 2400和2401(关闭、开启)命令返回状态
- 2042命令返回状态
;关闭
push ax
mov ax, 0x2400
int 0x15
pop ax
;检查A20
push ax
push cx
mov ax, 0x2402
int 0x15
pop cx
pop ax
;开启
push ax
mov ax, 0x2401
int 0x15
pop ax
使用系统0x92
这个方法是十分危险的,因为它可以导致和其他硬件冲突并强制关机。一般还是不要使用这种方法。
5.2 加载 GDT¶
GDT 有两个相关的指令:
lgdt xxxx
:xxxx有6字节,前2字节是一个gdt界限,后4字节是gdt起始地址。将 xxxx 存储到 gdtrsgdt xxxx
:获得gdtr的值,存储到 xxxx
进入保护模式一定需要加载gdt,首先在loader
建立一个GDT,然后将 GDT 的地址、界限存储到 GDTR 寄存器当中。
5.3 将 cr0 的 pe 位置 1¶
CR0 寄存器的第0位是 pe位,Protection Enable
,用来启动保护模式,是保护模式的开关。所以这一步一般都是最后一步。
PE 为 0 表示在实模式下运行,PE 为 1 表示在保护模式下运行。
5.4 代码¶
elephant
的打开保护模式代码
;----------------- 准备进入保护模式 -------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1
;----------------- 打开A20 ----------------
;elephant使用的0x92,不太安全啊
in al,0x92
or al,0000_0010B
out 0x92,al
;----------------- 加载GDT ----------------
lgdt [gdt_ptr]
;----------------- cr0第0位置1 ----------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
6. 总结¶
回到加电启动的问题上,CPU 开始加电之后,处于16位实模式,cs:ip 被自动设置为 0xf000:0xfff0,也就是跳转到 ROM 中的 BIOS 代码开始执行。在 BIOS 当中,可能会打开 A20 地址线,但是在进入 MBR 之前都会关掉 A20 地址线。
MBR 将程序的控制权转接给 loader,loader 的主要工作就是打开保护模式。打开保护模式分为三部:
- 打开A20地址线:这里最好使用键盘控制器打开,在 xv6、JOS、linux 0.11 都是这么做,而在 os-elephant 中通过 0x92 端口打开A20地址线。
- 加载GDT:GDT是本文的主要内容,OS 会在
loader
代码中定义GDT,然后更新gdtr
指向这一段内存,CPU 通过段寄存器存储段选择子,访问地址首先通过段描述符表获得段描述符,然后通过段描述符得到段基址去访问,在这一过程,加载段选择子和获得访问地址的过程中都会进行内存保护,如果保护失败就抛出异常。 - 将 cr0 的pe置为0:这个直接置为0就可以了。
保护模式解决了实模式下的问题了吗?
- 实模式下操作系统和用户程序属于同一特权级:保护模式在段描述符中有特权位,在段选择子中也有权限位,只要让操作系统和用户是不同的段选择子就可以不属于同一特权级的访问同一地址。
- 用户程序所引用的地址都是指向真实的物理地址:这个将会在虚拟地址分页处理,但是实模式肯定是没有虚拟地址的
- 用户程序可以自由修改段基址,不受阻碍地访问所有内存:现在需要用段选择子啦!加载段选择子的时候会有保护
- 访问超过 64KB 的内存区域时要切换,转来转去容易晕乎:段界限最大为4GB,开启平坦模式后不需要切换。而且切换时不是切换段基址,而是切换段选择子。
- 一次只能运行一个程序,无法充分利用计算机资源:虚拟地址分页解决
- 共 20 条地址线,最大可用内存为 1MB,这即使在 20 年前也不够用:32位保护模式!