在不知道保护模式之前,让我们先看一段代码,如果没有接触过保护模式,会一头雾水,不过没关系,在这种好奇心的驱使下,会学的更高效。
; ==========================================
; pmtest1.asm
; 编译方法:nasm pmtest1.asm -o pmtest1.com
; ==========================================
%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 ; 非一致代码段, 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 位代码段描述符
; 旨在将LABEL_SEG_CODE32设为保护模式的基址
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] ; 将GdtPtr指示的6字节加载到寄存器gdtr,其中32为GDT基地址,16为GDT界限
; 关中断
cli
; 打开地址线A20,即第20号地址位(从0开始),超过了1MB,开机时默认关闭
; 只有打开才能寻1MB以外的空间
in al, 92h
or al, 00000010b
out 92h, al
; 准备切换到保护模式
; 将寄存器cr0的第0号位置设为1,相当于打开了保护模式的开关
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 * 10 + 0) * 2 ; 屏幕第 10 行, 第 0 列。
mov ah, 0Ch ; 0000: 黑底 1100: 红字
mov al, 'P'
mov [gs:edi], ax
; 到此停止
jmp $
SegCode32Len equ $ - LABEL_SEG_CODE32
; END of [SECTION .s32]
这段代码会将OS从实模式跳转到保护模式,然后在屏幕的第10行第0列输出黑底红字的P,让我们先运行下看看效果,这里使用的工具为:Ubuntu虚拟机 + NASM + Bochs + FreeDos。
最基础的,可以将上如代码编译为pmtest1.bin然后直接写入Boot Sector(引导扇区),这样做很方便,可以直接运行,不需要FreeDos。但是,有个很明显的缺点,就是空间有限,限制为512B,一旦代码量大了起来就不行了,所以不是长久之计。
解决上述问题有两个方法,一个是写一个Boot Sector,然后是它读取我们写的程序并运行,即引导我们写的OS内核,不过难度较大。第二个方法就是借助别的东西,比如DOS,我们把程序编译成COM文件,然后让DOS来执行它,这里,我们使用FreeDos来完成这个任务。
首先去Bochs官方下载一个FreeDos,主要用到其中的a.img。解压后,将a.img复制到工作目录,更名为freedos.img。
用bximage生成一个软盘镜像,起名为pm.img。
新建文件bochsrc,内容如下,重点为floppya、floppyb和boot:
megs:32
romimage:file=$BXSHARE/BIOS-bochs-latest
vgaromimage:file=$BXSHARE/VGABIOS-lgpl-latest
floppya:1_44=freedos.img,status=inserted
floppyb:1_44=pm.img,status=inserted
#软盘映射
boot:a
#硬盘
#boot:disk
#分配HD为60M,根据分配硬盘大小,cylinders, heads, spt会变化
#ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
#ata0-master: type=disk, mode=flat, path="boot.img", cylinders=121, heads=16, spt=63
log:bochsout.txt
使用bochs -f bochsrc启动Bochs,待FreeDos启动完毕后格式化B盘:

这里说下为什么要用Ubuntu,因为Linux的挂载(mount)操作可以将宿主目录和Dos中的镜像关联起来,轻易实现将目标文件复制到Dos运行的镜像中。首先,编译出.COM文件,然后将其复制到虚拟软盘pm.img上:
nasm pmtest1.asm -o pmtest1.com
sudo mount -o loop pm.img /mnt/floppy
sudo cp pmtest1.com /mnt/floppy/
sudo umount /mnt/floppy
然后到FreeDos中,执行命令:B:\pmtest1.com,就运行起来了,如图所示,显示一个红色的p。

