天衣有缝

冠盖满京华,斯人独憔悴~
posts - 35, comments - 115, trackbacks - 0, articles - 0
   :: 首页 :: 新随笔 :: 联系 :: 聚合  :: 管理

2课:保护模式


声明:转载请保留:

译者http://www.cppblog.com/jinglexy

原作者:xiaoming.mo at skelix dot org

MSN & Email: jinglexy at yahoo dot com dot cn

目标        下载源程序

 

如前文所述,系统上电时处理器处于实模式。事实上,它还有另外一种工作模式:保护模式。skelix从磁盘启动后即进入该模式。在本课中我们进入保护模式并打印"Hello World!"



保护模式的优点

在实模式下,处理器不能简单寻址1MB以外的物理地址(实际上用某些方法是可以的),这等内存实在是太少了。所以i386系列处理器提供了保护模式:基于特权级的保护和访问更大的内存地址范围。我们在这里讲的是32位保护模式,16位保护模式不在讨论之列。

保护模式最大的好处就是可以直接范围最大4GB的地址空间,但是经过多年的更新换代,我们的机器还没有达到4GB内存,于是引入了虚拟内存的概念,它可以使用硬盘存储空间作为内存使用。保护模式下对内存访问进行保护,它阻止用户程序对内核代码或数据的访问,应用程序的crash也不会影响到整个系统。单个进程可以访问自己独有的4GB虚拟地址空间,而不是混乱在整个内存里面使用,它是通过地址映射来实现的,即逻辑地址转换成虚拟地址的过程。更详细的内容可以参考Intel的文档。


概述运行原理

好了,让我们结束无聊的理论知识吧,本课的目的是使我们的程序进入到保护模式。在保护模式中,我们仍然使用段(事实上,我们无法在处理器上禁用段特性),每个段可以访问单独的4GB地址空间。段转载在寄存器中,它表示一个描述符选择子,和实模式一样使用csds16位寄存器。这样说吧:一个内存段描述符寄存器 CS = 0x8,我们可以直接访问04G-1地址空间,注意我说的是可以,因为可以根据需要设置这个段有多大,而不是象实模式那样限制在64KB

 

我上面提到段是用选择子来表示的,这个说法可能不是很准确,实际上选择子是段描述符表的索引。这个描述符表是系统所有可以使用的段的地址和范围表的入口,一个描述符包括段起始地址,长度,类型(数据/代码/门),特权级等。为了范围到特定的内存地址,段选择子和偏移地址表示为如下形式:selector:offset,和实模式一样。例如,我们让 0x08选择子指向B8000(视频内存区域) 开始的内存范围,这样我们可以使用8:00000000来范围视频内存区域的第一个字节。在系统中存在以下几种描述符表:GDT(全局描述符表),LDT(局部描述符表),IDT(中断描述符表)。当进入到保护模式后,所有的内存范围都通过GDTLDT

在本课中我们使用GDT,正如它的名字全局GDT可以被所有任务共享。现在我们来使用一个代码段和一个数据段。

下面是代码段/数据段描述符的格式,一个描述符是8字节长(64位):

 

 63_______________56__55__54__53__52__51_____________48_

基地址(3124位) | G |D/B| X | U | 长度(1916位) |

|_______________________________________________________|

 

 _47__46__45__44____41______40____39_________________32_

| P |  DPL |     类型    |  A   |  基地址(2316位)   |

|_______________________________________________________|

 

 31____________________________________________________16

|                    基地址(150位)                  |

|_______________________________________________________|

 

 16_____________________________________________________

|                    长度(150位)                    |

|_______________________________________________________|

 

解释一下:为什么长度只有20位呢,这是因为粒度一般设置位4K,所以可以表示04GB大小的长度范围。

 

 

                                   -域说明

 

长度( 15-0)

长度的低16

基地址( 15-0)

基地址的低16

基地址( 23-16)

基地址的中16

A

是否已访问

类型

41

对于数据/堆栈段,为1表示可写,为0表示只读

对于代码段,为1表示可读可执行,为0表示不可读可执行

42

对于代码段,为0是一般段,为1是一致性代码段。对于数据段,为0表示数据段,为1表示堆栈段。

43

1表示代码段,0表示数据或堆栈段

44

1表示代码或数据段,0表示系统段(中断门,调用门,陷阱门)

DPL

特权级:我们只使用两个,内核0级和用户3

P

存在位,为1表示在内存中。一般在虚拟内存管理中会使用到这个位。

长度( 19-16)

长度的低8

U

用户定义位

X

恒为0

D

32位代码段还是16位代码段

G

段长度的粒度:4k大小或1字节

基地址( 31-24)

基地址的高16

 

