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

内存管理(2)linux的地址映射机制

2014-03-04 23:01 330 查看
/*尊重知识,谢绝盗版!本博文借鉴了linux内核社区的成果http://ilinuxkernel.com/?p=1276,再次归纳,转载烦请以超链接的方式标注来源http://blog.csdn.net/figtingforlove/article/details/20383689*/

现代操作系统如Linux都采用内存保护模式来管理内存。我们看Linux内核中的内存管理相关内容时,会遇到一个基本问题:普通用户程序中的地址是如何转换到内存上的物理地址的?IA-32架构的CPU规定地址映射过程是逻辑地址====》线性地址====》物理地址。Linux既然能在Intel架构的CPU上运行,就要遵守这个规定,那么Linux又是如何进行地址映射的?

(一)IA-32体系结构内存地址映射

1.CPU相关寄存器

IA-32架构中提供了10个32位和6个16位的寄存器。这些寄存器分为三大类:

通用寄存器、控制寄存器、段寄存器

通用寄存器进而分为数据、指针和索引寄存器。

通用寄存器包括EAX、EBX、ECX、EDX、ESI、EDI、EBP、ESP。

数据寄存器有EAX、EBX、ECX、EDX。

索引寄存器是两个:ESI(Source Index)、EDI(Destination Index),这两个寄存器都和字符串处理指令相关。

指针寄存器也有两个:ESP(Stack Pointer)和EBP(Base Pointer),这两个寄存器主要来维护栈。

我们知道程序运行和函数调用时,都用到栈,不同的程序以及程序中不同的函数,都有各自的栈空间,那么怎么来维护不同的栈?寄存器ESP就是指向当前栈的栈顶,而寄存器EBP指向当前栈的栈底。注意程序运行及函数调用时,不同的栈,EBP和ESP的值都不同。

控制寄存器有2个:EIP和EFLAGS。处理器使用EIP来跟踪下一条要执行的指令,也称为程序计数寄存器。

段寄存器有6个(16位):CS、ES、DS、FS、GS、SS。

1.2系统寄存器

为了初始化CPU和控制系统操作,IA-32提供了EFLAGS寄存器和几个系统寄存器。EFLAGS寄存器用来保存系统的一些状态标志。

(1)EFLAGS寄存器中的IOPL域,控制任务和模式的切换、中断处理、指令跟踪和访问权限;

(2)控制寄存器(CR0、CR2、CR3和CR4)包含用来控制系统级别操作的数据和信息

(3)调试寄存器允许设置程序的断点,以调试程序和系统软件;

(4)GDTR、LDTR和IDTR寄存器

(5)任务寄存器(TR)

(6)型号相关的寄存器

2.内存管理寄存器

处理器提供了4个内存管理寄存器:GDTR、LDTR、IDTR和TR。这4个寄存器在前面一小节(系统寄存器)中提到,我们关心地址映射(内存管理)相关的寄存器,故这里详细介绍一下。

Global Descriptor Table Register(GDTR)

GDTR寄存器保存GDT的基址和表限(table limit)。基址是GDT的第一个字节的地址,表限(table limit)给出表的大小。

全局描述符表基地址寄存器

Local Descriptor Table Register(LDTR)

LDTR寄存器的值包括:16位的段选择码、基址、段限(segment limit)和LDT描述符属性。基址是LDT段的起始地址,段限是段的大小。

局部描述符表基地址寄存器

Interrupt Descriptor Table Register(IDTR)

IDTR寄存器的内容是:IDT的基址和大小。

中断描述符表基地址寄存器

4. Task Register(TR)

TR寄存器的内容包括:16位的段选择码、基址、段限(segment limit)和描述符的属性。由于Linux中没有使用TR寄存器,这里不作详细介绍。

(二)保护模式的内存管理

