I386是Intel的x86系列CUP中一个重要的里程碑。Linux最初就是I386 CPU上实现的。本文介绍Linux内核对I386架构系统进行内存管理,以及从逻辑地址到物理地址的转换方式。对于绝大多数现代的操作系统,例如:Windows NT(包括Windows 2000、Windows XP)在内存管理的实现方式基本上大同小异,通过对Linux分析,也可以加强对非源码开放式操作系统的理解。在I386架构上的现代操作系统,无一例外都需要在保护模式下工作。所以,先来介绍I386在保护模式有别于实模式的特性。
一、 I386 CPU保护模式下的新特性
1. 特权级别
CPU分为4个特权级别,0-3, 0最高,3最低。每条指令有其适用的级别。Linux和各种I386 Unix都 只用到0和3级别,对应Linux/Unix中的系统态和用户态。
2. 增加段寄存器
增加FS,GS段地址寄存器。加上原来的4个段寄存器,I386一共提供6个段寄存器。在保护模式下,以前实模式下的段寄存器还是有用的。不过它不再用来存放段的基址,而是用来存放“段选择子”(段描述项),其实段寄存器的名字也变成了“段选择子寄存器”或“段描述项寄存器”。在访问内存的时候,我们需要给出的是“段选择子”(段描述项),而不是段基址了。在段描述项中包含段基址、权限等信息。
3. 增加段表寄存器
Ø 增加GDTR,LDTR,他们只用特权指令才能访问。配合段寄存器使用。
GDTR——全局段描述表寄存器 global descriptor table regisiter
LDTR——局部段描述表寄存器 local descriptor table regisiter
Ø GDTR或LDTR加载GDT或LDT中的基地址。
GDT——全局地址段描述数据项表。
LDT——局部地址段描述数据项表。
Ø 在Linux中只使用GDT。LDT在做虚拟机时候下才使用。
4. 中断向量
“中断向量”中不仅是程序的入口地址,而类似psw + “入口地址”的方式,但更复杂。把各种中断向量归为4种类型的门。例如:cup在穿越中断门的时候自动关闭中断。 ......,很多说道,归根结底就是为了提供对“进程切换”和“中断响应”的更细致的控制。
5. 中断向量的保存与恢复
每种类型门的中断向量都包含段选择码TSS,它是用于保存任务现场的数据结构。
TSS——任务状态段 Task_state segment。
当前任务(进程)被调度后,当前CPU各个寄存器的数据被保存到上一个任务(进程)的TSS中;当前任务的TSS的内容被装入各种寄存器,完成任务切换。
6. 增加的其他寄存器
Ø 增加IDTR,TR寄存器,只有特权指令才能访问它们。
IDTR——中断向量表指针寄存器
TR——任务寄存器,用于指向当前任务(进程)的TSS。
Ø 增加CR3寄存器,指向页表目录的基地址。页表目录,在有的操作系统教材上也叫做,外层页表。
7. 中断向量表的位置
IDT不再只保存在0开始的位置,可以在内存的任何位置,IDTR指向之。
IDT——中断向量表
IDTR——中断向量表指针寄存器
二、 Linux对I386的内存管理
1. 内存的初始化
1) 由于Linux需要在多种CPU上运行,而且大部分RISC CPU对段式内存管理的支持都很弱,所以在2.2内核以后,Linux基本上采取了绕过段式内存管理的策略。主要通过2个手段:
a) 通过所有进程指向共用两个GDT表项(其实一共四个表项,内核用2个,其他所有进程共用2个),一个代表数据段,一个代表代码段。
b) 并把这两个段基地址都设置为0,长度设置为最大,这就是所谓的平面内存。
2) 启用页式内存管理。初始化页表。
页表项:
0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
P |
R/W |
U/S |
PWT |
PCD |
A |
D |
0 |
0 |
OS专用 |
物理内存块号 |
页表项占32位,4个字节。其中高20位,为物理内存块号,2的20次方 = 1M,就是说共可以匹配1M个的物理块,其中一个物理块的大小规定为4K,这样共可以支持1M×4K=4G的物理内存。页表的低12位存放页面的控制信息。
下面看到就是页面表:
控制信息 |
物理块号 |
每项4个字节 |
页表0 |
每个页表4K |
… |
… |
|||
… |
… |
|||
… |
… |
|||
|
|
|
… |
|
|
|
… |
||
|
|
… |
||
|
|
|
… |
|
控制信息 |
物理块号 |
每项4个字节 |
页表1023s |
|
… |
… |
|||
… |
… |
|||
… |
… |
为了匹配4G的物理内存,需要建立1M个这样的页表项,每个页表项4个字节,1M×4=4M字节。这就是说,存储页表项的物理内存就需要4M字节,而物理内存是分块管理的,每块4K,这样就需要4M字节除以4K字节 = 1024个物理内存块存储页面表项。为了管理这1024个物理内存块,需要建立外层页表项。
外层页表项格式与内层页表项一致。占用空间:1024×4字节=4K,这样所有的外层表项就正好占用一个物理块。
外层页表在物理内存中的存放示意图:
控制信息 |
物理块号 |
外层页表 |
… |
… |
|
… |
… |
|
… |
… |
在实际的内存中,单个的页表一定占用一个物理页面(按4K边界对齐),页表的基地址的形式一定是 0xXXXXX000,
转换成2进制形式:
xxxxxxxxxxxxxxxxxxxx000000000000
12位的低地址都是0,这说明20位的物理块号,一定能找到页表的基地址。并且表项是按线性存储的。如果已知表项和页表的基地址,就可以通过指针的偏移找到相应的表项。
然而,各个内层页表在物理内存页面中则可能不是连续存放的,不过这没关系,外层页表的基地址是操作系统已知的,通过外层页表项的物理块号就可以找到它们。
3) Linux对页表的管理
Linux是怎么知道哪些页表已经分配出去了,哪些是空闲的呢?解决的办法是,在内核中设有专门的数据结构,记录没有分配出去的页表。内核为新创建的进程分配页面,已经存在的进程申请页面都是以此为依据。
4) Linux对物理内存的管理
在大多数情况下,系统实际拥有的物理内存可能没有达到4G。那么Linux有是怎么知道实际的物理内存的情况呢?解决办法依然是在内核中设有专门的数据结构,记录物理内存的情况。
2. 分配内存与寻址的过程
1) 程序的编译和链接
编译器把源代码文件编译成模块,链接器把各个模块链接起来。大体上把程序分为代码段、数据段、BSS段、堆(heap)段和栈段;并完成从符号到逻辑地址的转换。
2) 程序的装入和进程的创建
程序装入内存运行的功能是由execve()这一系统调用实现的。简单来讲,程序的装入主要包含以下几个步骤:
a) 读入可执行文件的头部信息以确定其文件格式及地址空间的大小;
b) 以段的形式划分地址空间(分配页面);
c) 将可执行程序读入地址空间中的各个段,建立虚实地址间的映射关系;
d) 将bbs段清零;
e) 创建堆栈段;
f) 建立程序参数、环境变量等程序运行过程中所需的信息;
g) 启动运行。
前面提到了,Linux内核已经不再为每个进程维护各自的段表了,相反为所有进程维护同一张段表,就是所谓的GDT,而且所有的用户进程共同使用其中的2条记录,而且由于使用Intel所谓平面地址空间,在理论上段内使用的逻辑地址可以是从0x00000000~0xFFFFFFFF之间的任意值。
现在有两个问题,一是如何为一个进程的各段分配逻辑地址,保证各段的逻辑地址不互相冲突呢?二是由于每个进程的寻址空间都是4G,在每个进程中都有可能使用像0x8048394这样逻辑地址,系统是怎么把他们区分开的呢,是怎么分别映射到不同的物理内存上的中的呢?
第一个问题,链接器来帮我们解决。在链接的过程中完成符号到逻辑地址的转换,并保持在同一个程序中不会为两个符号分配相同的逻辑地址。
第二个问题解决办法则十分巧妙,Linux内核为每个进程都建立一个页表目录,这个页表目录就是上面提到的外层页表,1024项,每项4字节。内核把进程已经使用的页面记录到这个页表目录中。由于在运行期间,内核负责空闲页表的维护,它不会把相同的页面分配给不同的进程,而页面是和物理内存地址直接映射的,所有尽管不同进程使用的逻辑地址可以相同,但不会映射到现同的物理内存地址上去的。下面就会提到逻辑地址到物理地址的转换的过程。
顺便提一下,由于绝大多数进程使用的内存都远小于4G,所有大多数页表目录项都是空的。
3. 从逻辑地址->线性地址->物理地址的转换
用户程序经过汇编后,所有的指令基本使用的都是逻辑地址。如果使用I386的段式内存管理,需要先把逻辑地址转换成线性地址,但Linux绕过了I386的段式内存管理(这需要gcc编译链接器的帮助),用户程序中的逻辑地址就已经是线性地址了,所以从逻辑地址到线性地址无需转换。
在CPU使用页式内存管理前,线性地址就是物理地址,不经转换可以直接使用。但是一旦启用了页式内存管理,就先把线性地址转换成为物理地址后才能使用。这工作由CPU内部的MMU硬件单元来完成。
下面给出的是逻辑内存的格式:
0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
页内地址偏移 |
页表项 10位 |
目录号 10位 |
下面给出的是一个逻辑内存的值:0x480492A0
按照2进制展开三个字段分别为:
0100100000 0001001001 001010100000
目录号 = 0100100000(二进制) = 0x120
页表项 = 0001001001(二进制) = 0x49
页内地址偏移 = 001010100000 (二进制) = 0x2A0
CPU在CR3(目录表寄存器)的支持下,找到当前进程的目录表基地址,加0x120个字(每个字等于4个字节)后,得到目录表项;从表项中取出页表的基地址,加0x49个字,得到页表项;从页表项中取出物理块号,加0x2A0,得到实际的物理地址。
在这个过程中,逻辑地址在程序的编译链接的时候就确定了。页表项中的物理块号在操作系统初始化的时候也确定了(因为使用虚拟存储器,会发生换入换出,在这里先不管它)。唯独页表的基地址是在运行时候才分配的,就是说,内核具体上给进程分配哪些页表,只用在进程创建或进程申请内存的时候才能定下来。
在这里假设上面的例子的被分配的页表的页表项中的物理块号是0x02000000,那么实际的物理地址就是 0x02000000 + 0x2A0 = 0x020002A0。
4. 关于PAE
Pentium pro的以后的Intel的X86系列CPU有了36根地址线,支持3级页式内存管理,为了保持与原有软件兼容,还是使用32位的线性地址。这就是Intel所谓的PAE(Physical Address Extensions)。 它的好处是,虽然每个进程的寻址空间还是4G,但是整个系统的可用内存已经达到64G。它的实现的基本思路是同时支持4K和2M的内存页面,而且使用3层页表进行内存管理。它可以看作对I386的扩展,但是具体实现十分繁琐,在这里就不详细介绍了。