我们从上面看到,一个描述符保护32位基地址和20位段长界限等属性。32位基地址表示32位物理地址,是一个段的开始地址,20位长度界限表示这个段的长度。读者可能注意到2^20只能表示1M大小范围。为了访问4GB地址范围,描述符中使用了G位来表示粒度。当G位为1时,粒度为4K,这是可以访问的范围是1M * 4K,即4GB大小;如果G为为0,粒度为1字节,可以访问的范围是1M字节大小。

 

特权级保护是保护模式的重要概念,为了解释这个,我们来看一下描述符选择子。上面已经提到了,选择子是描述符表的一个索引:

 

 15______________________________3___2____1___0__

|              Index              | TI |   RPL  |

|_______________________________________________|

 

RPL

请求特权级:requester privilege level

TI

使用 GDT(=0) 或者 LDT(=1)

Index

描述符表索引值

 

应用程序特权级(PL)和 cs寄存器中的PL(即RPL)是类似的。程序在低的特权级(即PL值更高)不能访问高特权级的数据段或执行高特权级的代码段。当选择子载入到寄存器中时,处理器会检查CPLRPL,根据这两个PL得到一个EPL(恕我直言,作者增加了一个新的概念并不明智),然后比较EPL和描述符中的DPL。当EPL的特权级更高时,才能正确访问目标段。注意,这里只是大致遵循该法则,处理器还要检测读写属性,存在位等。正如上面图所描述,选择子Index13位的,所以最多可以索引2^13个描述符,即8096个。这只是在GDT中最多索引的描述符个数,另外每个进程都可以有自己的LDT。处理器会保留第一个GDT中的描述符,它应当被清0,不应当用作访问内存使用。


进入保护模式

在上一课中,我们从软盘启动skelix。现在我们可以执行到实模式代码,并进入保护模式了,一些模式切换的代码必不可少,并且不准备让skelix在返回到那黑暗时代-实模式了。在进入保护模式之前,需要做一些准备工作,我们先创建GDT

02/bootsect.s

 

gdt:
        .quad    0x0000000000000000 # 
空描述符
        .quad    0x00cf9a000000ffff # cs
        .quad    0x00cf92000000ffff # ds
        .quad    0x0000000000000000 #
用作将来的段描述符
        .quad    0x0000000000000000 #
用作将来的段描述符

可以看到,我们在上面定义了5GDT描述符,但暂时只用到了第2个和第3个。第一个dummy描述符是Intel规定的,第2个是cs段(代码段)描述符,下面我们仔细分析一下这个8字节值:(红色表示cs描述符的值域)

 

Bits 15-0

FFFFh

长度界限低16

Bits 39-16

000000h

段基地址低24

Bit  40

0b

访问位:设置为0

Bit  41

1b