在IA-32架构中,内存管理分为两块:分段和分页。分段机制是将代码、数据和栈分开,这样处理器上运行多个程序,不会相互影响。分页是操作系统可以实现按需分页和虚拟内存功能。分页也可以用来隔离多个任务。当运行在保护模式时,分段的基址是必须使用的,不能关闭分段功能;然而分页是可选的。在Linux系统中,实际上是没有真正使用IA-32的分段机制,仅使用分页机制。在分析地址映射过程之前,先描述几个概念:逻辑地址(Logical
Addres)、线性地址(Linear Address)和物理地址(Physical Address)。

逻辑地址

是在机器语言指令中,来说明操作数和指令的地址;每个逻辑地址包括两部分:段(Segment)和偏移量(Offset)。

线性地址

也通常称为为虚拟地址,在32位系统中,它是32位的无符号整型,最大可以达到4G。

物理地址

就是真正物理内存上的地址。



图 段页式地址映射

从图可以看出,地址映射过程是逻辑地址-------->线性地址-------->物理地址。也就是要经过两个映射过程:第一步是段式映射,第二步是页式映射。

当一条访问内存指令发出一个内存地址时(逻辑地址还是线性地址)CPU就按照段式映射和页式映射两个步骤来计算出实际上存放数据的地址地址。

(三)32位时页面机制地址映射

1.逻辑地址到线性地址的映射



图 逻辑地址到线性地址映射过程

从逻辑地址到线性地址的映射过程如下:

(1)根据指令的性质来确定应该使用哪一个段寄存器,例如转移指令中的地址在代码段,而取数据指令中的地址在数据段;

(2)根据段存器的内容,找到相应的“地址段描述结构”,段描述结构都放在一个表中(GDT或LDT、TR、IDT),而表的起始地址保存在GDTR、LDTR、IDTR、TR寄存器中。这就是4个内存管理寄存器GDTR、LDTR、IDTR和TR的用途;

(3)从地址段描述结构中找到基地址;

(4)将指令发出的地址作为位移,与段描述结构中规定的段长度相比,看看是否越界;

(5)根据指令的性质和段描述符中的访问权限来确定是否越权;

(6)将指令中发出的地址作为位移,与基地址相加而得出线性地址。

从逻辑地址到线性地址映射过程,有几个细节地方需要关注两个问题:

a)逻辑地址就是CPU指令发出的地址,那么段选择码(Segment Selector)的值在哪里?

段选择码在段寄存器中(回忆一下IA-32中的6个段寄存器),如CS、DS等。

b)知道段选择码后,需要从描述符表(Descriptor Table)中找到相应的表项,那怎

描述符表的基址在内存管理寄存器中(GDTR、LDTR、IDTR、TR)。当然每个地址只会对应一个段寄存器和内存管理寄存器。

段选择码和段描述符的内容是怎样的?



图 段选择码



图 段描述符

段寄存器的高13位(低3位另作他用)用作访问段描述表中具体描述结构的下标(index)。GDTR或LDTR中的段描述表指针和段寄存器中给出的下标结合在一起,才决定了具体的段描述表项在内中的什么地方。

段描述符结构中,我们注意段基址(Base Address 15:00、Base 23:16、Base31:24)和段限(Segment Limit 15:00)。



图 全局描述符和局部描述符

依据段寄存器的第2位表示选择那个描述符表(GDT/LDT),高13位作为表中的索引下表查找段基地址。

2.线性地址到物理地址的映射

上面是将逻辑地址映射为线性地址的过程,此时还要将线性地址转换为物理地址,才是真正要访问的内存地址。

从线性地址到物理地址的映射过程为:

(1)从CR3寄存器中获取页面目录(Page Directory)的基地址;

(2)以线性地址的dir位段为下标,在目录中取得相应页面表(Page Table)的基地址;

(3)以线性地址中的page位段为下标,在所得到的页面表中获得相应的页面描述项;

(4)将页面描述项中给出的页面基地址与线性地址中的offset位段相加得到物理地址。



图 线性地址映射

CR3寄存器的值从哪里来的?每个进程都会有自己的地址空间,页面目录也在内存不同的位置上,这样不同进程就有不同的CR3寄存器的值。CR3寄存器的值一般都保存在进程控制块中,如linux中的task_struct数据结构中。

(四)Linux内核的32bit地址映射过程

Linux内核要在IA-32架构上运行,就要进行前面介绍的地址映射过程。现在开始结合Linux内核中的源码来分析普通应用程序的地址映射过程。假设我们有一段一个小程序,程序运行后打印临时变量tmp的地址为0xBFF42DEC。也许大家会有个疑问,这个地址到底是逻辑地址?线性地址?物理地址?事实上,打印的结果只是逻辑地址,而不是真正的物理地址。既然打印的结果0xBFF42DEC是逻辑地址,那么我们就可以以此作为开始,来逐步解析整个地址映射过程。

#include <stdio.h>
int main()
{
unsigned long tmp;
tmp = 0x12345678;
printf("tmp variable address:0x%08lX\n", &tmp);
return 0;
}


1.段式映射过程

段式映射过程实际上就是从逻辑地址到线性地址的映射过程。临时变量tmp的逻辑地址为0xBFF42DEC,那么在段式映射过程中(图 逻辑地址到线性地址映射),它就是偏移量(offset)。现在已经知道了偏移量,但还不知道段选择码。临时变量tmp存放在栈中,X86提供了SS(Stack
Segment)寄存器,那就从SS寄存器中读取段选择码。内核在建立一个进程时都要将其段寄存器设置好,有关代码在/arch/x86/kernel/process_32.c

257 void
258 start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
259 {
260         set_user_gs(regs, 0);
261         regs->fs                = 0;
262         set_fs(USER_DS);
263         regs->ds                = __USER_DS;
264         regs->es                = __USER_DS;
265         regs->ss                = __USER_DS;
266         regs->cs                = __USER_CS;
267         regs->ip                = new_ip;
268         regs->sp                = new_sp;
269         /*
270          * Free the old FP and other extended state
271          */
272         free_thread_xstate(current);
273 }
274 EXPORT_SYMBOL_GPL(start_thread);


X86中有6个段寄存器。Linux内核建立一个进程时把DS、ES、SS寄存器的值都设为__USER_DS,CS寄存器的值设为__USER_CS,而另外两个段寄存器FS和GS都设为0。这样Linux中事实上只有只使用了两个段:代码段(CS)和数据段(DS)。而且每个进程的6个段寄存器值都相同,只有EIP和ESP值不同。

虽然Linux中进程只分两个段(代码段和数据段),但X86的处理器并不知道操作系统把程序分为多少个段。接着前面临时变量tmp的逻辑地址转换到线性地址过程,偏移量已经

知道为0xBFF42DEC,因为tmp在栈中,那么就要从SS寄存器中读取段选择码。而SS寄存器的值为__USER_DS,其值定义在文件x86/include/asm/segment.h

187 #define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS* 8 + 3)

188 #define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS* 8 + 3)

72 #define GDT_ENTRY_DEFAULT_USER_CS 14

73

74 #define GDT_ENTRY_DEFAULT_USER_DS 15

__USER_CS(14*8 +3 = 115)的值展开二进制的结果为:

0000000001110 011

__USER_DS(15*8 + 3 =123)的值展开二进制的结果为:

0000000001111 011

高13位为index,而第三个bit都为0,表示仅使用GDT(Global Descriptor Table),而没有使用LDT(Local Descriptor Table)。事实上,Linux确实没有使用LDT。LDT只是在VM86模式中运行wine以及其他在Linux上模拟运行Windows软件或DOS软件的程序中才使用。

现在确定了偏移量(offset)和段选择码中的index,也确定了是从GDT表中,以15(index)为下标,找到相应的段描述符(segment descriptor),从段描述符中找到段的基址是多少,

段限又是多少?GDT表在内存中的位置,是由GDTR寄存器保存的。GDT表中的值是在操作系统启动时设置好的。因为我们得到的index为15(二进制为1111),那么就是全局描述符表中对应下面一项(老版本的内核,新的内核表示后面再介绍,意思是一样的)。

.quad 0x00cff2000000ffff /* 0x7b user 4GB data at 0x00000000 */

我们再来对照(图 段描述符)来确定段的基址和段限(segment limit)。0~15,48~51bits为Segment Limit(绿色部分)。而段的基址是16~31,32~39,56~63bits组成(红色部分)。所以段限的值为0xfffff,而段的基址值为0(16~31,32~39,56~63bits全为0)。而G为都是1,段长为4KB,而上限为0xfffff,这样数据段就是从0地址开始的整个4G虚存空间,逻辑地址到线性地址的映射保持不变(因为段的基址为0)。事实上,代码段也是如此。

段式映射结束后,发现逻辑地址和虚拟地址是一样的,没有变化(段基址为0)。临时变量tmp的逻辑地址为0xBFF42DEC,段式映射后的线性地址也为0xBFF42DEC。(linux的未使用段式管理,段基址为0)

2.页式映射过程

页式映射过程就是将线性地址映射到物理地址的过程。临时变量tmp的逻辑地址为0xBFF42DEC,段式映射后的线性地址也0xBFF42DEC,现在将线性地址0xBFF42DEC映射到真正的物理地址,就是放在地址总线上的地址。

为了使Linux能在32位和64位CPU上运行,就要采用统一的页面地址模型。从2.6.11内核开始,页面地址模型采用了4级页面

图 linux页式地址映射

完成线性地址到物理地址的映射过程,有一个寄存器很重要,就是CR3寄存器。知道CR3寄存器的值,我们就知道该进程的页面目录在内存中的位置,根据dir位段,找到相应的页面表,再根据Table位段,就可以找到tmp所在页面的起始地址。CR3寄存器的值是从哪里设置的?内核在创建进程时,会分配页面目录,页面目录的地址就保存在task_struct结构中,task_struct结构中有个mm_struct结构类型的指针mm,mm_struct结构中有个字段pgd就是用来保存该进程的CR3寄存器的值。在kernel/fork.c中,创建进程时,会分配也目录(见kernel/fork.c)

483 static inline int mm_alloc_pgd(struct mm_struct *mm)
484 {
485         mm->pgd = pgd_alloc(mm);
486         if (unlikely(!mm->pgd))
487                 return -ENOMEM;
488         return 0;
489 }
//下面一段代码是切换进程时,地址空间的切换过程。注意48行就是加载即将运行进程的页目录到CR3寄存器
arch/x86/include/asm/mmu_context.h
33 static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next,
34                              struct task_struct *tsk)
35 {
46                 /*......*/
47                 /* Re-load page tables */
48                 load_cr3(next->pgd);
49                 /*........*/
71 }


(五)Linux内核的64bit地址映射过程

1、x86_64 CPU中逻辑地址(段式)映射

x86_64段式地址过程和x86一致,即各段起始地址都是0,区别在于段大小不再是4G(后面的地址映射实验再详细解释)。

2、x86_64 CPU中线性地址(页式)映射

本文只考虑最常使用4K页面时的线性地址映射,图是4K页面时,线性地址映射模型。

//线性地址48bit

//注意x86_64线性地址不是64bit,物理地址也不是64位,Intel当前CPU最高物理地址是52bit,但实际支持的物理内存地址总线宽度是40bit,见图



图2 IA32-e(x86_64 CPU架构)模式下线性地址映射



//

(2)页面映射分为4级

48bit线性地址分为5段、bit位宽度分别为9、9、9、9、12。映射方法和x86一致,就是一层层查表。

(3)CR3寄存器保存最高一级表的起始物理地址

(4)每个表项的大小都为8字节

64位地址值

2)页面映射分为4级

48bit线性地址分为5段、bit位宽度分别为9、9、9、9、12。映射方法和x86一致,就是一层层查表。

(3)CR3寄存器保存最高一级表的起始物理地址

(4)每个表项的大小都为8字节

64位地址值
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: