Orange'S:从实模式到保护模式

这是在读Orange'S之后,做的一些实验,以及自己的思考,和对书中一些知识的解释。如果没读过原文的话,看代码可能会有点难理解,但是原理部分是很清晰的。

从BIOS到MBR

在计算机启动过程中,首先会进入ROM上的BIOS,进行一系列计算机硬件自检后,加载第一个可启动存储设备(如你的硬盘)的第一个扇区的内容(大小为512bits)到0x7c00开始出的内存,并跳转到0x7c00处,执行该扇区内存储的指令。

一般情况下,这个扇区里的内容叫做MBR。这521bits一部分是代码,保存了将操作系统的代码从磁盘加载到内存的指令,另一部分是数据,保存了磁盘的分区信息。为了方便起见,我们先略过从磁盘加载代码到内存这一步,直接在“MBR”里写我们的程序。


实模式(16位模式)

当年Intel推出8086的时候,CPU是16位的,特点是:

  1. 寄存器是16位的
  2. 数据总线是16位的
  3. 地址总线是20位的(1M寻址能力)

比较重要的是它的寻址方式。8086中有好多段寄存器,如CS,SS,DS,GS,ES等,各个段的寻址方式相同,都是(<段寄存器> << 4) + 偏移量,比如:

  1. 在执行代码的时候,当前指令地址为CS << 4 + IP
  2. 在访问内存的时候(如在执行指令mov ax, [bx])的时候,是将DS << 4 + bx中保存的数据,加载到ax寄存器里。
  3. 执行push ax的时候,是将ax寄存器的值push到SS << 4 + sp中。

下面我们来写一段实模式下可被BIOS加载到内存执行的汇编代码:

; boot.asm

org    07c00h
mov    ax, cs
mov    ds, ax
call   DispStr
jmp    $

DispStr:
mov    ax, BootMessage
mov    bp, ax
mov    ax, 01301h
mov    cx, 16
mov    bx, 000ch
mov    dl, 0
int    10h
ret

BootMessage:
db        "Hello, OS world!"
time      510 - ($ - $$)    db    0
dw        0xaa55

org 07c00h告诉编译器,这段代码将被加载到某个段内偏移量为0x07c00处,编译的时候会相应的修改一些地址(如BootMessage这个label的地址)。同时要注意到,这段代码是段无关的,也就是,无论加载到哪个段内,只要这段代码的起始位置相对于这个段的基址偏移量为0x07c00,代码就能正常执行。

为了方便,我们将DS也置为CS,让数据段和代码段指向同一块地址。CS << 4 + BootMessage为BootMessage实际加载到内存时候的物理地址。我们让DS和CS相同,则DS << 4 + BootMessage也能对应到BootMessage在内存中的实际地址。

在DispStr中,int 10h为BIOS提供的一个中断,对应很多功能。在int 10h前的mov指令都是在填充int 10h这个中断的参数。ax可以理解为函数编号,表示我们所要调用的功能是将数据段中bp处(DS << 4 + bp)存储的字符串打印到屏幕上。bp存储BootMessage地址,cx存储字符串长度,bx存储打印字的颜色,dl是打印字符的位置。(关于int 10h的功能,请参考这里)

$在nasm中表示当前地址。jmp $即进入一个死循环。

这段程序对应的内存模型为:

Orange'S:从实模式到保护模式_第1张图片
boot.asm 内存模型

将这段程序编译好,写入到一个虚拟软盘里,并让模拟器以这个软盘为启动盘,我们就可以看到一行Hello, OS world!输出到模拟器界面中了。


保护模式(32位模式)

后来Intel推出了80386,也就是我们现在所常说的x86 CPU,这种CPU是32位的,特点是:

  1. 寄存器是32位的
  2. 数据总线是32位的
  3. 地址总线是32位的

其寻址方式相较于16位比较复杂。我们需要引入两个名词GDT(全局描述符表)和Selector(段选择子)。

首先先直观地看一下,保护模式下段机制是如何发挥作用的。实模式下,段寄存器实际上就保存了段的基址,并且段的最大偏移量为0xffff。但是在保护模式下,段的基址、段最大长度都保存在GDT的表项中。这个表项称为段描述符,大小为64bits。一个GDT中可以有多个段描述符,每个描述符相对于GDT起始位置的偏移量即每个段对应的段选择子。在保护模式下,段寄存器保存的是段选择子。整个GDT是保存在内存中的,其内存地址通过lgdt指令加载到寄存器GDTR中。比如执行代码时,CS:EIP的寻址方式如下图:

Orange'S:从实模式到保护模式_第2张图片
保护模式段机制寻址方式

我们来看一下段描述符的具体结构:

Orange'S:从实模式到保护模式_第3张图片
段描述符

段基址、段界限不再赘述,段属性部分有点复杂,我们也先放放。这部分也是对段描述符有个直观的感受。


接下来,看一下如何在汇编代码中初始化GDT:

[SECTION .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

GDTLen      equ  $ - LABEL_GDT
GDTPtr    dw  GDTLen - 1
          dd  0

SelectorCode32    equ  LABEL_DESC_CODE32 - LABEL_GDT
SelectorVideo     equ  LABEL_DESC_VIDEO - LABEL_GDT

[SECTION xxx]在nasm表示一个段的开始,在该段中,$$表示该段的起始位置。

Intel规定,第一个段描述符必须是一个空描述符。Descriptor是一个宏定义,它后面的三个参数表示段基址、段长度和段属性。这个宏会将这三者按照之前我们看到的段描述符的格式,填充64bits的内存。

第一个描述符中,属性DA_C+DA_32表示这是一个32位代码段。第二个描述符中属性DA_DRW表示这是一个可读可写的数据段。

GDTLen保存了GDT表的长度,GDTPtr保存了要加载到GDTR中的数据。GDTR为6bits,其中前两个字节是GDT的长度,后面四个字节表示GDT的起始位置。

注意到LABEL_DESC_CODE32的基址部分以及GDTPtr中的GDT基址部分临时填充为0,因为这两个地方需要的是物理地址,而我们在将其加载到内存之前,只知道其段内偏移,不知道其段基址,因此只能在程序中动态填充。


最后,我们的程序结构如下:

%include    "pm.inc"

org 07c00h
jmp LABEL_BEGIN

[SECTION .gdt]
... ...

[SECTION .s16]
[BITS 16]
LABEL_BEGIN:
    mov ax, cs
    mov ds ,ax
    ;; 1. 将LABEL_SEG_CODE32所表示的地址填入LABEL_DESC_CODE32对应位置
    ;; 2. 将LABEL_GDT所表示的地址填入GDTPtr对应的位置,并通过lgdt加载GDTPtr地址中的内容
    ;; 3. 由实模式转换为保护模式

[SECTION .s32]
[BITS 32]
LABEL_SEG_CODE32:
... ...

%include "pm.inc"是将pm.inc中的代码填充到该位置。pm.inc里面保存了一些宏定义,如GDT里面用到的Descriptor和段属性。nasm会根据[BITS 16][BITS 32]`,将代码编译为16位代码或32位代码。

其中1、2步是为了填充上面所说的两个基址,其中1处的代码为:

    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

其原理即为将CS << 4+ LABEL_SEG_CODE32(32位代码段实际物理地址的基址)填充到DS << 4 + LABEL_DESC_CODE32(该32位代码段描述符)相应的位置。第2步和第1步相似,就省略了。我们将在下一节讲解完成3需要哪些步骤。这个时候,程序的内存模型为:

Orange'S:从实模式到保护模式_第4张图片
程序内存模型

从实模式到保护模式

A20

在8086中,段最大为0xffff, 偏移最大为0xffff,因此能表示的最大地址是(0xFFFF << 4 + 0xFFFF) = 10FFEFh。但是8086只有20位的地址总线,只能寻址到1MB。超过的话,会自动回卷。但是到了80286,真的可以访问到1M以上的内存了,并不会回卷,这就导致了不兼容。为了保证兼容,IBM使用A20开关来控制。如果A20关闭,则第20位地址线永远是0。

我们通过操作92h信号,来打开A20

in    al, 92h
or    al, 00000010b
out   92h, al

cr0

通过将cr0的第一位(PE位)置为1,来打开保护模式。

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

gdtr

通过lgdt指令,载入GDTR寄存器。在内存中GDTPtr处(实际上是DS << 4 + GDTPtr)保存了GDTR所需要的数据

lgdt    [GDTPtr]

jmp

最后,我们通过一个神奇的jmp指令,完成从实模式到保护模式的转变。dword表示目标地址是32位地址。这个jmp指令会将CS置为SelectorCode32,同时EIP置为0,这样程序就可以从LABEL_SEG_CODE32处开始运行了。

要理解这个jmp,需要知道在x86中,段寄存器有16位的可见部分(如CS、DS)等和64位的不可见部分,CPU在实际的寻址过程中,其实是利用不可见部分进行寻址的。在执行jmp之前,由于CS没有改变,其不可见部分也不会改变,因此即使我们将cr0的PE位打开了,程序的执行顺序也不会发生改变。只有在jmp之后,CS发生改变,同时带动与CS相关的不可见部分发生改变,根据SelectorCode32所指向的段描述符,改变不可见部的段基址,我们的程序才算真正使用了段机制。

jmp    dword SelectorCode32:0

附录

完整的程序如下:

%include    "pm.inc"

org 0100h
LABEL_PROGRAM_START equ $
jmp LABEL_BEGIN

[SECTION .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

GDTLen  equ $ - LABEL_GDT
GDTPtr  dw  GDTLen - 1
        dd  0

SelectorCode32  equ LABEL_DESC_CODE32 - LABEL_GDT
SelectorVideo   equ LABEL_DESC_VIDEO - LABEL_GDT

[SECTION .s16]
[BITS 16]
LABEL_BEGIN:
    mov ax, cs
    mov ds ,ax
    ;; 1. 将LABEL_SEG_CODE32所表示的地址填入LABEL_DESC_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    
    ;; 2. 将LABEL_GDT所表示的地址填入GDTPtr对应的位置,并通过lgdt加载GDTPtr地址中的内容
    xor eax, eax
    mov ax, cs
    shl eax, 4
    add eax, LABEL_GDT
    mov dword [GDTPtr + 2], eax

    xchg    bx, bx

    lgdt    [GDTPtr]
    ;; 3. 打开A20开关
    in  al, 92h
    or  al, 00000010b
    out 92h, al
    ;; 4. 将cr0寄存器置位1
    mov eax, cr0
    or  eax, 1
    mov cr0, eax
    ;; 5. 通过jmp,实现到保护模式的跳转
    jmp dword SelectorCode32:0

[SECTION .s32]
[BITS 32]
LABEL_SEG_CODE32:

    jmp $

SegCode32Len    equ $ - LABEL_SEG_CODE32

你可能感兴趣的:(Orange'S:从实模式到保护模式)