第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地址空间。段转载在寄存器中,它表示一个描述符选择子,和实模式一样使用cs,ds等16位寄存器。这样说吧:一个内存段描述符寄存器 CS = 0x8,我们可以直接访问0到4G-1地址空间,注意我说的是“可以”,因为可以根据需要设置这个段有多大,而不是象实模式那样限制在64KB。
我上面提到段是用选择子来表示的,这个说法可能不是很准确,实际上选择子是段描述符表的索引。这个描述符表是系统所有可以使用的段的地址和范围表的入口,一个描述符包括段起始地址,长度,类型(数据/代码/门),特权级等。为了范围到特定的内存地址,段选择子和偏移地址表示为如下形式:selector:offset,和实模式一样。例如,我们让 0x08选择子指向B8000(视频内存区域) 开始的内存范围,这样我们可以使用8:00000000来范围视频内存区域的第一个字节。在系统中存在以下几种描述符表:GDT(全局描述符表),LDT(局部描述符表),IDT(中断描述符表)。当进入到保护模式后,所有的内存范围都通过GDT或LDT。
在本课中我们使用GDT,正如它的名字“全局”,GDT可以被所有任务共享。现在我们来使用一个代码段和一个数据段。
下面是代码段/数据段描述符的格式,一个描述符是8字节长(64位):
63_______________56__55__54__53__52__51_____________48_
| 基地址(31到24位) | G |D/B| X | U | 长度(19到16位) |
|_______________________________________________________|
_47__46__45__44____41______40____39_________________32_
| P | DPL | 类型
| A | 基地址(23到16位) |
|_______________________________________________________|
31____________________________________________________16
|
基地址(15到0位)
|
|_______________________________________________________|
16_____________________________________________________
|
长度(15到0位)
|
|_______________________________________________________|
解释一下:为什么长度只有20位呢,这是因为粒度一般设置位4K,所以可以表示0到4GB大小的长度范围。
表-域说明
长度(位 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值更高)不能访问高特权级的数据段或执行高特权级的代码段。当选择子载入到寄存器中时,处理器会检查CPL和RPL,根据这两个PL得到一个EPL(恕我直言,作者增加了一个新的概念并不明智),然后比较EPL和描述符中的DPL。当EPL的特权级更高时,才能正确访问目标段。注意,这里只是大致遵循该法则,处理器还要检测读写属性,存在位等。正如上面图所描述,选择子Index是13位的,所以最多可以索引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 # 用作将来的段描述符
可以看到,我们在上面定义了5个GDT描述符,但暂时只用到了第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,即4G的32位代码段。第3个描述符用于数据段或堆栈段,区别在于第43位,设置为0表示数据段。
好了,还是让程序的使用来说明一切吧。处理器有几个专门的寄存器用于保护模式,GDTR寄存器使用LGDT来加载,GDTR是48位寄存器,低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我们使用了5个GDT描述符,这里我们先介绍前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呢。