从0开始写内核(四)保护模式入门
参考书籍:操作系统真相还原
源码:https://github.com/wutiaojian000/AFKernel.git
本文地址:https://www.cnblogs.com/angel-fish/p/18848214
0. 保护模式
0.1 寄存器
上一节介绍了实模式下的寄存器,这节进入了保护模式,自然也要介绍一下保护模式下的寄存器。

相比于实模式下的寄存器,保护模式下寄存器(之后写寄存器默认是保护模式下的,除非特殊说明)前面会加上字符E表示扩展。
前面的章节中有介绍过物理地址,线性地址和虚拟地址。其中后两者是在保护模式下的,仍然使用段基址:段内偏移来寻址,只不过段基址变成了选择子,对应于全局描述符表GDT中的一项,CPU在保护模式下如果没有开启分页功能,则GDT中地址为物理地址;开启分页后线性地址被当做虚拟地址,通过页表和MMU等部件转换为物理地址。
对CPU来说,从内存中读取选择子的操作会比较耗时,所以会有一个叫段描述符缓冲寄存器的部件用来缓冲选择子。
0.2 保护模式下寻址

实模式下基址寄存器只能是bx、bp,变址寄存器只能是si、di;保护模式下基址寄存器可以是所有32位通用寄存器,变址寄存器可以是处esp外所有通用寄存器,偏移量变成32位,还可以对变址寄存器乘以一个比例因子,比例因子只能是1、2、4、8。esp不能做变址寄存器,但可以做基址寄存器。

如
mov eax, [eax+edx*8+0x12345678]
mov eax, [eax+edx*2+0x8]
mov eax, [ecx*4+0x1234]
0.3 运行模式翻转
需要用[bits 16][bits 32]为编译器指定bits标签之间的代码需要编译成多少位的机器码。

然后由于不同模式下的资源可以相互访问,所以在访问资源的时候需要加上一个操作数反转前缀,用于指明访问相反模式下的资源。有两种反转前缀,操作数反转前缀0x66和寻址方式反转前缀0x67。
操作数反转前缀0x66:

寻址方式反转前缀0x67:

可以看到0x66和0x67两种反转是可以同时存在的。
0.4指令扩展
add、sub之类的双操作数指令以及inc、dec之类的单操作数指令需要同时支持8、16、32位操作数;有一些指令不用同时支持这3种,比如loop,实模式下使用cx存储循环次数,保护模式下使用ecx存储;mul是无符号数相乘,指令格式为mul 寄存器/内存(乘数),如果乘数是8位,则被乘数为al,结果16位存入ax,如果乘数是16位,则被乘数为ax,结果32位存入eax,如果乘数是32位,则被乘数为eax,结果64位存入edx:eax,有符号相乘imul也一样;无符号数除法div,格式是div 寄存器/内存(除数),如果除数是8位,则被除数是16位,存在ax里,商在al余数在ah,如果除数是16位,则被除数是32位,高16位在dx低16位在ax,商在ax余数在dx,如果除数是32位,则被除数是64位,高32位在edx低32位在eax,商在eax余数在edx。
| 指令 | 乘数/除数位数 | 被乘数/被除数位数 | 被乘数/被除数位置 | 结果的位置 |
|---|---|---|---|---|
| mul | 8 | 8 | al | ax |
| mul | 16 | 16 | ax | dx:ax |
| mul | 32 | 32 | eax | edx:eax |
| div | 8 | 16 | ax | 商在al余数在ah |
| div | 16 | 32 | 高16位在dx低16位在ax | 商在ax余数在dx |
| div | 32 | 64 | 高32位在edx低32位在eax | 商在eax余数在edx |
16位实模式下CPU仍然可以处理32位数据,以push为例,其操作数可以是立即数、寄存器、内存,对于立即数来说,可以压入8、16、32数据,处于对齐考虑,压入8位数据时,实模式下会扩展到16位再入栈,保护模式下会扩展到32位。例子看书。
0.5全局描述符表
0.5.1 段描述符
段描述符结构:

段界限表示段边界的扩展最值,对于数据段和代码段,段的扩展方向是向上;对于栈段,段的扩展方向是向下。G位为1则以字节为单位,为0则以4KB为单位。段界限一共20位,以字节为单位则段的大小为1MB,以4KB为单位则段的大小为4GB。
type要和S位搭配使用,S位为0是代表系统段,为1代表数据段(要区分于平时说的数据段)。系统段咱先暂时记住它是属于硬件系统需要的结构,不是软件使用的。现在主要关注S为1的非系统段。继续说type字段。

