译自:http://www.brokenthorn.com/Resources/OSDev8.html
第8 章:保护模式
Mike, 2008
本系列文章旨在向您展示并说明如何从头开发一个操作系统。
欢迎 ! :)
在这个系列中我们已经涉及了很多内容。我们深入了解了引导加载器,系统体系结构,文件系统,及实模式下的寻址方式。酷——但我们还没看过32 位的世界,我们不是要编一个32 位操作系统吗?
在这章里,我们将完成这个跳跃——欢迎来到32 位的世界! 当然我们还没完成16 位世界的一切,但我们现在就进入保护模式是很容易的。
好了,让我们开始,本章包含以下内容:
准备好了吗?
为了使事情更符合面向对象理念,我把所有的输入 / 输出程序移到了 stdio.inc 文件中。不要把它理解成 C 语言里面的 stdio.lib 库,它们几乎没什么共同点。我们在开发内核内核时在编写标准库。
下面是这个文件:
;*************************************************
; stdio.inc
; -输入/输出程序
;
; OS Development Series
;*************************************************
%ifndef __STDIO_INC_67343546FDCC56AAB872_INCLUDED__
%define __STDIO_INC_67343546FDCC56AAB872_INCLUDED__
;************************************************;
; Puts16 ()
; -打印空终结字符串
; DS=>SI: 0终结的字符串
;************************************************;
bits 16
Puts16:
pusha ; 保存寄存器
.Loop1:
lodsb ; 从SI加载下一字节到AL
or al, al ; AL=0?
jz Puts16Done ; 是,结束,退出
mov ah, 0eh ; Nope-Print the character
int 10h ; 调用BIOS
jmp .Loop1 ; 重复直到字符串结束
Puts16Done:
popa ; 恢复寄存器
ret ; 完成,返回
%endif ;__STDIO_INC_67343546FDCC56AAB872_INCLUDED__
对于那些不知道的人—— *.INC 文件是包含文件。如果你需要可以在这个文件里增加你需要的内容。后面我将不再解释 puts16 函数——它和我在引导加载器中是一样的,除了使用了 pusha/popa 。
引导加载器很小。小却做完了所有需要的工作,要知道引导加载器的512 字节的限制,不多不少。 我们加载Stage 2 的代码已经差不多有512 字节了!几乎再没有办法减少了。
这是为什么我们的引导加载器只加载另一个程序。由于使用了FAT12 文件系统, 我们的第二段程序几乎可以包括任意数量的扇区。所以,我们不再受限于512 字节。这太好了,现在我们编写Stage 2 。
Stage2 引导加载器将会为内核准备好一切。和Windows 里的NTLDR (NT Loader) 相似。事实上,我把它称作KRNLDR ( 内核 Loader) 。Stage2 将会加载我们的内核,它的文件名是KRNLDR.SYS 。
KRNLDR--Stage2 引导加载器,会完成如下工作:
……以及其他。它弘扬会位向 C 语言这样的高级语言设置启动环境,实际上 Stage 2 加载器有很多 C 原因和汇编语言混合编程的机会。
如你所想——编写stage2 引导加载器本身就是一个很复杂的工作,并且也很难在没有一个已经可以运行的内核之前编写一个高级的引导加载器。因此,我们只关心那些重要的细节——上面加粗的部分。当我们有了一个可运行的内核时,我们再回过头来完善我们的引导加载器。
我们将会首先看看如何进入保护模式。我想你们已经按捺不住要进入32 位实际的欲望了——我忍不住了!
是时候了!你曾听我说过很多" 保护模式" ,并且在前面我们也做了一些详细的讨论。如你所知, 保护模式支持内存保护。通过定义如何使用内存,我们可以保证哪些内存位置不能修改,或者向代码一样执行。80x86 处理器使用全局描述符表(GDT) 来映射内存。如果我们并按照GDT 的描述使用内存,处理器将会产生一个般性保护错误 (GPF) ,又因为我们没有设置中断处理程序,最终会导致三重错误。
让我们仔细看看。
描述符表用于定义或映射一些东西——在我们这里是内存,以及如何使用内存。有三种类型的描述符表:全局描述符表(GDT), 局部描述符表(LDT), 和中断描述符表(IDT) ;其基地址保存在GDTR, LDTR, 和IDTR 寄存器中。 因为它们使用特殊的 寄存器,它们也使用特殊的指令。注意:有些指令只能在环0 内核级使用,如果在一般的环3 程序中使用会产生一般性保护错误 (GPF) ,这里,因为没有中断处理程序,最终会产生一个三重错误。
这很重要— 你在引导加载器和内核中都会见到这些代码。
全局描述符表(GDT) 定义全局 内存映射。它定义哪块内存可以执行 ( 代码描述符 ), 哪块包含数据( 数据描述符 ).
要知道描述符定义了哪些属性,以GDT 为例,它描述了起始地址和基地址,段限制,甚至虚内存。当我们实际使用时会很清楚,别担心:)
GDT 一般有3 个描述符——一个空描述符 ( 全0), 一个代码描述符 , 和一个数据 描述符 .
那什么是" 描述符" 呢? 以GDT 为例, " 描述符" 是一个8 字节大小的用于描述属性的值,格式如下:
好丑啊!通过上面的为模式, 8 个字节就能描述多种属性,每个描述符定义一个内存段的属性。
简单起见,我们定义这样一张表:包含代码段和数据段的描述符,从内存的第1 字节到0xFFFFFFFF 字节有读、写的权限。也就是说我们可以在内存的任意位置读或写。
看一下GDT:
; GDT的开始,偏移为0。
; 空描述符
dd 0 ;空描述符——用8字节0填充 dd 0
; 每个描述符绝对是8字节,这很重要。
; 因此代码描述符的偏移是0x8.
; 代码描述符: ; 代码描述符,正好在空描述符之后
dw 0FFFFh ; 限制低位
dw 0 ; 基地址低位
db 0 ; 基地址中间的一部分
db 10011010b ; 访问位
db 11001111b ; 粒度
db 0 ; 基地址高位
; 每个描述符都是8字节,所以数据描述符到GDT开始的偏移为0x10
; 数据描述符: ; 数据描述符
dw 0FFFFh ; 限制低位(和代码描述符一样)
dw 0 ; 基地址低位
db 0 ; 基地址中间的一部分
db 10010010b ; 访问位
db 11001111b ; 粒度
db 0 ; 基地址高位
这就是万恶的GDT 。这个GDT 有3 个描述符— 每个8 字节。空描述符, 代码段, 和数据段描述符。描述符的每一位都与上面的表直接对应。
让我们一位一位的看看,到底是什么意思。空描述符全0 ,我们只考虑后面的两个。
把代码分节分析
再看一下:
; 代码描述符: ; 代码描述符,正好在空描述符之后
dw 0FFFFh ; 限制低位
dw 0 ; 基地址低位
db 0 ; 基地址中间的一部分
db 10011010b ; 访问位
db 11001111b ; 粒度
db 0 ; 基地址高位
要知道,在汇编语言里,可以声明 byte, word, dword, qword, 指令 , 每个都在前一个的后面。上面 0xffff 就是两个每位都是 1 的字节,这样,我们就可以把上面的描述符转换为二进制:
11111111 11111111 00000000 00000000 00000000 10011010 11001111 00000000
( 上表中 ), Bits 0-15 ( 前两个字节 ) 代表段限制 。这表示如果我们使用一个超过 0xffff ( 前两个字节 ) 的地址,会发生 GPF 。
Bits 16-39 ( 下3 字节) 代表基地址( 段开始的地址) 的Bits 0-23 。这里是0x0 。 因为基地址是0x0 ,限制地址是0xFFFF ,代码选择子可以访问从0x0 到0xFFFF 的美个字节。 酷不酷?
下一字节 (Byte 6) 很有趣。我们一位一位的看:
db 10011010b
;各位
为
访问位很重要,我们要为 环 3 程序设置不同的描述符,当我们开始内核编程的时候在详细介绍。
放在一起,这个字节表示:这是一个可读可写的段,是一个代码段,处在环0 。
下一字节:
db 11001111b ; 粒度 db 0 ; 基地址高位
我们一位一位的看。
最后一字节是基( 起始) 地址的24-32 位— —当然是 0 。
完了!
数据描述符
好了——看看我们创建的GDT ,比较一下代码描述符和数据描述符:它们几乎一样,除了第43 为,再看看,你就知道为什么:如果这位为1 就是代码段,否则就是数据段。
总结
这是我见过的(写的)最容易理解的GDT 描述,很好,对吗?
对,是的,我知道,GDT 很丑。但用起来很简单——所以它又很漂亮!实际上你要做的一切就是加载这个地址的指针。
GDT 指针保存了GDT 的大小( 减1 ! ) 以及GDT 的起始地址。例如:
toc:
dw end_of_gdt - gdt_data - 1 ; 限制(GDT的大小)
dd gdt_data ; GDT的基地址
gdt_data 是 GDT 的开始处。 end_of_gdt GDT 结束处的标签。注意这个指针的大小。同样注意它的格式, GDT 指针必须是这个格式,复制会发生你不希望看到的结果——经常是一个三重错误 .
处理器使用一个特殊的寄存器—GDTR 来保存GDT 的基指针 ,将GDT 指针加载到GDTR 寄存器,我们会使用一个特殊的指令——LGDT (Load GDT) 。很简单:
lgdt [toc] ; 加载GDT到GDTR
不是开玩笑就是这么简单。
局部描述符表(LDT) 与GDT 很相似,用于特殊目的。它并不定义整个内存映射,它最多只能定义8,191 个内存段。我们后面再说,就像它在保护模式不存在一样。
这很重要,但还没到那儿。中断描述符表(IDT) 定义中断向量表 (IVT) 。它总在地址0x0 到0x3ff 。前32 个项链保留给处理器产生的硬件异常,例如, 一般性保护错误 , 或双重错误异常 。允许我们停止处理器,以免产生三重错误,后面会详细介绍。
其它的中断映射到主板上的可编程中断控制器 芯片,在保护模式下,我们要直接为这个芯片编程。后文有更详细的解说。
要知道 PMode ( 保护模式 ) 使用与实模式不同的寻址方式。实模式使用段:偏移的方式而 PMode 使用描述符 : 偏移 的方式 。
这说明,在保护模式下访问内存,我们要通过GDT 中正确的描述符,这个描述符保存在CS 里,这允许我们通过当前描述符间接引用内存。
例如, 我们想要从一个内存位置读数据,我们不需要描述要使用哪个描述符,我们只需要使用当前在CS 里的那个。这样就行:
mov bx, byte [0x1000]
很好,但是有时,我们需要引用一个特定的描述符。例如, 实模式不使用GDT ,而PMode 需要,所以当进入保护模式时,我们需要选择在保护模式里使用的描述符。毕竟实模式不知道GDT 是什么,所以CS 不可能包含一个正确的描述符 ,我们得设置它。
这样,我们直接设置描述符:
jmp
0x8:Stage2
你会很快再见到这段代码的。记住,前一个数是描述符 (PMode 使用描述符 : 地址的寻址方式 ) 。
你可能会好奇0x8 是哪儿来的。看看上面的GDT ,每个描述符8 字节,代码描述符距GDT 起点有8 字节,这里是描述符在GDT 的偏移地址 。
理解这种寻址方式很重要。
To enter 保护模式 is fairly simple. At the same time, it can be a complete pain. To enter 进入保护模式很简单,我们需要加载一个新的 GDT 用于描述访问内存的授权。我们需要直接将处理器切换到保护模式 , ,再跳到 32 位的世界,听起来很简单,你认为呢?
问题是,一点小错误就会产生三重错误。换言之,小心!
GDT 描述我们如何访问内存。如果我们不设置 GDT ,会使用默认的 GDT (BIOS (不是 ROM BIOS) ,设置的 ) 。像你想象的一样, BIOS 没有一个标准。并且,如果我们不注意 GDT 的限制 ( 如:向代码段一样访问数据段处理器会产生一个一般性保护错误 (GPF) ,因为没有设置中断处理处理器会产生一个双重错误——最终导致一个三重错误。
所以……我们得创建一张表,例如:
; GDT的开始,偏移为0。
gdt_data:
; 空描述符
dd 0 ;空描述符——用8字节0填充 dd 0
; 每个描述符绝对是8字节,这很重要。
; 因此代码描述符的偏移是0x8.
; 代码描述符: ; 代码描述符,正好在空描述符之后
dw 0FFFFh ; 限制低位
dw 0 ; 基地址低位
db 0 ; 基地址中间的一部分
db 10011010b ; 访问位
db 11001111b ; 粒度
db 0 ; 基地址高位
; 每个描述符都是8字节,所以数据描述符到GDT开始的偏移为0x10
; 数据描述符: ; 数据描述符
dw 0FFFFh ; 限制低位(和代码描述符一样)
dw 0 ; 基地址低位
db 0 ; 基地址中间的一部分
db 10010010b ; 访问位
db 11001111b ; 粒度
db 0 ; 基地址高位
;其他描述符从偏移0x18开始,每个描述符8字节
; 为环3程序,栈等,添加其他的描述符
end_of_gdt:
toc:
dw end_of_gdt - gdt_data - 1 ; 限制(GDT的大小)
dd gdt_data ; GDT的基地址
完成了,注意 toc ,它是指向这张表的指针,第 1 个字是 GDT 的大小 -1 ,第二个双字是 GDT 的实际地址,这个指针必须是这样的,别忘了,减 1 !
我们用只有在环0 可以使用的特殊指令——LGDT 加载GDT ( 的基地址指针) 到GDTR 寄存器。简单的,一行指令:
cli ; 先关中断!
lgdt [toc] ; 加载GDT到GDTR
sti
简单,现在就是保护模式了!,这里是 Gdt.inc 把丑陋的 GDT 隐藏起来了。
;*************************************************
; Gdt.inc
; -GDT程序
;
; OS Development Series
;*************************************************
%ifndef __GDT_INC_67343546FDCC56AAB872_INCLUDED__
%define __GDT_INC_67343546FDCC56AAB872_INCLUDED__
bits 16
;*******************************************
; InstallGDT()
; - Install our GDT
;*******************************************
InstallGDT:
cli ; 关中断
pusha ; 保存寄存器
lgdt [toc] ; 加载GDT到GDTR
sti ; 开中断
popa ; 恢复寄存器
ret ; 完成!
;*******************************************
; 全局描述符表(GDT)
;*******************************************
gdt_data:
; 空描述符
dd 0 ;空描述符——用8字节0填充 dd 0
; 代码描述符
dw 0FFFFh ; 限制低位
dw 0 ; 基地址低位
db 0 ; 基地址中间的一部分
db 10011010b ; 访问位
db 11001111b ; 粒度
db 0 ; 基地址高位
; 数据描述符
dw 0FFFFh ; 限制低位(和代码描述符一样)
dw 0 ; 基地址低位
db 0 ; 基地址中间的一部分
db 10010010b ; 访问位
db 11001111b ; 粒度
db 0 ; 基地址高位
end_of_gdt:
toc:
dw end_of_gdt - gdt_data - 1 ; 限制(GDT的大小)
dd gdt_data ; GDT的基地址
%endif ;__GDT_INC_67343546FDCC56AAB872_INCLUDED__
还记得CR0 寄存器的位模式吗? 什么?是这样的:
第0 位很重要。通过设置第0 位,处理器会改变工作模式进入到保护模式。
例子:
mov eax, cr0 ; 设置CR0的第0位——进入保护模式
or eax, 1
mov cr0, eax
如果第 0 位置 1 , Bochs 模拟器就会知道你进入到保护模式 (PMode) 了。
记住:如果你没有指出bits 32 代码就还是16 位是,在16 位的时候就可以使用段:偏移的寻址方式。
注意!在进入到32 位代码之前,确保将中断关闭!如果开启中断,处理器会发生三重错误。 ( 要知道我们不能在保护模式下访问IVT)
进入保护模式之后,我们马上就遇到了问题,在实模式下, 我们使用段:偏移 的寻址方式? 但是,在保护模式 我们使用描述符: 地址 的寻址方式。
同样,实模式不知道GDT 是什么,而PMode, 必须使用它。因此实模式下, CS 中是最后使用的段地址,不是描述符。
PMode 使用CS 保存当前代码描述符,为了修改CS( 设成我们的代码描述符) 我们需要一个远跳 。
我们的代码描述符0x8 ( 距GDT 开始有8 字节) ,像这样跳:
jmp 08h:Stage3 ; 设置CS.
代码描述符在0x8!
在保护模式里,我们需要重设所有的段 ( 因为不正确) 为正确的描述符。
mov ax, 0x10 ;设置数据段为数据描述符
mov ds, ax
mov ss, ax
mov es, ax
数据描述符距GDT 起始有16(0x10) 字节。
你可能会好奇为什么引用描述符时会使用偏移。偏移是什么呢? GDT 的指针被LGDT 指令给保存起来了。处理器的所有偏移地址都具有GDT 指针指向的基地址。
这是整个Stage 2 :
bits 16
; 参考前面的内存映射--0x500到0x7bffBIOS不使用; We are loaded at 0x500 (0x50:0)
org 0x500
jmp main ; 转到入口
;*******************************************************
; 预处理指令
;*******************************************************
%include "stdio.inc" ; 基本i/o程序
%include "Gdt.inc" ; Gdt程序
;*******************************************************
; 数据段
;*******************************************************
LoadingMsg db "Preparing to load operating system...", 0x0D, 0x0A, 0x00
;*******************************************************
; STAGE 2 入口点
;
; -存储BIOS信息
; -加载内核
; -安装GDT; 进入保护模式(pmode)
; -跳到Stage 3
;*******************************************************
main:
;-------------------------------;
; 设置段和栈 ;
;-------------------------------;
cli ; 关中断
xor ax, ax ; 空段
mov ds, ax
mov es, ax
mov ax, 0x9000 ; 栈在0x9000-0xffff
mov ss, ax
mov sp, 0xFFFF
sti ; 开中断
;-------------------------------;
; 打印加载信息 ;
;-------------------------------;
mov si, LoadingMsg
call Puts16
;-------------------------------;
; 安装 GDT ;
;-------------------------------;
call InstallGDT ; 安装 GDT
;-------------------------------;
; 进入保护模式 ;
;-------------------------------;
cli ; 关中断
mov eax, cr0 ; 设置cr0的第0位—进入保护模式
or eax, 1
mov cr0, eax
jmp 08h:Stage3 ; 设置CS
; 注意:不要再开中断!会发生三重错误!
; 我们会在Stage 3修好它。
;******************************************************
; STAGE 3入口点
;******************************************************
bits 32 ; 欢迎来到32位的世界!
Stage3:
;-------------------------------;
; 设置寄存器 ;
;-------------------------------;
mov ax, 0x10 ; 设置数据段 (0x10)
mov ds, ax
mov ss, ax
mov es, ax
mov esp, 90000h ; 栈段从90000h开始
;*******************************************************
; 停机
;*******************************************************
STOP:
cli
hlt
我很兴奋,你呢?在这章里,我们做了很多。我们讨论了 GDT, 描述符表 , 并且进入到了保护模式。
欢迎来到32 位的世界!
太棒了,大多数的编译器值产生32 位代码,所以进入保护模式是必要的,现在我们可以执行32 位程序了,我们可以使用几乎任何语言编写——C 或者汇编语言。
我们并没有讨论完16 位的内容。下一章里,我们会从BIOS 获取信息,并通过FAT12 加载内核,这也意味着,我们将会创建一个小的内核片段。酷!不是吗?
下次见