萝卜甲@ZJU 2004 yujiazi@gmail.com
转载请保留以上信息
经过昨天的热身准备,今天可以进入一些比较细节的内容了。在开始设计前首先要熟悉一下x86的硬件体系,怎么说在Intel的平台下混只好这样啦。
在Intel PC处理器80386之前的都是属于玩具型的,没有大地址寻址,没有硬件保护,没有虚拟存储,没有分页机制,不可能支持现代操作系统。所以我们的目标是80386以上的CPU,从80386到现在最新的Pentium IV,体系结构都是差不多的,无非是速度快了,缓存加了,修修补补,增加了了一些新的功能。下面我们就来看一下Intel的保护模式机制,对了,在介绍Intel保护模式之前,你最好能搞到一份Intel IA-32体系结构手册(IA-32 Intel Architechure Software Developer's Manual),三卷装,第三卷System Programming对Intel的IA-32机制讲得很详细。不过想想Intel用整整780页描述的内容我们今天一天要把它搞定,真是。。。。。很寒啊。没有接触过保护模式的xdjm们准备好了吗?一定要坚定信念,顽强拼搏,义无反顾。。。。来吧!
保护模式对于没有接触过的人来说可能第一次接触很是让人困惑,他的核心机制其实是一个二级地址转换机制。在IA-32体系结构下存在的有三个地址空间的概念: 虚拟地址空间(又称逻辑地址空间),线性地址空间,物理地址空间。还有两个转换机制:段机制和分页机制。虚拟地址通过段机制转换成线性地址,线性地址通过分页机制转换成物理地址。如下:
虚拟地址 ---------> 线性地址 ---------> 物理地址
段机制 分页机制
先来看看最简单也最容易理解的物理地址空间吧,物理地址空间所对应的地址就是真正的物理内存空间,IA-32体系结构的物理地址是一个32-bit的数值,所以他的最大物理地址寻址就是0 到 2^32 - 1 共4G空间,比如你有一个物理地址0x00010000,这个地址就是你内存中1M的那个位置。
线性地址空间是一个想象的地址空间,32位,从0 到 2^32 - 1,即 0x00000000 到 0xFFFFFFFF,跟物理地址空间是一样的性质。线性地址空间通过分页转换机制转换成物理地址。
最后来看看虚拟地址空间,虚拟地址就是程序给出的地址,在IA-32,一个虚拟地址是48-bit的数值,其中16位为段地址,32位为段内偏移地址,比如有一个mov eax,ds:[eax],16位的ds就是段地址(其实是段选择子Selector,下面详细介绍),eax里面给出的就是32-bit偏移。这里顺便说一下386以后的Intel CPU寄存器的设置,8086下16-bit的ax,bx,cx,dx,si,di,bp,sp,ip,flag被扩展成了32-bit的eax,ebx,ecx,edx,esi,edi,ebp,esp,eip,eflag。cs,ds,es,ss的还是保持了16-bit,但是他们的意义已经变了,还增加了两个段寄存器fs和gs。另外IA-32下还有很多其他的寄存器,比如控制寄存器CR0,CR1,CR2,CR3,CR4用来控制CPU的工作状态,调试寄存器DR0 - DR7用来调试等。
OK,下面进入了比较实质性的内容,让我们来看看比较容易头晕的段机制,看看一个虚拟地址是怎么被转换成线性地址的。在8086下写过汇编的应该知道, 虚拟地址(20-bit) = 段的基地址(16-bit 左移4位,为20-bit) + 段内偏移(16-bit), 但是8086没有任何地址转换机制,所以这个20-bit的虚拟地址就是物理地址了。保护模式下的线性地址是由段的线性基地址(32-bit) + 段内偏移(32-bit)形成的,但是注意,这个段的线性基地址(Base Address)不是由段寄存器直接给出的,而是段寄存器指出一个特定的8个字节的一个结构,由这个结构给出该段的线性基地址,Intel把该结构称为段描述符(Segment Descriptor)。
这个8个字节的段描述符结构到底里面保存了哪些东西呢?第一,该段的线性基地址,就是说这个段在线性空间中从哪个位置开始,相当于8086下的段地址,只不过这个地址是32位的。第二,该段的长度,这个应该很好理解,如果一个偏移地址超过了段的长度,那么该偏移地址会被CPU判断为无效,CPU会产生一个异常(exception),相当于一个中断,然后操作系统会捕获这个异常,然后OS该怎么处置产生这个异常的程序就是OS的事了,我们这里先跳过了。第三,这个描述符里面还保存了该段的属性,比如该段的权限设置啊,怎么样的权限才能访问这个段,这个涉及到8086没有的权限问题,我们下面会提到,还有该段是什么内容啊,市数据段呢,还是代码段呢,还是其他呢,这个其他里面包含了很多类型,Intel共定义了16种类型,包括一些门啊,还有任务描述段啦等等一堆,这个门又分中断们,陷阱门....任务描述符里面是....好....慢点慢点,有的观众已经开始撤离了,那我们暂且把他们放在一边,否则就真的是无穷无尽的地狱了,阿弥陀佛!
除了这些以外,8个字节的描述符还保存了一些其他相关的信息,我们暂且不理他。这些描述符位于什么地方呢?这些描述符本身是位于线性地址空间中的,是由程序员设置的,也就是说我们作为OS开发者要干的活。我们在整个OS中可能需要设置很多个这样的描述符,比如要为内核的数据(堆栈也被认为是数据)设置一个描述符,为内核的代码设置一个描述符,为用户程序的数据设置一个,还要为用户的代码设置一个。看过OS理论的都知道,OS内核和用户程序永远是对立统一的,就是说OS既要为用户程序服务,又要限制用户的权限,管理和分配用户程序的资源,从这一点上来看得话,OS内核很像政府机构,用户程序很像人民大众,政府要为人民服务,又要限制,管理和分配人民的资源。从这个比喻来讲,单一内核很像计划经济,OS内核分配和管理一切,看似效率比较高,其实整体结构不佳。而微内核像市场经济,OS内核只起到监督和协调的作用,很多政府机构变成了服务者(相当于微内核中的Server).....
咳咳,貌似扯远了.....继续回到原来的内容,嗯.....刚才讲什么来着? 对!描述符,为什么那个该死的Intel要搞那么一套复杂的古怪的东西来折磨我们呢?其实据我猜测,其中原因是很多的,我把它归结如下:
1 向上兼容。 因为8086有段机制,留下了很多段寄存器,所以总不能空着不用吧,所以到了386就成了段机制。
2 段机制的保护作用。 刚才提到的段描述符里面保存了关于段的长度以及段的属性以及段的访问权限,这个就是为了保护OS内核不受外界非法访问的一个机制。
3 段机制在程序数据共享和重定位方面有优势。 这个就比较麻烦了,撤开去又是很大的一个话题,不讲了 :)
4 Intel纯粹耍酷,存心要玩死OS developer -_-!b,看了那个描述符的结构真是想杀到Intel总部把那帮家伙咔咔了。因为在设计80286这个不完全变态产品的时候,Intel的设计人员没有想到为80386留条后路,所以留下了一系列的古怪结构,就像古代女人的小脚 ---- 蹩足得很啊!
这里既然提到了保护,就说边说说Intel的保护机制吧。在IA-32下,所有的代码和数据共分4个级别的权限,0,1,2,3,也就是常说的Ring 0, Ring 1, Ring 2, Ring 3。数字越小,权限越大。权限大的能访问全县小的,反之不成立,同级能互访。由于4级的权限太多,一般很少都被用到,一般的OS只用到了Ring 0和Ring 3。把内核的代码和数据放在Ring 0下,把用户的代码和数据放在Ring 3下是很常见的一种做法。如果权限低的代码试图访问权限高的数据,CPU就会产生一个exception,CPU会自动把控制权交给处于Ring 0的OS kernel,由OS kernel来处置越权访问的代码(这就是很常见的General protection error)。至于为什么要保护就不用我在这里罗嗦了吧,你总不想你的Kernel数据和代码随便用户程序改动吧?否则还有快感?
看到这里,我做个调查:各位讨不讨厌那个复杂古怪的段机制?十有八九,我敢肯定,十分讨厌,至少我个人是不太喜欢。不过我不得不宣布一个好消息,那就是:我们可以跳过段机制! (西瓜皮,西红柿,烂橘子马上向我飞来: 不早说!害我死亡了那么多脑细胞!)只要我们恰当地设置好描述符,我们就可以将段机制的影响减到最少,可以让你几乎感觉不到他的存在。设置方法如下:设置4个描述符,OS kernel data, OS kernel code, User data, User code。访问权限分别为 0, 0, 3, 3。每个段的线性基地址都为0,这样的话,每个虚拟地址给出的偏移就是线性地址,这就是传说中的flat mode。怎么样,够简单吧?如果你还不是很明白的话我们来看一个简单的例子:
你写了一个程序,里面有
char * p;
p = (char *)(0x00010000);
*p = 'A';
经过编译以后变成了这样:
mov eax,0x00010000
mov [eax],65 ; 65是'A'的ASCII码
这里你没有看到段地址,是因为Intel规定使用eax寄存器寻址的话如果不指定段寄存器,则默认为ds,就像esp默认为ss一样,Intel有一套段寄存器默认规则。所以这个程序给出的虚拟地址其实是ds:eax即ds:0x00010000。下面我们来看看这个虚拟地址如何被转换成线性地址。首先CPU根据ds的内容,去查找相应的描述符,这些描述符都放在一些表中,成为描述符表(Description Table),就像描述符数组一样,很多描述符都放在那些表中,Intel规定有三种表:全局描述符表GDT, 局部描述符表LDT和中断描述符表IDT,这些表的本身都是放在线性地址空间中的,这些表的具体在线性空间中的位置是这样的:GDT和IDT分别由CPU的寄存器GDTR和IDTR指向,而LDT由GDT中的描述符指向。一般数据段或者代码段的描述符都放在GDT或者LDT中,而具体从这两个表中哪一个中取出来则取决于段寄存器中的bit0。反正就是由ds可以得到一个描述符,然后我们根据先前设置的描述符,取得该段的线性基地址。还记得刚才我们想跳过那该死的段机制吗?OK!如果刚才你的那几句代码是出自于你的OS KERNEL的话,那么我们从ds得到的应该就是OS kernel data描述符,然后还记得我们把所有描述符的线性基地址设为0了么?所以最后的线性地址为 0 + eax 即 0x00010000, omg!千万里我追寻着你~~~终于得到线性地址了,其中还涉及到许多寄存器设置以及映射寄存器等概念,我们暂且跳过了,如果想看得仔细的话就看一下那本Intel 圣经吧!
里程碑啊!绝对是里程碑,我们终于到了线性地址了!下面就要从线性地址转换成为物理地址了。
嗯,是不是已经头晕脑涨了呢?先休息一会儿吧,20分钟后我们再继续。(20分钟休息)
OK!休息完了我们继续。不要紧张,分页机制比分段机制简单得多,而且你应该也在很多OS理论书或者计算机组成里面看到过。Intel的分页机制就是把物理内存分割为一个一个的页,每个页的大小为4K(4096 bytes),成为页框(Page frame),从0 - 4K为一页,4K - 8K, 8K - 12K .... 一直到物理内存结束。线性地址空间也被分割为一个一个页,每个大小也是4K,跟物理内存一样。分页的实质其实是为了完成线性地址空间中的页到物理内存中的页的映射,从而完成虚拟存储以及数据保护和共享等。线性空间的页只能被映射到唯一的一个页框,而一个页框可以同时被多个线性空间中页映射,这样就有利于数据共享。
分页映射是怎么实现的呢?我们来看一下一个很简单的映射方案:线性空间中的每个页由一个数据结构描述其对应于物理空间中的哪个页框,由于物理空间最多可能有 2^32 / 4096 = 2^20 个页框,所以这个数据结构至少20-bit,在加上一些页的属性啊,访问权限等描述,一般都取32-bit,也就是4个字节。所以一个线性空间到物理空间的映射需要 2^32 / 4096 * 4 = 4M 的空间来描述映射函数。由于现代操作系统需要做到地址空间隔离,就是每个进程都有一个独立的线性空间,以便于数据隔离,这样的话这些映射函数的空间消耗十分大。
所以Intel采用了2级映射机制,首先每个线性空间有一个4K字节的页目录(Page directory),页目录里面存放有1K个页表指针,每个指针4个字节,这些指针可以指向一些称为页表(Page Table)的结构,每个页表也是4K字节大小,里面放着也是同样的指针,这些指针指向真正的物理内存的页框。不像刚才第一个方案一样,这个方案中每个线性空间必须有一个页目录,但页表的数量可以动态改变。我们来看看一个线性地址是怎么被转换成物理地址的。一个线性地址有32-bit,首先取高最高的10-bit,也就是bit 22 到 bit 31,以这10-bit的数字为索引,在页目录中找到相应的页表地址,然后取中间10-bit,也就是bit 12 到 bit 21,以这10-bit为索引找到刚才页表中的页框地址,然后将这页框地址 + 这个线性地址的低12位就是物理地址了。整个-过程如下:
线性地址:
31 22 21 12 11 0
-------------------------------------------------
| 10 bits | 10 bits | 12 bits |
-------------------------------------------------
| | |
| | |
| | |
| | |
| | |
| | | | | | |
| |--------| | |--------| |
| | | -->| XXXX ------+------> 物理地址
| |--------| |--------|
--->| XXXX ----| | |
|--------| | |--------|
| | | | |
|--------| | | |
| | --->|--------|
| | page table
page dir ->|--------|
这些页目录啊,页表啊都是OS developer设置的,放在物理空间中。为什么要采取这样一种2级映射的方案呢?原因是为了节省内存的开销。本来一个线性地址要消耗4M的空间来映射,现在的方案是每个线性地址空间只需要一个页目录(4K),而页表则可以按需添加和减少,比如线性地址0x80000000 - 0xFFFFFFFF都是没有映射的,则这些地址对应在页目录的位置中的第512项到1023项都可以设为空,就不需要页表,所以如果一个线性空间只映射了4M,则整个映射函数只要占用一个页目录和一个页表也就是总共(8K)的空间,大大节省了。
大家还记不记得刚才讲段模式的提到分页机制也可以有保护?大家有没有发现页目录和页表的每一项都是指向相应的页框的(页目录,页表本身都占用一个页框),但是每个页框的地址都是可以被4K整除的,所以每个指针的低12位都是0。但是页目录和页表的每一项都有4个字节,所以这4个字节的低12位就用来放一些其他的信息,比如访问权限啦,页属性啊等等。其中有一个属性很重要那就是页的Present属性。如果这个位为1的话说明这个页存在于物理内存中,如果是0的话则不在内存中。当页不在内存中时,如果程序访问到这个页,CPU就会产生一个exception叫page fault,然后OS捕获到这个fault后,判断为缺页错误,经过一系列的合法性检查之后,如果是进程缺页错误的话,OS就把程序的相应的页调入到内存中,然后让进程恢复执行,这就是请页机制(demanding page)。
呼~~~终于一口气讲完了IA-32的核心机制,看到这里大家一定快疯了吧,其实我也是....,把Intel的780页的文档压缩到这么短,真是很有挑战啊!
OK,温习一下今天的内容!准备明天的挑战吧!!