主要介绍这里的非系统段部分。
a代表已访问位,CPU访问过后将此位置1,创建新描述符时置0。
C指明一致性代码段,0为非一致性代码段,1为一致性代码段,可以看一下这个博客https://blog.csdn.net/feijj2002_/article/details/4597174,这里介绍了CPL,RPL,DPL的关系,简单来说就是当前段PL(CPL)和请求的PL(RPL)小于实际的PL(DPL),则能够访问目标代码段。具体的后面再介绍。
R为1代表可读,0代表不可读,指令对不可读的段进行访问时CPU会抛出异常。
X为1代表代码段,可执行,为0代表数据段,不可执行。
E表示段的扩展方向,1代表向下扩展(栈段),0代表向上扩展(如数据段代码段)。
W表示段是否可写,1为可写,0为不可写。
S上面有介绍过。
DPL是描述符特权级,就是我们平时说的ring0-3,0为最高特权级,CPU由实模式进入保护模式后,特权级自动为0,用户程序通常处于特权级3,一般只用到这两个。
P表示段是否存在于内存中,0为不存在,CPU只负责检查,若不存在则跳转到ISR处理然后将P置为1,不过这是不开启分页功能的换入换出,基本不用了。
AVL我们可以随意使用。
L表示是否设置64位代码段,我们先在32位下编程,所以设置为0。
D/B用来指示有效地址和操作数的大小,指定操作数大小是为了兼容286的保护模式(16位),代码段使用D,D为0时表示代码段的有效地址和操作数为16位,使用IP寄存器,D为1时表示代码段的有效地址和操作数为32位,使用EIP寄存器;栈段使用B位,用来确定栈指针寄存器的选择以及栈的地址上限,B为0时使用sp寄存器,为1时使用的是esp。
G位上面介绍过了。
0.5.2 全局描述符表
全局描述符表(GDT)用来存放段描述符,使用GDTR寄存器存放GDT表的地址和表的大小。GDTR是48位的寄存器。

GDT初始化有专门的指令lgdt,既可以在实模式下执行,也可以在保护模式下执行。指令格式为
lgdt 48位内存数据
这里的48位数据和GDTR的格式是一样的。可容纳2^16 /2^3=8k个段描述符。
LDT我们先不用,书里的描述看看就行。
0.5.3 段选择子
保护模式下段寄存器中存放段选择子,是段描述符在GDT中的索引。段选择子16位,低两位存RPL(以后会讲),第三位是TI位,为0是说明在GDT中索引描述符,为1时说明在LDT中索引描述符。高13位是索引值。

需要注意的是GDT中第一个段表述符不可用,比如段选择子0x8,索引为0x1,是从1即第二个描述符开始的,因为段选择子如果没有初始化则会选到第0个描述符,CPU会发出异常。
0.6 打开A20地址线
实模式下段基址:段内偏移的寻址范围为0x0-0x10FFEF,而20位地址线最大寻址范围为0x0-0xFFFFF,超出1MB的部分将会回绕到0地址,称为地址回绕。20位地址线下取余操作自动满足这一性质。这是8086的情况。而到了80286,地址线到了24位,由于还要满足8086实模式下的地址回绕特性,所以需要控制第21根地址线的有效性(A20无效代表进位丢弃),286及之后的CPU使用A20GATE控制A20的有效性,A20GATE为1时打开,0时关闭。保护模式下需要打开,将端口92的1位置1:
in al, 0x92
or al, 0000_0010B
out 0x92, al
0.5.4 CR0寄存器的PE位
CRx为控制寄存器,第0位PE位用于启用保护模式。


PE为0表示实模式下运行,1代表保护模式下运行。
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
1. 进入保护模式
在第三章中,我们最后有一个loader.bin,保护模式就是在这里进入的,不过在修改loader.S之前,需要先修改mbr.S,改为读入4个扇区(多腾点位置),另一个是include/boot.inc文件,存放一些配置信息。
mbr.S

include/boot.inc
LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2
;gdt描述符属性
DESC_G_4K equ 1_00000000000000000000000b ;下划线没有特别意义
DESC_D_32 equ 1_0000000000000000000000b
DESC_L equ 0_000000000000000000000b
DESC_AVL equ 0_00000000000000000000b
DESC_LIMIT_CODE2 equ 1111_0000000000000000b
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2
DESC_LIMIT_VIDEO2 equ 0000_0000000000000000b
DESC_P equ 1_000000000000000b
DESC_DPL_0 equ 00_0000000000000b
DESC_DPL_1 equ 01_0000000000000b
DESC_DPL_2 equ 10_0000000000000b
DESC_DPL_3 equ 11_0000000000000b
DESC_S_CODE equ 1_000000000000b
DESC_S_DATA equ DESC_S_CODE
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.S
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start
;构建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
GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ;预留60个描述符的空位
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ;相当于((CODE_DESC - GDT_BASE) / 8 << 3) + TI_GDT + RPL0 原文这里的注释感觉不对,没有左移
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
;gdt指针 前两字节是GDT界限,后四个字节是GDT基址 这里还是实模式下,dw是16位
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
loadermsg db '2 loader in real'
loader_start:
mov sp, LOADER_BASE_ADDR
mov bp, loadermsg
mov cx, 17
mov ax, 0x1301
mov bx, 0x001f
mov dx, 0x1800
int 0x10
;准备进入保护模式
;1.打开A20
;2.加载gdt
;3.cr0的pe为置1
in al, 0x92
or al, 0000_0010b
out 0x92, al
lgdt [gdt_ptr]
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
jmp dword 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:160], 'P' ;第二行打印字符'P',一行是80个字符
jmp $
nasm -I include/ -o mbr.bin mbr.S
dd if=mbr.bin of=/home/zcm/bochs/hd60M.img bs=512 count=1 conv=notrunc
nasm -I include/ -o loader.bin loader.S
dd if=loader.bin of=/home/zcm/bochs/hd60M.img bs=512 count=4 seek=2 conv=notrunc
书中把栈段当做数据段来处理,且扩展方向是向上扩展,段界限按照数据段的规则来检查。栈的向下增长与扩展方向无关,是与push等指令相关的,扩展方向只是用来配合段界限的。



