虚拟内存的核心概念是指代码所用的内存地址与物理地址没有关系.
在用户空间中,一个进程的虚拟地址A指向不同的物理内存,而不是另一个进程的地址A.
任何时候CPU发送指令向内存存取数据时,通过软件将虚拟地址的数据变为物理地址.
将虚拟地址变为物理地址变为物理地址的工作是由内存管理单元(MMU)完成的.
虚拟内存地址也可以称为逻辑地址.
内存管理单元是CPU功能的一部份,如果CPU有cache,它将有一个内存管理单元,反之亦然.
内存管理单元可以将两个进程对同一内存逻辑地址的访问映射到不同的物理地址.
内存管理单元同高速缓存密切协作,在RAM和高速缓存之间按要求传递内存.
内存管理单元将内存分成许多页,它是可利用物理内存的最小单位,每页包含4KB字节的地址空间.
虚拟地址到物理地址的转化是与体系结构相关的,在X86 CPU上是以分段,分页两种方式转化的.
Linux采用段页式管理方式是由于intel的X86 CPU的硬件体系结构决定的.这样的双重映射本身毫无必要,在Linux中段式映射不起什么作用.
可以理解为虚拟地址就是线性地址.
通过以下的程序来分析虚拟内存到线性地址再到物理内存的映射,我们还以X86为例:
:
8048354: 55 push %ebp
8048355: 89 e5 mov %esp,%ebp
8048357: 83 ec 08 sub $0x8,%esp
804835a: c7 04 24 70 84 04 08 movl $0x8048470,(%esp)
8048361: e8 2e ff ff ff call 8048294 <>
8048366: b8 00 00 00 00 mov $0x0,%eax
804836b: c9 leave
804836c: c3 ret
0804836d :
804836d: 8d 4c 24 04 lea 0x4(%esp),%ecx
8048371: 83 e4 f0 and $0xfffffff0,%esp
8048374: ff 71 fc pushl 0xfffffffc(%ecx)
8048377: 55 push %ebp
8048378: 89 e5 mov %esp,%ebp
804837a: 51 push %ecx
804837b: 83 ec 04 sub $0x4,%esp
804837e: e8 d1 ff ff ff call 8048354
8048383: b8 00 00 00 00 mov $0x0,%eax
8048388: 83 c4 04 add $0x4,%esp
804838b: 59 pop %ecx
804838c: 5d pop %ebp
804838d: 8d 61 fc lea 0xfffffffc(%ecx),%esp
8048390: c3 ret
函数main()通过call 8048354 调用了greeting函数.
首先可以看到ld给greeting分配的地址是0x08048354,在elf格式的可执行代码中,ld总是从0x08000000开始安排代码段,对每个程序都这样.
而程序在执行时在物理内存中的实际位置就要由内核在为其建立内存映射时临时作出安排,具体地址则取决于当时所分配的物理内存页面.这对于我们完全是透明的.
映射机制在程序运行时就已经建立起来了.
3.2)段式映射
从上例中,调用greeting()函数时,当前的地址是0x08048354,也就是EIP指针寄存器的值,那么CS的值是什么呢?
CS寄存器存放的是段式映射的选择码,可以理解为这是一个索引.
在LINUX中,选择码只有4个,也就是说只可能是以下4个其中1个,这4个选择码分别是:
段寄存器类型 数值 索引 TI RPL
__KERNEL_CS 0x10 0000 0000 00010 0 00
__KERNEL_DS 0x18 0000 0000 00011 0 00
__USER_CS 0x23 0000 0000 00100 0 11
__USER_DS 0x2B 0000 0000 00101 0 11
与上面的对照:
__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
对选择码进行解释说明:
1)关于段寄存器的赋值,依据以下的原则:
CS=__USER_CS
DS=__USER_DS
ES=__USER_DS
SS=__USER_DS
因为我们的程序在用户空间中运行,所以无论是代码段还是数据段都是__USER_XX
2)关于TI的值,TI可以是GDT(全局段描述表),也可以是LDT(局部段描述表).
GDT对映的是0
LDT对映的是1
LINUX的TI几乎都是0,LINUX内核中基本上不使用局部描述表LDT,LDT只是在vm86模式中运行wine以及其它在linux上模拟运行windows
或DOS软件的程序中才使用.
3)关于RPL,LINUX只用了0,3两种级别.
0代表内核进程,3代表用户进程.
通过以上的分析,我们的程序显然是用户进程,所以对映的就是__USER_CS,
最后CS寄存器的值就是0x23,而索引就是4.二进制(100)=十进制(4)
而在GDT全局描述表中4对映的是什么呢?
我们先来看看gdt全局描述表:
ENTRY(gdt_table)
.quad 0x0000000000000000 /*NULL desccriptor*/
.quad 0x0000000000000000 /*not used*/
.quad 0x00cf9a000000ffff /*0x10 kernel 4GB code at 0x00000000*/
.quad 0x00cf92000000ffff /*0x18 kernel 4GB code at 0x00000000*/
.quad 0x00cffa000000ffff /*0x23 user 4GB code at 0x00000000*/
.quad 0x00cff2000000ffff /*0x2b user 4GB code at 0x00000000*/
.quad 0x0000000000000000 /*not used*/
.quad 0x0000000000000000 /*not used*/
可以看到索引为4的GDT就是
.quad 0x00cffa000000ffff /*0x23 user 4GB code at 0x00000000*/
现在把这4项描述符展开:
63-60 59-56 55-52 51-48 47-44 43-40 39-36 35-32 31-28 27-24 23-20 19-16 15-12 11-8 7-4 3-0
Kernel_CS:0x00cf9a000000ffff -->0000 0000 1100 1111 1001 1010 0000 0000 0000 0000 0000 0000 1111 1111 1111 1111
Kernel_DS:0x00cf92000000ffff -->0000 0000 1100 1111 1001 0010 0000 0000 0000 0000 0000 0000 1111 1111 1111 1111
User_CS: 0x00cffa000000ffff -->0000 0000 1100 1111 1111 1010 0000 0000 0000 0000 0000 0000 1111 1111 1111 1111
User_DS: 0x00cff2000000ffff -->0000 0000 1100 1111 1111 0010 0000 0000 0000 0000 0000 0000 1111 1111 1111 1111
以下对描述符各位进行解析:
描述符格式如下:
63-56位存放的是基地址31-24位,基地址都为0
55位也叫G位,在LINUX中都为1,等于1时段长以4k字节为单位,等于0时以字节为单位
54位也叫D位,在LINUX中都为1,等于1表示对该段的访问为32位指令,等于0为16位指令
53位等于0
52位,CPU忽略该位,可由软件使用.
51-48位存放的是段地址上限19-16位,都是1
47位也叫P位,在LINUX中都是1,表示4个段都在内存中.
46-45位是DPL位,表示特权级别.分别有00(0级)和11(3级)两种组合.
44位也叫S位,等于1时表示一般的代码段或数据段,等于0时表示用于系统管理的系统段,如各类描述表.
43-41位叫做type位,因为各位之间有着紧密联系:
43位也叫E位,等于1时表示代码段,这时第42位叫C位,C位等于0时会忽视特权级别,C位等于1时会依照特权级别.这时41位叫R位,等于1时为可读,为0时不可读.
43位等于0时表示数据段,这时第42位叫ED位,ED位等于0时向上伸(数据段),ED位等于1时向下伸(堆栈段),这时41位叫W位,等于1时为可写,为0时不可写.
40位叫A位,在LINUX中都是1,表示以被访问过.
39-16位存放的是基地址23到0位,基地址都为0.
15-0位存放的是段地址上限15-0位,都是1
结论:每个段都是从0地址开始的整个4GB虚存空间,虚地址到线线地址的映射保持原值不变.
因此,LINUX内核的页式映射,可以直接将线性地址当作虚拟地址.二者完全一致.
3.3)页式映射
3.3.1)页式映射的概念:
1)在I386 CPU中页式存储的基本思路是:通过页面目录和页面表分两个层次实现从线性地址到物理地址的映射.
2)在LINUX中要考虑到各种不同的CPU,它以一种假想的,虚拟的CPU和MMU为基础,设计出一种通用的模型,再把它分别落实到各种具体的CPU上.
因此,LINUX内核的映射机制设计成三层,在页面目录和页面表中间增设了一层"中间目录".
逻辑上的三层映射对于i386 CPU和MMU就变成了二层映射,把中间目录PMD这一层跳过了,但是软件的结构却还保持着三层映射的框架.
3)页面目录称为PGD,中间目录称为PMD,页面表则称为PT.PT的表项则称为PTE.
页面目录,中间目录,页目表三者均为数组.
4)逻辑上把线性地址分成4个段位,分别用在页面目录PGD的偏移,中间目录PMD中的偏移,页表PT中的偏移以及物理页面内的偏移,而如果是I386的CPU则没有中间目录.
也就是被分成3个段位,分别是页面目录PGD的偏移,页表pt中的偏移以及物理页内的偏移量.
5)每个进程都有自己的页目录表和页表,进程的切换就是将当前进程的页目录表起始地址保存到CR3寄存器.
3.3.2)线性地址到物理地址的映射:
1)将一个进程的页面目录起始地址装入寄存器CR3.
2)用线性地址的第1个段位即PGD的偏移,找到页面表的物理地址.页目录表的大小为4k,刚好一个页的大小,包含1024项,每项4个字节(32位)
3)用线性地址的第2个段位即PT的偏移,找到表项,页面表的大小也是4k,同样包含1024项,每项4个字节(32位)
4)得到表项的高20位+低12位0组成,这个高20就是物理地址的高20位,再加上线性地址的第3段位即12位的偏移就得到了最终的物理地址.
3.3.3)用实例来说明映射的过程:
第一步:通过页目录表找到页面表
还是以上面的程序为例:
hello程序执行后,调用函数grreeting,这里的虚拟地址也就是线性地址为0x08048354
call 08048354
分解后的结果是:
0000 1000 0000 0100 1000 0011 0101 0100
第1个段位(高10位):
0000 1000 00
对映十进制的32,也就是在页目录表的偏移32找到其页面表的物理地址,也就是页面表的指针,它的低12位是0,因为页面表是4KB大小,所以肯定是边界对齐了.
第二步:通过页面表找到页的起始物理地址高
接下来是线性地址的第二个段位(中间10位):
00 0100 10 00
对映十进制的72,也就是在刚才找到的页面表的偏移72找到目标页的起始物理地址,高20位有效的地址,低12位填充为0.
第三步:得到最终的物理地址
通过找到的页起始物理地址,加上线性地址的第三个段位的偏移地址得到最终的物理地址.
例如:
第三个段位:0011 0101 01000
对映16进制为0x354
如果目标页的起始物理地址为:0x740000,那么最终的物理地址就是:
0x740000+0x354=0x740354