linux内存管理-地址映射的全过程

linux内核采用页式存储管理。虚拟地址空间划分成固定大小的页面,由MMU在运行时将虚拟地址映射成(或者说变换成)某个物理内存页面中地址。与段式存储管理相比,页式存储管理有很多好处。首先,页面都是固定大小的,便于管理。更重要的是,当要将一部分物理空间的内容换出到磁盘上的时候,在段式存储管理中要将整个段(通常都很大)都换出,而在页式存储管理中则是按页进行,效率显然要高得多。页式存储管理与段式存储管理所要求的硬件支持不同,一种CPU既然支持页式存储管理,就无需再支持段式存储管理。但是,i386的情况比较特殊,不管程序怎么写的,i386 CPU一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然CPU的硬件结构是这样,linux内核也只好服从Intel的选择。这样的双重映射其实是毫无必要的,也使映射的过程变得不容易理解,以至于有人还得出了linux采用段页式存储管理技术这样一种似是而非的结论。下面我们将会看到,linux内核所采用的办法是使段式映射的过程实际上不起什么作用(除特殊的VM86模式外,那是用来模拟80286的)。也就是说,你有政策我有对策,惹不起就躲着走。本博客通过一个情景,看看linux内核在i386 CPU上运行时地址映射的全过程。这里要指出,这个过程仅是对i386处理器而言的。对于其他的处理器,比如说M68K、power PC等,就根本不存在段式映射这一层了。反之,不管是什么操作系统(例如Unix),只要是在i386上实现,就必须至少在形式上要先经过段式映射,然后才可以实现其本身的设计。

假定我们写了这么一段程序:

#include<stdio.h>
greeting()
{
	printf("Hello World!\n");
}

main()
{
	greeting();
}

这个程序和我们平时写的有点不同,我们故意让main调用greeting来显示或打印“Hello World!”。

经过编译以后,我们得到可执行代码mem_test。先来看gcc和ld(编译与链接)执行后的结果。linux有个一个实用程序objdump是非常有用的,可以用来反汇编一段二进制代码。通过命令:

objdump -d mem_test

可以得到我们关心的那部分结果,输出的片段(反汇编的结果)为

0804840b <greeting>:
 804840b:	55                   	push   %ebp
 804840c:	89 e5                	mov    %esp,%ebp
 804840e:	83 ec 08             	sub    $0x8,%esp
 8048411:	83 ec 0c             	sub    $0xc,%esp
 8048414:	68 d0 84 04 08       	push   $0x80484d0
 8048419:	e8 c2 fe ff ff       	call   80482e0 <puts@plt>
 804841e:	83 c4 10             	add    $0x10,%esp
 8048421:	90                   	nop
 8048422:	c9                   	leave  
 8048423:	c3                   	ret    

08048424 <main>:
 8048424:	8d 4c 24 04          	lea    0x4(%esp),%ecx
 8048428:	83 e4 f0             	and    $0xfffffff0,%esp
 804842b:	ff 71 fc             	pushl  -0x4(%ecx)
 804842e:	55                   	push   %ebp
 804842f:	89 e5                	mov    %esp,%ebp
 8048431:	51                   	push   %ecx
 8048432:	83 ec 04             	sub    $0x4,%esp
 8048435:	e8 d1 ff ff ff       	call   804840b <greeting>
 804843a:	b8 00 00 00 00       	mov    $0x0,%eax
 804843f:	83 c4 04             	add    $0x4,%esp
 8048442:	59                   	pop    %ecx
 8048443:	5d                   	pop    %ebp
 8048444:	8d 61 fc             	lea    -0x4(%ecx),%esp
 8048447:	c3                   	ret    
 8048448:	66 90                	xchg   %ax,%ax
 804844a:	66 90                	xchg   %ax,%ax
 804844c:	66 90                	xchg   %ax,%ax
 804844e:	66 90                	xchg   %ax,%ax

