Refer to <<linux
内核源代码情景分析
>> and <<Linux kernel Version:2.4.0>>
Having any problems, send mails to viloner@163.com
Linux
内存管理的基本框架
I386CPU
中的页式存储管理的基本思路是:通过页面目录和页面表分两个层次实现从线性地址到物理地址的映射。这种采用两层的方式,只要一个目录项所对应的那部分空间一个空洞,就可以把该目录项设置成“空”
,
从而省下了与之对应的页面表(
1024
个页面描述项)。当地址的宽度为
32
位时,两层映射机制比较有效也比较合理。但是,当地址宽度大于
32
位时,两层映射就显得不尽合理,有够有效了。
Linux
内核的设计要考虑到在各种不同的微处理器上的实现,还有考虑到在
64
位的微处理器
(
如
Alpha)
上的实现,所以不能仅仅针对
i386
结构来设计它的映射机制,而要以只要假象的、虚拟的微处理器和
MMU(
内存管理单元
)
为基础,设计出一种通用的模式,再把它分别落实到具体的微处理器上。因此,
Linux
内核的映射机制被设计成三层,在页面目录和页表之间增设了一层
“
中间目录
”
。在代码中,页面目录称为
PGD,
中间目录称为
PMD
,而页表称为
PT
。
PT
的表项称为
PTE
(
Page Table Entry
)。
PGD
,
PMD
和
PT
均为数组。相应的,在逻辑上也把线性地址从高到低分为
4
各位段,各占若干位,分别用作目录
PGD
的下标、中间目录
PMD
的下标、页表中的下标以及物理页面内的位移。这样,对于
CPU
发出的线性地址,虚拟的
Linux
内存单元分如下四步完成从线性地址到物理地址的映射:
(1)
用线性地址中最高的那一个位段作为下标在
PGD
中找到相应的表项,该表项指向相应的中间目录
PMD
。
(2)
用线性地址中的第二个位段作为下标在此
PMD
中找到相应的表项,该表项指向相应的页面表。
(3)
用线性地址中的第三个位段作为下标在页面表中找到相应的表项
PTE
,该表项中存放的就是指向物理页面的指针。
(4)
线性地址中的最后位段为物理页面内的相对位移量,将此位移量与目标物理页面的起始地址相加便得到相应的物理地址。
但是,这个虚拟的映射模型必须落实到具体的
CPU
和
MMU
的物理映射机制。就
i386
微处理器来说,
CPU
实际上不是按三层而是按两层的模型来进行地址映射而是按两层的模型进行映射的。这就需要将虚拟的三层映射落实到具体的两层的映射,跳过中间的
PMD
层次。另一方面,从
Pentium Pro
开始,
Intel
引入了物理地址扩充功能
PAE
,允许将地址宽度从
32
位提高到
36
位,并且在硬件上支持三层映射模型。这样,在
Pentium Pro
及以后的
CPU
上,只要将
CPU
的内存管理设置成
PAE
模式,就能使虚存的映射变成三层模式。
那么,具体对于
i386
结构的
CPU
,
Linux
内核是怎样实现这种映射机制的呢?首先看
include/asm-i386/pgtable.h
中定义的一段代码:
106
#if CONFIG_X86_PAE
107
#include <asm/pgtable-3level.h>
108
#else
109
include <asm/pgtable-2level.h>
110
#endif
根据在编译
Linux
内核之前的系统配置
(config)
过程中的选择,编译的时候会把目录
include/asm
符号连接到具体的
CPU
专用的文件目录。对于
i386CPU
,该目录被符号连接到
include/asm-i386
。同时,在配置系统时还有一个选择项是关于
PAE
的,如果所用的
CPU
是
Pentium Pro
或以上时,并且决定采用
36
位地址,则在编译时选择项
CONFIG_X86_PAE
为
1,
否则为
0.
根据此项选择,编译时从
pgtable-3level.h
或
pgtable-2level.h
中二者选一,前者用于
36
位地址的三层映射,而后者用于
32
位地址的二层映射。这里集中讨论
32
位地址的二层映射。
文件
pgtable-2level.h
中定义了二层映射中
PGD
和
PMD
的基本结构:
04
/*
05
* traditional i386 two-level paging structure:
06
*/
07
08
#define PGDIR_SHIFT 22
09
#define PTRS_PER_PGD 1024
10
11
/*
12
* the i386 is two-level,so we don’t really have any
13
*PMD derectory physically.
14
*/
15
#define PMD_SHIFT 22
16
#define PTRS_PER_PMD 1
17
18
#define PTRS_PER_PTE 1024
这里
PGDIR_SHIFT
表示线性地址中
PGD
下标位段的起始位置,文件中将其定义为
22,
也即
bit22(
第
23
位
)
。由于
PGD
是线性地址中最高的位段,所以该位段是从第
23
位到第
32
位,一共是
10
位。在文件
pgtable.h
中定义了另一个常数
PGDIR_SIZE
为:
117#define PGDIR_SIZE (1UL<<PGDIR_SHIFT)
也就是说,
PGD
中的每一个表项所代表的空间
(
并不是
PGD
本身所占的空间
)
大小是
1x222
。同时,
pgtable-2level.h
中又定义了
PTRS_PER_PGD
,也就是每个
PGD
表中指针的个数为
1024
。显然,这是与线性地址中
PGD
位段的长度
(10
位
)
相符的,因为
210
=
1024
。
对
PMD
的定义就很有意思了。
PMD_SHIFT
也定义为
22,
与
PGD_SHIFT
相同,表示
PMD
位段的长度为
0,
一个
PMD
表项所代表的空间与
PGD
表项所代表的空间是一样大的。而
PMD
表中指针的个数
PTRS_PER_PMD
则定义为
1,
表示每个
PMD
表中只有一个表项。同样,这也是针对
i386CPU
及其
MMU
项定义的,因为要将
Linux
逻辑上的三层映射模型落实到
i386
结构物理上的二层映射,就要从线性地址逻辑上的
4
个虚拟位段中把
PMD
抽去,使它长度为
0,
所以逻辑上的
PMD
表的大小就成为
1(20=1)
。
这样,上述的四步映射过程对于内核
(
软件
)
和
i386MMU
就成为:
(1)
内核为
MMU
设置好映射目录
PGD
,
MMU
用于线性地址中最高的那一个位段
(10)
作为下标在
PGD
中找到相应的表项。该表项逻辑上指向一个中间目录
PMD
,但是物理上直接指向相应的页面表,
MMU
并不知道
PMD
的存在。
(2)
PMD
只是逻辑上存在,即对内核软件在概念上存在,但是表中只有一个表项,而所谓的映射就是保持原值不变,现在一转手却指向页面表了。
(3)
内核为
MMU
设置好了所有的页面表,
MMU
用线性地址中的
PT
位段作为下标在相应页面表中找到相应的表项
PTE
,该表项中存放的就是指向物理页面的指针。
(4)
线性地址中的最后位段为物理页面内的相对位移量,
MMU
将此位移量与目标物理页面的起始地址相加便得到相应的物理地址。
这样,逻辑上的三层映射对于
i386CPU
和
MMU
就变成了二层映射,把中间目录
PMD
这一层跳过了,但是软件的结构却还保持着三层映射的框架。
32
位地址意味着
4G
字节的虚存空间,
Linux
内核将这
4G
字节的空间分成两部分。将最高的
1G
字节
(
从虚存地址
0xC0000000
至
0xFFFFFFFF)
,用于内核本身,称为“系统空间”。而将较低的
3G
字节
(
从虚地址
0x0
至
0xBFFFFFFF),
用作各个进程的“用户空间”。这样,理论上每个进程可以使用的用户空间都是
3G
字节。当然,实际的空间大小受到物理存储器大小的限制。虽然各个进程拥有其自己的
3G
字节用户空间,系统空间却由所有的进程共享。每当一个进程通过系统调用进入了内核,该进程就在共享的系统空间中运行,不再有其自己的独立空间。从具体进程的角度来看,则每个进程都拥有了
4G
字节的虚存空间,较低的
3G
字节为自己的用户空间,最高的
1G
字节则与所有的进程以及内核共享的系统空间。
虽然系统空间占据了每个虚存空间中的
1G
字节,在物理的内存中却总是从最低的地址
(0)
开始。所以,对于内核来说,其地址的映射是很简单的线性映射,
0xC0000000
就是两者之间的位移量。因此,在代码中将此位移量称为
PAGE_OFFSET
而定义于文件
page.h
中:
81
#
define __PAGE_OFFSET (0xC0000000)
……..
114
#define PAGE_OFFSET ((unsigned long) __PAGE_OFFSET)
115
#define __pa(x) ((unsigned long) (x)-PAGE_OFFSET)
116
#define __va(x) ((void *)((unsigned long) (x)+PAGE_OFFSET))
也就是说,对于系统空间而言,给定一个虚地址
x
,其物理地址是从
x
中减去
PAGE_OFFSET;
相应地,给定一个物理地址
x
,其虚地址是
x+PAGE_OFFSET
。
同时,
PAGE_OFFSET
也代表着用户空间的上限,所以常数
TASK_SIZE
就是通过它定义的,在
processor.h
中:
258
/*
259
* User space process size:3GB(default).
260
*/
261
#define TASK_SIZE (PAGE_OFFSET)
这是因为在谈论一个用户进程的大小时,并不包括此进程在系统空间中共享的资源。
当然,
CPU
并不是通过这里所说的计算方法进行地址映射的,
__pa()
只是为内核代码中当需要知道与一个虚地址对应的物理地址提供方便。例如,在切换进程的时候要将寄存器
CR3
设置成指向新进程的页面目录
PGD
,而该目录的起始地址在内核代码中是虚地址,但
CR3
所需要的是物理地址,这时候就要乃至
__pa()
了。这行语句在文件
mmu_context.h
中:
43
/*Re-load page tables */
44
asm volatile(“movl %0,%%cr3”: :”r” (__pa(next->pgd));
这是一行汇编代码,说的是将
next->pgd
,即下一个进程的页面目录起始地址,对过
__pa()
转换成物理地址
(
存放在某个寄存器
)
,然后用
mov
指令将其写入寄存器
CR3
。经过这条指令后,
CR3
就指向新进程
next
的页面目录表
PGD
了。
每个进程的局部段描述表
LDT
都作为一个独立的段而存在,在全局段描述表
GDT
中要有一个表项指向这个段的起始地址,并说明该段的长度以及其他一些参数。除此之外,每个进程还有一个
TSS
结构
(
任务状态段
)
也是一样。所以,每个进程都要在全局段描述表
GDT
中占据两个表项。那么,
GDT
的容量有多大呢?段寄存器中用作
GDT
表下标的位段宽度是
13
位,所以
GDT
中可以有
8192
个描述项。除一些系统的开销
(
例如
GDT
中的第
2
项和第
3
项分别用于内核的代码段和数据段,第
4
项和第
5
项永远用于当前进程的代码段和数据段,第
1
项永远是
0,
等等
)
以外,尚有
8180
个表项可供使用,所以理论上系统中最大的进程数量是
4090.