前言
上一篇我们了解了x86-16 CPU计算机的内存访问方式,寻址方式,以及基于MS-DOS的应用程序的内存布局。这一篇会主要介绍32位处理器的内存访问,内存管理以及应用程序的内存布局。虽然目前64位CPU已经非常普及了,不过相对于32位的内存管理方式并没有大的变化,而32位相对于16位却有了极大的改变。
1. IA-32 CPU
1985年10月。Intel推出了80386 CPU 用来取代之前x86-16位的架构,一直到现在差不多块20年的时间里,虽然处理的速度,制造工艺都在不断提升,但x86-32的架构都没有大的改变。一般我们说的IA-32, I386和x86-32是一个意思。
从80386开始,地址线变为了32位,和CPU寄存器以及运算单元位数一样,最大寻址范围增加到4G。所以在也不会出现16位CPU时访问内存出现的问题。另外80386处理器都可以支持虚拟存储器,支持实模式,保护模式和虚拟8086模式,支持多任务。 而之后的CPU,主要的改进就在于:
- CPU内部集成DMA,计数器,定时器等;
- 制造工艺的提示,更多的晶体管,更快的速度
- 加入更多的指令集,如MMX,SSE,SSE2等
- 集成L1,L2,L3高速缓存,减少外部总线的访问
- 超线程,多核心提高CPU效率
但是在内存管理访问,却没有太大的变化,所以我们后面介绍的内容基本上可以试用所有的x86-32 CPU而不用特意去区分那个型号的CPU。
1.1 16位CPU内存访问的问题
前一篇我们已经比较详细的了解了16位CPU的内存访问技术,现在可以会头想想他所存在的缺点,
- 单任务: 16位的CPU只支持单任务,也就是同时只有一个程序在运行,随着计算机的发展,单任务的缺点在于体验较差;
- 内存小: 前面我们知道,在运行程序时,会把程序全部加载到内存中,但是当程序大于内存时,程序就无法运行了;
- 地址不确定:每次程序装载时分配的地址可能都不一样,使得程序在编写时处理转跳等问题非常麻烦。
- 安全差: 因为对于内存访问没有太多的限制,所以应用程序很容易去修改操作系统以及BIOS和硬件映射的内存空间,导致系统崩溃;
而当80386引入多任务的支持后,以前的内存管理方式已经不能满足现状的需求的了。于是我们需要新的内存管理方式来解决上面的问题:
- 地址空间:这个是对物理内存的一个抽象,就好像进程是对CPU的一个抽象。一个进程可用于寻址的一套地址的集合,每个进程都有自己的地址空间,相互独立,这就解决了安全问题。
- 交换:把程序全部加载到内存,当进程处于空闲时,把他移除内存,存放到硬盘上,然后载入其他程序。这样使得每个进程可以使用更多的内存。
- 虚拟内存:在老的内存管理中,一次把程序加载到内存,而当程序过大时就无法正常运行了。而利用到计算机系统的局部性和存储分层,我们可以只加载一部分需要使用的代码和数据到内存,当访问的内容不在内存时,和已经使用完的部分进行交换,这样就能在小内存的机器上运行大的程序了。对于程序来说这是透明的,看起来自己好像使用了全部内存。而多个应用完全可以使用相同的虚拟地址。
1.2 IA-32 CPU的内存访问
32位CPU中开始,因为地址线和计算单元同为32位,所以采用了一种全新的内存访问方式,虚拟寻址。也就是CPU发出的地址并不是真正的物理地址,而是需要转换才能得到真实的物理地址。初看起来和16位计算机的分段内存访问好像差不多。但本质却不同。16位的分段访问是为了解决地址线位数大于CPU位数的问题。而虚拟寻址则是真正解决了上面提到的那些问题。 当然,Intel为了兼容,仍旧支持16位的分段式内存访问。
CPU在内部增加了一个MMU(Memory Management Unit)单元来管理内存地址的转换。我们知道在16位时代,仅仅使用一个地址加法器来计算地址,而这里MMU单元除了可以转换地址,还能提供内存访问控制。 比如操作系统固定使用一段内存地址,当进程请求访问这一段地址时,MMU可以拒绝访问以保证系统的稳定性。而MMU的翻译过程则需要操作系统的支持。所以可见硬件和软件在计算机发展过程中是密不可分的。后面会详细介绍虚拟地址转换的过程。这也是本文的重点。
1.3 IA-32 CPU工作模式
从80286开始为了兼容8086引入了实模式和保护模式。但是80386因为引入了对虚拟内存的支持,使得保护模式相对80286有了很大改变。而80286也受限于当时MS-DOS只能工作于实模式,所以无法使用到保护模式。所以我们一般谈到保护模式都是指386之后的32位保护模式。而CPU工作模式也和操作系统有关。
- 实模式: 实际就是8086的工作模式,可寻址空间为1M,采用分段式内存访问。程序可以直接访问BIOS和硬件的内存映射,所以目前计算机在启动时都是在实模式下。
- 保护模式: 80386以后因为引入了虚拟存储器,所以能更好的保护操作系统和各个进程的内存, 它主要体现在4G可寻址空间,采用段页式虚拟内存访问,支持多任务。当计算机启动后,BIOS把控制权交给操作系统,从实模式切换到保护模式。
- 虚拟8086模式: 主要是在保护模式下虚拟执行8086的实模式,这并不是一个CPU的模式,本质还是工作在实模式下,但可以实现多任务。
我们平时会经常听到实模式和保护模式,我们现在可以了解到他们主要的区别就在于内存访问的方式上,而CPU工作模式也离不开操作系统的支持。在DOS和Windows 1.X系统中,只支持实模式;Windows3.0中,同时支持实模式和保护模式;而到了Windows3.1,从上面微软操作系统和Intel CPU的版本图来看,当时主流已经是80386和80486了,所以移除了对实模式的支持。
1.4 IA-32 CPU寄存器
1.4.1 通用寄存器
IA32的CPU主要包含了8个32位的通用寄存器,
- EAX,EBX,ECX,EDX相对于16位的CPU来说扩展成了32为,当然为了兼容16为CPU,低位的16位和8位寄存器可以被单独使用。
- ESI,EDI两个个变址寄存器升级到了32位,其低位的16位对应之前的SI,DI寄存器、
- ESP,EBP2个指针寄存器同样变为32位,其低位的16位对应之前的SP,BP寄存器。
在8086内存寻址中有介绍,只有BX,BP,SI,DI可以用来存放基址和变址的地址,但是80386开始,以上8个寄存器都可以用来存放指针地址,所以更加的通用。
1.4.2 段寄存器
32位CPU为了保持对16位CPU的兼容性,保留了4个16位段寄存器,CS,SS,DS,ES,同时增加了2个段寄存器FS,GS
- CS,SS,DS,ES: 工作在实模式时与16位CPU的段寄存器作用相同;工作在保护模式则不在存放段值,而是作为选择子,在虚拟地址转换时使用。
- FS和GS是新增的附加数据段,通过把段地址存入这两个寄存器可以实现自定义寻址。
1.4.3 指令指针寄存器和标志寄存器
EIP扩展到了32位,和数据线相同。 低16位作用和IP寄存器相同。在32位计算机中存放的是指令的虚拟地址,16位计算机中存放的是CS段内有效地址。EFLAGS寄存器同样扩展到32位,具体含义我们这里不做介绍。
1.4.4 新增的寄存器
另外我们也看到,在IA-32中也新增了一些寄存器,GDTR/IDTR/LDTR/TR。他们主要在CPU保护模式下需要用到的寄存器,具体使用在后面会介绍到。
- GDTR是全局描述附表寄存器,主要存放操作系统和各任务公用的描述符;
- LDTR是局部描述符表寄存器,主要存放各个任务的私有描述符;
- IDTR指出了保护模式中断向量表的起始地址和大小(2K,最多256个中断);
- TR是任务寄存器;
2. 虚拟存储器
虚拟存储器我们一般也称为虚拟内存(和Windows中的虚拟内存不是一个概念,但是有关联),它的基本思想是:
- 每个进程都有自己的地址空间;
- 每个地址空间被分为多个块,每个块称为页,每个页有连续的地址空间;
- 这些页被映射到物理内存,但不是所有也都在内存中程序才能运行;
- 当使用的页不在物理内存中时,由操作系统负责载入相应的页;
在实模式下,CPU将偏移地址和段寄存器,基址寄存器等进行计算得到的实际的物理地址。 而在保护模式下,引入了虚拟内存的概念,在虚拟内存中使用的地址称为虚拟地址(线性地址),虚拟地址通过MMU将虚拟地址映射为物理地址,然后送到总线,进行内存访问。这里最关键的就是虚拟地址的映射。
2.1 分页
对于虚拟内存来说,是对物理内存的抽象,整个虚拟内存空间被划分成了多个大小固定的页(page),每个页连续的虚拟地址,组合成了一个完整的虚拟地址空间。同样,操作系统也会把物理内存划分为多个大小固定的块,我们称为页框(page frame),它只是操作系统为了方便管理在逻辑上的划分的一个数据结构,并不存放实际内存数据,但是我们可以简单的认为它就是内存。这样一个虚拟内存的page就可以和一个物理内存的page frame对应起来,产生映射关系。
关于一个虚拟页的大小,现在的操作系统中一般是512B-64K(准确的说是地址范围大小,而非容纳数据的大小)。但是内存页的大小会对系统性能产生影响,内存页设得太小,内存页会很多,管理内存页的数组会比较大,耗内存。内存页设大了,因为一个进程拥有的内存是内存页大小的整数倍,会导致碎片,即申请了很多内存,真正用到的只有一点。目前Windows和Linux上默认的内存页面大小都是4K。
从上图我们也可以看出,虚拟内存的页和物理内存的页框并不一定是一一对应的,虚拟内存的大小和系统的寻址能力相关,也就是地址线的位数,而物理内存的页框数取决于实际的内存大小。所以可能只有一部分页有对应的页框,而当访问的数据不在物理内存中时就会出现缺页,这个时候操作系统会负责调入新的页,也就是建立新的映射。这样就不需要一次把程序全部加载到内存。
2.1.1 虚拟页是什么?
很多人会有一个疑问,虚拟页到底是实际存在的还是虚拟的?我们知道内存中存放的是执行文件的代码和数据,而程序在运行前,它的数据和代码是存放在这个程序的可执行文件中的(比如.exe和.so),而在运行时需要把可执行文件加载到内存。所以我们把这个硬盘上的文件也划分为4K大小的页(逻辑上划分,实际是加载过程中加载器完成的),这就是虚拟页里面实际的东西。但是程序在运行是可能会申请内存,这个时候需要新的虚拟页来映射,所以我们可以得知虚拟页应该有3种状态:
- 已映射:虚拟页面被创建已经被加载到物理内存,和物理页之间存在映射关系。
- 未映射:虚拟页面被创建,但是没有被加载到内存或已经被调出内存,和物理页面之间没有映射关系,当需要使用时调入内存建立映射。
- 未创建:虚拟页面没有被创建,可能是因为还没有访问到此页面所以没有加载或者是调用macllo来分配内存,只有在运行是才会被创建。
2.1.2 存储器映射
加载应用程序到内存时,因为和虚拟地址有关,我们需要把应用程序文件和虚拟内存关联起来,在Linux中称为存储器映射,同时提供了一个mmap的系统调用来实现次功能。文件被分成页面大小的片,每一片包含一个虚拟页面的内容,因为页面调度程序是按需求调度,所以在这些虚拟页面并没有实际的进入内存,而是在CPU发出访问请求时,会创建一个虚拟页并加载到内存。我们在启动一个进程时,加载器为我们完成了文件映射的功能,所以此时我们的执行文件又可以称为映像文件。实际上加载器并不真正负责从磁盘加载数据到内存,因为和虚拟内存建立了映射关系,所以这个操作是虚拟内存自动进行的。 正是有了存储器映射的存在,使得我们可以很方便的将程序和数据加载到内存中。
2.1.3 交换分区
当CPU请求一个虚拟页是,虚拟页会被创建并加载到内存,而页面调度算法可能在页面休眠或在内存满的情况下更具调度算法将虚拟页交换出去,在适当的时候可能被交换回来。这个时候就需要一个区域来存放被交换出来的虚拟页,这个区域称为交换分区。 这个分区在Linux中称为swap分区,而在Windows中我们称为虚拟内存(注意这里和我们谈到的虚拟内存技术不是一回事)。
以前电脑内存很小,特别是玩一些游戏时经常会提示内存不足,网上一般会告诉你增大你的虚拟内存(交换分区),这样一来在内存不足的时候可以存放更多交换出来的虚拟页,看起来好像内存变大了一样。从这方面来说Windows把他叫虚拟内存(交换分区)也是很正确的。 交换分区虽然也是硬盘的一部分,但是交换分区没有普通的文件系统,这样消除了将文件偏移转换为页地址的开销。但是过于频繁的交换页面,IO操作会导致系统性能下降。但是在内存不足时可以保证系统 正常运行。当然这也和交换分区的大小有关。
而如今,一般使用的电脑都已经4G,8G内存了,对于普通需求来说足够大了。所以虚拟页会长时间存在与内存中而不被交换出去。所以我们可以禁用掉交换分区,以便提高性能。对于Windwos 从Vista开始有一个Superfetch的内存管理机制,而linux有Preload与之类似。这种内存机制会将用户经常用的应用的部分虚拟页提前加载到内存,当用户使用时就无需在从硬盘加载。而当应用休眠或关闭时,也不会将这些虚拟页交换出去。
如下图就是Windows 8上内存使用情况,其中最左灰色部分是给BIOS和硬件保留的内存映射区域;绿色为操作系统,驱动以及用户进程使用的内存;橙色表示已经修改的内存也,当交换出来时需要先写回到硬盘;而蓝色部分5G内存则是用来缓存了未激活进程的数据和代码页;最后剩余的3M才是空闲内存。 当活动进行需要更多内存时会优先使用可用部分,当可用部分没有内存可用时,会释放一部分备用区域的内存。
2.2 页表
上面我们看到当实际物理内存小于虚拟内存时,会存在缺页以及页面交换等问题。此时操作系统会处理这些事情,是的这一切对于程序来说是透明的,它们不知道发生了什么,只知道自己可以使用全部的虚拟内存空间。而对于操作系统来说,它们需要负责一切,需要知道程序的那些页在实际内存中,那些不在。于是出现了页表,就是用来记录虚拟内存的页和物理页框之间的映射关系。MMU也正是利用页表来进行虚拟地址和物理地址的转换。
上面这张图是一张虚拟内存页和物理内存页框之间通过页表的映射关系,其中虚拟页面从VP0-VP7,物理页为PP0-PP3,我们从图中可以得到几点信息:
- 不是所有的虚拟内存页都加载到了物理内存中(VP3,VP6未映射状态);
- 不是所有虚拟内存页都被创建(VP0,VP5未被创建)
- 所有的虚拟内存页在页表中都有一项纪录,我们称为PTE(Page Table Entry);
- 虚拟内存的页是存放在磁盘上的;
- 页表纪录需要占用内存空间;
- MMU通过页表,将虚拟地址转换为物理地址;
这里可能会有疑问,为什么VP5没有被创建?虚拟页不是应该连续的吗?这就涉及到内存分段,程序编译和加载一些列问题了,这个会在介绍程序加载时解释。
最后我们看下页表中PTE的结构,一个PTE大小是32位,系统在操作页表时则会根据这些属性进行相应操作。
- P:存在标志(1表示当前页是加载到了物理内存中)
- W:读写标志(0时表示只读)
- U/S: 用户/超级用户(0时表示用超级用户权限)
- PWT:连续写入
- PCD:禁用缓存
- A:访问过
- D:脏位(1表示被写过)
- PAT:页面属性索引表
- G:全局标志(TLB中使用)
- Avail:方便操作系统使用
2.3 虚拟地址转换
通过上图我们来分析一下虚拟地址转换的过程:
- CPU送出要访问的虚拟地址,地址的结构是【页号+页内地址】;
- 页表存放在内存中,页表的地址和长度信息则存放在一个页表专用的寄存器中;通过读取寄存器的信息获得页表的起始地址;
- 将虚拟地址的页号与页表其实地址相加可以得到页表的实际地址
- 通过页表的映射项目,可以得到对应的物理内存页的号码
- 通过物理页号和业内偏移地址就能得到实际要访问的物理地址
当然,如果访问过程中出现缺页,会产生一个中断,然后操作系统会载入需要的页面并进行映射(设置页表),最后返回物理页号得到物理内存。从上面的过程我们可以知道,每次进行地址变化,MMU都要访问内存。回忆8086地址变换时是不需要访问内存的,于是虚拟地址的转换会影响系统性能,但是相当于虚拟内存带来的好处,这点代价还是值得的。
2.4 页表分级
在IA-32平台上,地址线为32位,所以最大的寻址范围是4G,那么最多能够支持使用4GB的内存(内存按字节编码)。那么对于虚拟内存来说,它的地址范围为4G(0x00000000 ~ 0xFFFFFFFF),而一个内存页的大小是4K,那么一个程序虚拟内存空间中有1048576个页(实际上进程可访问的虚拟地址范围没有4G,Linux是3G,Windows是2G或3G)。
从上面我们知道每个虚拟页都会在页表中有一个PTE,每个PTE为32位,那么对于一个进程至少需要4MB的内存来维护自己的页表;而一个系统中可能存在多个进行,仅仅维护页表这一项就需要消耗比较多内存。但实际上很多PTE项并没有映射到对应的物理页,这就造成了浪费。
有人会说那我们就动态建立页表,在映射时才增加这一项。但是从虚拟地址转换我们可以看到,找到PTE是通过PT首地址+页号得到的,所以页表PTE必须是连续的,但我们又知道并不是所有的虚拟页都会马上被创建,在访问是就会出现问题,比如VP0-VP8中的VP5没有被创建,当访问页号是5时,就会错误的访问到VP6。所以为了解决页表占用内存过多的问题,引入了分级页表。注意分级页表也需要CPU硬件提供支持。
2.4.1 二级页表
上图是Linux系统上二级页表的示意图。与一级页表不同的是,多增加了一层目录,虚拟地址的组成变为了【目录地址+页表地址+页内偏移】。其中页内偏移地址为12位,页表(PGT)地址为10位,页表目录(PGD)地址为10位。因为总过是32位,他们表示的PTE的个数是不变的。同样,PEG的每一个项目也有自己的结构。
二级页表地址转换的过程也很简单:
- 首先从cr3寄存器中找到PGD的首地址;(cr3寄存器用来保存PGD的地址)
- PGD首地址和目录号进行计算得到页表的首地址;
- 页表的首地址和页号进行计算得到物理页的首地址;
- 页内偏移和物理页地址通过计算得到最终的物理地址;
现在谈一谈为什么页表分级可以解决内存问题。首先因为页表需要连续的大的内存空间,通过引入目录级,我们可以离散对连续大空间的需求,这样,只有在同一个目录下的页表才需要连续的空间。另一方面,如果某个目录下的页表中没有任何映射的记录,那么这一张页表就不需要加载。因为其他页表可以通过其他目录项来获得,而不会存在一级表中不加载页表项导致访问出错的问题。但是同一个页表中,如果只有一个PTE被使用,这张页表也是需要被加载的。分级的方法同样用到了程序的局部性原理。
2.4.2 多级页表
对于32位系统最多能使用4G内存,为了让系统可以使用更多内存,加入了 物理地址扩展(Physical Address Extension,缩写为PAE)功能,可以支持36位。在前面8086时候我们见过类似的技术来使用更多内存。这个时候2级页表就无法满足要求了,于是引入了三级页表。其中增加了PMD中间目录一级。
为了适应64位CPU,操作系统又引入了4级页表。但是总体上来说他们工作原理都是相同的,这里就不叙述工作工程了。但是要注意的是,分级页表需要处理器的支持,对于只支持二级或三级页表的CPU来说,内核中体系结构相关的代码必须通过空页表对缺少的页表进行仿真。因此,内存管理代码剩余部分的实现是与CPU无关的。目前Windows 2000/XP使用的是二级页表,而使用PAE时则使用的是三级页表,对于64位操作系统则采用了四级页表。Linux则使用了四级页表。
2.4.3 倒排页表
在64位操作系统中,因为有64位地址线,所以页表的大小可能非常非常大,虽然分级页表可以不必加载全部页表,IA-32,IA64系统一般使用四级页表来处理,而在PowerPC等体系中则使用倒排页表来解决这个问题。与传统页表的区别: 使用页框号而不是虚拟页号来索引页表项。因为不是X86体系中常用的方法,这里就不相信介绍了。具体可以查看《现代操作系统》P113
2.5 TLB缓存
前面介绍虚拟地址转换时说过,相对于8086上的地址转换而言,这里多了一次内存访问查找页表的过程。我们知道内存速度比CPU慢很多,每次从内存取数据都要访问2次内存,会使得系统性能下降。
为了解决这个问题,在MMU中包含了一个关于PTE的缓冲区TLB(Translation Lookaside Buffer ),TLB是一个寄存器,所以它运行的速度和CPU速度相同。TLB中每一行保存了一个PTE,如上图所示,每次在去页表中查找之前,可以先在TLB中进行查找,如果命中则直接拿到物理页地址,如果不命中则再次访问内存。我们多次提到程序的局部性,在这里下一个要访问的地址很可能和前一个是在同一个内存页中,于是我们可以直接从寄存器中拿到物理内存页号,而不需要在访问内存,这样大大提高的了系统的速度。
上图是TLB的一个基本结构,对于多级页表来说,TLB可以缓存每一级的地址,所以同样能起作用。因为局部性原理,多级页表的访问速度并不比一级页表差。关于TLB更详细的内容,可以查看《深入理解计算机系统》P607 - P619
2.6 进程调度和虚拟内存
我们知道在系统中,每个进程都有自己独立的虚拟空间,于是每个进程都有一张属于自己的内页表。 而我们翻译地址时,从cr3中取出页表目录的首地址。对于不同的进程,他们都使用同一个寄存器。于是在CPU调度进程的时候,虚拟的地址空间也需要切换。于是对于普通用户程序需要做下面几件事情:
- 保存当前进程的页表目录地址,也就是保存cr3中存放的地址到进程上下文中
- 清空TLB中缓存的数据
- 调度新的进程到CPU,并设置cr3寄存器的值为页表目录的首地址
但是内存中除了用户程序之外还存在操作系统自身占用的内存。我们可以简单的把操作系统看成一个超大的进程,他和其他普通进程一样需要使用虚拟内存,需要使用到页表。当然作为内核程序,它必须是有一些特权的,下一篇我们将会介绍虚拟内存的布局。而对于内核而言不是存在进程调度的。因为所有的内核进程都是共享内核的虚拟地址空间,而我们一般都称之为内核线程,而非进程。 当然对于Linux而言,没有线程的概念,线程和进程唯一不同就是是否共享虚拟地址空间。一般来说内核代码是常驻在内存的,那么内核会不会缺页呢?
3 小结
这一篇文章主要介绍了IA-32上的虚拟内存管理,主要的核心就是虚拟内存分页。这也是现代操作系统和计算机的核心部分。整个虚拟内存部分涉及的内容也非常广,加上自己理解不深,很多东西就只能简单介绍了。而且内存管理这一块不同操作系统可能会有一些不同的,这里我尽量避开这些差异,总体来说都是比较通用的。在下一篇将主要介绍虚拟内存中的分段管理。
参考:
实模式与保护模式