从上列结果可以看到,ld给greeting分配的地址为0x0804840b。在ELF格式的可执行代码中,ld总是从0x08000000开始安排程序的代码段,对每个程序都是这样。至于程序在执行时在物理内存中的实际位置则就要由内核为其建立内存映射时作出安排,具体地址则取决于当时所分配到的物理内存页面。

假定该程序已经在运行,整个映射机制都已经建立好,并且CPU正在执行main中的call 0804840b这条指令,要转移到虚拟地址0x0804840b去。接下去我们就耐着性子一步一步地走过这个地址的映射过程。

首先是段式映射阶段。由于地址0x0804840b是一个程序的入口,更重要的是在执行的过程中是由CPU中的指令计数器EIP所指向的,所以在代码段中,i386 CPU使用代码段寄存器CS的当前值来作为段式映射的选择码,也就是用它作为在段描述表中的下标。哪一个段描述表呢?是全局段描述表GDT还是局部段描述表LDT?那就要看CS中的内容了。先看下保护模式下段寄存器的格式,如图:

也就是说,当bit2为0表示使用GDT,为1表示用LDT。Intel的设计意图是内核用GDT而各个进程都用其自己的LDT。最低两位RPL为所要求的特权级,共分为4级,0位最高。

现在,可以来看看CS的内容了。内核在建立一个进程时都要将其寄存器设置好(在进程管理的博客中会讲到这个问题),有关代码在include/asm-i386/processor.h中:

#define start_thread(regs, new_eip, new_esp) do {		\
	__asm__("movl %0,%%fs ; movl %0,%%gs": :"r" (0));	\
	set_fs(USER_DS);					\
	regs->xds = __USER_DS;					\
	regs->xes = __USER_DS;					\
	regs->xss = __USER_DS;					\
	regs->xcs = __USER_CS;					\
	regs->eip = new_eip;					\
	regs->esp = new_esp;					\
} while (0)

这里regs->xds是段寄存器DS的映像,其余类推。这里已经可以看到一个有趣的事情,就是除CS被设置成__USER_CS外,其他所有的段寄存器都设置成__USER_DS。这里特别值得注意的是堆栈寄存器SS,它也被设成__USER_DS。就是说,虽然Intel的意图是将一个进程的映像分成代码段、数据段和堆栈段,但linux内核并不买账。在linux内核中堆栈段和数据段是不分的。

再来看看__USER_DS和__USER_CS到底是什么。

#define __KERNEL_CS	0x10
#define __KERNEL_DS	0x18

#define __USER_CS	0x23
#define __USER_DS	0x2B

也就是说,linux内核只使用四种不同的寄存器数值,两种用于内核本身,两种用于所有的进程。现在,我们将这四种数值用二进制展开并与段寄存器的格式相对照:

                                                                 Index                TI            RPL

--------------------------------------------------------------------------------------------------------------

__KERNEL_CS     0x10      0 0 0 0  0 0 0 0  0 0 0 1  0 |    0 | 0 0

__KERNEL_DS    0x18       0 0 0 0  0 0 0 0  0 0 0 1  1 |    0 | 0 0

__USER_CS        0x23       0 0 0 0  0 0 0 0  0 0 1 0 0  |    0 | 1 1 

__USER_DS        0x2B       0 0 0 0  0 0 0 0  0 0 1 0 1  |    0 | 1 1 

-----------------------------------------------------------------------------------------------------------------

对照一下就清楚了,那就是:

__KERNEL_CS:          index = 2,     TI  = 0,     RPL = 0

__KERNEL_DS:          index = 3,     TI  = 0,     RPL = 0

__USER_CS:                 index = 4,     TI = 0,     RPL = 3

__USER_CS:                 index = 5,     TI = 0,     RPL = 3

首先,TI都是0,也就是说全部使用GDT。这就与Intel的设计意图不一致了。实际上,在linux内核中基本上不使用局部段描述表LDT。LDT只是在VM86模式中运行wine以及其他在linux上模拟运行Windows软件或DOS软件的程序中才使用。

再看RPL,只用了0和3两级,内核为0级而用户(进程)为3级。

