细说保护模式

逻辑地址:即逻辑上的地址,实模式下由“段基地址+段内偏移”组成;保护模式下由“段选择符+段内偏移”组成。

线性地址:逻辑地址经分段机制后就成线性地址,它是平坦的;如果不启用分页,那么此线性地址即物理地址。

物理地址:线性地址经分页转换后就成了物理地址。

可以看到保护模式和实模式的区别在于它是用段选择符而非段基地址,这也许就是保护模式的真谛所在,从段选择符入手,全面理解保护模式编程基本概念和寻址方式。

特权指令系统

在计算机系统层次结构中,应用层在操作系统层之上,只能看到和使用指令系统的一个子集,即指令系统的用户态部分。每个应用程序都有自己的寄存器、内存空间以及可执行的指令。

现代计算机的指令系统在用户态子集之外还定义了操作系统核心专用的特权态部分,我们称之为特权指令系统。

特权指令系统的存在主要是为了让计算机变得更好用、更安全。操作系统通过特权指令系统管理计算机,使得应用程序形成独占CPU的假象,并使应用间相互隔离,互不干扰。

应用程序只能在操作系统划定的范围内执行,一旦超出就会被CPU切换成操作系统代码运行。

运行模式定义及其转换

现代计算机的操作系统都实现了保护模式,至少需要用户态和核心态两种运行模式。应用运行在用户态模式下,操作系统运行在核心态模式下。因此,指令系统必须有相应的运行模式以做区分。比如MIPS定义了user、supervisor、kernel三种模式,X86定义了Ring0Ring3四种模式,LoongArch定义了PLV0PLV3四种模式。

20221105233634

应用程序运行在用户态,从CPU的角度来说,就是r3;内核运行在内核态,从CPU的角度来说,就是r0

刚开机时,CPU初始化为操作系统核心态对应的运行模式,执行引导程序加载操作系统。操作系统做完一系列初始化后,控制CPU切换到操作系统用户态对应的运行模式去执行应用程序。应用程序执行过程中,如果出现用户态对应的运行模式无法处理的事件,则CPU会通过异常或中断回到核心态对应的运行模式,执行操作系统提供的服务程序。操作系统完成处理后再控制CPU返回用户态对应的运行模式,继续运行原来的应用程序或者调度另一个应用程序。

运行模式的转换过程与虚拟存储和异常中断紧密相关,共同构建出完备的保护模式。不少指令系统还支持虚拟机模式、调试模式等,使计算机系统更为易用。

虚拟存储管理

虚拟存储管理的基本思想是让软件(包括系统软件)运行在“虚地址”上,与真正访问存储的“实地址”(物理地址)相隔离。虚实地址的转换根据地址段属性的不同,有查表转换和直接映射两种方式。查表转换是应用程序使用的主要方式。不同的进程有自己独立的虚地址空间。CPU执行访存指令时,根据操作系统给出的映射表来完成虚地址空间到物理内存的转换。

直接映射的方式与使用物理地址差别不大,主要给操作系统使用,因为在初始化之前负责虚存管理的代码本身不能运行在被管理的虚地址空间。通常用户态应用程序无法使用直接映射方式。

异常与中断处理

异常与中断是一种打断正常的软件执行流,切换到专门的处理函数的机制。它在各种运行模式的转换中起到关键的纽带作用。比如用户态代码执行过程中,当出现对特权空间的访问,或者访问了虚实地址映射表未定义的地址,或者需要调用操作系统服务等情况时,CPU通过发出异常来切换到核心态,进入操作系统定义的服务函数。操作系统完成处理后,返回发生异常的代码并同时切换到用户态。

控制状态寄存器

控制状态寄存器位于一个独立的地址空间,是支撑前面3种机制的具体实现,不同的指令系统差别较大。

控制状态寄存器虽然重要,但对其操作的频率通常远远低于通用寄存器,所以指令系统中通常不会设计针对控制状态寄存器的访存和复杂运算指令。

标志寄存器EFLAGS存放有关处理器的控制标志,如下图所示。标志寄存器中的第1、3、5、15位及18~31位都没有定义。

