第三章 保护模式
非常困难、内容非常多的一章。这一章的学习流程大致:
1.从实模式到保护模式的跳转。熟悉保护模式下寻址流程。GDT的实现,段选择子的实现,熟悉GDT中段描述符的格式,尤其是属性一项。
2.在GDT中,添加一个LDT。
3.从保护模式如何跳转回实模式?
4.CPL,RPL,DPL的关系,深入理解“保护”二字的意义
5.从0优先级,跳转到3优先级,体会“门”、TSS的详细意义
6.开启分页机制,熟悉页目录表基址寄存器cr3,以及页目录表-PDE,页表-PTE,以及32位CPU为什么最大寻址4GB。
下面就每一点说说自己的学习体会:
1.从实模式到保护模式的跳转,熟悉保护模式下寻址流程。GDT的实现,段选择子的实现。
这在上一篇博客中详细说明了。
要弄清楚跳转,就要弄清实模式和保护模式下的区别。
1.实模式下,用CS:IP的方式获得指令地址,然后取指,执行。保护模式下,CS中存放的是“代码段段选择子”,IP继续存放偏移量。所以跳转时一定要把CS里放进“代码段段选择子”。怎么放呢?
使用 jmp dowrd 代码段段选择子:00000000,就可以更改CS中的内容了。
2.可是,CPU怎么知道jmp语句执行时,是保护模式还是实模式?更改32位的cr0寄存器(0号控制寄存器),因此,jmp之前,一定要这样做:
MOV EAX, CR0
OR EAX,1
MOV CR0,EAX
当CR0的最低位为0——CPU在实模式下工作
当CR0的最低位为1——CPU在保护模式下工作
3.在Jmp 段选择子:0这句话之前,应该先把段选择子和对应的段描述符的内容填好。怎么填?看了这段代码,你会一目了然:
段描述符的宏实现:
%macro Descriptor 3
dw %2 & 0FFFFh ; 段界限1
dw %1 & 0FFFFh ; 段基址1
db (%1 >> 16) & 0FFh ; 段基址2
dw ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; 属性1 + 段界限2 + 属性2
db (%1 >> 24) & 0FFh ; 段基址3
%endmacro ; 共 8 字节
GDT的实现:建立了一个很普通的section,里面放着至关重要的GDT
[SECTION .gdt]
; GDT
; 段基址, 段界限, 属性
LABEL_GDT: Descriptor 0, 0, 0 ; 空描述符
LABEL_DESC_1: Descriptor 0, 0FFFFH, 如何如何 ; 非一致代码段
LABEL_DESC_2: Descriptor 0B8000h, 0FFFFH, 如何如何 ; 显存首地址
; GDT 结束
段选择子
Selector1 equ LABEL_DESC_1 - LABEL_GDT
Selector2 equ LABEL_DESC_2 - LABEL_GDT
这就是他们的真正实现
4.冷门知识:A20地址线的问题/跳转之前要关中断/段描述符的填充方法
把《Orange's》书中,完整的代码贴出来。关键的部分看懂了,别的部分多看几次就知道啦。
; ==========================================
; pmtest1.asm
; 编译方法:nasm pmtest1.asm -o pmtest1.bin
; ==========================================
%include "pm.inc" ; 常量, 宏, 以及一些说明
org 07c00h
jmp LABEL_BEGIN
[SECTION .gdt]
; GDT
; 段基址, 段界限 , 属性
LABEL_GDT: Descriptor 0, 0, 0 ; 空描述符
LABEL_DESC_CODE32: Descriptor 0, SegCode32Len - 1, DA_C + DA_32; 非一致代码段
LABEL_DESC_VIDEO: Descriptor 0B8000h, 0ffffh, DA_DRW ; 显存首地址
; GDT 结束
GdtLen equ $ - LABEL_GDT ; GDT长度
GdtPtr dw GdtLen - 1 ; GDT界限
dd 0 ; GDT基地址
; GDT 选择子
SelectorCode32 equ LABEL_DESC_CODE32 - LABEL_GDT
SelectorVideo equ LABEL_DESC_VIDEO - LABEL_GDT
; END of [SECTION .gdt]
[SECTION .s16]
[BITS 16]
LABEL_BEGIN:
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0100h
; 初始化 32 位代码段描述符
xor eax, eax
mov ax, cs
shl eax, 4
add eax, LABEL_SEG_CODE32
mov word [LABEL_DESC_CODE32 + 2], ax
shr eax, 16
mov byte [LABEL_DESC_CODE32 + 4], al
mov byte [LABEL_DESC_CODE32 + 7], ah
; 为加载 GDTR 作准备
xor eax, eax
mov ax, ds
shl eax, 4
add eax, LABEL_GDT ; eax <- gdt 基地址
mov dword [GdtPtr + 2], eax ; [GdtPtr + 2] <- gdt 基地址
; 加载 GDTR
lgdt [GdtPtr]
; 关中断
cli
; 打开地址线A20
in al, 92h
or al, 00000010b
out 92h, al
; 准备切换到保护模式
mov eax, cr0
or eax, 1
mov cr0, eax
; 真正进入保护模式
jmp dword SelectorCode32:0 ; 执行这一句会把 SelectorCode32 装入 cs,
; 并跳转到 Code32Selector:0 处
; END of [SECTION .s16]
[SECTION .s32]; 32 位代码段. 由实模式跳入.
[BITS 32]
LABEL_SEG_CODE32:
mov ax, SelectorVideo
mov gs, ax ; 视频段选择子(目的)
mov edi, (80 * 11 + 79) * 2 ; 屏幕第 11 行, 第 79 列。
mov ah, 0Ch ; 0000: 黑底 1100: 红字
mov al, 'P'
mov [gs:edi], ax
; 到此停止
jmp $
SegCode32Len equ $ - LABEL_SEG_CODE32
; END of [SECTION .s32]
2.添加一个LDT
在有了GDT之后,为什么还要LDT?一个是global(全局的),一个是local(本地的)
我的理解:GDT是给系统进程/内核这样的“重要”进程使用的;如果所有的进程都是用同一描述符表,一定会造成管理的混乱。所以,每个进程应该有自己的描述符表,即GDT。
3.如何从保护模式跳回实模式?
要学会跳回实模式,就必须要透彻懂得实模式和保护模式的区别。大概可以概括为以下几点:
1.段寄存器中放的是段选择子,不再是段基址。
2.A20地址线打开了
3.cr0的最后一位变成1了
4.一旦跳到保护模式之后,就找不到以前的CS(代码段寄存器)了。(这个问题非常难解决,下面谈)
5.实模式下,不能设置段属性(在此不得不提,每一个段寄存器其实都是96位的,下面谈)
只要把这些问题逐一解决就好了。
谈几个疑难事件
1.不能从32位代码段跳回实模式,只能从16位代码段跳回去,为什么?
不信的同学可以试试,这句话是真的。原因在下个问题中解决。
2.技术细节:如果想跳回16位代码段,需要在GDT中多写一个"默认"描述符,为什么?
在此不得不提,段寄存器(CS/ES/DS/SS)本质上都是96位的,然而我们只能控制其中的16位,即WORD Selector;这部分,剩下的80位,是CPU自动将对应段描述符中的内容载入进去的。载入的目的是,加快运行速度,不必每次都到GDT中去寻找。
Struct Segment
{
WORD Selector; 16位段选择子(实模式下的段基址)
WORD Attribute; 16位属性
DWORD Base; 32位段基地址(保护模式下的基地址)
DWORD Limit; 32位段界限
};
我们在保护模式下,会出于各种各样的目的,为不同的段设置不同的段属性。比如有的段Limit可能是0FFFFFFH,有的段可能是一致/非一致代码段,有的段可能优先级是1/2/3级(优先级默认0级,实模式下都是0级),我们就改变了CS/ES/DS/SS寄存器中其余80位的内容。如果我们在16位代码段中,不管不顾,直接跳回实模式,那就可能会把错误的设置带回实模式——而实模式下是不能改变段属性/段界限/段基址的。
所以要写一个默认的段描述符
LABEL_DESC_NORMAL: Descriptor 0(实模式下默认段基址), 0ffffh(实模式下默认段界限), DA_DRW(实模式下默认段属性) ; Normal 描述符 段界限是64K
在跳转之前,把这个段描述符赋给段寄存器
mov ax, SelectorNormal
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
然而我们这样做,不能改变CS寄存器中后80位的内容:mov cs,ax是不被允许的,能改变cs的只有jmp和call指令。所以,我们必须要从32位保护模式代码段中跳入一个16位代码段中,再从这个16位代码段中跳回实模式。
4.CPL,RPL,DPL的关系,深入理解“保护”二字的意义
保护模式的麻烦——特权级校验,终于来了。在实模式下,畅快访问所有段的日子已经一去不复返了。这部分只是庞杂冗余,决心做一次大的整理。
优先级(Privilege Level):保护模式下,每一个段都有一个优先级(不仅仅代码段有优先级,数据段,堆栈段都有)。共分ring0 ring1 ring2 ring3四个等级,其中0级优先级最高,3级优先级最低。内核代码段一般是0级,用户代码一般是3级。
一致代码段和非一致代码段 找到一篇好的博客,可以看看。-
CPL:Current Privilege Level,当前优先级。通常等于当前所在代码段的DPL。
DPL:Descriptor Privilege Level,描述符优先级。存储在段描述符中。每一个段对应一个段描述符,每一个段描述符都有一个DPL,这个DPL即代表这个段的优先级。
RPL:Requested Privilege Level,请求优先级。存储在段选择子中。
每次访问其他段(包括跳转到其它代码段和访问数据段和使用堆栈段),CPU都会把CPL、RPL和目标段DPL比较。比较的策略随着情况不同而变化。如果校验通过,就可以访问,如果校验不通过,就会引发错误。
Gate:即门,门描述符的简称。分为调用门/中断门/陷阱门/任务门。
问题一:校验的策略究竟有多少种花样?
在不调用门的情况下,如果目标代码段是:
一致代码段,校验规则:CPL>=目标段的DPL,RPL不要求。
非一致代码段,校验规则:CPL==目标段DPL,RPL<=目标段DPL。
调用门时:(盗图一张)
在使用调用门时,校验分为2大部分:CPL/RPL和调用门的DPL比较;CPL/RPL和目标段的DPL比较
借此可以实现从低优先级到高优先级的转换。
问题二:为什么有了CPL,还要有RPL?感觉很没用啊。
这个问题请看转载的这篇博客……
问题三:当Jmp指令执行的时候,究竟发生了什么?
jmp大致可以分为2大类:
1.jmp 选择子:偏移量被称为直接转移,丝毫不绕弯。直接转移时,CPL是不会变的。
2.jmp 包含选择子的谜之事物:偏移量间接转移,选择子被包裹起来
选择子被什么包裹起来了?大致分为3类:
1.包含目标代码段选择子的call gate descriptor
2.包含目标代码段选择子的TSS(Task State Segment 任务状态段)
3.任务门,这个门指向一个TSS,TSS中有着选择子。(相当于第2种情况外边又包了一层任务门)
目标代码段的selector会被加载到cs中。在加载过程中进行段界限/类型/权限校验,如果校验成功,cs加载。
问题四:为什么要有门(Gate)这种打破规则的东西?
只通过jmp/call这样的直接转移,CPL是不会变的——活动范围实在太小了。为了扩大活动范围又不失安全性,创立了门。
在面对问题五之前,我想先介绍一个小技巧:实模式所有段都在最高优先级0,我们如果想实验从低优先级爬到高优先级,就要先想办法把自己降落下去。——使用ret(返回)指令
问题五:特权级发生变化时,堆栈会发生变换是怎么一回事?
为了防止不同特权级,有不同的堆栈。因为优先级有4个,所以堆栈也有对应的4个。
与此同时,TSS也要做啊!