之三:linux的页式内存管理

本文深入探讨了Linux操作系统中页式内存管理机制,特别是在32位和64位架构下的实现差异。文章详细分析了Linux如何适配不同CPU和MMU特性,包括使用三层页表映射模型以及在i386架构上实现两层映射的方法。

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这种的页式管理也被人称作“段页式”。虚拟地址的映射过程可以用下图示意(本图来自网络)。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值