i386的页式内存管理机制

    学过操作系统原理的读者都知道,内存管理有两种,一种是段式管理,另一种是页式管理,

而页式管理更为先进,从80年代中期开始,页式内存管理进入了种操作系统(以Unix为主) 的

内核,一时成为操作系统领域的一个热点。

      Intel 从80286开始实现其“保护模式”, 也即段式内存管理。但是很快就发现,光有段式管理而

没有页式管理是不够的,那样会使他的X86系列逐渐失去竟争力以及作为主流CPU产品的地位。

因此,在不久以后的80386中就实现了对页式内存管理的支持。也就是说,80386除了完成并完善

从80286开始的段式内存管理的同时还实现了页式内存管理。

     80386的段式内存管理机制,是将指令中结合段寄存器使用的32位逻辑地址映射(转换)成

同样是32位的物理地址。之所以称为“物理地址”,是因为这是真正放到地址总线上去,并且以寻

访物理上存在着的具体内存单元的地址。但是,段式存储管理机制的灵活性和效率都比较差。一

方面“段”是可变长度的,这就给盘区交换操作带来了不便;另一方贡,如果为了增加灵活性而将一个进程

的空间划为成很多小段时,就势必要求在程序中频繁地改变段寄存器的内容。同时,如果将段分小,虽然

一个段描述表中可以容纳8192 个描述项(因为有13位下标),也未必就能保存足够使用。

所以,比较好的办法还是采用页式管理。本来,页式存储管理并不需要建立在段式存储管理的基础之

上,这是两种不同的机制。可是,在80386中,保护模式的实现与段式存储密不可分的。例如,CPU

的当前执行权限就是在有关的代码段描述项中规定的。页式存储管理的作用是在由段式存储管理所映射而

成的地址上再加上一层地址映射。由于此时由段式存管映射而成的地址不再是“物理地址”了,Intel 就称之为

“线性地址”。于是段式存储管理先将逻辑地址映射成线性地址,然后再由页式存储管理将线性地址映射成物理

地址,或者,当不使用页式存储管理时,就将线性地址直接用作物理地址。

    80386 把线性地址空间划分成4K字节的页面,每个页面可以被映射至物理存储空间中任意一块4K字节大小

的区间(边界必须与4K字节对齐)。 在段式存储管理中,连续的逻辑地址经过映射后在线性地址空间还是连续的。

但是在页式存储管理中,连续的线性地址经过映射后在物理空间却不一定连续(其灵活性也正在于此)。这里需要指出的是,

虽然页式存储管理是建立在段式存储管理的基础上,但一旦启用了页式存储管理,所有的线性地址都要经过页式映射,连GDTR 与LDTR 中给出的段描述表起始地址也不例外。

    由于页式存储管理的引入,对32位的线性地址有了新的解释(以前就是物理地址):

   typedef struct {

      unsigned int   dir:10;       /* 用作页面目录中的下标,该目录项指向一个页面表 */

      unsigned int    page:10;   /* 用作具体页面表的下标,该表项指向一个物理页面 */

      unsigned int    offset:12;   /* 在4K 字节物理页面内的偏移量 */

地址映射的全过程

Linux 内核采用页式存储管理。虚拟地址空间划分成固定大小的“页面”, 由MMU在运行时,将

虚拟地址“映射”成(或者说变换成)某个物理内存页面中的地址。与段式存储管理相比,页式存储管理

有很多好处。首先,页面都是固定大小的,便于管理,更重要的是,当要将一部分物理空间的内容换出到磁盘上的时候,在段式存储管理中要将整个(通常都很大)都换出,而在页式存储管理中则是按页进行,效率显然要高得多。页式存储管理与段式存储

管理所要求的硬件支持不同,一种CPU即然支持页式存储管理,就无需再支持段式存储管理。但是,我们在前面讲过,i386 的情况是特殊的。由于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();
}

读者一定很熟悉。这个程序与大部分人写的第一个C程序只有一点不同,我们故意让main() 调用greeting()来显示

或打印“Hello, world!".