20221102000656

  • 第8位TF(Trap Flag)是自陷标志,当将其置1时则可以进行单步执行。当指令执行完后,就可能产生异常1的自陷。也就是说,在程序的执行过程中,每执行完一条指令,都要由异常1处理程序 (在Linux内核中叫做debug())进行检验。当将第8位清0后,且将断点地址装入调试寄存器DR0~DR3时,才会产生异常1的自陷。
  • 第12、13位IOPL是输入输出特权级位,这是保护模式下要使用的两个标志位。由于输入输出特权级标志共两位,它的取值范围只可能是0、1、2和3共4个值,恰好与输入输出特权级0~3级相对应。但Linux内核只使用了两个级别,即0和3级,0表示内核级,3表示用户级。在当前任务的特权级CPL(Current Privilege Level)高于或等于输入输出特权级时,就可以执行像IN、OUT、INS、OUTS、STI、CLI和LOCK等指令而不会产生异常13(即保护异常)。在当前任务特权级CPL为0时,POPF(从栈中弹出至标志位)指令和中断返回指令IRET可以改变IOPL字段的值。
  • 第9位IF(Interrupt Flag)是中断标志位,是用来表示允许或者禁止外部中断(参看第四章)。若第9位IF被置为1,则允许CPU接收外部中断请求信号;若将IF位清0,则表示禁止外部中断。在保护模式下,只有当第12、13位指出当前CPL为最高特权级时,才允许将新值置入标志寄存器EFLAGS以改变IF位的值。
  • 第10位DF(Direction Flag)是定向标志。DF位规定了在执行串操作的过程中,对源变址寄存器ESI或目标变址寄存器EDI是增值还是减值。如果DF为1,则寄存器减值;若DF为0,则寄存器值增加。
  • 第14位NT是嵌套任务标志位。在保护模式下常使用这个标志。当80386在发生中断和执行CALL指令时就有可能引起任务切换。若是由于中断或由于执行CALL指令而出现了任务切换,则将NT置为1。若没有任务切换,则将NT位清0。
  • 第17位VM (Virtual 8086 Mode Flag)是虚拟8086方式标志,是80386新设置的一个标志位。表示80386CPU是在虚拟8086环境中运行。如果80386CPU是在保护模式下运行,而VM为又被置成1,这时80386就转换成虚拟8086操作方式,使全部段操作就像是在8086CPU上运行一样。VM位只能由两种方式中的一种方式给予设置,即或者是在保护模式下,由最高特权级(0)级代码段的中断返回指令IRET设置,或者是由任务转换进行设置。Linux内核实现了虚拟8086方式

还有四个32位的控制寄存器,它们是CR0,CR1,CR2和CR3。这几个寄存器中保存全局性和任务无关的机器状态。

90e3dfb7cbc2f20a2bbac3d59c535f7

CR0中包含了6个预定义标志

  • 0位是保护允许位PE(Protedted Enable),用于启动保护模式,如果PE位置1,则保护模式启动,如果PE=0,则在实模式下运行。
  • 1位是监控协处理位MP(Moniter coprocessor),它与第3位一起决定:当TS=1时操作码WAIT是否产生一个“协处理器不能使用”的出错信号。
  • 2位是模拟协处理器位 EM (Emulate coprocessor),如果EM=1,则不能使用协处理器,如果EM=0,则允许使用协处理器。
  • 3位是任务转换位(Task Switch),当一个任务转换完成之后,自动将它置1。随着TS=1,就不能使用协处理器。
  • 4位是微处理器的扩展类型位ET(Processor Extension Type),其内保存着处理器扩展类型的信息,如果ET=0,则标识系统使用的是287协处理器,如果 ET=1,则表示系统使用的是387浮点协处理器。
  • 31位是分页允许位(Paging Enable),它表示芯片上的分页部件是否允许工作。由PG位和PE位定义的操作方式如下
PG PE 方式
0 0 实模式
0 1 保护模式,不分页
1 0 出错
1 1 分页的保护模式

CR1是未定义的控制寄存器,供将来的处理器使用。

CR2是页故障线性地址寄存器,保存最后一次出现页故障的全32位线性地址。

CR3是页目录基址寄存器,保存页目录表的物理地址,页目录表总是放在以4K字节为单位的存储器边界上,因此,它的地址的低12位总为0,不起作用,即使写上内容,也不会被理会。

保护模式组成

在保护模式下,CPU的32条地址线全部有效,可寻址高达4G字节的物理地址空间; 但是我们的内存寻址方式还是得兼容老办法(这也是没办法的,有时候是为了方便,有时候是一种无奈),即(段基址:段偏移量)的表示方式。当然此时CPU中的通用寄存器都要换成32位寄存器(除了段寄存器)来保证寄存器能访问所有的4GB空间。

偏移值和实模式下是一样的,就是变成了32位而已,而段值仍旧是存放在原来16位的段寄存器中,但是这些段寄存器存放的却不再是段基址了,毕竟之前说过实模式下寻址方式不安全,我们在保护模式下需要加一些限制,而这些限制可不是一个寄存器能够容纳的,于是我们把这些关于内存段的限制信息放在一个叫做全局描述符表(GDT)的结构里。