这里PE大写表示置1。
注意到
jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线
[bits 32]
p_mode_start:
这两句之间本来顺序执行就行,为什么要加入跳转呢?尝试把jmp去掉


运行出错了。原因在于从实模式向保护模式转换时,由于流水线技术的原因,CPU会同时处理到16位和32位的指令,这样会出问题解决方法是用jmp指令清空流水线。
2.处理器微架构简介
以下基本上都是计组的内容,我就挑一些来记了。
2.1 流水线
之前jmp指令清空流水线的原因是CPU是按指令顺序填充流水线的,一旦碰到jmp指令,地址上紧邻的后续指令就无效了,因此要清空流水线。
2.2 乱序执行
指令间不具备相关性时可以乱序执行。x86是CISC架构,但后来也用上RISC内核了,译码除了分析机器码,还需要将指令拆分成多个RISC指令,这样有助于乱序执行。
2.3 缓存
CPU用SRAM做缓存,缓存可以存储指令或数据。缓存需要依据局部性原理,时间局部性,空间局部性。
2.4 分支预测
碰到分支指令时选择哪一个分支放到流水线上。预测方法有两位预测法,根据上一次的跳转结果来预测本次的跳转,用一个两位的计算器来记录状态,每跳转一次就加一,不跳转就减一,大于1时碰到跳转指令就跳,小于1就不跳。
intel使用BTB分支目标缓冲器来记录jmp指令的跳转信息,BTB中不存在的jmp指令就通过静态预测器(写死的预测策略)来预测,并在跳转后吧信息更新回BTB。

2.5 远跳转指令清空流水线,更新段描述符缓冲寄存器
这里用远转移的原因,主要是一下几点:
(1)段缓冲寄存器还是实模式下的状态,只有段基址而没有段属性,在不重新引用一个段时不会更新;
(2)CPU通过段描述符的D位来判断当前指令按多少位来译码,当CPU进入保护模式后,所有指令都需要编译成32位指令,比如添加0x66,0x67前缀,但由于段描述符缓冲寄存器中还没有更新,D还是0指示CPU将指令按16位处理,这时就会出错。
用远跳转指令既能重载段描述符缓冲器,又能清空流水线。
3. 内存段保护
3.1 段寄存器加载选择子时的保护
简单来说分为两个部分:
(1)检查选择子索引的范围是否合理;
(2)检查是否可以加载到目标寄存器,需要满足下表所示的约束

只有具备可执行属性的段(代码段)才能加载到CS中;
只具备可执行属性的段(代码段)不能加载到除CS以外的寄存器中;
只有具备可写属性的段(数据段)才能加载到SS中;
至少具备可读属性的段才能加载到DS、ES、FS、GS中;
3.2 代码段、数据段的保护
对数据段来说,访问的数据一定要落在段内,不能在段外或骑在段的边界上,对于代码段来说也同样如此。实际段界限的值是(描述符中的段界限 + 1)* (段界限粒度的大小:4k或1) - 1,4k粒度的公式为:描述符中的段界限 * 0x1000 + 0xfff,访问的数据需要满足:偏移地址 + 数据长度 - 1 <= 实际段界限的大小,访问的指令需要满足:EIP中的偏移地址 + 指令长度 - 1 <= 实际段界限的大小。如果不在范围内CPU会抛出异常。
3.3 栈段的保护
前面栈段用的是向上扩展的数据段,这里要介绍用向下扩展的数据段做为栈段。向下扩展的栈段是从高地址向低地址增长的,它的段界限与向上增长的不太一样,不需要减一,就是下边界上最后一个可访问字的下一个字。

这里看看就行,后面不会用到的,溜了。

浙公网安备 33010602011771号