经过编译以后,我们得到可执行代码hello. 先来看看gcc 和ld (编译和连接) 执行后的结果。

假定该程序已经在运行,整个映射机制都已经建立好,并且CPU 正在执行main()中的”call  848368" 这条指令,要转移到

虚拟地址0x8048368 去。

    首先是段式映射阶段。由于地址0x8048386 是一个程序的入口,更重要的是在执行过程中是由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 内核中堆栈段和数据段是不分的。

     再来看看USER_CS 和USER_DS 到底是什么。 那是在include/asm-i386/segment.h 中定义的:

  #define _KERNEL_CS   0X10

  #define _KERNEL_DS  0X18

   #define _USER_CS  0X23

   #define _USER_DS  0X28

也就是说,Linux 内核中只使用四种不同的段寄存器数值,两种用于内核本身,两种用于所有的进

程。现在,我们将这四种数值用二进制展开并与段寄存器的格式相对照:

首先,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 中定义的,其主要内容在运行中并不改变:

GDT 中的第一项(下标为0) 是不用的,这是为了防止在加电后段寄存器未经初始化就进入保护模式并

使用GDT, 这也是Intel 的规定。第二项也不用。从下标2至5 共4项对应于前面的4种段寄存器数值。

  读者结合下页图2.4 段描述项的定义仔细对照,可以得出如下结论:

(1) 四个段描述项的下列内容都是相同的。

    b0-b15, b16-b31 都是0   

结论: 每个段都是从0 地址开始的整个4GB 虚存空间,虚地址到线性地址的映射保持原值不变。

因此,讨论或理解Linux 内核的页式映射时,可以直接将线性地址当作虚拟地址,二者完全一致。

所以,Linux内核设计的段式映射机制把地址0x8048368 映射到了其自身,现在作为线性地址出现了。

下面才进入了页式映射的过程。

     与段式映射过程所有进程全都共用一个GDT 不一样,现在可是动真格的了,每个进程都有其自身的页面

目录PGD, 指向这个目录的指针保存在每个进程的mm_struct 数据结构中。每当调度一个进程进入运行的时候,内核

都要为即将运行的进程设置好控制寄存器CR3, 页MMU 的硬件总是从CR3 中取得指向当前页面目录的指针。不过,

CPU 在执行程序时使用的虚拟地址,而MMU 硬件在进行映射时所用的则是物理地址。这是在inline函数switch_mm()

中完成的,其代码凡见include/asm-i386/mmu_contest.h