好了,演示完毕,接下来开始介绍各部分代码的含义,由此了解保护模式,不过更重要的,是要先了解保护模式的一些历史。
初始保护模式
保护模式重在“保护”二字,顾名思义,其主要提供了安全上的服务,那它到底安全在哪?保护了什么?这个还在要从8086的16位寄存器时开始说起。
在Intel 8086中,CPU是16位的,它有着16位的寄存器、16位的数据总线以及20位的地址总线,共1MB的寻址空间,由于数据总线是少于地址总线的,所以物理地址是又段:偏移组成的,两者都是16位。
物理地址 = 段基址*16 + 偏移
上述模式就是我们后来所称的实模式,它的弊端是很大的:
1> 操作系统和用户程序属于同一特权级,平起平坐,没有区别;
2> 用户程序引用的地址直接为物理地址,即逻辑地址=物理地址,指哪打哪,没有限制很不安全;
3> 用户程序可以自由设定段基址,在1MB空间中随意访问,不安全;
4> 16位寄存器只能访问64KB,所以当访问的地址跨越64KB时,需要更改段基址,很麻烦;
5> 一次只能运行一个程序,无法充分利用计算机资源;
6> 最大的弊端,空间只有1MB,这在20年前就已经不够用了;
其中,(1)(2)(3)是安全方面的缺陷,没有安全可言的CPU是注定不可依赖的,它从本质上决定了程序乃至操作系统的数据有被随意删改的风险。(4)(5)是使用方面的缺陷,这个再20年前似乎还能忍受,但是随着计算需求的提升必会被淘汰。最硬伤的就是(6),1MB完全不够用,因此急需更高的寻址空间。
从80286开始,Intel的CPU进入32位时代,32位CPU具有两种运行模式,分别为实模式和保护模式,可以兼容8086时代的16位运行环境。实际上,在8086时代,16位CPU根本没有实模式的概念!当时的人用的只有16位CPU,他们从没想过自己习惯的模式还要重新命名。直到CPU发展到了32位,新的运行模式和之前不大相同,但不管怎么发展,都一定要满足一个原则:兼容。
也就是说,32位CPU具有两种模式,一种是为了克服8086运行模式弊端的新模式,即它自己的运行模式;另一种就是为了兼容8086运行模式而存在的,实模式。为了凸显前者的优势,将其称为保护模式。再强调一遍:
实模式是32CPU时才提出的概念,旨在兼容8086的16位运行模式
实模式的运行环境为16位,保护模式为32位。但是要注意的是,当PC运行在实模式下时,CPU依然是32位,硬件本身不会变!相当于高中生去做初中生的题,当它处在16位的运行模式时,依然可以处理32位的操作数,因为它在硬件层面上是32位的。也即:
实模式指32位CPU运行在16位模式下的状态,但CPU本身是32位的,依然可以处理32位操作数
了解完其基础概念后,来看看保护模式究竟从哪些方面得到了进化。
寄存器扩展
无论PC怎么发展,兼容都是最基本的要求,包括寄存器、访存方式、指令格式等的兼容。CPU发展到了32位后,地址总线和数据总线也发展到了32位,即寻址空间夸大到了4GB,一次可以处理的操作数升至32位。4GB的空间如果还用曾经的段:偏移法来寻址的话,段基址要左移16位,这显然很不合适。
最关键的,从设计思想上将,段基址本身就应该是某个内存段的起始地址,它不应该在应用前先做个处理。在原来的16位模式下,由于数据总线小于地址总线,所以迫不得已预先将段基址*16,才产生了这种蹩脚的方式。现在,数据总线和地址总线一样宽了,就可从根本上解决这个问题——扩展寄存器到32位。
寄存器要保持向下兼容,不能推翻之前的方案重来,因此要在原有16位的基础上向高位扩展16位,延申到32位,新的寄存器用前缀e表示扩展,即eax、ebx等等,如图:
可以看到,通用寄存器、标志寄存器、指令指针寄存器都扩展到了32位,但是段寄存器没有,仍然只有32位,这是因为段寄存器16位就够用了,这个后面会解释到。
上图中,寄存器的低16位是为了兼容实模式的,在任何模式下都可以单独使用,但是高16位不行,必须在引用32位寄存器时才能用到他们。
之前提到过,在80286之后,引入了保护模式的概念,大大提高了安全性,其中很重要的一部分,就是对内存段的描述。它改变了段基址的实质,引入了GRT(Global Descriptor Table)的概念。
GDT
保护模式下,偏移地址和实模式是一样的,但是段基址不再是简单的一个地址了。之前说过,实模式下段基址可以任意设定,随意访问空间内的任何地方,很不安全。因此为了更加安全,要给它加一些约束条件,这些约束条件就是对内存段的描述信息。由于信息太多了,所以专门找了个数据结构——全局描述符表,即GDT。该表很大,故放在了内存中,由GDTR寄存器指向它的首址,这个寄存器有6字节。
既然是表,那一定有表项,每一个表项就是一个段描述符,大小为8字节,用来描述一个内存段的段基址、段界限和段属性等等。注意,表项就是描述符,而不是描述符的索引。不过这里不会详细介绍,只浅谈一下即可,后面会专门开一篇博客专门介绍GDT和描述符。

