地址映射的全过程
Linux 内核采用页式存储管理。虚拟地址空间划分成固定大小的“页面”,由 MMU 在运行时将虚拟地址“映射”成某个物理内存中的地址。与段式存储管理相比,页式存储管理有很多好处。首先,页面都是固定大小的,便于管理。更重要的是,当要将一部分物理空间的内容换出到磁盘上的时候,在段式存储管理中要将整个段 ( 通常很大 ) 都换出,面在页式存储管理中则是按页进行,效率显然要高得多。由于 i386 系列的历史演变过程,它对页式存储管理的支持是在其段式存储管理已经存在了相当长的时间以后才发展起来的。所以,不管程序是怎样写的, i386 微处理器一律对程序中的地址先进行段式映射,然后才能进行页式映射。而 Linux 所采用的方法实际上使段式映射的过程中不起什么作用。
下面通过一个简单的程序来看看 Linux 下的地址映射的全过程:
#include
greeting()
{
printf(“Hello world!
”);
}
main()
{
greeing();
}
该程序在主函数中调用 greeting 来显示 “Hello world!” ,经过编译和反汇编(% objdump -d hello),我们得到了它的反汇编的结果。
08048568:<greeting>:
8048568: 55 push1 %ebp
8048856b:89 e5 mov1 %esp,%ebp
804856b: 68 04 94 04 08 push1 $0x8048404
8048570: e8 ff fe ff ff call 8048474
8048575: 83 c4 04 add1 $0x4,%esp
8048578: c9 leave
8048579: c3 ret
804857a: 89 f6 mov1 %esi,%esi
0804857c :
804857c: 55 push1 %ebp
804857d: 89 e5 mov1 %esp,%ebp
804857f: e8 e4 ff ff ff call 8048568
8048584: c9 leave
8048585: c3 ret
8048586: 90 nop
8048587: 90 nop
从上面可以看出, greeting() 的地址为 0x8048568 。在 elf 格式的可执行代码中,总是在 0x8000000 开始安排程序的 “ 代码段” ,对每个程序都是这样。
当程序在 main 中执行到了 “call 8048568” 这条指令,要转移到虚拟地址 8048568 去。
首先是段式映射阶段。地址 8048568 是一个程序的入口,更重要的是在执行的过程中有 CPU 的 EIP 所指向的,所以在代码段中。I386cpu 使用 CS 的当前值作为段式映射的“选择码”,也就是用它作为在段描述表中的下标。哪一个段描述表呢》是全局段描述表GDT 还是局部段描述表 LDT ?那就要看 CS 中的内容了。
内核在建立一个进程时都要将其段寄存器设置好,有关代码在 include/asm-i386/processor.h 中:
# define start_thread(regs, new_eip, new_dsp) 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)
这里把 DS 、 ES 、 SS 都设置成 _USER_DS, 而把 CS 设置成 _USER_CS, 这也就是说,虽然 Intel 的意图是将一个进程的映象分成代码段、数据段和堆栈段,但在 Linux 内核中堆栈段和代码段是不分的。
再来看看 USER_CS 和 USER_DS 是什么。那是在 include/asm-i386/segment.h 中定义的:
Index TI DPL
#define_KERNEL_CS 0x10 0000 0000 0001 0|0|00
#define_KERNEL_DS 0x18 0000 0000 0001 1|0|00
#define_USER_CS 0x23 0000 0000 0010 0|0|11
#define_USER_DS 0x2B 0000 0000 0010 1|0|11
_KERNEL_CS: index=2,TI=0,DPL=0
_KERNEL_DS: index=3,TI=0,DPL=0
_USERL_CS: index=4,TI=0,DPL=3
_USERL_DS: index=5,TI=0,DPL=3
TI 全都是 0 ,都使用全局描述表。LDT在Linux中没有使用,只有在Linux模拟运行Windows软件和DOS软件时才会使用。内核的 DPL 都为 0 ,最高级别;用户的 DPL 都是 3 ,最低级别。 _USER_CS 在 GDT 表中是第 4 项,初始化 GDT 内容是在 arch/i386/kernel/head.S 中定义的,其主要内容在运行中并不改变。代码如下:
ENTRY(gdt-table)
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x0000000000000000 /* not used */
.quad 0x00cf9a00000ffff /* 0x10 kernel 4GB code at 0x00000000 */
.quad 0x00cf9200000ffff /* 0x18 kernel 4GB data at 0x00000000 */
.quad 0x00cffa00000ffff /* 0x23 user 4GB code at 0x00000000 */
.quad 0x00cff200000ffff /* 0x2b user 4GB data at 0x00000000 */
GDT 表中第一、二项不用,第三至第五项共四项对应于前面的四个段寄存器的数值。
将这四个段描述项的内容展开:
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
U_CS: 0000 0000 1100 1111 11111 1010 0000 0000
0000 0000 0000 0000 1111 1111 1111 1111
U_DS: 0000 0000 1100 1111 1111 0010 0000 0000
0000 0000 0000 0000 1111 1111 1111 1111
这四个段描述项的下列内容都是相同的。
·BO-B15/B16-B31 都是 0 基地址全为 0
·LO-L15 、 L16-L19 都是 1 段的上限全是 0xfffff
·G 位都是 1 段长均为 4KB
·D 位都是 1 32 位指令
·P 位都是 1 四个段都在内存中
结论:每个段都是从 0 地址开始的整个 4GB 虚存空间,虚地址到线性地址的映射保持原值不变。可以看到段基址相同,虚地址到线性地址的映射保持不变。段式映射机制把地址0x08048368映射到了其自身,作为线性地址。因此,讨论或理解 Linux 内核的页式映射时,可以直接将线性地址当作虚拟地址,二者完全一致。
不同之处在于权限级别不同,内核的为 0 级,用户的为 3 级。另一个是段的类型,或为代码,或为数据。这两项都是 CPU 在映射过程中要加以检查核对的。如果 DPL 为 0 级,而段寄存器 CS 中的 DPL 为 3 级,那就不允许了,因为那说明 CPU 的当前运行级别比想要访问的区段要低。或者,如果段描述项说是数据段,而程序中通过 CS 来访问,那也不允许。实际上,这里所作的检查比对在页式映射的过程中还要进行,所以既然用了页式映射,这里的检查比对就是多余的。
再回到 greeting 的程序中来,通过段式映射把地址 8048568 映射到自身,得到了线性地址。现在 8048568 是作为线性地址出现了,下面才进入页式映射的过程。
与段式映射过程中所有的进程全都共用一个 GDT 不一样,现在可是动真格的了,每个进程都有自身的页目录 PGD ,指向这个目录的指针保持在每个进程的 mm_struct 数据结构中。每当调度一个进程进入运行时,内核都要为即将运行的进程设置好控制寄存器 CR3 ,而MMU 硬件总是从 CR3 中取得当前进程的页目录指针。不过, CPU 在执行程序时使用的是虚存地址,而 MMU 硬件在进行映射时所使用的则是物理地址。这是在 inline 函数 switch_mm() 中完成的,其代码见 include/asm-i386/mmu_context.h 。这里关心的只是其中最关键的一行:
28 static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk, unsigned cpu)
29 {
……
44 asm volatitle(“movl %0,%%cr3”: :”r” ( __pa(next->pgd) ) );
……
59 }
这里 __pa() 的用途是将下一个进程的页面目录 PGD 的物理地址装入寄存器 %%cr3, 也即 CR3 。这时可能有疑问:这样,在这一行以前和以后 CR3 的值不一样,也就是使用不同的倒买倒卖目录,不会使程序的执行不能连续了吗?答案是,这是在内核中。不管什么进程,一旦进入内核就进了系统空间,都有相同的页面映射,所以不会出问题。
当程序要转到地址 0x8048568 去的时候,进程正在运行中, CR3 已经设置好了,指向本进程的页目录了。
8048568 : 0000 1000 0000 0100 1000 0101 0110 1000
按照线性地址的格式,最高 10 位 0000100000 ,十进制的 32 ,所以 i386CPU( 确切地说是 CPU 中的 MMU ,下同 ) 就以下标32 去页目录表中找其页目录项。这个页目录项的高 20 位指向一个页面表。 CPU 在这 20 位后面添上 12 个 0 就得到该页面表的指针。前面讲过,每个页面表占一个页面,所以自然就是 4K 字节边界对齐的,其起始地址的低 12 位一定是 0. 正因如此,才可以把 32 位目录项中的低 12 位挪着它用,其中的最低位为 P 标志柆,为 1 时表示该页面在内存中。
找到页表后,再看线性地址的中间 10 位 001001000 ,十进制的 72 。就以 72 为下标在找到的页表中找到相应的表项。与目录项相似,当页面表项的 P 标志位为 1 时表示所映射的页面在内存中。 32 位的页面表项中的高指向一个物理内存页面,在后边添上 12 个 0 就得到了物理内存页面的起始地址。所不同的是,这一次指向的不再是一个中间结构,而是映射的目标页面了。在其起始地址上加上线性地址中的最低 12 位就得到了最终的物理内存地址。这时这个线性地址的最低 12 位为 0x568 。所以,如果目标页面的起始地址为0x740000 的话 ( 具体取决于内核中的动态分配 ) ,那么 greeting() 入口的物理地址就是 0x740568,greeting() 的执行代码就存储在这里。
在页式映射的过程中, CPU 要访问内存三次,第一次是页面目录,第二次是页面表,第三次才是真正要访问的目标。这样,把原来不用分页机制一次访问内存就能得到的目标,变为三次访问内存才能得到,明显执行分页机制在效率上的牺牲太大了。
页目录与页表示意图
为了减少这种开销,最近被执行过的地址转换结果会被保留在 MMU 的转换后备缓存( TLB )中。虽然在第一次用到具体的页面目录和页面表时要到内存中读取,但一旦装入了 TLB 中,就不需要再到内存中去读取了,而且这些都是由硬件完成的,因此速度很快。
TLB 对应权限大于 0 级的程序来说是不可见的,只有处于系统 0 层的程序才能对其进行操作。
当 CR3 的内容变化时, TLB 中的所有内容会被自动变为无效。 Linux 中的 _flush_tlb 宏就是利用这点工作的。 _flush_tlb 只是两条汇编指令,把 CR3 的值保存在临时变量 tmpreg 里,然后立刻把 tmpreg 的值拷贝回 CR3 ,这样就将 TLB 中的全部内容置为无效。除了无效所有的 TLB 中的内容,还能有选择的无效 TLB 中某条记录,这就要用到 INVLPG 指令。