您的位置:首页 > 运维架构 > Linux

Linux地址映射的全过程(Linux内核源代码情景分析读书笔记连载#)

2017-03-03 08:46 381 查看
1 Linux内核采用页式存储管理。虚拟地址空间划分成固定大小的“页面”,由MMU在运行时将虚拟地址“映射”成某个物理内存页面中的地址。与段式存储管理相比,页式存储管理有很多好处。首先,页面都是固定大小的,便于管理。更重要的是,当要将一部分物理空间的内容换出到磁盘上的时候,在段式存储管理中要将整个段(通常都很大)都换出,而在页式存储管理中则是按页进行,效率显然要高的多。页式存储管理与段式存储管理所要求的硬件支持不同,一种CPU即然支持页式存储管理,就无需再支持段式存储管理。但是,我们在前面讲过,i386的情况是特殊的。由于i386系列的历史演变过程,它对页式存储管理的支持是在其段式存储管理已经存在了相当长的时间以后才发展起来的。所以,不管程序怎样写,i386CPU一律对程序中使用的地址先进性段式映射,然后才能进行页式映射。既然CPU的硬件结构是这样,Linux内核也只好服从Intel的选择。这样的双重映射其实是毫无必要的,也使映射的过程变得不容易理解,以致有人还得出LInux采用“段页式”存储管理技术这样一种似是而非的结论。下面将会看到,Linux内核所采取的办法是使段式映射的过程实际上不起什么作用(除特殊的VM86模式外,那是用来模拟80286的)。不管是什么操作系统,只要在i386上实现,就必须至少在形式上要先经过段式映射,然后才可以实现其本身的设计。

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

#include<stdio.h>

void greeting()

{

printf("Hello world!\n");

}

void main()

{

greeting();

}

经过编译之后,我们得到可执行代码hello。Linux有一个使用程序objdump是非常有用的,可以用来反汇编一段二进制代码。通过命令

objdump       -d   hello

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

0804841d <greeting>:

 804841d: 55                     
push   %ebp

 804841e: 89 e5                
mov    %esp,%ebp

 8048420: 83 ec 18            
sub    $0x18,%esp

 8048423: c7 04 24 d0 84 04 08
movl   $0x80484d0,(%esp)

 804842a: e8 c1 fe ff ff      
call   80482f0 <puts@plt>

 804842f:  c9                    
leave  

 8048430: c3                    
ret

 08048431 <main>:

 8048431: 55                  
push   %ebp

 8048432: 89 e5                
mov    %esp,%ebp

 8048434: 83 e4 f0            
and    $0xfffffff0,%esp

 8048437: e8 e1 ff ff ff      
call   804841d <greeting>

 804843c: c9                  
leave  

 804843d: c3                
  ret    

 804843e: 66 90                
xchg   %ax,%ax

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

假定该程序已经在运行,整个映射机制都已经建立好,并且CPU正在执行main()中的"call0X0804841d "这条指令,要转移到虚拟地址0X0804841d去。下面我们一步步的走过这个地址的映射过程。

(1)首先是段式映射阶段。由于地址0X0804841d是一个程序的入口,更重要的是在执行过程中是由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被设置成USE_CS外,其他所有的段寄存器都设置成USER_DS。这里特别值得注意的是堆栈寄存器SS,它也被设置成USER_DS。就是说,虽然Intel的意图是将一个进程的映象分成代码段、数据段、和堆栈段,Linux内核却并不买这个账。在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 0x2B

#endif

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


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

_ _ 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_DS: 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个段描述项的内容按二进制展开如下:



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

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

B0-B15、B16-B31都是0 ——基地址全为0;
L0-L15、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_DS:     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来访问,那也不允许。实际上,这里所作的检查比对在页式映射的过程中还要进行,所以既然用了页式映射,这里的检查比对就是多余的。

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

与段式映射过程中所有进程全都共用一个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。在内核中,不管什么进程,一旦进入了内核就进了系统空间,都有相同的页面映射。当我们 在程序中要转移到地址0X0804841d去的时候,进程正在运行中,CR3早已设置好,指向我们这个进程的页面目录了。先将线性地址0X0804841d按二进制展开:

0000 1000 0000 0100 0001 1101 

对照线性地址的格式,可见最高10位为二进制的0000 1000 00,也就是十进制的32,所以i386 CPU中的MMU,就以32为下标去页面目录中找到目录项。这个目录项中的高20位指向一个页面表。CPU在这20位后边添上12个0就得到该页面表的指针。以前讲过,每个页面表占一个页面,所以自然就是4K字节边界对齐的,其起始地址的低12位一定是0.

找到页面表以后,CPU再来看线性地址中的中间10位。线性地址0X0804841d的第二个10位为0001 0010 00,即十进制的72.于是CPU就以此为下标在已经找到的页表中找到相应的表项。与目录项相似,当页面表项的P标志位为1时表示所映射的页面在内存中。32位的页面表项中的高20位指向一个物理内存页面,在后边添上12个0就得到这物理内存页面的起始地址。所不同的是这一次指向的不再是一个中间结构,而是映射的目标页面了。在其起始地址上加上线性地址中的最低12位,就得到了最终的物理内存地址。

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

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

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

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

struct modify_ldt_ldt_s {
unsigned int  entry_number;
unsigned long base_addr;
unsigned int  limit;
unsigned int  seg_32bit:1;
unsigned int  contents:2;
unsigned int  read_exec_only:1;
unsigned int  limit_in_pages:1;
unsigned int  seg_not_present:1;
unsigned int  useable:1;

};

其中entry_number是想要改变的表项的序号,即下标。而结构中其余的成分则给出要设置到各个位段中去的值。

读者可能会要问:这样岂不是在内存管理机制上挖了个洞?既然一个进程可以改变它的局部段描述表,它岂不就可设法侵犯到其他进程或内核的空间中去?这要从两方面看。一方面它确实是在内存管理机制上开了一个小小的缺口,但另一方面它的背后仍然是Linux内核的页式存储管理,只要不让用户进程掌握修改页面目录和页面表的手段,系统还是安全的。

(2)vm86(struct   vm86_struct    *info)

与modify_ldt()相类似,还有一个系统调用vm86(),用来在linux上模拟运行DOS软件。i386 CPU专门提供了一种寻址方式VM86,用来在保护模式下模拟运行实址模式的软件。其目的是为采用保护模式的系统提供与实模式软件的兼容性。事至如今,需要加以模拟运行DOS软件已经很少了,或者干脆已经绝迹了。内核中有关的源代码主要有,include/asm-i386/vm86.h和arch/i386/kernel/vm86.c。

显然,这两个系统调用以及由此实现的 功能实际上并不属于Linux内核本身的存储管理框架,而是为了与Windows软件和DOS软件兼容而采取的权宜之计。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: