2.2 段机制
段是虚拟地址空间的基本单位,段机制必须把虚拟地址空间的一个地址转换为线性地址空间的一个线性地址。
2.2.1 段描述符
为了实现地址映射,仅仅用段寄存器来确定一个基地址是不够的,至少还得描述段的长度,并且还需要段的一些其他信息,比如访问权之类。所以,这里需要的是一个数据结构,这个结构包括三个方面的内容:
(1) 段的基地址(Base Address):在线性地址空间中段的起始地址。
(2) 段的界限(Limit):在虚拟地址空间中,段内可以使用的最大偏移量。
(3) 段的保护属性(Attribute): 表示段的特性。例如,该段是否可被读出或写入,或者该段是否作为一个程序来执行,以及段的特权级等等。
如图2.5所示,虚拟地址空间中偏移量从0到limit范围内的一个段,映射到线性地址空间中就是从Base到Base+Limit。
把图2.5用一个表描述则如图2.6:
这样的表就是段描述符表(或叫段表),其中的表项叫做段描述符(Segment Descriptor)。
所谓描述符(Descriptor),就是描述段的属性的一个8字节存储单元。在实模式下,段的属性不外乎是代码段、堆栈段、数据段、段的起始地址、段的长度等等,而在保护模式下则复杂一些。将它们结合在一起用一个8字节的数表示,称为描述符 。80x86通用的段描述符的结构如图2.7所示。
从图可以看出,一个段描述符指出了段的32位基地址和20位段界限(即段长)。
第六个字节的G位是粒度位,当G=0时,以节长为单位表示段的长度,即一个段最长可达220(1M)字节。当G=1时,以页(4K)为单位表示段的长度,即一个段最长可达1M×4K=4G字节。D位表示缺省操作数的大小,如果D=0,操作数为16位,如果D=1,操作数为32位。第六个字节的其余两位为0,这是为了与将来的处理器兼容而必须设置为0的位。
第5个字节是存取权字节,它的一般格式如图2.8所示:
第7位P位(Present) 是存在位,表示这个段是否在内存中,如果在内存中。P=1;如果不在内存中,P=0。
DPL(Descriptor Privilege Level),就是描述符特权级,它占两位,其值为0~3,用来确定这个段的特权级即保护等级。
S位(System)表示这个段是系统段还是用户段。如果S=0,则为系统段,如果S=1,则为用户程序的代码段、数据段或堆栈段。
类型占3位,第三位为E位,表示段是否可执行。当E=0时,为数据段描述符,这时的第2位ED表示扩展方向。当ED=0时,为向地址增大的方向扩展,这时存取数据段中的数据的偏移量必须小于等于段界限,当ED=1时,表示向地址减少的方向扩展,这时偏移量必须大于界限。当表示数据段时,第1位(W)是可写位,当W=0时,数据段不能写,W=1时,数据段可写入。在80x86中,堆栈段也被看成数据段,因为它本质上就是特殊的数据段。当描述堆栈段时,ED=0,W=1,即堆栈段朝地址增大的方向扩展。
在保护模式下,有三种类型的描述符表,分别是全局描述符表GDT(Gloabal Descriptor Table)、中断描述符表IDT(Interrupt Descriptor Table)及局部描述符表LDT(Local Descriptor Table)。为了加快对这些表的访问,Intel设计了专门的寄存器gdtr,ldtr和idtr,以存放这些表的基地址及表的长度界限。各种描述表的具体内容详参见相关参考书。
由此可以推断,在保护模式下段寄存器中该存放什么内容了,那就是图2.6中的索引。因为索引表示段描述符在描述符表中位置,因此,把段寄存器也叫选择符,其结构如图2.9所示:
可以看出,选择符有三个域。其中,第15~13位是索引域,第2位TI(Table Indicator)为选择域,决定从全局描述符表(TI=0)还是从局部描述符表(TI=1)中选择相应的段描述符。这里我们重点关注的是RPL域,RPL表示请求者的特权级(Requestor Privilege Level)。
保护模式提供了四个特权级,用0~3四个数字表示,但很多操作系统(包括Linux)只使用了其中的最低和最高两个,即0表示最高特权级,对应内核态;3表示最低特权级,对应用户态。保护模式规定,高特权级可以访问低特权级,而低特权级不能随便访问高特权级。
2.2.1 地址转换及保护
程序中的虚拟地址可以表示为“选择符:偏移量”这样的形式,通过以下步骤可以把一个虚拟地址转换为线性地址:
(1) 在段寄存器中装入段选择符,同时把32位地址偏移量装入某个寄存器(比如ESI、EDI等)中。
(2) 根据选择符中的索引值、TI及RPL值,再根据相应描述符表中的段地址和段界限,进行一系列合法性检查(如特权级检查、界限检查),如果该段无问题,就取出相应的描述符放入段描述符高速缓冲寄存器3中。
(3)将描述符中的32位段基地址和放在ESI、EDI等中的32位有效地址相加,就形成了32位线性地址。
注意,在上面的地址转换过程中,从两个方面对段进行了保护:
(1) 在一个段内,如果偏移量大于段界限,虚拟地址将没有意义,系统将产生异常。
(2) 如果要对一个段进行访问,系统会根据段的保护属性检查访问者是否具有访问权限,如果没有,则产生异常。例如,如果要在只读段中进行写入,系统将根据该段的属性检测到这是一种违规操作,则产生异常。
2.2.2 Linux中的段
Intel微处理器的段机制是从8086开始提出的,那时引入的段机制解决了从CPU内部16位地址到20位实地址的转换。为了保持这种兼容性,386仍然使用段机制,但比以前复杂得多。因此,Linux内核的设计并没有全部采用Intel所提供的段方案,仅仅有限度地使用了一下分段机制。这不仅简化了Linux内核的设计,而且为把Linux移植到其他平台创造了条件,因为很多RISC处理器并不支持段机制。但是,对段机制相关知识的了解是进入Linux内核的必经之路。
从2.2版开始,Linux让所有的进程(或叫任务)都使用相同的逻辑地址空间,因此就没有必要使用局部描述符表LDT。但内核中也用到LDT,那只是在VM86模式中运行Wine,也就是说在Linux上模拟运行Winodws软件或DOS软件的程序时才使用。
在80X86上任意给出的地址都是一个虚拟地址,即任意一个地址都是通过“选择符:偏移量”的方式给出的,这是段机制存访问模式的基本特点。所以在80X86上设计操作系统时无法回避使用段机制。一个虚拟地址最终会通过“段基地址+偏移量”的方式转化为一个线性地址。但是,由于绝大多数硬件平台都不支持段机制,只支持分页机制,所以为了让Linux具有更好的可移植性,我们需要去掉段机制而只使用分页机制。
但不幸的是,80X86规定段机制是不可禁止的,因此不可能绕过它直接给出线性地址空间的地址。万般无奈之下,Linux的设计人员干脆让段的基地址为0,而段的界限为4GB,这时任意给出一个偏移量,则等式为“0+偏移量=线性地址”,也就是说“偏移量=线性地址”。另外由于段机制规定“偏移量 < 4GB”,所以偏移量的范围为0H~FFFFFFFFH,这恰好是线性地址空间范围,也就是说虚拟地址直接映射到了线性地址,我们以后所提到的虚拟地址和线性地址指的也就是同一地址。看来,Linux在没有回避段机制的情况下巧妙地把段机制给绕过去了。
另外,由于80X86段机制还规定,必须为代码段和数据段创建不同的段,所以Linux必须为代码段和数据段分别创建一个基地址为0,段界限为4GB的段描述符。不仅如此,由于Linux内核运行在特权级0,而用户程序运行在特权级别3,根据80X86的段保护机制规定,特权级3的程序是无法访问特权级为0的段的,所以Linux必须为内核和用户程序分别创建其代码段和数据段。这就意味着Linux必须创建4个段描述符——特权级0的代码段和数据段,特权级3的代码段和数据段。
Linux在启动的过程中设置了段寄存器的值和全局描述符表GDT的内容,内核代码中可以这样定义段:
#define __KERNEL_CS 0x10 /*内核代码段,index=2,TI=0,RPL=0*/
#define __KERNEL_DS 0x18 /*内核数据段, index=3,TI=0,RPL=0*/
#define __USER_CS 0x23 /*用户代码段, index=4,TI=0,RPL=3*/
#define __USER_DS 0x2B /*用户数据段, index=5,TI=0,RPL=3*/
从定义看出,没有定义堆栈段,实际上,Linux内核不区分数据段和堆栈段,这也体现了Linux内核尽量减少段的使用。因为这几个段都放在GDT中,因此,TI=0 , index就是某个段在GDT表中的下标。内核代码段和数据段具有最高特权,因此其RPL为0,而用户代码段和数据段具有最低特权,因此其RPL为3。可以看出,Linux内核再次简化了特权级的使用,使用了两个特权级而不是4个。
内核代码中可以这样定义全局描述符表:
ENTRY(gdt_table)
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x0000000000000000 /* not used */
.quad 0x00cf9a000000ffff /* 0x10 kernel 4GB code at 0x00000000 */
.quad 0x00cf92000000ffff /* 0x18 kernel 4GB data at 0x00000000 */
.quad 0x00cffa000000ffff /* 0x23 user 4GB code at 0x00000000 */
.quad 0x00cff2000000ffff /* 0x2b user 4GB data at 0x00000000 */
.quad 0x0000000000000000 /* not used */
.quad 0x0000000000000000 /* not used */
…
从代码可以看出,GDT放在数组变量gdt_table中。按Intel规定,GDT中的第一项为空,这是为了防止加电后段寄存器未经初始化就进入保护模式而使用GDT的。第二项也没用。从下标2到5共4项对应于前面的4种段描述符值。对照图2.7,从描述符的数值可以得出:
· 段的基地址全部为0x00000000
· 段的上限全部为0xffff
· 段的粒度G为1,即段长单位为4KB
· 段的D位为1,即对这四个段的访问都为32位指令
· 段的P位为1,即四个段都在内存。
通过上面的介绍可以看出,Intel的设计可谓周全细致,但Linux的设计者并没有完全陷入这种沼泽,而是选择了简洁而有效的途径,以完成所需功能并达到较好的性能为目标。
但是,如果这么定义段,则上一节所说的段保护的第一个作用就失去了,因为这些段使用完全相同的线性地址空间(0~4GB),它们互相覆盖。可以设想,如果不使用分页的话,线性地址空间直接被映射到物理空间,则你修改任何一个段的数据,都会同时修改其它段的数据,段机制所提供的通过“基地址:界限”方式本来将线性地址空间分割,以让段与段之间完全隔离,这种实现段保护的方式根本就不起作用了。那么,这是不是意味着用户可以随意修改内核数据?显然不是的,这是因为,一方面用户段和内核段具有不同的特权级别,另一方面,Linux之所以这么定义段,正是为了实现一个纯的分页,而分页机制会提供给我们所需要的保护。