这样,段寄存器中保存的再也不是段基址了,里面保存的内容叫选择子,selector。实际上,这个选择子就是个数,用这个数来索引GDT中的段描述符。把全局描述符当成数组,选择子就像数组下标一样。
1> 段描述符是在内存中的,访问会很耗时
2> 段描述符的格式很奇怪,CPU把这些七零八落的数拼凑起来也会耗时
既然访问内存中的段描述符很耗时间,那CPU如何提高效率呢?使用缓存。在80286的保护模式中,为了提高获取段信息的效率,对段寄存器率先应用了缓存技术,将段信息用一个寄存器来缓存,这就是段描述符缓冲寄存器。对程序员而言它是不可见的。CPU每次访问描述符并获取到内存段信息后,会将其存入上述缓冲寄存器中,下次访问相同段时,直接从中取就行了。
另外,虽然段描述符缓冲寄存器是保护模式下的产物,但是它也可以用在实模式下。在16为的实模式下,段基址左移4位这个操作也是耗时的。因此,CPU会将段基址左移后的结果存在上述寄存器中,以后每次引用这个段时,就直接走缓冲寄存器,直到换段,也就是段寄存器被重新赋值。
在进入代码解释之前按,先简单概括下描述符各属性的含义:
P位:存在位,P=1表示段在内存中存在;P=0表示在内存中不存在。
DPL:描述符特权级(Privilege Level),可以是0123,数字越小特权级越大。
S位:指明描述符是数据段/代码段(S=1)还是系统段/门描述符(S=0).
TYPE:描述符类型,详情见下表:
其中,当S=1时,TYPE<8对应数据段,>=8对应代码段;当S=0时,TYPE<8对应系统段,>=8对应门描述符。
G位:段界限粒度(Granularity)位。G=0时段界限为字节;G=1时段界限粒度为4KB。
D/B位:这一位比较复杂,分三种情况:
- 在可执行代码段描述符中,这一位叫做D位。D=1时,在默认情况下指令使用32位地址以及32位或8位操作数;D=0时,在默认情况下使用16位地址以及16位或8位操作数;
- 在向下扩展数据段的描述符中,这一位叫做B位。B=1时,段的上部界限为4GB;B=0时,段的上部界限为64KB;
- 在描述堆栈段(由ss寄存器指向的段)的描述符中,这一位叫做B位。B=1时,隐式的堆栈访问指令(如push、pop和call)使用32位堆栈指针寄存器esp;D=0时,隐式的堆栈访问指令(如push、pop和call)使用16位堆栈指针寄存器sp;
AVL位:保留位:可以被系统软件使用。
代码解释
好了,知道保护模式和GDT的相关基础后,可以开始通过解释文初的代码来进一步学习保护模式了。
代码一共分为三个部分,即三个段:[SECTION .gdt]、[SECTION .s16]、[SECTION .s32]。顾名思义,第一部分定义了GDT,第二部分为16位环境下运行的代码,即实模式,第三部分为32位环境下运行的代码,即保护模式。我们一个一个看。
[SECTION .gdt]
这个部分定义了一个数组,每一个元素都是一个Descriptor,很明显,这个数组就是GDT。其中,Descriptor是一个宏,这个宏用比较自动化的方法把段基址、段界限和段属性放入一个描述符中合适的位置,这个宏在pm.inc中,内容如下:
; 描述符
; usage: Descriptor Base, Limit, Attr
; Base: dd
; Limit: dd (low 20 bits available)
; Attr: dw (lower 4 bits of higher byte are always 0)
%macro Descriptor 3
dw %2 & 0FFFFh ; 段界限 1 (2 字节)
dw %1 & 0FFFFh ; 段基址 1 (2 字节)
db (%1 >> 16) & 0FFh ; 段基址 2 (1 字节)
dw ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; 属性 1 + 段界限 2 + 属性 2 (2 字节)
db (%1 >> 24) & 0FFh ; 段基址 3 (1 字节)
%endmacro ; 共 8 字节
代码的GDT中共有3个描述符,为方便起见,这里分别简称为DESC_DUMMY、DESC_CODE32和DESC_VIDEO。其中DESC_VIDEO的段基址是0B800h,这个地址是显存的起始地址,故该描述符指向了显存。
段基址和段界限都好说,来看下段属性。DESC_CODE32的属性为DA_C + DA_32,DESC_VIDEO的属性为DA_DRW。这些值都定义在pm.inc中:
;---------------------------------------------------------------
; 描述符类型值说明
; 其中:
; DA_ : Descriptor Attribute
; D : 数据段
; C : 代码段
; S : 系统段
; R : 只读
; RW : 读写
; A : 已访问
; 其它 : 可按照字面意思理解
;---------------------------------------------------------------
DA_32 EQU 4000h ; 32 位段
DA_DPL0 EQU 00h ; DPL = 0
DA_DPL1 EQU 20h ; DPL = 1
DA_DPL2 EQU 40h ; DPL = 2
DA_DPL3 EQU 60h ; DPL = 3
;---------------------------------------------------------------
; 存储段描述符类型值说明
;---------------------------------------------------------------
DA_DR EQU 90h ; 存在的只读数据段类型值
DA_DRW EQU 92h ; 存在的可读写数据段属性值
DA_DRWA EQU 93h ; 存在的已访问可读写数据段类型值
DA_C EQU 98h ; 存在的只执行代码段属性值
DA_CR EQU 9Ah ; 存在的可执行可读代码段属性值
DA_CCO EQU 9Ch ; 存在的只执行一致代码段属性值
DA_CCOR EQU 9Eh ; 存在的可执行可读一致代码段属性值
;---------------------------------------------------------------
; 系统段描述符类型值说明
;---------------------------------------------------------------
DA_LDT EQU 82h ; 局部描述符表段类型值
DA_TaskGate EQU 85h ; 任务门类型值
DA_386TSS EQU 89h ; 可用 386 任务状态段类型值
DA_386CGate EQU 8Ch ; 386 调用门类型值
DA_386IGate EQU 8Eh ; 386 中断门类型值
DA_386TGate EQU 8Fh ; 386 陷阱门类型值
;---------------------------------------------------------------
DA_C + DA_32的二进制为0100 0000 1001 1000,更具描述符TYPE的含义可知该内存段为存在的只执行的32位代码段,DPL为0。同理可知,DESC_VIDEO段为可读写数据段。
定义完GDT后,用变量GdtLen存储GDT的长度,结构变量GdtPtr存储GDT的长度和基地址。
随后,开始存储后两个描述符的选择子,可以看到,SelectorCode32和SelectorVideo分别存储了DESC_CODE32和DESC_VIDEO相对于GDT首址的偏移,这个数就是他们各自的选择子。实际上,选择子不止是一个偏移,它的结构要稍微复杂一些,这里不详细介绍。
数据结构GdtPtr是用来记录GDT界限和GDT基址的。前文提到过,GDTR寄存器要指向GDT的首址,先来看看这个寄存器的结构:

可以看到,这个寄存器和我们定义的GdtPtr结构是一样的,实际上,定义GdtPtr的目的就是将其赋值给GDTR。
[SECTION .s32]
这个部分为保护模式下要执行的代码,目的很简单,在指定位置打印一个红色的P。首先,把显存的内存段也就是描述符DESC_VIDEO的选择子写入段寄存器GS:
mov ax, SelectorVideo
mov gs, ax ; 视频段选择子(目的)
接着,把想要显示的位置写入edi,然后通过[gs:edi]要显示的东西写入显存的对应位置中:
mov edi, (80 * 10 + 0) * 2 ; 屏幕第 10 行, 第 0 列。
mov ah, 0Ch ; 0000: 黑底 1100: 红字
mov al, 'P'
mov [gs:edi], ax
保护模式运行的代码到此为止,使用一个死循环来停在此处即可。可以看到,这部分并不难。核心目的就是把字符写入显存对应的位置,不过由于保护模式用描述符来表示各个内存段,因此需要借助GDT来完成。
[SECTION .s16]
我们知道,当PC启动时,首先进入的是实模式,只有1MB的寻址空间,要转入保护模式,就需要另做一些操作,比如打开某些开关,然后jmp到保护模式,[SECTION .s16]就是在实模式中做这些事的。
在将cs、ds、es、ss相统一之后,开始初始化保护模式代码段描述符。因为最后要跳到保护模式所在的内存段(代码段),因此需要一个描述符来存储它,就是DESC_CODE32,不过由于描述符结构比较蹩脚,所以得分步来赋值。
; 初始化 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
初始化完该描述符后,开始加载GDT,即将GDT的首址和界限写入寄存器GDTR,期间要借助数据结构GdtPtr:
; 为加载 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]
最后一行的lgdt的作用是将以GdtPtr为首址的6字节加载到寄存器GDTR中。
接下来就是关中断,因为保护模式下中断处理的机制是不同的,不关掉会出错误:
; 关中断
cli
再下面几行的作用就是打开A20地址线,这里简单介绍下什么时候A20地址线。在8086中,地址线只有20位,空间为1MB,那么如果试图访问1MB之外的空间呢?系统并不会发生异常,而是回卷,重新从0地址开始。但是到了80286时,空间变成了4GB,访问1MB之外不再需要回卷了,这就造成了和旧模式的不兼容,怎么办呢?IBM想出一个办法,使用8042键盘控制器来控制第20号(从0开始)地址位,这就是A20地址线,如果不被打开,默认为0,地址会回卷。也即,A20是程序或OS能够访问到1MB之外的空间,可以认为是进入保护模式的第一步。代码通过操作端口92h来打开它:
; 打开地址线A20
in al, 92h
or al, 00000010b
out 92h, al
接下来,离最后的jmp就只差一步了,那就是打开保护模式的开关:
; 准备切换到保护模式
mov eax, cr0
or eax, 1
mov cr0, eax
上述代码很简单,就是把cr0寄存器的第0位设1。寄存器cr0的第0位是PE位,此位为0时,CPU运行于实模式,为1时,CPU运行于保护模式。因此,这一步打开了进入保护模式的开关。
此时,系统已经进入了保护模式,但是cs仍然是实模式的值,即依然停留在实模式的代码处。因此,需要将其更改为保护模式的代码段,那就是jmp。通过之前定义的DESC_CODE32的选择子SelectorCode32,来执行保护模式处的代码:
; 真正进入保护模式
jmp dword SelectorCode32:0 ; 执行这一句会把 SelectorCode32 装入 cs, 并跳转到 Code32Selector:0 处
总结
至此,成功进入了保护模式,下面总结一下进入保护模式的主要步骤:
1> 准备GDT;
2> 用lgdt加载gdtr;
3> 打开A20;
4> 置cr0的PE位,使系统处于保护模式;
5> 跳转,进入保护模式;