全局描述符表中含有一个个表项,每一个表项称为段描述符。而段寄存器在保护模式下存放的便是相当于一个数组索引的东西,通过这个索引,可以找到对应的表项。段描述符存放了段基址、段界限、内存段类型属性(比如是数据段还是代码段,注意一个段描述符只能用来定义一个内存段)等许多属性

20221122132859

寄存器结构

保护模式下自然使用的是32位CPU,其对应的寄存器自然也是32位的。当然,众所周知,兼容性是计算机科学的优良传统,因此CPU自然兼容原始的16位下的CPU及其工作环境,32位CPU中寄存器主要如下所示

20221015184942

可以看到,通用寄存器、指令指针寄存器和标志寄存器都由原来的16位拓展到了32位,对应的低16位适用于兼容实模式的,可以单独使用;而段寄存器和原来的保持一样,仍然是16位没有变化。

GDTR和LDTR

GDT可以被放在内存的任何位置,那么当程序员通过段寄存器来引用一个段描述符时,CPU必须知道GDT的入口,也就是基地址放在哪里,所以Intel的设计者门提供了一个寄存器GDTR用来存放GDT的入口地址,程序员将GDT设定在内存中某个位置之后,可以通过LGDT指令将GDT的入口地址装入此寄存器,从此以后,CPU就根据此寄存器中的内容作为GDT的入口来访问GDT了

GDTR中存放的是GDT在内存中的基地址(32位)和其表长界限(16位)

lgdt [gdt_ptr] 把 gdt_ptr 内存处的内容存到 GDTR 寄存器

sgdt [gdt_ptr] 把 GDTR 寄存器的内容存到 gdt_ptr 内存处

IA-32为LDT的入口地址也提供了一个寄存器LDTR,因为在任何时刻只能有一个任务在运行,所以LDT寄存器全局也只需要有一个。如果一个任务拥有自身的LDT,那么当它需要引用自身的LDT时,它需要通过lldt指令将其LDT的段描述符装入此寄存器。lldt指令与lgdt指令不同的时,lgdt指令的操作数是一个32-bit的内存地址,这个内存地址处存放的是一个32-bit GDT的入口地址,以及16-bit的GDT Limit。而lldt指令的操作数是一个16-bit的选择子,这个选择子主要内容是:被装入的LDT的段描述符在GDT中的索引值。

至此,我们可以这样理解GDT和LDT:GDT为一级描述符表,LDT为二级描述符表。如图:

20221105235405

段选择符

段选择符为16位,它不直接指向段,而是通过指向的段描述符

20221015191409

第0~1位是RPL请求特权级RPL(Request Privilege Level),RPL说明的是进程对段访问的请求权限,意思是当前进程想要的请求权限。

当选择子成功装入CS寄存器后,相应的选择子中的RPL就变成了CPL。因为它的位置变了,已经被装入到CS寄存器中了,所表达的意思也发生了变——原来的要求等级已经得到了满足,就是当前自己的等级。CPL当前任务特权(Current Privilege Level),表示当前正在执行的代码所处的特权级。CPL保存在CS中的最低两位,是针对CS而言的。

RPL的值由程序员自己来自由的设置,并不一定RPL>=CPL,但是当RPL<CPL时,实际起作用的就是CPL了,因为访问时的特权检查是判断:EPL=max(RPL,CPL)<=DPL是否成立,所以RPL可以看成是每次访问时的附加限制,RPL=0时附加限制最小,RPL=3时附加限制最大。所以你不要想通过来随便设置一个rpl来访问一个比cpl更内层的段。

选择子可以有许多个,因此RPL也就有许多个。而CPL就不同了,正在执行的代码在某一时刻就只有这个值唯一的代表程序的CPL

第2位是TI用来指明全局描述符表GDT还是局部描述符表LDT

索引值为13位

在保护模式下最多可以表示2^13=8192个段描述符,而TI又分GDT和LDT(如图3所示),所以一共可以表示8192*2=16384个段描述符,每个段描述符可以指定一个具体的段信息,所以一共可以表示16384个段。

段内偏移地址最大可达4GB,这样16384*4GB=64TB,这就是所谓的64TB最大寻址能力,也即逻辑地址/虚拟地址。

20221015191938

段描述符表中的每一项为一个段描述符,每一项为8字节

段描述符

段描述符里有三个部分基地址信息,这三部分组成一个32位地址就决定了段基地址位置,此地址再加上段内偏移最终确定线性地址位置

