物理内存
计算机的主存是由多个连续的单元组成的,每个单元称为一个字节,每个字节都有一个唯一的物理地址 (Physical Address, PA),地址编码是从 0 开始的。所以,如果计算机上配有 2G 的内存,那么,这个计算机可用的物理内存空间就是 0 到 2G。
如今,X86 架构的 CPU 在上电以后,为了与 8086 保持兼容(注:8086架构的 CPU 的 mov 指令就可以直接访问物理内存),还是运行在 16 位实模式下,实模式的特点是所有访存指令访问的都是物理内存地址。
直接访问物理内存带来一系列的问题
程序员需要手动管理内存,负责进程的每一数据都存储在物理内存的什么位置(和当前进程同时运行的其他进程,不能与当前进程共用同一个物理地址,否则就会造成地址冲突)、物理内存不够了怎么办、每个进程分配多少物理内存以及如何保证访存指令中地址的正确性。这些问题,在多任务系统中显得尤其复杂。
但是在嵌入式设备中,手动管理内存的操作还是广泛存在的。这是因为在嵌入式开发中,往往没有进程的概念,也就是说整个应用独享全部内存,这种情况下,手动管理内存才有可能性。在单进程的系统中,所有的物理资源都是单一进程在管理,直接管理物理内存的操作复杂度还可以接受。
局部性原理
由于程序的数据访问会表现出明显的局部性。基于局部性原理,CPU 和操作系统为程序员虚拟化了一层中间的内存,我们只需要与虚拟内存打交道就可以了。虚拟内存的出现,解决了直接操作物理内存的系统难以支持多任务调度的问题。
我们可以从两个方面来理解局部性原理。第一个方面是时间局部性,也就是说被访问过一次的内存位置很可能在不远的将来会被再次访问;另一方面是空间局部性,说的是如果一个内存位置被引用过,那么它邻近的内存位置在不远的将来也有很大概率会被访问。
无论一个进程占用的内存资源有多大,在任一时刻,它需要的物理内存都是很少的(没有哪条指令能够一次访问超过 4KB 的内存)。因此,操作系统只需要为每个进程保留很少的物理内存就可以保证进程的正常执行了
为了让方便程序员编程,CPU 和操作系统联手编织了一个假象:每个进程都独享一个巨大的虚拟内存空间,并且每个进程的地址空间都是相互隔离的。这样,操作系统上是否还运行了其他进程,我们已经不关心了。
任何虚拟内存最终都会映射到物理内存,但虚拟内存的大小又远超真实的物理内存的大小。
虚拟内存与页表
虚拟内存与物理内存的映射关系,是以页为单位,由操作系统来维系的。
- 在虚拟内存中连续的页面,在物理内存中不必是连续的;
- 虽然虚拟内存机制为进程提供了巨大的空间,但是这些空间并不是直接就能正常使用的,需要程序员通过 malloc 等接口为程序分配虚拟内存,这些虚拟内存由未分配状态转换为已分配状态,但此时这些虚拟内存还未映射到物理内存,直到程序对这些虚拟内存进行实际地读写时,操作系统才会为这些虚拟内存真正的分配物理内存。
虚拟地址到物理地址的映射过程,是由 CPU 的内存管理单元 (Memory Management Unit, MMU) 自动完成的,但它依赖操作系统设置的页表。
页表的本质是页表项 (Page Table Entry, PTE) 的数组,虚拟空间中的每一个虚拟页面在页表中都有一个 PTE 与之对应,PTE 中会记录这个虚拟页面所对应的实际物理页的起始地址(PTE 中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等)。一个页表项对应着一个大小为 4K 的页,所以 1024 个页表项所能支持的空间就是 4M。为了能够编码更多地址,我们必须使用更多的页表。而且,为了管理这些页表,我们还可以继续引入页表的数组:页目录表。页目录表中的每一项叫做页目录项 (Page Directory Entry, PDE),每个 PDE 都对应一个页表,它记录了页表开始处的物理地址,这就是多级页表结构。
在现代 64 位处理器上,为了编码更大的空间,还存在更多级的页表。
虚拟地址到物理地址的映射过程(以 32 位处理器为例)

- 确定页目录基址。在 X86 上,每个 CPU 都有一个页目录基址寄存器 CR3(进程切换时会将目标进程的页目录基址存入 CR3 寄存器中,该基址本身存放在该进程的 task_struct 中),用于保存当前运行进程的最高级页表的基地址。每一次计算物理地址时,MMU 都会从 CR3 寄存器中取出页目录所在的物理地址。
定位页目录项(PDE)。一个 32 位的虚拟地址可以拆成 10 位,10 位和 12 位三个段,第 1 步找到的页目录表基址加上高 10 位的值乘以 4,就是页目录项的位置。
一个页目录项正好是 4 字节,所以 1024 个页目录项共占据 4096 字节,刚好组成一页,而 1024 个页目录项需要 10 位进行编码。这样,我们就可以通过最高 10 位找到该地址所对应的 PDE 了
定位页表项(PTE)。页目录项里记录着页表的位置,MMU 通过页目录项找到页表的位置以后,再用中间 10 位计算页表中的偏移,可以找到该虚拟地址所对应的页表项了。
- 确定真实的物理地址。上一步 MMU 已经找到页表项了,这里存储着物理地址,此时才真正找到该虚拟地址所对应的物理页。虚拟地址的低 12 位,刚好可以对一页内的所有字节进行编码,所以我们用低 12 位来代表页内偏移。计算的公式是物理页的地址直接加上低 12 位。
而采用四级页表的 64 位操作系统,其 PDE、PTE 都是 8 字节的,所以一页物理内存只能存放 512 项,只需要 9 位就可以编码了,因此 64 位操作的虚拟地址被划分 9 位、9 位、9 位和 12 位四段(64 位的虚拟地址只用到低 48 位)。
页面的换入换出
由于程序的运行符合局部性原理,对于那些没有被经常使用到的内存,我们可以把它换出到主存之外,比如硬盘上的 swap 区域,而新的虚拟内存页可以被映射到刚腾出来的这些物理页。
一个小例子:假如进程 A 一开始将虚拟内存的 0 至 4K 映射到物理内存的 0 至 4K 空间。基于局部性原理,4K 以后的虚拟地址大概率是不会被访问的,当程序开始访问 4K ~ 8K 之间的虚拟地址时,我们就可以将现在的物理地址里的内容换出到磁盘的 swap 区域,然后再将虚拟内存的 4K ~ 8K 映射到物理内存的 0~4K 。在理想情况下,虽然进程 A 需要的虚拟内存非常大,比如 256T,但是实际只需要一个 4K 大小的物理内存页就能满足它的需求了。
从效率的角度看,当物理内存足够时,操作系统会尽量让尽可能多的页驻留在物理内存中,毕竟将内存中的数据写到磁盘里是非常耗时的操作
