linux的页式内存管理
备注:本文中引用的内核代码的版本是3.14。
当地址的宽度是32位时,页面目录表+页面表的两级映射合情合理,但如果地址总线的宽度超过32位,比如64位时,两级映射就不合理了。linux要设计一套适用于所有地址宽度的页式内存管理机制,就不能不考虑这个问题。linux的页式管理将映射分成三层,在页面目录表和页面表之间加入一层“中间目录”。在代码中,页面目录称为PGD,中间目录称为PGM,页面表称为PT,页面表中项称为PTE(PT Entry),PGD、PGM和PT三者均为数组。相应的,把线性地址也分为四个位段,每个位段占若干个字节,线性地址到物理地址的转换示意如下:
需要指出,linux三层页式管理只是一个通用的模型,最终还是要落到具体的CPU和MMU上。以i386来说,CPU实际上是按两层来管理的,将三层模型落到具体的两层映射上,需要跳过中间的PMD层次。另一方面,intel引入了物理地址扩充功能PAE,运行将地址宽度有32位拓宽到36位,因此便具备了实施三层映射的硬件基础。
也就是说i386支持两层地址映射也支持三层地址映射,至于最终选择哪一种,我们把选择权交给编译内核的人,在内核代码中只需要简单的根据编译选项来区分就可以了。
<arch/x86/include/asm/pgtable_32.h> 45 #ifdefCONFIG_X86_PAE 46 # include<asm/pgtable-3level.h> 47 #else 48 # include <asm/pgtable-2level.h> 49 #endif
如果编译选项CONFIG_X86_PAE被设置,则选择三层映射,否则选择两层映射,这里我们只分析两层映射的代码。后面若无特殊说明,我们均至分析X86架构地址总线为32位的情形。
我们可以想象一下,将linux的三层模型落实到intel的两层映射上,有这样两个问题需要处理:
问题1、linux三层模型中多出的PMD该如何处理
问题2、在intel的段式内存管理基础上,怎么建立linux页式内存管理,具体地,全局段描述符表GDTR如何设置?段的数目需要固定吗?段寄存器的内容该设置为多少?
1. PMD该如何处理?
应该有下面这两种思路:
这里内核采用了方案2。在pgtable-2level_types.h中没有定义PMD相关信息,取而代之,将SHARED_KERNEL_PMD定义为0,并且将PAGETABLE_LEVELS定义为2,表示两层映射。可以想象,因为内核的代码时针对三层映射而写,这里的两层映射相当于一个特例,在页面内存管理相关的代码中,这两个宏会被拿用来判断是否为两层映射。
<arch/x86/include/asm/pgtable-2level_types.h> 19 #defineSHARED_KERNEL_PMD 0 20 #definePAGETABLE_LEVELS 2
为了便于后续情景分析的展开,这里有必要说一下2.4.0内核的实现。在2.4.0的内核中,采用了第一种方案,我们在下一篇文章中可以看到更多的细节。
2 段选择符与段描述符的设计
对于此问题,我们再补充些额外的信息。首先与段式内存管理相比,页式内存管理有很多好处,一种CPU既然支持页式管理就没必要再支持段式管理,但前面介绍了,i386比较特殊,它对地址一律先进行段式映射,再进行页式映射。我们知道,段式映射和页式映射都对内存访问进行了保护,这里有重复保护之嫌。其次,i386 CPU支持4即特权,而在linux中,只需要两种特权,用户特权(用户空间)和系统特权(系统空间即内核空间)。
介绍了这些信息之后,相信大家已经有想法了。首先,既然重头戏是页式映射,那么必须让段式映射过程“轻量级”,让段式映射“走走过场”;其次,具体到段选择符(段寄存器中的内容)和段描述符(GDTR指向的内容)的设计上,我们希望尽量不要进行段切换,而且段的特权只需要设置两种即可。
我们来看看内核是如何实现的。每当内核新建一个进程(task_struct),都要将其段寄存器设置好。代码如下:
<arch/x86/kernel/process_32.c> 201 void 202 start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp) 203 { 204 set_user_gs(regs, 0); 205 regs->fs = 0; 206 regs->ds =__USER_DS; 207 regs->es =__USER_DS; 208 regs->ss =__USER_DS; 209 regs->cs =__USER_CS; 210 regs->ip =new_ip; 211 regs->sp =new_sp; 212 regs->flags =X86_EFLAGS_IF; 213 /* 214 * force it to the iret return path by making it look as if there was 215 * some work pending. 216 */ 217 set_thread_flag(TIF_NOTIFY_RESUME); 218 }
pt_regs是i386寄存器的“映像”(??在哪里被装入真正的寄存器)。第204-205行,设置附加段寄存器gs/fs的值为0,可见,linux中没有使用这两个段寄存器(??还存在疑问)。除了209行中将CS设置为__USER_CS外,ds/ss/es都设置为__USER_DS。可见,虽然intel意图将进程的映像分成代码段、数据段、堆栈段,但linux并不买账,linux中,数据段和堆栈段是不区分的。
段选择符__USER_DS与__USER_CS时如何定义的呢?
<arch/x86/include/asm/segment.h> 26#ifdef CONFIG_X86_32 27 /* 28 * The layout of the per-CPU GDT under Linux: 29 * 30 * 0 -null 31 * 1 -reserved 32 * 2 -reserved 33 * 3 -reserved 34 * 35 * 4 -unused <==== newcacheline 36 * 5 -unused 37 * 38 * ------- start of TLS (Thread-Local Storage) segments: 39 * 40 * 6 -TLS segment #1 [ glibc'sTLS segment ] 41 * 7 -TLS segment #2 [ Wine's%fs Win32 segment ] 42 * 8 -TLS segment #3 43 * 9 -reserved 44 * 10 -reserved 45 * 11 -reserved 46 * 47 * ------- start of kernel segments: 48 * 49 * 12 -kernel code segment <====new cacheline 50 * 13 -kernel data segment 51 * 14 -default user CS 52 * 15 -default user DS 53 * 16 -TSS 54 * 17 -LDT 55 * 18 -PNPBIOS support (16->32 gate) 56 * 19 -PNPBIOS support 57 * 20 -PNPBIOS support 58 * 21 -PNPBIOS support 59 * 22 -PNPBIOS support 60 * 23 -APM BIOS support 61 * 24 -APM BIOS support 62 * 25 -APM BIOS support 63 * 64 * 26 -ESPFIX small SS 65 * 27 -per-cpu [ offsetto per-cpu data area ] 66 * 28 -stack_canary-20 [ forstack protector ] 67 * 29 -unused 68 * 30 -unused 69 * 31 -TSS for double fault handler 70 */ 73 74 #defineGDT_ENTRY_DEFAULT_USER_CS 14 75 76 #defineGDT_ENTRY_DEFAULT_USER_DS 15 77 78 #defineGDT_ENTRY_KERNEL_BASE (12) 79 80 #defineGDT_ENTRY_KERNEL_CS (GDT_ENTRY_KERNEL_BASE+0) 81 82 #defineGDT_ENTRY_KERNEL_DS (GDT_ENTRY_KERNEL_BASE+1) ...... 109 /* 110 * The GDT has 32entries 111 */ 112 #define GDT_ENTRIES 32 ...... 185 #endif 187 #define __KERNEL_CS (GDT_ENTRY_KERNEL_CS*8) 188 #define __KERNEL_DS (GDT_ENTRY_KERNEL_DS*8) 189 #define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS*8+3) 190 #define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS*8+3)
第27-70行的注释给出了GDT的布局。可见GDT中并没有用全部的8192个段描述符,只用了32个段描述符,用宏GDT_ENTRIES表示。GDT中第12个段描述符是内核的CS段,第13个表项是内核的DS段,第14个表项是用户空间的CS段,第15个表项是用户空间的DS段。这里注意,虽然第17个表项指向LDT(局部段描述符表),但linux中基本没有用到LDT,只有在VM86模式中运行WINE,或在linux上模拟运行Windows软件才会用到。
对照前面段选择符的定义,16位的段选择符高13为用作索引,bit2用作TI,bit0~bit1用来定义特权级别。这里,四个段都是用GDT,所以TI应该为0,内核的特权级别是0级,而用户空间的特权级别是3级。由此,我们应该很容易得到段选择符的定义:
第187~190行的宏定义不难理解,* 8表示左移3位,+3表示设置特权级别为3级。
那么,对应的段描述符又是如何定义的呢?我们只关注上面提到的四个段。
<arch/x86/kernel/cpu/common.c> 91DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = { 92 #ifdefCONFIG_X86_64 ...... 107 #else 108 [GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xc09a, 0,0xfffff), 109 [GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff), 110 [GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xc0fa, 0, 0xfffff), 111 [GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f2, 0, 0xfffff), ...... 141 #endif 142 } };
GDT表是一个per cpu变量,即每个CPU都一个副本,per cpu变量的实现我们在后面还会讲到。结构体gdt_page是对GDT表的抽象,其成员gdt是一个段描述符数组,数组共有32个元素,8字节的段描述符对应的结构体为desc_struct。这段代码用到了gnu c中对结构体和数组初始化的特殊语法。第108~111行,分别对gdt数组中的第12~15个元素进行初始化,这几个元素__KERNEL_CS/__KERNEL_DS/__USER_CS/__USER_DS对应的段描述符。
段描述符结构体desc_struct与GDT_ENTRY_INIT宏的定义如下:
<arch/x86/include/asm/desc_defs.h> 21 /* 8 bytesegment descriptor */ 22 structdesc_struct { 23 union { 24 struct { 25 unsigned int a; 26 unsigned int b; 27 }; 28 struct { 29 u16 limit0; 30 u16 base0; 31 unsigned base1: 8,type: 4, s: 1, dpl: 2, p: 1; 32 unsigned limit: 4, avl:1, l: 1, d: 1, g: 1, base2: 8; 33 }; 34 }; 35 }__attribute__((packed)); 36 37 #defineGDT_ENTRY_INIT(flags, base, limit) { { { \ 38 .a = ((limit) & 0xffff) |(((base) & 0xffff) << 16), \ 39 .b = (((base) & 0xff0000)>> 16) | (((flags) & 0xf0ff) << 8) | \ 40 ((limit) &0xf0000) | ((base) & 0xff000000), \ 41 } } }
结构体desc_struct中只包含一个union成员,union中的第二个结构图完全按照8字节段描述符的布局来定义,参考下图,我们可以重温下段描述符的布局。
union中第一个结构体将8字节的段描述符简化为两个32位无符号整形数a和b,这是为了方便我们来设置段描述符,GDT_ENTRY_INIT宏就是通过操作a和b来设置段描述符的。因为段描述的定义中有很多位段,特别是表示基址以及表示长度限制的位段分别不连续,一一去设置显然比较麻烦。
GDT_ENTRY_INIT宏的定义比较简单,对比段描述符的结构体,可知该宏设置段的基址为base,段的长度为limit,至于长度的单位以及其他的标志则根据flag来设置。
回过头来看看108~111的代码,可见__KERNEL_CS/__KERNEL_DS/__USER_CS/__USER_DS对应的段的基址都是0,段的长度限制都是0xfffff,只有flag不同,将flag展开来看:
①、四个段的相同的部分有
G位都为1,表示4个段的长度单位为4KB,
D位都为1,表示对4个段的访问都是32位指令
P位都为1,表示4个段都在内存中
S位都为1,表示代码段或者数据段
可见,4个段的基址都为0,长度限制都为0xfffff,每个段都是从0到4GB整个虚存空间,linux采用前面所说的Flat地址,虚拟地址经过段式映射后的线性地址保持不变,因此在讨论linux页式映射时,可以直接将线性地址当作虚拟地址,二者一致。
②、四个段不相同的部分只有DPL和type。
|
DPL |
type |
__KERNEL_CS |
0级 |
代码段、可读、可执行、尚未受到访问 |
__KERNEL_DS |
0级 |
数据段、可读、可写、尚未受到访问 |
__USER_CS |
3级 |
代码段、可读、可执行、尚未受到访问 |
__USER_DS |
3级 |
数据段、可读、可写、尚未受到访问 |
i386的CPU在做段式映射时会检查这两个字段。比如__KERNEL_CS段描述符中的DPL为1,如果CS寄存器中的DPL为3,则说明CPU当前运行级别比想要访问的区段低,则不允许访问;或者__KERNEL_DS为数据段,而试图通过CS段寄存器来访问,这也不允许,因为type不匹配。这里所做的比对在页式映射过程中还会进行。
可见linux将intel的复杂的段式映射简单化了,只用GDT,而且只设置了32个段描述符,只用到4个段寄存器,而且基本上不会进行段切换,这里的段映射只是“走走过场”。linux这么“胆大妄为”的原因,是因为在页式映射中,它还会对地址访问进行严格保护。
由于必须建立在段式映射的基础上,linux这种的页式管理也被人称作“段页式”。虚拟地址的映射过程可以用下图示意(本图来自网络)。