2.1.2 平坦内存模型
随着现代多任务操作系统的出现,每个任务都可以访问整个地址空间,而不必考虑实际物理内存的大小,而且通常寻址空间要比实际物理内存大得多,更不用说多个任务同时运行需要的内存了,于是出现了虚拟内存(Virtual Memory)的概念。操作系统将物理内存划分为大小相同的若干页面,在程序运行时,操作系统并不会为程序的全部地址空间都分配实际的物理内存页面,而是在访问时按需分配。当内存紧张时,可能会将最近很少使用的页面交换出去,需要时再载入进来。使用虚拟内存后,操作系统可以运行的任务数量不再受实际物理内存大小的限制。相对于段式内存管理,这种方式被称为页式内存管理。
新的处理器数据总线和地址总线宽度一致,不再需要段寄存器叠加产生更大的寻址空间。而且,页式内存管理方式也可以提供比段式内存管理更多、更灵活、更细粒度的内存保护方式,于是段式内存就显得多余了。Intel也知道在页式内存管理出现后,段机制成了一个“鸡肋”,而为了向后兼容,又不能将其去掉,于是Intel为系统设计者建议了一种平坦内存模型(flat model)。
平坦模型建议创建4个段,分别是用于特权级3的用户代码段、数据段以及用于特权级0的内核代码段、数据段,这4个段基址都是0,并且地址空间完全相同。平坦内存模型几乎完全隐藏了分段机制。Linux内核从2.1.43版本开始使用这种平坦内存模型,其定义的4个段如下:
linux-2.1.43/arch/i386/kernel/head.S ENTRY(gdt) .quad 0x0000000000000000 /* NULL descriptor */ .quad 0x0000000000000000 /* not used */ .quad 0x00cf9a000000ffff /* 0x10 kernel 4GB code … */ .quad 0x00cf92000000ffff /* 0x18 kernel 4GB data … */ .quad 0x00cffa000000ffff /* 0x23 user 4GB code … */ .quad 0x00cff2000000ffff /* 0x2b user 4GB data … */
段描述符的格式如图2-1所示。
图2-1 段描述符的格式
字段Base Address是段的基址;字段DPL是段的权限;字段Type表示段是代码段还是数据段,高32位的第11位为1表示是代码段,为0是数据段,第8~10位用于附加修饰,比如数据段是只读还是可写。字段Segment Limit表示段的长度,其单位依赖于另外一个字段G(Granularity)。如果字段G的值是0,那么段的长度以字节为单位,最大长度为220×1字节,即1MB;如果字段G的值是1,那么段的长度以4KB为单位,段的最大长度可达220×4KB,即4GB。读者可能有个疑问,64位CPU可寻址的长度要远远大于4GB,那么这怎么应对呢?事实上,虽然不是完全的,但64位CPU基本上禁用了段模式,忽略段界限的检查。
我们根据段描述符的定义将这4个段描述符中的主要字段提取一下,如表2-1所示。
表2-1 段描述符中的主要字段
对应这4个段,内核中定义了引用这4个段的段选择子的宏:
linux-2.1.43/include/asm-i386/segment.h #define KERNEL_CS 0x10 #define KERNEL_DS 0x18 #define USER_CS 0x23 #define USER_DS 0x2B
段选择子的格式如图2-2所示。
图2-2 段选择子的格式
段选择子长度为16位,其中第3~15位为GDT或者LDT段描述符表中的索引。第2位为TI(table indicator),表示使用GDT还是LDT,0表示GDT,1表示LDT。最后2位是权限相关的。我们将这几个段的十六进制转换为二进制就容易看出其表达的意思了,如表2-2所示。
表2-2 段选择子各个字段的二进制表示
将十六进制转换为二进制后,意义就很直观了。这4个段选择分别对应GDT中的第2、3、4、5项,即内核代码段、内核数据段、用户代码段和用户数据段。所有程序都使用这一套段定义,按需切换段寄存器。从内核空间返回到用户空间时,cs段选择子被设置为USER_CS,其他数据相关的段选择子则被设置为USER_DS;进入内核空间时,cs段选择子被设置为KERNEL_CS,其他数据相关的段选择子则被设置为KERNEL_DS。我们通过2个典型的例子体会一下。
Linux 1.0版本的内核在系统初始化完成后,准备切换到用户空间的第1个进程前会执行如下代码:
linux-1.0/include/asm/system.h #define move_to_user_mode() \ __asm__ __volatile__ ("movl %%esp,%%eax\n\t" \ "pushl %0\n\t" \ "pushl %%eax\n\t" \ "pushfl\n\t" \ "pushl %1\n\t" \ "pushl $1f\n\t" \ "iret\n" \ "1:\tmovl %0,%%eax\n\t" \ "mov %%ax,%%ds\n\t" \ "mov %%ax,%%es\n\t" \ "mov %%ax,%%fs\n\t" \ "mov %%ax,%%gs" \ : /* no outputs */ :"i" (USER_DS), "i" (USER_CS):"ax")
因为代码段寄存器和指令指针EIP不能直接通过指令操作,所以通常会将返回地址先压入栈中,然后通过iret指令将压入的返回地址弹出到cs和eip中。上述代码采用的就是这种方式,其中%1引用的是“i”(USER_CS):“ax”,代码pushl %1就是相当于将用户代码段压栈;pushl $1f是将标号1:处的地址压栈,即相当于压栈eip。在执行完iret指令后,代码段寄存器将被加载为用户空间代码段,CPU从特权级0跳转到特权级3,从内核空间进入用户空间,然后跳转到标号1:处执行,其中%0引用的是“i”(USER_DS),所以这里除了代码段寄存器cs外,其他段寄存器全部都设置为用户数据段USER_DS了。
与上述例子相对应的是从用户空间切换到内核空间。当系统运行在用户空间,发生中断时,CPU需要切换到内核空间去运行中断处理函数。在中断的那一刻,CPU将使用中断描述符中的段选择子更新代码段寄存器cs,而中断描述符中的段选择子为内核代码段,因此,在穿越中断门后,CPU从特权级3跳转到特权级0,从用户空间进入内核空间。内核在初始化时将中断描述符中的段选择子设置为内核代码段的代码如下:
linux-1.0/include/asm/system.h #define _set_gate(gate_addr,type,dpl,addr) \ __asm__ __volatile__ ("movw %%dx,%%ax\n\t" \ "movw %2,%%dx\n\t" \ "movl %%eax,%0\n\t" \ "movl %%edx,%1" \ :"=m" (*((long *) (gate_addr))), \ "=m" (*(1+(long *) (gate_addr))) \ :"i" ((short) (0x8000+(dpl<<13)+(type<<8))), \ "d" ((char *) (addr)),"a" (KERNEL_CS << 16) \ :"ax","dx")
中断描述符的格式如图2-3所示。
图2-3 中断描述符格式
从内联汇编代码的输入部分我们看到,内联汇编告知编译器将eax寄存器的高16位初始化为内核代码段选择子KERNEL_CS,并将edx寄存器初始化为中断处理函数地址addr。然后内联汇编的第1条语句,将dx寄存器(edx寄存器中的低16位)即中断处理函数地址的低16位装载到ax寄存器,即eax寄存器的低16位。至此,中断描述符第0~31位的内容已经在eax寄存器组织好。然后,内联汇编代码将eax寄存器的内容装载到%0指代的位置,%0指代的是输出部分的第1个操作数,即中断描述符的低32位。
介绍完了中断描述符低32位的设置,我们再来看看高32位的组织。内联汇编代码的输入部分将edx寄存器初始化为段内的中断处理函数地址,然后内联汇编的第2条语句使用%2覆盖了edx寄存器的低16位。其中,%2指代的是输入部分的第1个操作数,即由dpl、type等属性组成的一个立即数,这条代码执行过后,edx寄存器的内容就是中断描述符的高32位。然后,内联汇编代码将eax寄存器的内容装载到%1指代的位置。这里%1指代的是输出部分的第2个操作数,即中断描述符的高32位。