回到我们的程序中。我们的程序显然不属于内核,所以在进程的用户空间中运行,内核在调度该进程进入运行时,把CS设置成__USER_CS,即0x23。所以,CPU以4为下标,从全局段描述表GDT中找对应的段描述项。

初始的GDT内容是在arch/i386/kernel/head.S中定义的,其主要内容在运行中并不改变:

/*
 * This contains typically 140 quadwords, depending on NR_CPUS.
 *
 * NOTE! Make sure the gdt descriptor in head.S matches this if you
 * change anything.
 */
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中的第一项(下标为0)是不用的,这是为了防止在加电后段寄存器未经初始化就进入保护模式并使用GDT,这也是Intel的规定,第二项也不用。从下标2至5共4项对应于前面的四种段寄存器数值。为便于对照,下面再次给出段描述项的格式,同时,将4个段描述项的内容按二进制展开如下:

K_CS: 0000 0000 1100 1111 1001 1010 0000 0000   0000 0000 0000 0000 1111 1111 1111 1111

K_DS:0000 0000 1100 1111 1001 0010 0000 0000    0000 0000 0000 0000 1111 1111 1111 1111

K_CS:0000 0000  1100 1111 1111 1010 0000 0000   0000  0000 0000 0000 1111 1111 1111 1111

K_DS:0000 0000 1100 1111 1111 0010 0000 0000    0000 0000  0000 0000 1111 1111 1111 1111 

我们结合下上面的页图的段描述项的定义对照,可以得出如下结论:

  1. 四个段描述项的下列内容都是相同的。
    B0-B15、B16-B31都是0 基地址全为0
    L0-L5、L16-L19都是1        段的上限全是0xfffff
    G位都是1 段长单位均为4KB
    D位都是1 对四个段的访问都是32位指令
    P位都是1 四个段都在内存

    结论:每个段都是从0地址开始的整个4GB虚存空间,虚地址到线性地址的映射保持原值不变。因此,讨论或理解linux内核的页式映射时,可以直接将线性地址当做虚拟地址,而这完全一致。

  2. 有区别的地方只是在bit40-bit46,对应于描述项中的type以及S标志和DPL位段。
  • 对__KERNEL_CS:DPL=0,表示0级;S位为1,表示代码段或数据段;type为1010,表示代码段,可读,可执行,尚未受到访问。
  • 对__KERNEL_DS:DPL=0,表示0级;S位为1,表示代码段或数据段;type为0010,表示数据段,可读,可写,尚未受到访问。
  • __USER_CS: DPL=3,表示3级;S位为1,表示代码段或数据段;type为1010,表示代码段,可读,可执行,尚未受到访问。
  • 对__USER_DS:即下标为5时,DPL=3,表示3级;S位为1,表示代码段或数据段;type为0010,表示数据段,可读、可写,尚未受到访问。

有区别的其实只有两个地方:一是DPL,内核为最高的0级,用户为最低的3级;另一个是段的类型,或为代码,或为数据。这两项都是CPU在映射过程中要加以检查核对的。如果DPL为0级,而段寄存器CS中的DPL为3级,那就不允许了,因为那说明CPU的当前运行级别比想要访问的区段要低。或者,如果段描述项说是数据段,而程序中通过CS来访问,那也不允许。实际上,这里所作的检查比对在页式映射的过程中还要进行。所以既然用了页式映射,这里的检查比对就是多余的。要不是i386 CPU中的MMU要作这样的检查比对,那就只要一个段描述项就够了。进一步,要不是i386 CPU中的MMU规定先做段式映射,然后才可以作页式映射。那就根本不需要段描述项和段寄存器了。所以,这里linux内核只不过是装模作样地糊弄i386 CPU,对付其检查比对而已。

我们也许会问:如此说来,怀有恶意的程序员岂不是可以通过设置寄存器CS或DS,甚至连这也不用,就可以打破i386的段式保护机制吗?是的,但是不要忘记,linux内核之所以这样安排,原因在于它采用的是页式存储管理,这里只不过是在对付本来就毫无必要却又非得如此的例行公事而已。真正重要的是页式映射阶段的保护机制。