20221105234629

  • G=0时,段限长的20位为实际段限长,最大限长为2^20=1MB
  • G=1时,则实际段限长为20位段限长乘以2^12=4KB,最大限长达到4GB
  • D/B:当描述符指向的是可执行代码段时,这一位叫做D位,D=1使用32位地址和32/8位操作数,D=0使用16位地址和16/8位操作数。
  • D/B:如果指向的是向下扩展的数据段,这一位叫做B位,B=1时段的上界为4GB,B=0时段的上界为64KB。
  • D/B:如果指向的是堆栈段,这一位叫做B位,B=1使用32位操作数,堆栈指针用ESP,B=0时使用16位操作数,堆栈指针用SP。
  • DPL:特权级,0为最高特权级,3为最低,表示访问该段时CPU所需处于的最低特权级

一个程序可以使用多个段(Data,Code,Stack)也可以只用一个code段等。正常的情况下,当程序的环境建立好后,段描述符都不需要改变——当然DPL也不需要改变,因此每个段的DPL值是固定。

段描述符中的S位和TYPE字段(四位)的不同又分为数据段描述符、代码段描述符(S=1)和系统段描述符(S=0)。

20221015192432

20221015192524

分页机制

分页机制把物理内存分成相同固定大小的页面,2^12=4KB。每个页面的0~4KB范围由线性地址的低12位表示

线性地址空间的高10位用来指定页目录中的位置,可以选择2^10=1024个目录项,每个目录项为四字节,所以页目录为1024*4B=4KB

每个目录项中的高20位用以查找页表在物理内存中的页面,每个页表含1024个页表项,每个页表项也是四字节,这样一页表也是1024*4B=4KB

所以一个页目录可以查找1024个页表,每个页表为4KB,所以总共可以查找的页表大小为1024*4KB=4MB大。

最后每个页表项的高20位用以定位物理地址空间中的某个页基地址,此地址再加上线性地址空间的偏移值就是最后物理内存空间单元。

20221015193413

段页合璧

总的来说整个过就是逻辑地址经分段机制变成线性地址,如果不启用分页的情况下,此线性地址就是物理地址;如果启用分页,那么线性地址经分页机制变成物理地址。

20221015193526

一个多段模型充分发挥了段机制的对代码、数据结构和程序提供硬件保护的能力。每个程序都有自己的段描述符表和自己的段。段可以完全属于程序私有也可以和其它程序之间共享。

访问权限的检查不仅仅用来保护地址越界,也可以保护某一特定段不允许操作。例如代码段是只读段,硬件可以阻击向代码段进行写操作。

分页为需求页、虚拟内存提供实现机制。

20221015193943

进入保护模式

进入保护模式前需要做环境准备,三件事:

  1. 构建好GDT表,至少要有代码段、数据段
  2. 开A20总线
  3. 调整CPU至保护模式(cr0)

构建gdt

gdt_base:
dd 0, 0
gdt_code:
dw SEG_LIMIT & 0xffff
dw SEG_BASE & 0xffff
db SEG_BASE >> 16 & 0xff
; P_DPL_S_TYPE
db 0b1_00_1_1000
; G_DB_AVL_LIMIT
db 0b0_1_00_0000 | (SEG_LIMIT >> 16 & 0xf)
db SEG_BASE >> 24 & 0xf
gdt_data:
dw SEG_LIMIT & 0xffff
dw SEG_BASE & 0xffff
db SEG_BASE >> 16 & 0xff
; P_DPL_S_TYPE
db 0b1_00_1_0010
; G_DB_AVL_LIMIT
db 0b1_1_00_0000 | (SEG_LIMIT >> 16 & 0xf)
db SEG_BASE >> 24 & 0xf
gdt_ptr:
dw $ - gdt_base
dd gdt_base

开启A20和cro

所谓A20,就是第21根地址线(从零开始),8086只有20根地址线寻址1M,保护模式要突破1M,就需要开启第21根地址线,也就是A20

如果不开启A20,那么访问大于1M地址时,cpu会为了兼容实模式而产生地址回绕(对1M取模)

; 开A20
in al, 92h
or al, 00000010b
out 92h, al

;开CR0
mov eax, cr0
or eax , 1
mov cr0, eax

;初始化保护模式
jmp CODE_SELECTOR:protect
ed_mode

保护模式下装入内核


[BITS 32]
protected_mode:
mov ax, DATA_SELECTOR
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax

mov esp, 0x9fbff

; 将内核读入内存
mov edi, KERNEL_ADDR
mov ecx, 3
mov bl, 60
call read_hd

jmp CODE_SELECTOR:KERNEL_ADDR