前情提要
上节我们已经可以调用硬盘,加载loader了,但是loader是什么我们还没写,其实loader也是一段程序,负责在加载内核之前将需要用到的数据准备好。
一、保护模式
保护模式是Intel为了用户与内核程序之间的区分,设计的一种工作模式,这种模式相较于实模式存在以下特点
- 内存保护:在保护模式下,处理器提供了内存分段和分页机制,允许操作系统将内存划分为多个段和页面,并为每个段和页面设置访问权限,从而实现对内存的细粒度保护。这样可以防止用户程序越界访问内存、修改操作系统数据结构或者恶意篡改其他程序的数据。
- 特权级别:保护模式引入了特权级别的概念,通常有至少两个特权级别,比如 Ring 0 和 Ring 3。Ring 0 表示最高特权级别,通常用于操作系统内核的执行;Ring 3 表示用户程序的特权级别,具有更少的权限。通过特权级别的划分,处理器可以限制用户程序对关键资源的访问,确保系统的稳定性和安全性。
- I/O保护:保护模式下,处理器提供了对输入输出设备的保护机制,可以限制用户程序对设备的直接访问,确保设备的安全和可靠性。
- 异常和中断处理:保护模式为操作系统提供了更强大的异常和中断处理能力,操作系统可以利用这些机制来响应硬件事件、处理错误情况以及进行多任务调度。
在实模式下,x86处理器处于一种简单的工作模式,没有对内存或硬件资源的保护机制,用户程序和内核程序之间没有明确的区分。在这种模式下,任何程序都可以直接访问系统内存和硬件资源,包括对关键数据结构和设备的直接操作,这样可能导致系统不稳定甚至崩溃。为此,保护模式应运而生。
二、全局描述符表
保护模式下,段寄存器就不是简单加载一下段基址就可以用的了,必须在全局描述符表(Global Descriptor Table,GDT)下登记才可以使用,这是保护模式区别实模式最显著的特征。
2.1、段描述符
段描述符格式如下
段基址
:这没什么好说的,32位,也表示32位计算机能访问的最大内存4GB
段界限
:对于数据段和代码段,段向上增长,对于栈段,段向下增长。段界限只有20位,所以段界限单位可以是1Byte,也可以是4KB。这取决于G位
G
:段界限的单位,为0,1Byte,为1,4KB
S
:表示这个段描述的是系统段还是数据段,为0,系统段,为1,数据段
TYPE
:表示内存段或者门的子类型,可见下表
DPL
:特权级,从0到3
P
:段是否存在。如果段存在于内存中,P为1,否则P为0。P字段是由CPU来检查的,如果为0,CPU将抛出异常并转到相应的异常处理程序
AVL
:保留,无特殊用处
L
:是否是64位代码段。L为1表示64位代码段,否则表示32位代码段。我们只用到32位。
D/B
:对于代码段来说,此位是D位,若D为0,表示指令中的有效地址和操作数是16位,指令有效地址用IP寄存器。若D为1,表示指令中的有效地址及操作数是32位,指令有效地址用EIP寄存器。对于栈段,则是B位,意思是一样的,我们当然是设置为1。
2.2、全局描述符表
一个段描述符只用来定义一个内存段,代码段要占用一个段描述符、数据段和栈段等,多个内存段也要各自占用一个段描述符,这些描述符就放在全局描述符表中。全局体现在多个程序都可以在里面定义自己的段描述符,是公用的。
加载全局描述符表需要用到专门的寄存器GDTR,这是一个48位的寄存器
前16位表示界限,后32位表示地址,并且为了给这个寄存器赋值,我们有专门的指令 lgdt
2.3、选择子
段寄存器CS、DS、ES、FS、GS、SS,在实模式下时,段中存储的是段基地址,即内存段的起始地址。而在保护模式下时,由于段基址已经存入了段描述符中,所以段寄存器中再存放段基址是没有意义的,在段寄存器中存入的是选择子,其实我们也可以理解,选择子就是全局描述符表的下标。
选择子结构如下
由于选择子的索引值也就13位,所以最多索引8192个描述符。这和GDT中最多定义8192个描述符是吻合的。
所以现在访问内存还是使用段基址+偏移地址的方式,只不过段寄存器中存放的是索引,索引再从全局描述符表中取出段基址,取出的段基址加偏移地址即要访问的内存地址。
TI
为1则表时是从局部描述表LDT中索引,为0则表示是从全局描述符表GDT中索引
RPL
特权级
三、打开A20地址线
实模式下存在地址回绕,也就是段基址+偏移地址的方式会超出1MB内存,那么就对1MB求模,但是保护模式下内存大大增加,已经不要地址回绕,想要利用更多的内存,就需要打开A20地址线,利用更多的地址线访问更多的内存。
方法很简单
in al,0x92
or al,0000_0010B
out 0x92,al
将端口0x92的第1位置1即可。
四、CR0寄存器
CR0 寄存器是 x86 架构中的控制寄存器之一,用于控制处理器的一些关键特性和工作模式。CR0 寄存器的各个位表示的含义如下:
- PE(Protection Enable)位(位 0):
- 当 PE 位为 1 时,表示处理器工作在保护模式下,启用内存分段、特权级别等保护机制。
- 当 PE 位为 0 时,表示处理器工作在实模式下,不启用保护模式。
- MP(Monitor Coprocessor)位(位 1):
- 该位用于控制协处理器(如 FPU)监视功能的启用和禁用。
- EM(Emulation)位(位 2):
- 该位用于控制协处理器的工作模式,当 EM 位为 1 时表示使用仿真模式,否则表示使用硬件协处理器。
- TS(Task Switched)位(位 3):
- 该位指示处理器是否经历了任务切换,用于支持协处理器的任务切换。
- ET(Extension Type)位(位 4):
- 该位用于支持处理器的一些扩展特性,如支持 80387 数学协处理器。
- NE(Numeric Error)位(位 5):
- 该位用于启用数学协处理器的数字错误处理功能。
- WP(Write Protect)位(位 16):
- 当 WP 位为 1 时,表示只读页面不能被写入;当 WP 位为 0 时,只读页面可以被写入。
- AM(Alignment Mask)位(位 18):
- 该位用于控制内存对齐检查的开启和关闭。
- NW(Not Write-through)位(位 29):
- 该位用于控制缓存写透传(write-through)策略的禁用。
- CD(Cache Disable)位(位 30):
- 当 CD 位为 1 时,表示禁用处理器缓存;当 CD 位为 0 时,允许使用缓存。
- PG(Paging)位(位 31):
- 当 PG 位为 1 时,表示启用分页机制;当 PG 位为 0 时,禁用分页机制。
很显然,我们需要将PE位置1
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
五、进入保护模式
首先我们在宏定义中定义一些内容,这些内容就是全局描述符需要的内容
; os/src/boot/boot.inc
LOADER_BASE_ADDR equ 0x600
LOADER_START_SECTOR equ 0x1
;-------------- gdt描述符属性 -------------
DESC_G_4K equ 1_00000000000000000000000b ; 段界限单位:4KB
DESC_D_32 equ 1_0000000000000000000000b ; 有效地址和操作数是 32 位
DESC_L equ 0_000000000000000000000b ; 64位代码标记,此处标记为0便可。
DESC_AVL equ 0_00000000000000000000b ; 保留位
DESC_LIMIT_CODE2 equ 1111_0000000000000000b ; 代码段段界限的高位
DESC_LIMIT_DATA2 equ 1111_0000000000000000b ; 数据段段界限的高位
DESC_LIMIT_VIDEO2 equ 0000_0000000000000000b ; 视频段段界限的高位
DESC_P equ 1_000000000000000b ; 段是否存在标志位
DESC_DPL_0 equ 00_0000000000000b ; 0特权级内存
DESC_DPL_1 equ 01_0000000000000b ; 1特权级内存
DESC_DPL_2 equ 10_0000000000000b ; 2特权级内存
DESC_DPL_3 equ 11_0000000000000b ; 3特权级内存
DESC_S_CODE equ 1_000000000000b ; 代码段的段描述为数据段
DESC_S_DATA equ 1_000000000000b ; 数据段的段描述为数据段
DESC_S_sys equ 0_000000000000b ; 系统段的段描述为系统段
DESC_TYPE_CODE equ 1000_00000000b ;x=1,c=0,r=0,a=0 代码段是可执行的,非依从的,不可读的,已访问位a清0.
DESC_TYPE_DATA equ 0010_00000000b ;x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写的,已访问位a清0.
DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00
DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0b
;-------------- 选择子属性 ---------------
RPL0 equ 00b
RPL1 equ 01b
RPL2 equ 10b
RPL3 equ 11b
TI_GDT equ 000b
TI_LDT equ 100b
然后修改我们的loader,让程序通过三个步骤来进入保护模式
1、打开A20地址线
2、加载GDT描述符
3、修改cr0寄存器的PE标志位
; os/src/boot/loader.s
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR ; 程序开始的地址
jmp loader_start
LOADER_STACK_TOP equ LOADER_BASE_ADDR ; 栈顶地址
;构建gdt及其内部的描述符
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_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; 此处预留60个描述符的slot
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ; 第一个选择子
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0 ; 第二个选择子
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0 ; 第三个选择子
; 以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
loader_start:
mov byte [gs:160],'L'
mov byte [gs:161],0x0F
mov byte [gs:162],'O'
mov byte [gs:163],0x0F
mov byte [gs:164],'A'
mov byte [gs:165],0x0F
mov byte [gs:166],'D'
mov byte [gs:167],0x0F
mov byte [gs:168],'E'
mov byte [gs:169],0x0F
mov byte [gs:170],'R'
mov byte [gs:171],0x0F
; 打开A20地址线
open_A20:
in al,0x92
or al,0000_0010B
out 0x92,al
; 加载gdt描述符
load_gdt:
lgdt [gdt_ptr]
; 修改cr0标志寄存器的PE位
change_cr0_PE:
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
jmp SELECTOR_CODE:p_mode_start ; 刷新流水线,避免分支预测的影响
; 远跳将导致之前做的预测失效,从而起到了刷新的作用。
; 下面就是保护模式下的程序了
[bits 32]
p_mode_start:
mov ax, SELECTOR_DATA
mov ds, ax
mov es, ax
mov ss, ax
mov esp,LOADER_STACK_TOP
mov ax, SELECTOR_VIDEO
mov gs, ax
mov byte [gs:320], 'O'
mov byte [gs:321],0x0F
mov byte [gs:322], 'K'
mov byte [gs:323],0x0F
jmp $
可以看到这里实验成功了
这里也遇到一个小问题,一开始始终无法打印出LOADER这几个字母,程序好像卡在某个位置无法执行了,后面才发现是在mbr加载loader的时候,只加载一个扇区的内容到内存,这显然是不够用的,我们前面的gdt描述符表都已经很大了,所以后面改为加载10个扇区就ok了。
六、计算机流水线
计算机为了提高运行效率想了很多办法
6.1、运用流水线
进入32位程序用到了一个指令
jmp SELECTOR_CODE:p_mode_start
这个指令就是远跳,为的就是将流水线刷新,那么什么是流水线。计算机正常的执行顺序是 取指->译码->执行
这样做当然什么问题都没有,但是太慢了,一条指令要三个时钟周期。那怎么办呢?就可以在第一个指令执行的时候,第二个指令像已经上流水线一样进行译码过程,第三个指令在取指过程,三条指令一起执行,反正执行的是CPU的三个部分,互不干扰,如下图所示
6.2、乱序执行
除了流水线,计算机为了提高效率还干了什么呢?那就是乱序执行,你可能会问,指令顺序乱了,那执行结果不就错了吗,那我们就找那些不存在先后顺序的指令,例如
mov eax, [0x1234]
add ecx, ebx
这两条指令,一条是从内存中取值,另一条是加法,那么前一条指令需要的时钟周期就比较长,且和后一条指令没有逻辑上的相互关系,所以这两条指令就可以乱序。这里还是看不出乱序的优势,不着急。
x86最初的指令集为CISC,为复杂指令集,直接在CPU硬件一层直接支持软件中的操作,但是其实这些指令集很庞大,80%用不到,与之对应的时RISC,即精简指令集,保留了常用的指令,这些指令大多是不可再分的。其实CISC发展到今天,其内核已经是RISC了,虽然一个大的操作还是存在,但是是分解成了很多小的操作执行的。当一个“大”操作被分解成多个“微”操作时,它们之间通常独立无关联,所以非常适合乱序执行。
总结一下,乱序执行的好处就是后面的操作可以放到前面来做,利于装载到流水线上提高效率。
6.3、缓存
为了进一步的提高效率,计算机还用到了缓存,现代计算机大多有三级缓存,L1,L2,L3。有些数据我们是经常会访问到的,所以为了不一次一次的从较为低速的内存中拿,这个值会被放到缓存中。对应的,如果我们要从内存中找一个值,那么计算机其实先会从L1缓存中找缓存数据,不存在再去L2找,再去L3,最后才是去内存。可能你会认为这样不是更慢了嘛,找好几次,但是三级缓存是在计算机CPU里面的SRAM,他们速度和寄存器一样的。
现在 AMD 的 EPYC 处理器,都有一些型号突破1GB的缓存了。
6.4、分支预测
CPU中的指令是在流水线上执行。分支预测,是指当处理器遇到一个分支指令时,是该把分支左边的指令放到流水线上,还是把分支右边的指令放在流水线上呢?
如C语言程序中的if、switch、for等语言结构,编译器将它们编译成汇编代码后,在汇编一级来说,这些结构都是用跳转指令来实现的,所以,汇编语言中的无条件跳转指令很丰富,以至于称之为跳转指令“族”,多得足矣应对各种转移方式。
从统计学的角度来看,某些事情一旦出现,下一次出现的机率还会很大。我们这里不详细讲分支预测,我们讲两种结果
1、分支预测对了,那么之前处理器提前准备好的流水线就是正确的,可以直接执行下去
2、分支预测错了,那么之前处理器提前准备好的流水线就是错误的,需要清空流水线,把正确分支上的指令加入到流水线,只是说清空流水线需要时钟周期,代价比较大。
所以这里我们明白为什么要用一个远跳指令了嘛,因为之前的流水线是实模式下的,这里我们必须清空这些指令,才能将准备好的保护模式下的程序送上流水线正确执行。
七、何为保护
保护模式为什么能称之为保护模式呢,这就是他对内存的保护
1、选择子的索引值不能超过描述符表中描述符的个数
2、段类型检查,如下表
3、段是否存在检查,CPU通过段描述符中的P位来确认内存段是否存在,如果P位为1,则表示存在,这时候就可以将选择子载入段寄存器了,同时段描述符缓冲寄存器也会更新为选择子对应的段描述符的内容,随后处理器将段描述符中的A位置为1,表示已经访问过了
对于代码段和数据段来说,CPU每访问一个地址,都要确认该地址不能超过其所在内存段的范围。
对于栈段,我们使用了向上扩展的数据段作为栈段,其访问检查我认为和代码段与数据段一样的。
总结
结束语
这一章我们将计算机通过打开推入到了保护模式中,三步走 打开A20地址线,加载GDT,修改cr0
下一章我们将获取真实的物理内存