所以,linux内核设计的段式映射机制把地址0x0804840b 映射到了其自身,现在作为线性地址出现了。下面才进入了页式映射的过程。

与段式映射过程中所有进程全都共用一个GDT不一样,现在可是动真格的了,每个进程都有其自身的页面目录PGD,指向这个目录的指针保持在每个进程的mm_struct数据结构中。每当调度一个进程进入运行的时候,内核都要为即将运行的进程设置好控制寄存器CR3,而MMU的硬件则总是从CR3中取得指向当前页面目录的指针。不过,CPU在执行程序时使用的是虚拟地址,而MMU硬件在进行映射时所用的则是物理地址。这是在inline函数switch_mm中完成的,其代码在include/asm-i386/mmu_context.h。但是我们在此关心的只是其中最关键的一行:

static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk, unsigned cpu)
{
。。。。。。
		asm volatile("movl %0,%%cr3": :"r" (__pa(next->pgd)));
。。。。。。
}

我们以前曾用这行代码说明__pa()的用途,这里将下一个进程的页面目录PGD的物理地址装入寄存器%%cr3,也即CR3。细心的我们可能会问:这样,在这一行以前和以后CR3的值不一样,也就是使用不同的页面目录,不会使程序的执行不能连续了吗?答案是,这是在内核中。不管什么进程,一旦进入内核就进了系统空间,都有相同的页面映射,所以不会有问题。

当我们在程序中要转移到地址0x0804840b去的时候,进程正在运行中,CR3早已设置好,指向我们这个进程的页面目录了。先将线性地址0x0804840b按二进制展开:

0000 1000 0000 0100 1000 0100 0000 1011

对照线性地址的格式,可见最高10位为二进制的0000100000,也就是十进制的32,所以i386 CPU(确切地说是CPU中的MMU,下同)就以32为下标去页面目录中找到其目录项。这个目录项中的高20位指向一个页面表。CPU在这20位后边添上12个0就得到该页面表的指针。以前我们讲过,每个页面表占一个页面,所以自然就是4K字节边界对齐的,其起始地址的低12位一定是0。正因为如此,才可以把32位目录项中的低20位挪作他用,其中的最低位为P标志位,为1时表示该页面表在内存中。

找到页面表以后,CPU再来看线性地址中的中间10位。线性地址0x0804840b的第二个10位为00 0100 1000,即十进制的72。于是CPU就以此为下标已经找到的页表中找到相应的表项。与目录项相似,当页面表项的P标志位为1时表示所映射的页面在内存中。32位的页面表项中的高20位指向一个物理地址页面,在后边添上12个0就得到这个物理内存页面的起始地址。所不同的是,这一次指向的不再是一个中间结构,而是映射的目标页面了。在其起始地址上加上线性地址中的最低12位,就得到了最终的物理内存地址。这时这个线性地址的最低12位为0x40b。所以,如果目标页面的起始地址为0x740000的话(具体取决于内核中的动态分配),那么greeting入口的物理地址就是0x74040b,greeting的执行代码就存储在这里。

读者可能已经能注意到,在页面映射的过程中,i386 CPU要访问内存三次,第一次是页面目录,第二次是页面表,第三次才是访问真正的目标。所以虚存的高效实现有赖于高速缓存(cache)的实现。有了高速缓存,虽然在第一次用到具体的页面目录和页面表时要到内存中去读取,但是一旦装入了高速缓存以后,一般都可以在高速缓存中找到,而不需要再到内存中去读取了。另一方面,这整个过程是由硬件实现的,所以速度很快。

除常规的页式映射之外,为了能在linux内核上仿真运行采用段式存储管理的Windows或DOS软件,还提供了两个特殊的、与段式存储管理有关的系统调用。

Guess you like

Origin blog.csdn.net/guoguangwu/article/details/120602723