1.1 保护模式
1.1.1 分页机制
内存按字节编址,每个地址对应一个字节的存储单元,早期的程序直接使用物理地址。在单任务操作系统时代,物理内存被划分为两部分,一部分地址空间由操作系统使用,另外一部分由应用程序使用。到了多任务时代,由于程序中的全局变量,起始加载地址是在链接期决定的。如果直接使用物理地址,则很可能有多个起始地址一致的应用程序需要同时被加载运行,这就需要把冲突的程序加载到另外的地址上去,然后重新修正程序中的所有相关的全局符号的地址。然而在早期的计算机系统上内存容量十分有限,即便是通过重定位解决了加载地址冲突的问题,由于内存大小的限制,能够同时加载运行的程序仍然十分有限。而在多任务系统上,某些进程在部分时间内处于等待状态,于是人们很自然地想到,当内存不够的时候把处于等待状态的进程换入磁盘,腾出一些内存空间来加载新程序。这又带来了新的问题:每次腾出来的空间地址可不是固定不变的,这就意味着把磁盘上的内容加载进来的时候,又要重新修正程序中的相关地址,在每次换入换出的过程中要不断地修正相关程序的地址。而且还有一个更为严重的问题:假设进程A出现一个错误,对某个物理地址进行了写入操作,恰好这个地址又属于进程B,当进程B被调度运行的时候,必然会出现错误。很难想象一个软件产品的BUG却导致用户抱怨另外一个软件产品。于是虚拟内存技术发展起来。在虚拟内存中,程序代码中访问的不再是物理地址,而是虚拟地址。
以32位系统为例,每一个进程有4GB的虚拟地址空间,每个进程中有一个表,它记录着每个虚拟地址对应的物理地址是多少,这样当程序加载的时候,可以先分配好物理内存,然后把物理内存的地址填入这个表里面,这样进程之间互不影响。假设程序A和B都是要求在地址BASE处加载(程序中使用的都是虚拟地址。),由于每个进程都有4GB的私有虚拟地址空间,因此两个进程没有加载冲突。操作系统分配的物理地址分别是A1和B1,然后A1和B1起始的物理内存地址分别被填入两个进程虚拟地址映射表中,从而建立虚拟地址和物理地址的一一映射关系。当进程A访问虚拟地址BASE+X的时候,由于MMU的硬件支持,硬件自动查找进程A的地址映射表,从而访问到物理地址为A1+X的内存单元。同理,当进程B访问虚拟地址为BASE+X的时候,MMU自动查找进程B的地址映射表,从而访问到B1+X的内存单元。当然,实际上的虚拟地址机制比这个复杂得多,但是在对它又了总体认识之后,再来学习一个实际的例子就要简单得多了。接下来就以32位的X86系统为例,进一步介绍虚拟内存机制。
表1.1 R/W与U/S的组合保护
表1.2 系统段的类型
每个进程拥有4GB的虚拟地址空间,每个字节的虚拟地址可以通过地址映射表映射到一个字节的物理地址上面去。因此这个映射表本身必然要占据很大的内存空间,如何设计映射表成为问题的关键。如果在虚拟地址映射表中为每一个字节建立映射关系,那么映射4GB的虚拟地址需要230 × 4B(32位系统地址为4Byte)的内存。可见简单的一一填表映射是不能满足现实要求的。为了要减少虚拟地址映射表项占用的内存空间,所有操作系统都采用了页式管理。把物理内存划分为4KB,8KB或者16KB大小的页,这样每个页面在虚拟地址映射表中仅仅占用4Byte的内存。以4KB的页大小为例,4GB的虚拟地址空间有220个页面,那么映射4GB空间的映射表仅仅需要220 × 4B(32系统地址为4Byte)的内存。
其映射原理如图1.1所示:程序要访问的地址是0x12345A10,CPU中的MMU首先找到这个进程的虚拟地址映射表,其起始物理地址为0x10000000。在4KB页大小的情况下,4GB虚拟地址空间含有220个页面,只需要20位就可以表示220的大小了,所以虚拟地址的高20位0x12345作为虚地址映射表中的索引,在32位系统上虚地址映射表中的每一项是4个字节,所以MMU根据地址0x10000000+0x12345*4取得虚拟地址0x12345A10对应的物理页面起始地址为0x54321000,该地址的低12位总是为0,这是由于每一个4KB大小的物理页面总是在4KB的边界上对齐的。而虚地址0x12345A10中的低12位被用做页内偏移量,最终虚地址0x12345A10对应的物理地址为0x54321000+A10,而CPU访问到的内容是0x12345678。
由于页大小为4KB,虚地址表中的表项低12位总是为0,因此可以把低12位用来做标识位。例如把第0位用做存在位,当第0位为1时表示该页面在物理内存中,反之表示该页面不在物理内存中。假设一个进程要占用10MB的内存空间,在进程初始化的时候,虚地址映射表初始化为0,在内存不足的情况下,系统只分配了5MB的内存,这5MB内存的物理地址被填入到映射表中,同时表项中最低位被设置成1,当进程访问到另外5MB的虚地址的时候,MMU在查表时发现最低位为0,于是触发一个缺页中断,这个时候,系统缺页中断处理例程再分配内存页面,同时更新相应的映射表项,之后程序就可以正常运行了。
同理,还可以把一位划分出来作为读写位,如果对一个地址进行写入操作,MMU在查表的时候会根据其读写位判断是否允许写入。几乎每一个程序员都知道访问NULL指针时,一定会出错,那么操作系统是如何捕捉到这个错误的呢?地址0(NULL)实实在在地对应了内存中的一个物理内存地址,如何根据一个地址来判断指针是不是合法的呢?各个操作系统都保证在一个进程中虚拟地址从0开始的某一段区域是不映射的,其页表项为0。例如Windows中把0~64K的地址区域划分到NULL指针区而不被映射。因此访问这部分地址的时候必然会触发缺页中断,这个时候操作系统就可以判断出这个地址是否落在NULL指针区内。否则无论像malloc这一类的函数返回的指针是0还是其他的值,都无法判断分配成功或是失败。
另外Windows,Linux等操作系统都有一个文件映射技术,它可以把一个大小远超出系统物理内存的文件映射到进程的虚地址空间X起始处,之后程序访问通过数组的方式,X[0],X[1]....X[n]来访问文件的第0到n个字节。由于物理内存小于文件大小,所以内核只读入一部分的文件内容,并建立映射,当访问到没有被映射的部分X[m]的时候就会触发缺页中断,这个时候中断处理例程会再分配一定的物理内存页面,然后把它映射到X[m]上,同时从磁盘读入文件对应的内容到该处。这个过程对程序来说是透明的,程序根本不用关心系统物理内存到底有多大。还可以通过把同一物理页面映射到不同的进程的虚拟地址空间中去来实现内存共享,无论各个进程映射的虚拟地址是相同还是不同的,访问到的都是同一个物理页面。在操作系统中有一个重要的Copy-On-Write机制,多个进程共享同一片内存,这片内存的读写位被设置成0,当某个进程对其写入的时候,触发中断,在中断处理程序中,把要写入的相关页面复制一份,之后该进程单独使用这些内存,而进程间仍然共享其他的内存。通过这些例子读者可以看到虚拟内存、虚拟地址、进程的虚地址空间及MMU之间的区别和联系。
虚地址到物理的转换过程是由硬件自动完成的。由于虚地址映射表是进程私有的,因此各个进程的虚地址映射表被放在不同的物理内存中,而且每个进程都必须把这个表的起始物理地址告诉MMU,这就是Page Table Start Address的作用,很容易想到这个起始地址必须是物理地址。前面说过虚地址映射表大小为4MB,而虚地址的高20位是这个表中的索引,这意味着这4MB空间必须作为进程的必备资源在启动的时候一次分配,而且这4MB的内存必须在物理地址上是连续的。这可不是一个好消息,想想虚拟内存的设计理念就是要在一个小内存系统上运行尽可能多的程序,而这个限制将导致一个配备64M内存的系统也运行不了几个进程。考虑到一个进程不需要同时访问到4GB的内存,因此映射表也可以被分散开来,像物理页面那样在需要的时候再分配映射。于是两级,三级甚至四级页表的概念被提出来。
图1.2是32位X86系统的两级页表结构。地址映射表分为两级,第一级被称为页目录表,第二级为页表,每个进程的页目录表起始物理地址由CPU中的寄存器CR3指定,而虚拟地址的最高10位将作为页目录的索引,CR3+(页目录索引× 4)就可以得到PDE。PDE的高20位指定了一个页表的起始地址,低12位是一些标致位,同时虚拟地址的中间10位被用做页表的索引,从而得到PTE。PTE的高20位指定了一个4KB页面的起始地址,而虚拟地址的最低12位被用做页内偏移量,从而访问到虚拟地址指定的内存单元。最高10位用做页目录的索引,每一项4个字节,所以页目录大小正好为4KB,1024项,每一项指向一个页表,每个页表又有1024项指向对应的4KB页,总共可以映射的内存就是1024 × 1024 × 4KB,也就是4GB。这样的话,一个进程的页表可以被分散开来,在页目录索引时如果发现页表没有被映射,就可以通过缺页中断来分配并建立新的映射。
两级页表虽然能解决进程一次要连续分配4MB页表的问题,但是每次访问内存都需要两次查表才得到物理地址最后访问到指定的内存,这样一来降低了系统内存的访问速度,为此CPU内部设置了最近存取的页面的缓存,被称为TLB,程序给出虚拟地址后CPU先到TLB中查找,如果TLB没有命中再访问两级页表,从而极大地提高了访问速度。
通过前面的讨论可以看到,每一个进程都有独立的页表,进程总是通过查本进程页表获取物理地址的,因此无法直接修改其他进程的内存。这样一来,进程就被保护起来而不受其他进程的影响了。这是保护模式的一个重要特征,但是这样的保护还是不够的。我们知道几乎每一个进程都要通过操作系统提供的接口执行一些操作系统内核提供的代码。例如进程通过read系统调用读取文件内容,而具体读取文件的代码则是操作系统内核提供的,同时内核的这些代码也需要使用一些数据,这部分代码和数据必须映射到每一个进程的地址空间中,但是如果任何一个进程有意或无意地修改了这部分代码或数据的话,那么其后果是严重的。
一种由硬件支持的进程权限级别就是用来解决这个问题的,多数构架的CPU提供4个级别,0代表最高级别,3则是最低级别。然而多数操作系统的设计只使用了其中两个级别,内核运行在级别0上,而应用程序运行在级别3上,人们通常把它们称做ring0和ring3,当ring3的代码试图访问ring0的代码或者数据的时候,硬件自动进行权限检查,只有通过检查的才能访问成功。于是进程有两个执行环境,应用层和内核层,应用层程序在ring3执行程序自己的代码,而只能通过特殊的途径进入ring0执行内核代码。有了这个保护,ring3的代码无法直接修改ring0的代码和数据,而进程进入ring0时执行的那些代码都是内核提供的,内核保证这部分代码不会有错误,也不会恶意修改数据或代码,从而保证了系统的健壮性。除此之外,CPU的指令也被分类,某些特权指令只能在ring0的级别上执行。这样保证某些重要的资源不能被应用程序随意修改,例如中断向量表的起始地址。分页保护和权限保护机制被称为保护模式,而之前的8086不具备这两种机制,被称为实模式。
图1.3是X86页表,页目录中的一些常用标志位,操作系统通过设置这些标志位来控制硬件实现前面介绍的保护模式的功能。其中页表项和页目录项大致相同。更加详细的信息请参看《Intel Architecture Software Developer's Manual Volume 3:System Programming》。
? 第0位是Present位,在页目录项中,该位为0的话说明指定的页表不在物理内存中,页表项中该位为0的话,说明对应的物理页面不在物理内存中,当系统访问到Present位为0的页表或页目录项的时候将触发缺页异常,异常处理例程会根据需要分配物理内存,把磁盘上的相关内容调入内存并建立好页表/页目录项,然后返回继续执行。
? 第1位是Read/Write位,为0表示对应的页面只读。第2位是User/Supervisor位,为0表示特权级页面,否则表示普通权限。U/S为和R/W位组合对页面进行保护,其规则如表1.1所示。其中第一行表示当一个页面的U/S,R/W位都为0的时候,运行在ring0级别的进程可以对该页进行读写访问,而运行在ring3的进程既不能对该页进行读取访问,也不能进行写入访问。
? 第3位是Write-through位,1表示写直达,当对这个页面写入时,要立即写到内存中,为0的时候,写到缓存中,在必要的时候再一次性刷缓存。
? 第4位是Page-level cache disable位,当该位为1的时候,表示对应的页禁用缓存,对页目录项而言表示对应的页表不缓存。
? 第5位是Accessed位,当一个页面/页表被初始载入内存的时候,该位初始化为0,之后当对该页面进行访问(读/写)后,该位被置1。
? 第6位是Dirty位,当一个内存页面被初始载入内存的时候,该位为0,之后软件对该页进行写入操作的时候该位被置为0。这是一个很重要的标志,比如在物理内存不够的情况下,系统把一部分内存写到磁盘上,之后在需要的时候再读出来,之后再次写入磁盘的时候,可以根据页面的Dirty标志来判定哪个页面要写入,而没被修改的页面就不需要再次写入磁盘了。
? 第7位是Page Size位,在页目录中,该位为0表示一个物理页面大小是4K,为1的时候表示一个物理页面是4M。4M页面的时候,没必要使用两级页表,所以高10位表示页目录的索引,而低22位表示4M中的偏移。在页表中的该位没被使用,总是为0。
? 第8位是Global位,当进程切换的时候要改变页目录基地址CR3寄存器,于是CPU内部的TLB内容将被丢弃,而对于一些各个进程都要共享访问的页面,可以通过在页表中设置该位来阻止对应页面的TLB被丢弃。页目录中的该位被忽略。
? 第9~11位是保留给系统软件自由使用的。
? 高20位是页表/页面的起始地址。
1.1.2 分段机制
仅仅是分页机制就能够满足虚拟内存管理和保护模式的要求了,现在某些构架的处理器完全不使用分段机制,如MIPS。段机制实际上是x86的一个历史遗留问题,Linux内核也是尽量不使用段机制。Intel在16位处理器时代,由于16位地址线只支持64KB的内存寻址,但是64KB的内存显得太少了。于是提出了分段机制,地址由段基址和偏移组成,用BASE:OFFSET表示,而物理地址由BASE<<4+OFFSET形成。BASE和OFFSET都是16位,OFFSET是段内偏移,因此一个段最多有64KB,而1MB内存最多有16个段,如果使用两个16位的地址分别作为高16位和低16位地址的话,一共是32位,可以最大寻址4GB内存,但是从当时的软硬件条件来看,没有必要这么做。
随着操作系统的发展,提出了保护模式和虚拟内存管理,Intel在分段机制的基础上实现保护模式和虚拟内存管理。后来更为优越的分页机制逐步占据了主流,但是Intel采用分页机制的同时,出于兼容的目的而保留了分段机制。但是16位的段寄存器已经不能作为32位系统的段基址了,于是段寄存器被用做选择子,而段基址以及段的一些其他属性被存放在一片内存中,被称为段描述符表。为什么不直接把段寄存器也扩充为32位或者更长的呢?因为x86允许不使用分页机制的同时,也能实现保护模式和虚拟内存管理的需要。因此段的基本信息除了段基址外,还需要类似分页机制中页表项中的一些标志位,于是出现了段描述符表。表中的每一项占8字节,它定义了一个段的基本属性。描述符格式如图1.4所示。
? Segment Base 0-31位表示一个段的起始地址。
? Segment Limit 0-19位表示一个段的最大长度。
? Byte5的最低位A为0,表示本段还从未被访问过,为1表示本段已经被访问过。
? Byte5中的ED位表示段的增长方向,ED=0时,段地址增长方向是向上的,也就是说从Base开始到Base+Limit这段区间是属于本段的。ED=1时,表示段的地址增长方向是向下的,也就是说本段的地址区间从Base开始到Base-Limit结束。通常堆栈段是向下增长的。
? Byte5中的E位表示可执行位,当E=1时,表示代码段(可执行),E=0表示数据段。
? Byte5中RW为读写位,在数据段中(E=0),RW=0表示该段不能被写入,RW=1表示本段可以被写入。在代码段中(E=1),表示代码段,这时ED=0表示忽略特权级别,ED=1时表示遵守特权级别,RW=0,段不可读,RW=1,可读。
? Byte5的第4位S=1,表示代码段或者数据段,堆栈段也是数据段。S=0,表示系统段,例如中断门,调用门等(见第1.2节)。
? Byte5的DPL占两位(Descriptor Privilege Level),00~11分别表示访问本段所需要的特权级别,只有级别大于等于DPL中指定的权限才能访问本段。0是最大权限。
? Byte5的P位为0表示本段不在内存中,当段中的内存被换入磁盘时可以设置为0,当访问这样的段时将触发中断。
? Byte6的第4位保留给系统软件使用。
? Byte6的第5位是保留位总是为0。
? Byte6的第6位,DB=0,表示默认地址和操作数是16位,DB=1,表示默认地址和操作数是32位。
? Byte6的第7位G=0,表示20位的段界限的单位是字节,这样一个段的最大长度是1M,G=1表示20位段界限的单位是4K,这样段的最大长度是4G。
可以看到,上面许多属性的作用和页表项中重复,实际上在x86上抛开分页机制,也能够实现保护模式的各种功能。系统中每一个段都由一个段描述符来表示,这些描述符依次存放在描述符表中,系统中描述符表分全局描述符表和局部描述符表:
? 全局描述符表GDT,CPU内部寄存器GDTR的高32位指向该表的起始地址,低16位表示全局描述符表的界限,界限以字节为单位。低16位可以表示65536个字节,而每一个段描述符表项有8个字节,因此全局描述符表最多有8192项。汇编指令LGDT把一个48位的内存操作数加载到GDTR寄存器中,而SGDT把GDTR保存到一个48位的内存操作数中。
? 局部描述符表LDT,由LDTR寄存器指定。由于Intel的设计本意是每个进程有自己的局部描述符表,因此描述符表的位置不是固定的,它需要随着进程的切换而切换,可能是考虑到进程A切换到进程B的时候,A把自己的LDTR保存在自己的进程地址空间中,之后切换到进程B,再切换回A的时候,由于保护模式下的地址空间隔离,进程B不能恢复A的LDTR,所以进程的LDTR必须保存在全局共享的内存中。于是Intel把每一个局部描述符表的首地址放到全局描述符表中,这样通过16位的LDTR寄存器在GDT的索引得到一个段描述符表项,该描述符的基地址部分指定了一个局部描述符表的起始地址,在这个局部描述符表中又有许多局部描述符表项。其访问过程如图1.5所示。16位的寄存器LDTR是一个GDT中的索引,实际的LDT由GDT中的一个64位的表项来描述。这样一来每次访问LDT表都要两次查表,为了节省开销,LDTR寄存器包括软件可见的16位和软件不可见的64位,LLDT指令的16位操作数作为索引,在GDT中找到64位的一个表项,然后把它加载到LDTR不可见的部分。
在32位CPU上,段寄存器CS,DS,ES,SS,FS,GS仍然是16位的,被用做全局段描述符表和局部段描述符表的索引,被称为段选择子,其格式如图1.6所示。
其中第0位和第1位表示Request Privilege Level,0-3分别代表3个不同的请求级别,最高13位是索引位,可以表示8192项,当TI=1时,表示从局部描述符表中选择相应的描述符,TI=0时,从全局描述符表中选择相应的描述符。这样当CPU通过DS:Offset访问内存的过程是:
(1) 如果TI=0,则根据GDTR寄存器指定的首地址找到全局描述符表,再利用DS的高13位作为索引找到对应的描述符表项,在通过相应的权限检查之后,从表项中取出32位的段地址,最后再加上Offset从而形成线性地址,如果分页机制开启,该地址还要经过分页机制处理,最后才得到物理地址。
(2) 如果TI=1,情况类似,先根据GDTR寄存器指定的首地址找到全局描述符表,再利用LDTR的16位软件可见部分作为索引,找到一个表项,然后从该表项中取出32位的基地址,再根据这个基地址找到局部描述符表,然后把DS的高13位作为索引在该表中找到一个表项,取出基地址再加上Offset最后得到线性地址。实际上在执行LLDT指令把16位的操作数加载到LDTR软件可见部分的同时,就从这个全局描述符表中取出了对应的64位表项加载到LDTR软件不可见部分的缓存中,所以访问的时候就可以根据不可见部分直接找到局部描述符表了,这里是为了强调LDT和GDT的关系。
这里段描述符号中的DPL(见图1.4)和段选择子中的RPL都是用来做权限检查的,段寄存器CS和SS中的RPL是两个特殊的权限级别,它们代表进程的当前权限级别,又被称为CPL(Current Privilege Level)。进程在执行指令时,目标操作数中的段选择子中的RPL代表请求级别。例如,设当前进程的CS和SS中的RPL位为0,它表示该进程的当前级别(CPL)为Ring0,当执行MOV DS:Offset,0这条指令时,操作数DS中的RPL位表示想要获取对该段的访问权限级别,这里假设为1,而DS选择子选中的描述符中的那个DPL(见图1.4)则代表访问该段所需要的权限,假设为3。这样权限检查的依据就是,一个权限为CPL的进程,试图以RPL权限去访问一个要求DPL权限的段,在这个例子中,当前权限(CPL)为0,试图以权限(RPL)为1的身份,去访问一个最小权限要求(DPL)为3,这个检查是可以通过的。
除此之外,系统中还包括一个特殊的段---任务状态段TSS,任务状态是为在硬件级别支持多进程管理而设置的。从CPU的角度来看,进程上下文包括一组CPU的寄存器的值,当进程切换的时候,CPU会把当前进程的寄存器保存到一片内存中,然后再把新进程的寄存器值从内存中加载到CPU中,这片内存被称为TSS,其格式如图1.7所示。
每一进程都有一个TSS段来保存CPU上下文,每一个这样的TSS段也占用GDT中的一个描述表项。描述符的格式如图1.8所示。硬件通过TSS提供进程切换机制,TR寄存器指向当前进程的TSS。当通过段间跳转指令JMP Seg:Offset或者CALL Seg:Offset的时候,如果段寄存器的高13位选择子选中的描述符项是一个TSS段的时候,硬件先根据TR寄存器找到当前进程的TSS,把当前进程上下文保存到TSS中,然后根据JMP或者CALL指令中的段寄存器的选择子找到目标进程的TSS,再把新的TSS加载到CPU中,这样就完成了进程切换。在图1.8任务段描述符中,Byte5中的S位为1的时候,表示这是一个系统段描述符,而Byte5的0~3位被称为Type位,表示系统段的类型。10B1表示这是一个TSS段描述符,其中B位为1表示忙。通过为当前正在执行的任务设置忙标志可以避免任务嵌套切换(从任务A切换到任务A本身。)。
其中Type位所表示的其他类型如表1.2所示,其中任务门、调用门、中断门和陷阱门请参见1.2节。
在图1.7中,除了当前SS:ESP外还可以看到SS0:ESP0,SS1:ESP1和SS2:ESP2这三组堆栈指针,这是为什么呢?前面说过CPU有4个级别,分别是ring0~ring3,为了避免相互影响,进程在不同级别使用的是独立的堆栈,假设进程从ring3切换到ring0,CPU从TSS中取出SS0:ESP0,同时把ring3的SS:ESP保存在新的ring0的堆栈中,当从ring0返回到ring3的时候,CPU根据ring0堆栈的值恢复ring3的SS:ESP。只有从低级别向高级别切换的时候,才从TSS中取出高级别的堆栈指针,而从高级别向低级别切换的时,是从高级别的堆栈中恢复低级别的堆栈指针,所以TSS中有SS0:ESP0-SS2:ESP2这3对堆栈指针。