我们的目标

在上一节中提到,加电重启后处理器处于实模式,它提供了8086环境。而现在所有的操作系统工作在另一种模式下工作:保护模式。Skelix从磁盘移动有也将进入到保护模式。本教程中,我们将在保护模式下打印“Hello World!”。

下载代码


保护模式的优势

在实模式下,处理器只能存取区区1M内存,这是远远不够的。80386使用保护模式来提供权限保护和比实模式下大很多的内存空间。注意,这里的保护模式指的是32位,16位的保护模式我们不关心。

保护模式的第一个好处是给了我们存取4GB内存空间的能力,但是几十年的发展过来,我们的电脑上还没有4GB内存(这篇教程写于2006年,在21世纪的第二个年代,4GB内存已经没什么了不起了),所以将硬盘空间当成物理内存的虚拟内存机制被引进了。如上所述,它还提供了内存保护,它能防止用户程序改写内核代码,进程崩溃时也不会影响到整个系统。它允许每个进程都有存取它们的独立4GB内存空间。地址转换机制被引入以防止进程间的内存混乱,它允许进程用逻辑地址工作,在需要时内存管理单元会把逻辑地址转换成物理地址,通过这种方式每个进程都会认为它有4GB的独立空间。另外的细节你需要查询Intel的架构文档。


它是怎么工作的……大体上

在保护模式下,令人惊讶的是,我们仍然要使用分段(实际上我们无论怎样都不肯禁用分段的),每个段都可以包含4GB的内存空间。段是用叫做选择子的寄存器来表示的,其实也就是实模式下的段寄存器像CSDS等等。这么说吧,如果有一个值为0x08的CS选择子来表示的内存段,我们就可能有能力直接存取0~4G-1字节的内存空间,我说“可能”是因为段的大小事可以选择的,不像实模式下始终是64KB大小。

我提到过段由选择子描述,这实际上是不准确的,实际上选择子是一个指向存储有系统可用的所有段信息列表的索引值。这些段信息叫做描述符,它包括段的基地址、段长、段的类型和权限之类。就想在实模式中那样,为了访问一个内存地址,段选择子和段内偏移必须组成一个选择子:偏移对。例如,我们可以让选择子0x8指向一个基地址为B8000的段,然后就可以用8:00000000来存取视频内存的第一个字节。这里有三个不同的系统表:GDT(全局描述符表)、LDT(局部描述符表)和IDT(中断描述符表)。一旦处理器处于保护模式中,所有的内存访问必须通过GDT或者LDT。

在本节中我们将使用GDT,像它名字说明的那样,它可以被所有的任务共享。我们会使用一个数据段和一个代码段。

每个描述符有64位长,这是数据/代码描述符的格式,

XDT format
Limit(位 15-0)长度的低16位
Base Address(位15-0)基地址的低16位
Base Address(位23-16)基地址的中8位
A
存取信息,上一次存取是读(=0)还是写(=1)
Type
位41对数据/堆栈段而言,可写(=1);对代码段而言可读(=1)
位42对数据/堆栈段而言表示扩展方向,向下(=1);对代码段而言confirming(=1)
位43是代码段(=1)还是数据/堆栈段(=0)
位44对代码/数据段必须是1
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内存。

权限保护正是保护模式名字的由来,为了解释它是怎么工作的,我们必须看一下选择子的格式。如上所述,选择子是描述符表的索引,

Selector format
RPL请求权限等级
TIGDT(=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-0FFFFh长度的低16位
位39-16000000h基地址的低24位
位400b设为0
位411b可读
位420bconfirming
位431b代码段
位441b必须为1
位45,4600b内核权限
位471b存在
位48-51Fh长度的中8位
位520b设为0
位530b设为0
位541b32位的指令和数据
位551b使用4KB长度单位
位63-5600h基地址的高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


再说一次:“Hello World!”

进入保护模式后,所有的寄存器仍然持有它们在实模式的值,代码此时有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

movement 1

图片 2

movement 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

making process of tutorial02

再说一次“Hello World!",

hello world result


你可以自由使用我的代码,如有疑问请联系我