/写,或读/执行(值表示可读可执行代码

Bit  42

0b

栈还是数据段,普通代码段还是一致代码段

Bit  43

1b

代码段还是数据段

Bit  44

1b

代码数据段,还是门描述符

Bits 45,46

00b

内核特权级

Bit  47

1b

存在位

Bits 48-51

Fh

长度界限高4

Bits 52

0b

软件可用位,设置为0

Bits 53

0b

设置为恒0

Bits 54

1b

32位段还是16位段

Bits 55

1b

粒度为4k还是1字节

Bits 63-56

00h

段基地址高8

根据上面的解释,这个段描述符描述的段从00000000地址开始,界限是FFFFF*4K,即4G32位代码段。第3个描述符用于数据段或堆栈段,区别在于第43位,设置为0表示数据段。


好了,还是让程序的使用来说明一切吧。处理器有几个专门的寄存器用于保护模式,GDTR寄存器使用LGDT来加载,GDTR48位寄存器,低16位表示GDT的长度,高32位表示GDT的基地址。

 

02/bootsect.s

gdt_48:
        .word  .-gdt-1        
当前地址减gdt地址减1得到GDT的长度
        .long  GDT_ADDR       
这里使用了一些常量,如GDT_ADDR,定义在一个头文件中

02/include/kernel.inc

.set CODE_SEL, 0x08        # 内核代码段选择子,二进制值是00001000,表示GDT的第2项(索引值为1
.set DATA_SEL, 0x10        #
内核代码段选择子
.set IDT_ADDR, 0x80000     # IDT
起始地址

我们将所有数据设置为固定地址,IDT表(后面课程会介绍到)是所有数据的起始部分。

.set IDT_SIZE, (256*8)     # IDT 大小
.set GDT_ADDR, (IDT_ADDR+IDT_SIZE)        # GDT
IDT的后面

我们用GDT_ADDR,而不是用bootsector.s文件中的gdt符合,是因为在进入保护模式后7c00地址将被覆盖,于是我们把系统中用到的一些表搬移到固定地址。


.set GDT_ENTRIES, 5        # GDT 
5个描述符
                           # 
空描述符
                           # 
内核代码段描述符
                           #
内核数据段描述符
                           #
当前进程tss
                           #
当前进程ldt

skelix我们使用了5GDT描述符,这里我们先介绍前3个,最后两个将会在后面的课程中介绍。
.set GDT_SIZE, (8*GDT_ENTRIES)
                           # GDT
大小,每个描述符是8个字节大小,所以GDT大小是该值,但是我们用的并不是它
.set KERNEL_SECT, 72       #
内核大小,单位是,36k对于现在来说已经足够了
.set STACK_BOT, 0xa0000    #
堆栈从640K 内存处开始向下增长,应该是STACK_TOP才对?

 

下载我们来看一下引导程序
02/bootsect.s

        .text
        .globl    start
        .include "kernel.inc"
include the above file
        .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
gdt_48:
        .word    .-gdt-1
        .long    GDT_ADDR
code:
        xorw    %ax,    %ax
        movw    %ax,    %ds    # 
数据段 = 0x0000
        movw    %ax,    %ss    # 
堆栈段 = 0x0000
        movw    $0x1000,%sp    #
保护模式前用的堆栈,不要让他覆盖到7c00处的引导程序即可

        # 我们将加载内核到地址 0x10000
        movw    $0x1000,%ax
        movw    %ax,    %es
        xorw    %bx,    %bx    # es:bx 
加载内核的目标地址
        movw    $KERNEL_SECT,%cx
        movw    $1,     %si    # 0
,跳过去,所以是1
rd_kern:
        call    read_sect      #
入口参数:si是起始扇区数,es:bx是指定内存地址
        addw    $512,    %bx
        incw    %si
        loop    rd_kern

我们先把内核读到0x10000这个临时地址,然后再把它搬移到0x0(进入保护模式后搬移)。这个函数讲起来有些烦,读者可以自己分析:)


        cli                    #
就要进入保护模式了,所以关掉实模式下的中断

        cld                    # 将内核的前512字节移到0x0
        movw    $0x1000,%ax
        movw    %ax,    %ds
        movw    $0x0000,%ax
        movw    %ax,    %es
        xorw    %si,    %si
        xorw    %di,    %di
        movw    $512>>2,%cx
        rep
        movsl

为什么要这样做?因为内核的这个部分是load.s这个文件编译出来的(本课后面会介绍到),load.s会读取真正的内核0x200处,但是在这一课,我们只准备打印"Hello World!",除此之外什么都不做。

        xorw    %ax,    %ax
        movw    %ax,    %ds    #
复位 ds  0x0000


        movw    $GDT_ADDR>>4,%ax       # (0x80000 + 256 * 8) >> 2
        movw    %ax,    %es            # gdt
所在的数据段
        movw    $gdt,   %si
        xorw    %di,    %di            # 
ds:si 拷贝到 es:di
        movw    $GDT_SIZE>>2,%cx       #
拷贝数据段中的gdt到指定地址
        rep
        movsl

enable_a20:        
        inb    $0x64,   %al   
        testb  $0x2,    %al
        jnz    enable_a20

        movb   $0xbf,   %al
        outb   %al,     $0x64

这种开启a20地址线的方法来自一本书:"The Undocumented PC",中文纸版是《PC技术内幕》,可惜已绝版。a20地址线通过键盘控制器一个端口使能(ibm早期这样设计),当系统启动时,该地址线是关闭的,使能它之后才能访问1MB以外的地址空间。

        lgdt    gdt_48                 # 加载gdt地址到寄存器中
        #
进入保护模式
        movl   %cr0,    %eax
        orl    $0x1,    %eax
        movl   %eax,    %cr0           #
使能CR0 控制寄存器中的PE位(即第0位)

现在我们已经进入到保护模式了,是不是简单的另你不敢相信?呵呵


        ljmp   $CODE_SEL, $0x0

我们还需要进行一个绝对地址跳转,因为解码管线中预取了16位指令,需要刷新成后面的32位指令。关于ia32的指令预取和解码管线,网络上有很多相关的文章,建议读者阅读一下相关文章。这个指令跳转到0x08描述符选择子指向的偏移0x的指令处,并开始执行,这个描述符即GDT中的第2项:内核代码段描述符。代码就是load.s的开始处,一会我们开始分析load.s这个程序。

 

 

bootsector.s中的函数:

        # 输入:    si:    LBA 地址,从0开始
        # 
输出     es:bx  读取扇区到这个内存地址
read_sect:
        pushw   %ax
        pushw   %cx
        pushw   %dx
        pushw   %bx

        movw    %si,    %ax       
        xorw    %dx,    %dx
        movw    $18,    %bx    #
