手写简易操作系统(四)--保护模式

时间:2024-03-12 18:29:03

前情提要

上节我们已经可以调用硬盘,加载loader了,但是loader是什么我们还没写,其实loader也是一段程序,负责在加载内核之前将需要用到的数据准备好。

一、保护模式

保护模式是Intel为了用户与内核程序之间的区分,设计的一种工作模式,这种模式相较于实模式存在以下特点

  1. 内存保护:在保护模式下,处理器提供了内存分段和分页机制,允许操作系统将内存划分为多个段和页面,并为每个段和页面设置访问权限,从而实现对内存的细粒度保护。这样可以防止用户程序越界访问内存、修改操作系统数据结构或者恶意篡改其他程序的数据。
  2. 特权级别:保护模式引入了特权级别的概念,通常有至少两个特权级别,比如 Ring 0 和 Ring 3。Ring 0 表示最高特权级别,通常用于操作系统内核的执行;Ring 3 表示用户程序的特权级别,具有更少的权限。通过特权级别的划分,处理器可以限制用户程序对关键资源的访问,确保系统的稳定性和安全性。
  3. I/O保护:保护模式下,处理器提供了对输入输出设备的保护机制,可以限制用户程序对设备的直接访问,确保设备的安全和可靠性。
  4. 异常和中断处理:保护模式为操作系统提供了更强大的异常和中断处理能力,操作系统可以利用这些机制来响应硬件事件、处理错误情况以及进行多任务调度。

在实模式下,x86处理器处于一种简单的工作模式,没有对内存或硬件资源的保护机制,用户程序和内核程序之间没有明确的区分。在这种模式下,任何程序都可以直接访问系统内存和硬件资源,包括对关键数据结构和设备的直接操作,这样可能导致系统不稳定甚至崩溃。为此,保护模式应运而生。

二、全局描述符表

保护模式下,段寄存器就不是简单加载一下段基址就可以用的了,必须在全局描述符表(Global Descriptor Table,GDT)下登记才可以使用,这是保护模式区别实模式最显著的特征。

2.1、段描述符

段描述符格式如下

image-20240311221211244

段基址:这没什么好说的,32位,也表示32位计算机能访问的最大内存4GB

段界限:对于数据段和代码段,段向上增长,对于栈段,段向下增长。段界限只有20位,所以段界限单位可以是1Byte,也可以是4KB。这取决于G位

G:段界限的单位,为0,1Byte,为1,4KB

S:表示这个段描述的是系统段还是数据段,为0,系统段,为1,数据段

TYPE:表示内存段或者门的子类型,可见下表

image-20240311221952719

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位的寄存器

image-20240311222751554

前16位表示界限,后32位表示地址,并且为了给这个寄存器赋值,我们有专门的指令 lgdt

2.3、选择子

段寄存器CS、DS、ES、FS、GS、SS,在实模式下时,段中存储的是段基地址,即内存段的起始地址。而在保护模式下时,由于段基址已经存入了段描述符中,所以段寄存器中再存放段基址是没有意义的,在段寄存器中存入的是选择子,其实我们也可以理解,选择子就是全局描述符表的下标。

选择子结构如下

image-20240311223151555

由于选择子的索引值也就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 寄存器的各个位表示的含义如下:

  1. PE(Protection Enable)位(位 0)
    • 当 PE 位为 1 时,表示处理器工作在保护模式下,启用内存分段、特权级别等保护机制。
    • 当 PE 位为 0 时,表示处理器工作在实模式下,不启用保护模式。
  2. MP(Monitor Coprocessor)位(位 1)
    • 该位用于控制协处理器(如 FPU)监视功能的启用和禁用。
  3. EM(Emulation)位(位 2)
    • 该位用于控制协处理器的工作模式,当 EM 位为 1 时表示使用仿真模式,否则表示使用硬件协处理器。
  4. TS(Task Switched)位(位 3)
    • 该位指示处理器是否经历了任务切换,用于支持协处理器的任务切换。
  5. ET(Extension Type)位(位 4)
    • 该位用于支持处理器的一些扩展特性,如支持 80387 数学协处理器。
  6. NE(Numeric Error)位(位 5)
    • 该位用于启用数学协处理器的数字错误处理功能。
  7. WP(Write Protect)位(位 16)
    • 当 WP 位为 1 时,表示只读页面不能被写入;当 WP 位为 0 时,只读页面可以被写入。
  8. AM(Alignment Mask)位(位 18)
    • 该位用于控制内存对齐检查的开启和关闭。
  9. NW(Not Write-through)位(位 29)
    • 该位用于控制缓存写透传(write-through)策略的禁用。
  10. CD(Cache Disable)位(位 30)
    • 当 CD 位为 1 时,表示禁用处理器缓存;当 CD 位为 0 时,允许使用缓存。
  11. 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 $

可以看到这里实验成功了

image-20240312112153386

这里也遇到一个小问题,一开始始终无法打印出LOADER这几个字母,程序好像卡在某个位置无法执行了,后面才发现是在mbr加载loader的时候,只加载一个扇区的内容到内存,这显然是不够用的,我们前面的gdt描述符表都已经很大了,所以后面改为加载10个扇区就ok了。

六、计算机流水线

计算机为了提高运行效率想了很多办法

6.1、运用流水线

进入32位程序用到了一个指令

jmp SELECTOR_CODE:p_mode_start

这个指令就是远跳,为的就是将流水线刷新,那么什么是流水线。计算机正常的执行顺序是 取指->译码->执行 这样做当然什么问题都没有,但是太慢了,一条指令要三个时钟周期。那怎么办呢?就可以在第一个指令执行的时候,第二个指令像已经上流水线一样进行译码过程,第三个指令在取指过程,三条指令一起执行,反正执行的是CPU的三个部分,互不干扰,如下图所示

image-20240312145529373

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、段类型检查,如下表

image-20240312152405039

3、段是否存在检查,CPU通过段描述符中的P位来确认内存段是否存在,如果P位为1,则表示存在,这时候就可以将选择子载入段寄存器了,同时段描述符缓冲寄存器也会更新为选择子对应的段描述符的内容,随后处理器将段描述符中的A位置为1,表示已经访问过了

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

对于栈段,我们使用了向上扩展的数据段作为栈段,其访问检查我认为和代码段与数据段一样的。

总结

结束语

这一章我们将计算机通过打开推入到了保护模式中,三步走 打开A20地址线,加载GDT,修改cr0

下一章我们将获取真实的物理内存