进入32位保护模式

有一说一 x86是有点恶心。

进入保护模式很简单,只需要将cr0寄存器的PE位置1就行了。但是进入之前以及进入后需要一些额外的工作。

16位实模式和32位保护模式有几点不同:

由于这些不同,在进入保护模式前我们需要一些准备工作。

设置GDTR寄存器

GDTR

gdtr是一个48位的寄存器,高32位用来存放段描述符地址,低16位用来存放段描述符的界限,由于段描述符占8个字节,所以低16位最多可以存放\(2^{16} \div 8 = 8192\)个段描述符,界限为(0~8191)。

GDT (Global Descriptor Table)

前面提到的段描述符就是对要使用段的描述,比如数据段ds可以给它一些属性,属性描述如下:

                                        high 32 bits

                31             24 23 22 21 20 19     16 15  14  13 12        7             0
                +----------------+--+--+--+--+--------+--+----+--+--------+----------------+
                | base 24~31     |G |DB|L |A |seg16~19|P |DPL |S |  TYPE  |   base 16~23   | 
                +----------------+--+--+--+--+--------+--+----+--+--------+----------------+

                                        low 32 bits

                31                                 16 15                                   0
                +------------------------------------+-------------------------------------+
                |            base 0~15               |            seg 0~15                 |
                +------------------------------------+-------------------------------------+

如你所见,基地址(base)和 段大小(seg)被分割成了好几块,实在是恶心(兼容性的锅)。下面解释下:

seg: 段大小,即段可用内存的范围(栈段向下),共20位
base: 基地址,共32位,被分割成了3份
G:粒度,如果为0,表示段以为1字节扩展,否则以4KB为单位扩展,由于段大小为20位,所以当G置0时最多能使用1MB内存,置1时则为4GB(\(2^{20} \cdot 4096\))
D/B: Default Operation Size 或 Upper Bound,表示是否用32位偏移地址、操作数、栈指针,总之32位保护模式总是置1就对了
L:留给64位用的,这里总是置0
A: Available, 多出来的位,CPU不用,留给OS用的,我们不用,总是置0
P:Segment Present,给CPU判断通过描述符访问的对象是否在内存中,我们还没用分页,总是在内存中,所以置1
DPL: Descriptor Privilege Level,表示描述符的特权等级,总共有4级特权(0~3)所以占两个位,某些指令只能由0特权的程序跑(比如OS),我们就是在写OS,所以总是用0特权级
S:表示描述符的类型,置1时表示代码段或数据段,置0时表示系统段和门描述符(同样根据TYPE细分)
TYPE:这个就有点复杂了,对数据段来说这4位分别表示

对代码段来说这4位分别表示

准备GDT

我们的bootloader会加载到0x7c00这里,并且这个bootloader会用掉512字节,我们会把GDT给放在这个0x7c00 + 512后面,即物理地址0x00007e00(当然GDT可以随便放,只要不影响使用)。这里我们会用到3个段,数据段、代码段、栈段,所以GDT应该有3个条目,但是,CPU规定第零个条目必须是空的,即填0,所以,实际上会有4个条目,一共占用 \(4 \cdot 8 = 32\) 字节,也就是说gdtr的低16位的界限就是31 (0~31共32字节)。

根据上面对段描述符的解释,我们可以得到,数据段的高位是 0x004920b 即: 段基地址的17~31位为0,16位为b(显示地址的最高位),
粒度是字节(G=1),
不是系统段(S=1),
是32位的段(D=1),
在内存中(P=1),
特权为0(DPL=00),
可读写的数据段(TYPE=0010),

这里我们直接把数据段其实放在了0xb8000(文本模式的显示地址)大小是64KB,所以低位是 0x8000ffff 画个图就是这样:

注意:第一个条目应该是空的,这里只是演示
         +-----------+ -> 0x00007e08
         | 0x004920b |
         +-----------+ -> 0x00007e04
         | 0x8000fff |
         +-----------+ -> 0x00007e00
         |bootloader |
         +-----------+ -> 0x00007c00
         ~           ~
         ~           ~
         |           |
         +-----------+ -> 0x00000000

同样的,我们可以得到代码段为:

high: 0x00409800
low : 0x7c0001ff

栈段为:

high: 0x00409600
low : 0x00007a00

所以准备GDT的代码就是:

; ds is set to logic base of 0x00007e00
; bx is set to offset of 0x00007e00

; empty descriptor
mov dword [bx+0x00], 0x0
mov dword [bx+0x04], 0x0

; data seg descriptor
mov dword [bx+0x08], 0x8000ffff
mov dword [bx+0x0c], 0x0040920b

; code seg descriptor
mov dword [bx+0x10], 0x7c0001ff
mov dword [bx+0x14], 0x00409800

; stack seg descriptor
mov dword [bx+0x18], 0x00007a00
mov dword [bx+0x1c], 0x00409600

设置GDTR

设置gdtr需要设置高32位的基地址和低16位的界限,这里我们把这两个放在一起

gdt_bound dw 0 ; 2 bytes
gdt_base dd 0x00007e00 ; 4 bytes

这连续的6个字节刚好可以放进gdtr,由于已经设置了基地址为0x00007e00这里只需要设置界限即可

mov word [cs:gdt_bound+0x7c00], 31

注意,这里必须加上0x7c00(因为bootloader代码地址在这里开始)。
接下来就是设置gdtr寄存器了:

lgdt [cs:gdt_boud+0x7c00]

这样lgdt就会从[cs:gdt_boud+0x7c00]出读取连续的6个字节,完成设置。

进入保护模式

只需要

mov eax, cr0
or eax, 1
mov cr0, eax

好了,现在就是保护模式了。接下来的代码必须按32位编译了,因此需要加上 bits 32,原因是32下指令的机器码都不一样了。

进入之前

前面提到,保护模式下访问内存的方式变了, 拿0x0000:0x7c00举例,以前是逻辑地址(段地址+偏移),现在是段选择器+偏移,同样的段寄存器,但使用方式变了。下面是段选择器(即扩展后段寄存器的低16位)的描述:

         15                    3  2    0 
         +----------------------+--+----+
         | descriptor index     |TI| RPL|
         +----------------------+--+----+

其中descriptor index即前文GDT中条目的索引,TI即 table indicator,为1时表示描述符在LDT(后面再说)中,为0时表示在GDT中,RPL表示特权,现在填0即可(即00)。

还有点需要注意的是,在进入32位保护模式前,我们的代码是按照16位模式编译的,在为进入时很多指令可能进到了流水线,一旦进到32位保护模式,这些已经缓存的指令还是16位的,这样会对后续的执行造成影响,甚至出错。所以,我们需要清空这些东西,最简单的办法就是做一个远跳转。

有了这些前置准备,下面可以写代码了

mov eax, cr0
or eax, 1
mov cr0, eax

jmp dword 0x0010:start

bits 32

start:
 ; do something

代码中段选择器是0x0010,按照上面的描述,换成2进制表示为0000_0000_0010_000表示代码段(见准备GDT处)的索引。


演示

Screenshot_20201220_201402.png

完整代码protect_mode.asm