在上一节中提到,加电重启后处理器处于实模式,它提供了8086环境。而现在所有的操作系统工作在另一种模式下工作:保护模式。Skelix从磁盘移动有也将进入到保护模式。本教程中,我们将在保护模式下打印“Hello World!”。
在实模式下,处理器只能存取区区1M内存,这是远远不够的。80386使用保护模式来提供权限保护和比实模式下大很多的内存空间。注意,这里的保护模式指的是32位,16位的保护模式我们不关心。
保护模式的第一个好处是给了我们存取4GB内存空间的能力,但是几十年的发展过来,我们的电脑上还没有4GB内存(这篇教程写于2006年,在21世纪的第二个年代,4GB内存已经没什么了不起了),所以将硬盘空间当成物理内存的虚拟内存机制被引进了。如上所述,它还提供了内存保护,它能防止用户程序改写内核代码,进程崩溃时也不会影响到整个系统。它允许每个进程都有存取它们的独立4GB内存空间。地址转换机制被引入以防止进程间的内存混乱,它允许进程用逻辑地址工作,在需要时内存管理单元会把逻辑地址转换成物理地址,通过这种方式每个进程都会认为它有4GB的独立空间。另外的细节你需要查询Intel的架构文档。
在保护模式下,令人惊讶的是,我们仍然要使用分段(实际上我们无论怎样都不肯禁用分段的),每个段都可以包含4GB的内存空间。段是用叫做选择子的寄存器来表示的,其实也就是实模式下的段寄存器像CS
,DS
等等。这么说吧,如果有一个值为0x08的CS
选择子来表示的内存段,我们就可能有能力直接存取0~4G-1字节的内存空间,我说“可能”是因为段的大小事可以选择的,不像实模式下始终是64KB大小。
我提到过段由选择子描述,这实际上是不准确的,实际上选择子是一个指向存储有系统可用的所有段信息列表的索引值。这些段信息叫做描述符,它包括段的基地址、段长、段的类型和权限之类。就想在实模式中那样,为了访问一个内存地址,段选择子和段内偏移必须组成一个选择子:偏移对。例如,我们可以让选择子0x8指向一个基地址为B8000的段,然后就可以用8:00000000来存取视频内存的第一个字节。这里有三个不同的系统表:GDT(全局描述符表)、LDT(局部描述符表)和IDT(中断描述符表)。一旦处理器处于保护模式中,所有的内存访问必须通过GDT或者LDT。
在本节中我们将使用GDT,像它名字说明的那样,它可以被所有的任务共享。我们会使用一个数据段和一个代码段。
每个描述符有64位长,这是数据/代码描述符的格式,
Limit(位 15-0) | 长度的低16位 | ||||||||
Base Address(位15-0) | 基地址的低16位 | ||||||||
Base Address(位23-16) | 基地址的中8位 | ||||||||
A | 存取信息,上一次存取是读(=0)还是写(=1) | ||||||||
Type |
| ||||||||
DPL | 描述符权限级,在Skelix中我们使用0级内核权限和3级用户权限 | ||||||||
P | 段是否存在,本教程中始终是1 | ||||||||
Limit(位19-16) | 长度的中8位 | ||||||||
U | 用户定义 | ||||||||
X | 未使用 | ||||||||
D | 指令和数据是32位(=1)还是16位(=0) | ||||||||
G | 长度单位是4KB还是1字节 | ||||||||
Base Address(位31-24) | 基地址的高8位 |
如你所见,一个描述符包括一个32位的基地址和20位的长度还有其它的一些属性,32位的基地址指出了段从哪里开始,20位的长度吃出了段的长度。然而,20位的长度只能表示2^20 = 1MB内存,为了表示4GB内存,描述符使用G位表示本段使用1字节还是4K字节的长度单位,也就是说如果G位为1的话,20位长度单位可以表示2^20*4K = 4GB内存。
权限保护正是保护模式名字的由来,为了解释它是怎么工作的,我们必须看一下选择子的格式。如上所述,选择子是描述符表的索引,
RPL | 请求权限等级 |
TI | GDT(=0)还是LDT(=1)的索引 |
Index | 索引值 |
程序的权限级别(PL)与存在寄存器CS
中选择子的RPL域值相同,一般来说它也等于当前权限等级(CPL)。在低权限等级的程序不能存取高权限等级的数据段,也不能执行一些特定的指令。当选择子载入寄存器时,处理器会检查RPL和CPL值,并使用较低的值作为生效权限等级(EPL),接着比较EPL和描述符中的DPL值,如果EPL有较高的权限等级,那么访问被许可@_@b。大约它是这样工作的,实际上它还要检查读/写属性,存在与否等等。
如你所见,索引为13位长,它可以表示2^13=8192 (#Bug 003: Arshad Hussain指出我原来算错数了)条描述符的表。
系统中只有一个GDT表,但每个进程都可以有它们自己的LDT。处理器保留了GDT表的第一条描述符,据手册所述它的值应为零并且不能被使用。但是,出题一会儿,它好像是可以被安全使用的,我似乎记得这种代码……
上一节教程中,我们从软盘启动了Skelix,有了实模式下执行代码的能力。我们必须执行模式转换以进入保护模式,并且Skelix不会再返回实模式下执行代码。在进入保护模式前,必须进行一些准备工作,首先必须建立一个GDT,
02/bootsect.s
gdt:
.quad
0x0000000000000000 # null descriptor
.quad
0x00cf9a000000ffff # cs
.quad
0x00cf92000000ffff # ds
.quad
0x0000000000000000 # reserved for further use
.quad
0x0000000000000000 # reserved for further use
GDT中有五条描述符,我们准备使用头3个。第一个是Intel要求的空描述符,第二个是为CS
准备的描述符,格式如下,
位15-0 | FFFFh | 长度的低16位 |
位39-16 | 000000h | 基地址的低24位 |
位40 | 0b | 设为0 |
位41 | 1b | 可读 |
位42 | 0b | confirming |
位43 | 1b | 代码段 |
位44 | 1b | 必须为1 |
位45,46 | 00b | 内核权限 |
位47 | 1b | 存在 |
位48-51 | Fh | 长度的中8位 |
位52 | 0b | 设为0 |
位53 | 0b | 设为0 |
位54 | 1b | 32位的指令和数据 |
位55 | 1b | 使用4KB长度单位 |
位63-56 | 00h | 基地址的高8位 |
所以第二条描述符指向一个开始于0000000、长度为FFFFF*4K = 4G字节存在于内存中的内核权限内存段,所有的数据和指令在本段中都是32位。第三个描述符用于数据和堆栈段,和数据段的区别在于为43,值为0意味着它是数据段。
现在我们把GDT准备好了,但怎么让处理器找到这个表呢?与实模式下相比较,保护模式下有几个新的寄存器可以使用,我们要用寄存器GDTR
,它使用指令LGDT
装入新值,
GDTR
有48字节长,它有一个字表示GDT长度,另一双字表示GDT的起始地址。
继续之前我们需要看一下02/include/kernel.inc
中定义的一些常量,
.set
CODE_SEL, 0x08 # code segment selector in kernel mode
选择子的二进制值是00001000,它指向GDT中的第二个描述符,即CS
段描述符。
.set
DATA_SEL, 0x10 # data segment selector in kernel mode
.set
IDT_ADDR, 0x80000 # IDT start address
我们把所有的系统信息放在固定的地址,IDT(下面的教程会介绍它)是这些信息的开始。
.set
IDT_SIZE, (256*8) # IDT has fixed length
.set
GDT_ADDR, (IDT_ADDR+IDT_SIZE)
# GDT starts after IDT
我们使用GDT_ADDR
而不是gdt
的地址,因为我们在进入保护模式之前要将所有的系统表移入固定地址,而7C000的内存区域将被覆盖。
.set
GDT_ENTRIES, 5 # GDT has 5 descriptors
# null descriptor
# cs segment descriptor for kernel
# ds segment descriptor for kernel
# current process tss
# current process ldt
Skelix将使用5个GDT中的描述符,我们已经介绍的头三个,后两个将在将来介绍。
.set
GDT_SIZE, (8*GDT_ENTRIES)
# GDT length
每个描述符有8字节长,所以整个GDT有8*GDT_ENTRIES
字节长。
.set
KERNEL_SECT, 72 # Kernel lenght, counted by sectors
因为内核大小大于一个512字节的扇区,因此我们设置一个值让启动代码知道多少扇区需要从磁盘上读取。
.set
STACK_BOT, 0xa0000 # stack starts at 640K
内核栈从640KB字节处开始,因为640KB向上会被其它硬件占用。
让我们看一下启动扇区的代码,02/bootsect.s
.text
.globl
start
.include
"kernel.inc"
.code16
start:
jmp
code
gdt:
.quad
0x0000000000000000 # null descriptor
.quad
0x00cf9a000000ffff # cs
.quad
0x00cf92000000ffff # ds
.quad
0x0000000000000000 # reserved for further use
.quad
0x0000000000000000 # reserved for further use
从02/include/kernel.inc
引入常量,并设置GDT。
gdt_48:
.word
.-gdt-1
.long
GDT_ADDR
code:
xorw
%ax
, %ax
movw
%ax
, %ds
# ds = 0x0000
movw
%ax
, %ss
# stack segment = 0x0000
movw
$0x1000,%sp
# arbitrary value
# used before pmode
将栈顶设为一个任意值,不让它们覆盖在7C00处的启动扇区代码就好。
## read rest of kernel to 0x10000
movw
$0x1000,%ax
movw
%ax
, %es
xorw
%bx
, %bx
# es:bs destination address
movw
$KERNEL_SECT,%cx
movw
$1, %si
# 0 is boot sector
rd_kern:
call
read_sect
addw
$512, %bx
incw
%si
loop
rd_kern
暂时读入剩下的内核代码到0x10000处,进入保护模式后,它们将会被移到地址0。没法解释read_sect
的细节,我也不打算作,你可以读着玩:)
cli
因为我们将进入保护模式,用CLI
禁止了所有的可屏蔽中断,所有的实模式下的中断都无法在保护模式下使用。
## move first 512 bytes of kernel to 0x0000
## it will move rest of kernel to 0x0200,
## that is, next to this sector
cld
movw
$0x1000,%ax
movw
%ax
, %ds
movw
$0x0000,%ax
movw
%ax
, %es
xorw
%si
, %si
xorw
%di
, %di
movw
$512>>2,%cx
rep
movsl
将新读到的512位内核代码移到地址0x0000,这些内核代码由02/load.s
编译产生,这个文件稍后介绍。这512位代码将把剩下的内核代码读入0x0200处,正处在第一个512字节之后。在本教程中,02/load.s
除了打印“Hello World!”外什么都不做。
xorw
%ax
, %ax
movw
%ax
, %ds
# reset ds to 0x0000
## move gdt
movw
$GDT_ADDR>>4,%ax
movw
%ax
, %es
movw
$gdt, %si
xorw
%di
, %di
movw
$GDT_SIZE>>2,%cx
rep
movsl
为了将来使用,将GDT移到地址GDT_ADDR
。
enable_a20:
## The Undocumented PC
inb
$0x64, %al
testb
$0x2, %al
jnz
enable_a20
movb
$0xdf, %al
outb
%al
, $0x64
《The Undocumented PC》中介绍了打开A20的方法。A20是键盘控制器中的一条总线(别问我Intel为啥把它放这里),启动后它处于关闭状态,将它打开就可以存取1MB以上内存
lgdt
gdt_48
将GDT描述符载入GDTR
。
## enter pmode
movl
%cr0
, %eax
orl
$0x1, %eax
movl
%eax
, %cr0
通过设置CR0
寄存器中的PE标志位,我们就可以让处理器处于保护模式。容易吧?如果我们忽略这些准备工作的话。
ljmp
$CODE_SEL, $0x0
现在我们处于保护模式下,但在做任何事之前,我们必须使用一个远转跳来清空预取指令序列,因为在里面的还是16位的指令,而我们要开始使用32位的指令和操作数了。ljmp
使处理器开始执行选择子0x8处,也就是GDT中的第二个描述符描述的地址0处的代码。记住我们已经将内核代码的头512字节移到此处了。
## in: ax: LBA address, starts from 0
## es:bx address for reading sector
read_sect:
pushw
%ax
pushw
%cx
pushw
%dx
pushw
%bx
movw
%si
, %ax
xorw
%dx
, %dx
movw
$18, %bx
# 18 sectors per track
# for floppy disk
divw
%bx
incw
%dx
movb
%dl
, %cl
# cl=sector number
xorw
%dx
, %dx
movw
$2, %bx
# 2 headers per track
# for floppy disk
divw
%bx
movb
%dl
, %dh
# head
xorb
%dl
, %dl
# driver
movb
%al
, %ch
# cylinder
popw
%bx
# save to es:bx
rp_read:
movb
$0x1, %al
# read 1 sector
movb
$0x2, %ah
int
$0x13
jc
rp_read
popw
%dx
popw
%cx
popw
%ax
ret
read_sect
读取磁盘扇区,ES
:DX
指向目标内存地址,SI
指向该读的扇区,例如0意味着启动扇区,CX
表示应读多少扇区。如果你确实想读这段代码,那就慢慢享受吧:)
.org
0x1fe, 0x90
.word
0xaa55
进入保护模式后,所有的寄存器仍然持有它们在实模式的值,代码此时有CPL值0,意味着我们有权限执行任何指令存取任何端口和内存地址。02/load.s
编译出的指令将在地址0处执行。
.text
.globl
pm_mode
.include
"kernel.inc"
.org
0
告诉连接器,本段代码将在逻辑地址0处执行,此处也就是物理地址0。
pm_mode:
movl
$DATA_SEL,%eax
movw
%ax
, %ds
movw
%ax
, %es
movw
%ax
, %fs
movw
%ax
, %gs
movw
%ax
, %ss
movl
$STACK_BOT,%esp
我们用选择子0x10载入数据和代码段,它指向GDT中的第三个描述子,RPL也是0。这一步非常重要,所有的段寄存器必须指向有效的描述符。
cld
movl
$0x10200,%esi
movl
$0x200, %edi
movl
$KERNEL_SECT<<7,%ecx
rep
movsl
把内核的其它部分移到02/load.s
编译成代码之后。(#Bug 1: Song Jiang指出我们应该移动KERNEL_SECT-1
个扇区而不是KERNEL_SECT
个,因为我们已经把第一个内核扇区移动到了0x0000。他是对的,但是KERNEL_SECT
只是个足够大的数字以让我们把所有的内核读入,所以继续使用KERNEL_SECT
是没有问题的)。
movb
$0x07, %al
movl
$msg, %esi
movl
$0xb8000,%edi
我们可以使用32位地址了!
1:
cmp
$0, (%esi
)
je
1f
movsb
stosb
jmp
1b
1: jmp
1b
msg:
.string
"Hello World!\x0"
用图片把这些移动说的更明白些吧,一开始启动扇区被载入00007C00,它把栈设置在00001000,接着它把剩余的内核读入00010000。之后,把包含着02/load.s
代码的内核的第一扇区移到地址0。图片1展示了这时的内存映像,
图片 1 | 图片 2 |
进入保护模式后,02/load.s
把剩余的内核移到它之后,并设置栈顶为A0000。图片2展示了这是的内存映像。
最后,看看Makefile,
02/Makefile
AS=as -Iinclude
-I
告诉汇编器在文件夹include
中寻找文件kernel.inc
。
LD=ld
KERNEL_OBJS= load.o
内核现在只包含由02/load.s
生成的代码。
.s.o:
${AS} -a $< -o $*.o >$*.map
all: final.img
final.img: bootsect kernel
cat bootsect kernel > final.img
@wc -c final.img
bootsect: bootsect.o
${LD} --oformat binary -N -e start -Ttext 0x7c00 -o bootsect $<
kernel: ${KERNEL_OBJS}
${LD} --oformat binary -N -e pm_mode -Ttext 0x0000 -o $@ ${KERNEL_OBJS}
@wc -c kernel
内核代码开始于地址0x0000。
clean:
rm -f *.img kernel bootsect *.o
产生final.img
再说一次“Hello World!",
你可以自由使用我的代码,如有疑问请联系我。