这是在读Orange'S之后,做的一些实验,以及自己的思考,和对书中一些知识的解释。如果没读过原文的话,看代码可能会有点难理解,但是原理部分是很清晰的。
从BIOS到MBR
在计算机启动过程中,首先会进入ROM上的BIOS,进行一系列计算机硬件自检后,加载第一个可启动存储设备(如你的硬盘)的第一个扇区的内容(大小为512bits)到0x7c00开始出的内存,并跳转到0x7c00处,执行该扇区内存储的指令。
一般情况下,这个扇区里的内容叫做MBR。这521bits一部分是代码,保存了将操作系统的代码从磁盘加载到内存的指令,另一部分是数据,保存了磁盘的分区信息。为了方便起见,我们先略过从磁盘加载代码到内存这一步,直接在“MBR”里写我们的程序。
实模式(16位模式)
当年Intel推出8086的时候,CPU是16位的,特点是:
- 寄存器是16位的
- 数据总线是16位的
- 地址总线是20位的(1M寻址能力)
比较重要的是它的寻址方式。8086中有好多段寄存器,如CS,SS,DS,GS,ES等,各个段的寻址方式相同,都是(<段寄存器> << 4) + 偏移量
,比如:
- 在执行代码的时候,当前指令地址为
CS << 4 + IP
。 - 在访问内存的时候(如在执行指令
mov ax, [bx]
)的时候,是将DS << 4 + bx
中保存的数据,加载到ax寄存器里。 - 执行
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 $
即进入一个死循环。
这段程序对应的内存模型为:
将这段程序编译好,写入到一个虚拟软盘里,并让模拟器以这个软盘为启动盘,我们就可以看到一行Hello, OS world!
输出到模拟器界面中了。
保护模式(32位模式)
后来Intel推出了80386,也就是我们现在所常说的x86 CPU,这种CPU是32位的,特点是:
- 寄存器是32位的
- 数据总线是32位的
- 地址总线是32位的
其寻址方式相较于16位比较复杂。我们需要引入两个名词GDT(全局描述符表)和Selector(段选择子)。
首先先直观地看一下,保护模式下段机制是如何发挥作用的。实模式下,段寄存器实际上就保存了段的基址,并且段的最大偏移量为0xffff。但是在保护模式下,段的基址、段最大长度都保存在GDT的表项中。这个表项称为段描述符,大小为64bits。一个GDT中可以有多个段描述符,每个描述符相对于GDT起始位置的偏移量即每个段对应的段选择子。在保护模式下,段寄存器保存的是段选择子。整个GDT是保存在内存中的,其内存地址通过lgdt指令加载到寄存器GDTR中。比如执行代码时,CS:EIP的寻址方式如下图:
我们来看一下段描述符的具体结构:
段基址、段界限不再赘述,段属性部分有点复杂,我们也先放放。这部分也是对段描述符有个直观的感受。
接下来,看一下如何在汇编代码中初始化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需要哪些步骤。这个时候,程序的内存模型为:
从实模式到保护模式
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