打开APP
userphoto
未登录

开通VIP,畅享免费电子书等14项超值服

开通VIP
自己动手写内核(第2课:保护模式)(原创)
第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处,但是在这一课,我们只准备打印"HelloWorld!",除此之外什么都不做。
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"
.org0                    #告诉加载器,该代码将从逻辑地址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呢。
本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请点击举报
打开APP,阅读全文并永久保存 查看更多类似文章
猜你喜欢
类似文章
【热】打开小程序,算一算2024你的财运
linux 0.11 内核学习
从头开始编写操作系统(9) 第8章:保护模式 - xiaoxiaoyaya的专栏 - CS...
NASM汇编笔记
计算机内存地址转换与分段
总结一下linux中的分段机制
Linux0.00 “boot.s” 程序详解
更多类似文章 >>
生活服务
热点新闻
分享 收藏 导长图 关注 下载文章
绑定账号成功
后续可登录账号畅享VIP特权!
如果VIP功能使用有故障,
可点击这里联系客服!

联系客服