asm volatile("movl %0, %%cr3": : "r"(pa(next->pdg));

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

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

   000 1000 0000 0100 1000 0011 0110 1000

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

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

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

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

      modify_ldt(int func, void *ptr, unsigned bytecount)

       这个系统调用可以用来改变当前进程的局部段描述表,在自由软件基金会FSF下面,除Linuxc 以外还有许多个项目在进行。其中一个叫“WINE", 其名字来自”Windos Emulation“, 目的是在Linux上仿真运行Windows 的软件。多年来,有些Windows 软件已经广泛地为人们所接受和熟悉(如MS Word等), 而在linux 上没有相同的软件往往成了许多人不愿意转向linx 的原因。所以,在linux上建立一个环境,使得用户可以在上面运行Windows 的软件,就成了一开拓市场的举措。而系统调用modify_ldt() 就是因开发WINE的需要而设置的。当func 参数的值为0时,该调用返回本进程局部段描述表的实际大小,而表的内容就在用户通过ptr 提供的缓冲区中。当func参数的值为1时,ptr 应指向一个结构modify_ldt_ldt_s.  而bytecount 则为sizeof(struct modifyl_ldt_ldt_s). 该数据结构的定义见于include/asm-i386/ldt.h:

几个重要的数据结构和函数

     从硬件的角度来说,linux内核只要能为硬件准备好页面目录PGD, 页面表PT 以及全局段描述表GDT 和局部段描述表LDT, 并正确地设置有关寄存器,就完成了内存管理机制中地址映射部分的准备工作。虽然最终的目的是地址映射,但是实际上内核所需要做的管理工作却要复杂得多。在与内存管理有关的内核代码中,有几个数据结构是很重要的,这些数据结构及其使用构成了代码中内存管理的基本框架。

     页面目录pgd、中间目录PMD 和页面表PT 分别是由表项pgd_t, pmd_t 以及pte_t 构成的数组,而这些表项又都是数据结构,定义于include/asm-i386/page.h中:

/*
 * These are used to make use of C type-checking..
 */
#if CONFIG_X86_PAE
typedef struct { unsigned long pte_low, pte_high; } pte_t;
typedef struct { unsigned long long pmd; } pmd_t;
typedef struct { unsigned long long pgd; } pgd_t;
#define pte_val(x)    ((x).pte_low | ((unsigned long long)(x).pte_high << 32))
#else
typedef struct { unsigned long pte_low; } pte_t;
typedef struct { unsigned long pmd; } pmd_t;
typedef struct { unsigned long pgd; } pgd_t;
#define pte_val(x)    ((x).pte_low)
#endif
#define PTE_MASK    PAGE_MASK

可见,当采用32位地址时,pgd_t, pmd_t 和pte_t 实际上就是长整数,而当采用36位地址时则是long long

整数。之所以不直接定义成长整数的原因在于这样可以让GCC 在编译时加以更加严格的类型检查。同时,代码中又定义了几个简单的函数来访问这些结构的成分: 如pte_val(), pgd_val() 等(难怪有人说Linux 内核的代码吸收了面向对象的程序设计手法)。但是,如我们以前说过的那样,表项PTE 作为指针实际上只需要它的高20位。同时,所有物理页面都是跟4k字节的边界对齐的,因而物理页面起始地址高20位又可以看作是物理页面的序号。所以,pte_t 中的低12位用于页面的状态信息和访问权限。在内核代码中并没有在pte_t 等结构中定义有关的位段,而是在include/asm-i386/page.h中另行定义了一个用来说明页面保护的结构pgprot_t:

typedef struct {unsigned long pgprot;} pgprot_t;

参数pgprot的值与i386 MMU r的页面表项的低12位相对应,其中9位是标志位,表示所映射页面的当前状态和访问权限。内核代码中作为相应的定义:

#define _PAGE_PRESENT    0x001
#define _PAGE_RW    0x002
#define _PAGE_USER    0x004
#define _PAGE_PWT    0x008
#define _PAGE_PCD    0x010
#define _PAGE_ACCESSED    0x020
#define _PAGE_DIRTY    0x040
#define _PAGE_PSE    0x080    /* 4 MB (or 2MB) page, Pentium+, if present.. */
#define _PAGE_GLOBAL    0x100    /* Global TLB entry PPro+ */

#define _PAGE_PROTNONE    0x080    /* If not present */

注意这里的_PAGE_PROTNONE对应于页面表项中的bit7, 在Intel 的手册中说这一位保留不用,所以

对MMU不起作用。

     在实际使用中,pgprot 的数值总是小于0x1000, 而pte中的指针部分则是大于0x1000, 将二者合在

一起就得到实际用于页面中的表项。具体的计算是由include/asm-i386/pgtable-2level.h中定义的宏操作

mk_pte完成的:

#define _mk_pte(page_nr, pgprot) _pte((page_nr) << PAGE_SHIFT) | pgprot_val(pgprot))

这里将页面序号左移12位,再与页面的控制/状况位段相或,就得到了表项的值。这里引用的两个宏操作

均定义于include/asm-i386/page.h中:

#define pgprot_val(x)   ((x).pgprot)

#define _pte(x) ((pte_t) {(x})

内核中有个全局变量mem_map, 是一个指针,指向一个page数据结构的数组(下面会讨论page结构)。每个page 数据结构代表着一个物理页面,整个数组就代表着系统中的全部物理页面。因此,页面表项的高20位对于软件和MMU硬件有着不同的硬义。对于软件,这是一物理页面的序号,将这个序号用作下标就可以从mem_map找到代表这个物理页面的page数据结构。对于硬件,则(在低位补上12个0后),就是物理页面的起始地址。

   还有一个常用的宏操作set_pte(),用来把一个表项的值设置到一个页面表项中,这个宏操作定义于include/asm-i386/pgtable-2level.h中:

#define set_pte(pteptr, pteval) (*(pteptr) = pteval)

在映射的过程中,MMU首先检查的是P标志位,就是上面的PAGE_PRESENT, 它指示着所映射的页面是否在内存中。只有在P标志位为1的时候MMU才会完成映射的全过程: 否则就会因不能完成映射而产生一次缺页异常,此时表项中的其它内容对MMU就没有任何意义了。除MMU硬件根据页面表项的内容进行页面映射外,软件也可以设置或检测页面表项的内容,上面的set_pte()就是用来设置页面表项。内核中还为检测页面表项的内容定义了一些工具性的函数或宏操作,其中最重要的有:

#define pte_none(x)    (!(x).pte_low)

#define pte_present(x) ((x).pte_low &(_PAGE_PRESENT | _PAGE_PROTNONE))

对软件来说,页面表项为0表示尚未为这个表项(所代表的虚存页面)建立映射,所以还是空白:而如果页面表项不为0,但P标志位为0,则表示映射已经建立,但是所映射的物理页面不在内存中(已经换出到交换设备上)

代表物理页面的page数据结构是在文件include/linux/mm.h中定义的:

/*

  * Try to keep the most commonly accessed fields in single cache lines 

   * here (16 bytes or greater). This ordering should be particularly

    * beneficial on 32-bit processor.

     * The first line is data used in page cache lookup, the second line

     * is used for linear seraches (eg. clock algorithm scans).

     */

     typedef struct page {

           struct list_head  list;

           struct address_space *mapping;

             unsigned long index;

             struct page *next_hash;

             atomic_t count;

              unsigned long flags;    /* atomic flags, some possibly updated asynchronously */

              struct list_head lru;

              unsigned long age;

             wait_queue_head_t wait;

              struct page **pprev_hash;

              struct buffer_head *buffers;

               void *virtual;   /* non-NULL if kmapped */

               struct zone_struct *zone;

}  mem_map_t;

内核中用来表示这个数据的变量名常常是page 或map.

 当页面的内容来自一个文件时,index 代表着该页面在文件中的序号;当页面的内容被换出到交换设备上,但还保留着内容作为缓冲时,则index指明了页面的去向。结构中各个成分的次序是有讲究的,目的是尽量使用联系紧密的若干成分在执行时被装填入高速缓存的同一缓冲线(16个字节)中。

      系统中的每一个物理页面都有一个page结构(或者mem_map_t).系统在初始化时根据物理内在的大小建立起一个page 结构数组mem_map, 作为物理页面的“仓库”, 里面的每个page数据结构都代表着系统中的一个物理页面。每个物理页面的page结构在这个数组里的下标就是该物理页面的序号。“仓库”里的物理页面划分成ZONE_DMA 和ZONE_NORMAL两个管理区(根据系统配置,还可能有第三个管理区ZONE_HIGHMEM, 用于物理地址超过1GB的存储空间)。

      管理区ZONE_DMA里的页面是专供DMA 使用的。为什么供DMA 使用的页面要单独加以管理呢?首先,DMA使用的页面是磁盘I/O所必需的,如果把仓库中所有的物理页面都分配光了,那就无法进行页面与盘区的交换了。此外,还有些特殊的原因。在i386CPU中,页式存储管理的硬件支持是在CPU内部实现的,页不像另有些CPU那样由一个单独的MMU提供,所以DMA不经过MMU提供的地址映射。这样,外部设备就要直接提供访问物理页面的地址,可是有些外设(特别是插在ISA总线上的外设接口卡)在这方面往往有些限制,要求用于DMA的物理地址不能过高。另一方面,正因为DMA不经过MMU提供的地址映射,当DMA

所需的缓冲区超过一个物理页面的大小时,就要求两个页面在物理上连续,因为此时DMA控制器不能依靠在CPU内部的MMU将连续的虚存映射到物理上不连续的页面。所以,用于DMA物理页面是要单独加以管理的。

      每个管理区都有一个数据结构,即zone_struct 数据结构。在zone_struct 数据结构中有一组“空闲区间”(free_area_t) 队列。为什么是“一组”队列,而不是“一个”队列呢?这也是因为常常需要成”块“ 地分配在物理空间内连续的多个页面,所以要按块的大小分别加以管理。因此,在管理区数据结构中即要有一个队列来保持一些离散(连续长度为1)的物理页面,还要有一个队列来保持一个连续长度为2的页面以及连续长度为4, 8, 16, ... 直至2^MAX_ORDER的页面块。常数MAX_ORDER定义为10,也就是说最大的连续页面块可以达到2^10 = 1024 个页面,即4M字节。这两个数据结构以及几个常数都是在文件include/linux/mmzone.h中定义的:

typedef struct zone_struct {
    /*
     * Commonly accessed fields:
     */
    spinlock_t        lock;
    unsigned long        offset;
    unsigned long        free_pages;
    unsigned long        inactive_clean_pages;
    unsigned long        inactive_dirty_pages;
    unsigned long        pages_min, pages_low, pages_high;

    /*
     * free areas of different sizes
     */
    struct list_head    inactive_clean_list;
    free_area_t        free_area[MAX_ORDER];

    /*
     * rarely used fields:
     */
    char            *name;
    unsigned long        size;
    /*
     * Discontig memory support fields.
     */
    struct pglist_data    *zone_pgdat;
    unsigned long        zone_start_paddr;
    unsigned long        zone_start_mapnr;
    struct page        *zone_mem_map;
} zone_t;

#define ZONE_DMA        0
#define ZONE_NORMAL        1
#define ZONE_HIGHMEM        2
#define MAX_NR_ZONES        3

管理区结构中的offset 表示该分区在mem_map中的起始页面号。一旦建立起管理区,每个物理页面便永久地属于某一个管理区,具体取决于页面的起始地址,就好像是幢建筑物属于哪一个派出所管辖取决于其地址一样。空闲区free_are_struct结构中用来维持双向链队列的结构list_head是一个通用的数据结构,linux 内核中需要使用双向链队列的地方都使用这种数据结构。结构很简单,就是prev 和next两个指针。回到上面的page结构,其中的第一个成分就是一个list_head结构,物理页面的page结构正是通过它进入free_area_struct结构中的双向链队列的。在”物理页面的分配“一节中,我们将讲述内核怎样从它的仓库中分配一块物理空间,即若干连续的物理页面。

      在传统的计算机结构中,整个物理空间都是均匀一致的,CPU访问这个空间中的任何一个地址所需的时间都相同,所以称为”均质存储结构“(uniform Memory architecture), 简称uma. 可是,在一些新的系统结构中,特别是在多CPU结构的系统中,物理存储空间在这方面的一致性却成了问题。试想有这么一种系统结构:

系统的中心是一条总线,例如PCI总线。

有多个CPU模块连接在系统总线上,每个CPU模块都有本地的物理内存,但是也可以通过系统总线访问其它CPU模块上的内存

系统总线上还连接着一个公用的存储模块,所有的CPU模块都可以通过系统总线来访问它。

所有这些物理内存的地址互相连续而形成一个连续的物理地址空间。

显然,就某个特定的CPU而言,访问其本地的存储器是速度最快的,而穿过系统总线访问公用存储模块或者其它CPU模块上的存储器就比较慢,而且还面临因可能的竞争而引起的不确定性。也就是说,在这样的系统中,其物理存储空间虽然连续,”质地"却不一致,所以称为”非均质存储结构"(non-uniform memory architecture), 简称NUMA. 在NUMA结构的系统中,分配连续的若干物理页面时一般要求分配在质地相同的区间(称为node,即"节点”). 举例来说,要是CPU模块1要求分配4个物理页面,可是由于本模块上的空间已经不够,所以前3个页面分配在本模块上,而最后一个页面却分配到了CPU模块2上,那显然是不合适的。在这样的情况下,将4个页面都分配在公用模块上显然要好得多。

      事实上,严格意义上的UMA结构几乎是不存在的。就拿配置最简单的单CPU的PC 来说,其物理存储空间就包括了RAM, ROM,还有图形卡上的静态RAM. 但是在UMA结构中,除“主存”RAM以外的存储器都很小,所以把它们放在特殊的地址上成为大在小小的孤岛,再在编程的时特别加以注意就可以了。然而,在典型的NUMA结构中就需要来自内核中内存管理机制的支持了。由于多处理器结构的系统日益广泛的应用,Linux内核2.4.0版提供了对NUMA的支持

         由于NUMA结构的引入,对于上述的物理页面管理机制也作了相应的修正。管理区不再是属于最高层的机构,而是在每个存储节点都至少两个管理区。而且前述的page结构数组也不再是全局性的,而是从属于具体的节点了。从而,在zone_struct结构(以及page结构数组)之上又有了另一层代表着存储节点的pglist_data 数据结构,定义于include/linux/mmzone.h中:

struct bootmem_data;
typedef struct pglist_data {
    zone_t node_zones[MAX_NR_ZONES];
    zonelist_t node_zonelists[NR_GFPINDEX];
    struct page *node_mem_map;
    unsigned long *valid_addr_bitmap;
    struct bootmem_data *bdata;
    unsigned long node_start_paddr;
    unsigned long node_start_mapnr;
    unsigned long node_size;
    int node_id;
    struct pglist_data *node_next;
} pg_data_t;

显然,若干存储节点的pglist_data 数据结构可以通过指针node_next形成一个单键队列。每个结构中的指针node_mem_map指向具体节点的page结构数组,而数组node_zones[] 就是该节点的最多三个页面管理区。反过来,在zone_struct 结构中也有一个指针zone_pgdat, 指向所属节点的pglist_data 数据结构。

同时,又在pglist_data 结构里设置了一个数组node_zonelists[], 其类型定义也在同一个文件中:

typedef struct zonelist_struct {

    zone_t *zones[MAX_NR_ZONES+1];   // NULL delimited

     int  gfp_mask;

}  zonelist_t;

这里的zones[] 是个指针数组,各个元素按特定的次序指向具体的页面管理区,表示分配页面时先试zones[0] 所指向的管理区,如不能满足要求就试zone[1]所指向的管理区,等等。这些管理区可以属于不同的存储节点。这样,针对上面所举的例子就可以规定:先把本节点,即CPU模块1的ZONE_DMA管理区,若不够4个页面就全部人公用模块的ZONE_DMA管理区中分配。就是说,每个zonelist_t规定一种分配策略。然而,每个存储节点不应该只有一种分配策略,所以在pglist_data结构中提供的是一个zonelist_t数组,数组的大小为NR_GFPINDEX,定义为:

#define NR_GFPINDEX 0X100

就是说,最多可以规定256种不同的策略。要求分配页面时,要说明采用哪一种分配策略。

前面几个数据结构都是用于物理空间管理的,现在来看看虚拟空间的管理,也就虚存页面的管理。虚存空间的管理不像物理空间的管理那样有一个总的物理页面的仓库,而是以进程为基础的,每个进程都有各自的虚存(用户)空间。不过,如前所述,每个进程的系统空间是统一为所有进程所共享的。以后我们对进程的虚存空间“和”用户空间“这两个词常常会不加区分。

       如果说物理空间是从"供"的角度来管理的,也就是"仓库中还有些什么“; 则虚存空间的管理是从”需“的角度管理的,就是”我们需要用虚存空间中的哪些部分“。拿虚存空间中的”用户空间“部分来说,大概没有一个进程会真的需要使用全部的3G字节的空间。同时,一个进程所需要需使用的虚存空间中的各个部位又未必是连续的,通常形成若干离散的虚存”区间“。很自然的,对虚存空间的抽象是一个重要的数据结构。在linux 内核中。这就是vm_area_struct 数据结构,定义于include/linux/mm.h中:

/*

  * This struct defines a memory VMM memory area. There is one of these per VM-area/task.

    * A VM area is any part of the process virtual memory

    * space that has a special rule for the page-fault handlers (ie a shared library, the executable area etc).

    */

struct vm_area_struct {

      struct mm_struct  *vm_mm;   /* VM area parameters */

       unsigned long vm_start;

       unsigned long vm_end;

    

     /* linked list of VM areas per task,  sorted by address */

       struct vm_area_struct *vm_next;

     pgprot_t vm_page_prot;

     unsigned long vm_flags;

     /* AVL tree of VM areas per task, sorted by address */

    short vm_avl_height;

    struct vm_area_struct *vm_avl_left;

    struct vm_area_struct   * vm_avl_right;

     /* For area with an address space and backing store.

        * one of the address_space->i_mmap{, shared} list,

        * for shm areas, the list of attaches, otherwise unused.

     */

      struct vm_area_struct *vm_next_share;

       struct vm_area_struct **vm_pprev_share;

      struct vm_operations_struct  *vm_ops;

       unsigned long vm_pgoff;     /* offset in PAGE_SIZE units, not PAGE_CACHE_SIZE */

      struct file * vm_file;

     unsigned long vm_raend;

     void  * vm_private_data;    /* was vm_pte  (shared mem) */

};

在内核的代码中,用于这个数据结构的变量名常常是vma。

  结构中的vm_start 和vm_end决定了一个虚存区间。vm_start是包含在区间内的,而vm_+end 则不包含在区间内。区间的划分并不仅仅取决于地址的连续性,也取决于区间的其他属性,主要是对虚存页面的访问权限。如果一个地址范围内的前一半页面和后一半页面有不同的访问权限或其他属性,就得要分成两个区间。所以,包含在同一个区间里的所有页面都有相同的访问权限(或者说保护属性)和其它一些属性,这就是结构中的成分vm_page_prot和vm_flags的用途。属于同一个进程的所有区间都要按虚存地址的高低次序键接在一起,结构中的vm_next指针就是用于这个目的。由于区间的划分并不仅仅取决于地址的连续性,一个进程的虚存(用户)空间很可能会被划分成大量的区间。内核中给定一个虚拟地址而要找出其所属的区间是一个频繁用到的操作,如果每次都要顺着vm_next在链中作线性搜索的话,势必会显著地影响到内核的效率。所以,除了通过vm_next指针把所有的区间串成一个线性队列以外,还可以在区间数量较大时为之建立一个AVL(Adelson-Velskii and Landis) 树。AVL树是一种平衡的树结构,读者从有关数据结构专著中可以了解到,在AVL树中搜索的速度快而代价是O (lg n), 即与树的大小的对数(而不是树的大小)成比例。虚存区间结构vm_area_struct中的vm_avl_height, vm_avl_left以及vm_avl_right三个成分就是用于AVL树,表示本区间在AVL树中的位置的。

       在两种情况下虚存页面(或区间)会跟磁盘文件发生关系。一种是盘区交换(swap), 当内存页面不够分配时,一些久未使用的页面可以被交到磁盘上去,腾出物理页面以供更急需的进程使用,这就是大家所知道的一般意义上的”按需调度" 页式虚存管理(demand paging). 另一种情况则是将一个磁盘文件映射到一个进程的用户空间中。Linux提供了一个系统调用mmap()。使一个进程可以将一个已经打开的文件映射到其用户空间中,此后就可以像访问内存中的一个字符数组那样来访问这个文件的内容,而不必通过lseek(), read() 或write() 等进行文件操作。

 由于虚存区间(最终是页面)与碰盘文件的这种联系,在vm_area_struct结构中相应志设置了一些成分,如mapping, vm_next_share, vm_pprev_share, vm_file等,用以记录和管理此种联系。我们将在以后结合具体的情景介绍这些成分的使用。

虚存区间结构中另一个重要的成分是vm_ops, 这是指向一个vm_operations_struct数据结构的指针。这种数据结构也是在include/linux/mm.h中定义的:

 /*

   * These are the virtual MM functions - opening of an area, closing and unmapping it (needed to keep files on disk up-to-date etc), pointer to the functions called when a no-page or wp-page exception occurs.

  */

struct vm_operations_struct {

    void (*open) (struct vm_area_struct *area);

     void (*close)(struct vm_area_struct *area);

     struct page * (*nopage)(struct vm_area_struct *area, unsigned long address, int write_access);

};

结构中全是函数指针。其中open, close, nopage 分别用于虚存区间的打开、关闭和建立映射。为什么要

有这些函数呢? 这是因为对于不同的虚存区间可能会需要一些不同的附加操作。nopage指示当因(虚存)页面不在内存中而引起”页面出错" (page fault) 异常时所应调用的函数。

     最后,vm_area_struct 中还有一个指针vm_mm, 该指针指向一个mm_struct数据结构,那是在include/linux/sched.h中定义的:

     struct mm_struct {

                    struct vm_area_struct *mmap;   /* list of VMAS */

                    struct vm_area_struct *mmap_avl;   /* tree of VMAS */

                     struct vm_area_struct * mmap_cache; /* last find_vma result */

                    pdg_t *pgd;

                     atomic_t mm_users;    /* how many users with user space ? */

                      atomic_t mm_count;    /* how many references to ‘struct mm_struct" (users count as 1) */

                       int map_count;      /* number of VMAS */

                       int map_count;

                       struct semaphore mmap_sem;

                       spinlock_t page_table_lock;

                       unsinged long start_code, end_code, start_data, end_data;

                        unsigned long start_brk, brk, start_stack;

                        unsigned long arg_start, arg_end, env_start, end_end;

                        unsigned long rss, total_vm, locked_vm;

                        unsigned long def_flags;

                        unsigned long cpu_vm_mask;

                         unsigned long swap_cnt;   /* number of pages to swap on next pass */

                        unsigned long swap_address;

                        /* Architecture-specific MM context */

                       mm_context_t context;

 };

在内核的代码中,用于这个数据结构(指针)的变量名常常是mm.

显然,这是在比vm_area_struct 更高层次上使用的数据结构。事实上,每个进程只有一个mm_struct结构,在每个进程的”进程控制块", 即task_struct 结构中,有一个指针指向该进程的mm_struct结构。可以说,mm_struct 数据结构是进程整个用户空间的抽象,也是总的控制结构。结构中的头三个指针都是关于虚存区间的。第一个mmap用来建立一个虚拟区间结构的单链线性队列。第二个mmap_avl 用来建立一个虚存区间结构的AVL树,这在前面已经谈过。第三个指针mmap_cache,用来指向最近一次用到的那个虚存区间结构;

越界访问

    页式存储管理机制通过页面目录和页面表将每个线性地址(也可以理解为虚拟地址)转换成物理地址。如果在这个过程中遇到某种阻碍而使CPU无法最终访问到相应的物理内存单元,映射便失败了,页当前的指令也就不能执行完成。此时CPU会产生一次页面出错(Page Fault) 异常(Exception)(也称缺页中断),进页执行预定的页面异常处理程序,使应用程序得以从因映射失败而暂停的指令处开始恢复执行,或进行一些善后处理。这里所说的阻碍可以有以下几种情况:

  1.      相应的页面目录项或页面表项为空,也就是该线性地址与物理地址的映射关系尚未建立,或者已经撤销。
  2.       相应的物理页面不在内存中。
  3.       指令中规定的访问方式与页面的权限不符,例如企图写一个“只读”的页面。

在这个情景里,我们假定一段用户程序曾经将一个已经打开文件通过mmap()系统调用映射到内存,然后又已经将映射撤销(通过munmap()系统调用)。 在撤销一个映射区间时,常常会虚存地址空间中留下一个孤立的空洞,而相应的地址则不应继续使用了。但是,在用户程序中往往会有错误,以致在程序中某个地方还再次访问这个已经撤销的区域(程序员们一定会同意,这是不足为奇)。这时候,一次因越界访问一个无效地址(Invalid Address)

                       

猜你喜欢

转载自blog.csdn.net/robinsongsog/article/details/99677195