对于1.44M软盘:每磁道18扇区


        divw    %bx
        incw    %dx
        movb    %dl,    %cl    # cl =
扇区号
        xorw    %dx,    %dx
        movw    $2,     %bx    #
每磁道2磁头
        divw    %bx

        movb    %dl,    %dh    # 
磁头
        xorb    %dl,    %dl    # 
软驱号
        movb    %al,    %ch    #
柱面

        popw    %bx            # 读取到:es:bx
rp_read:
        movb    $0x1,   %al    #
1个扇区
        movb    $0x2,   %ah
        int     $0x13
        jc      rp_read
        popw    %dx
        popw    %cx
        popw    %ax
        ret

 

.org    0x1fe,  0x90              # 填充nop指令,机器码是0x90
.word   0xaa55

当我们进入到保护模式后,所有的通用寄存器和段寄存器保持原来实模式的值,代码段从特权级0开始执行。load.s文件将从地址0处开始执行。
02/load.s

        .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的第3项,特权级为0。这个步骤非常重要!

        cld
        movl    $0x10200,%esi     #
bootsector程序中,我们将内核加载到了0x10200这个地址
        movl    $0x200, %edi      #
现在把内核搬移到0x200
        movl    $KERNEL_SECT<<7,%ecx        #
拷贝2^7次方个,注意下面是movsl,每次4个字节
        rep
        movsl


        movb    $0x07, %al                  #
颜色
        movl    $msg,  %esi
        movl    $0xb8000,%edi
1:
        cmp     $0,    (%esi)               #
打印"Hello World!"字符串
        je      1f
        movsb
        stosb
        jmp     1b
1:      jmp     1b
msg:
                .string "Hello World!\x0"

现在我们用图来清晰的描述它,引导程序被加载在00007c00,它设置栈顶在00001000,然后读取内核到00001000,然后把内核映象的前一个sector(即load.s)程序读到地址0。在load.s程序中移到内核到地址0

 

        1                                        2

|                   |                    |___________________|a0000

|                   |                    |       内核栈      |

|       GDT         |                    |                   |

|       IDT         |                    |       GDT/IDT     |

|___________________| 8000:系统数据     |___________________|80000

|                   |                    |                   |

|                   |                    |                   |

|                   |                    |                   |

|       内核        |                    |                   |

|                   |                    |                   |

|___________________|10000               |                   |

|                   |                    |                   |

|                   |                    |                   |

|___________________|7e00                |                   |

|     bootsector.s  |                    |                   |

|___________________|7c00                |                   |

|                   |                    |                   |

|                   |                    |___________________|

|                   |                    |                   |

|                   |                    |                   |

|___________________|1000                |       内核        |

|      stack        |                    |                   |

|___________________|200                 |___________________|200

|      load.s       |                    |                   |

|___________________|0                   |___________________|0


当进入到保护模式后,load.s移到内核到它后面,设置内核栈,如图2

最后,我们翻开Makefile看看:
02/Makefile

 

AS=as -Iinclude            -I选项告诉汇编工具查找头文件的路径
LD=ld

KERNEL_OBJS= load.o        
到现在为止,内核只保护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

执行make,用vmware运行一下刚才的image看看,是不是hello world呢。

 

Feedback

# re: 自己动手写内核(第2课:保护模式)(原创)  回复  更多评论   

2007-07-13 10:09 by way
set CODE_SEL, 0x08 # 内核代码段选择子
.set DATA_SEL, 0x10 # 内核数据段选择子
kernel.inc中有点小问题
请问这里为何知道选择子分别是0x08及0x10
谢谢回答

# re: 自己动手写内核(第2课:保护模式)(原创)[未登录]  回复  更多评论   

2007-07-13 12:12 by 天衣有缝
0x08的二进制值是1000,0x10的二进制值是10000
第三位是属性(一位表示gdt或者ldt,两位表示特权级)
高13为是在gdt或ldt中的偏移,所以CODE_SEL和DATA_SEL分别表示偏移1项和偏移两项。
可以在intel网站上找到ia32/64的三卷手册下载,里面有详细的描述。

# re: 自己动手写内核(第2课:保护模式)(原创)  回复  更多评论   

2007-09-30 15:32 by way
请问以你的设计来讲code,data段都是0~4G的寻址
然后你并没有用data段,这两个段完全重迭又没有paging
所以进入保护模式这里将“ljmp $CODE_SEL, $0x0”
改为“ljmp $DATA_SEL, $0x0”是不是效果完全一样呢?
这样还会正常运作吗?我看很多资料都没提到为何要重迭
这问题也困扰我很久了
谢谢解答

只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理