我们在编写程序的时候,大多数情况都是感知不到内存的情况的,例如,在使用 Python 的时候,创建一个数组直接用 list 就完结了,对于很多中级语言例如 C,也是可以直接创建一个数组变量就好了。这些都得感谢于操作系统和编程语言,编程语言为我们提供了很多的语法和特性,让我们可以在基本上不关注内存的情况下可以完成很多工作,但是高不高效这个得看个人。而操作系统的功劳就更厉害了,它为我们提供了完整的内存区间,对于每个进程来说,都可以简单得认为自己占用了整个内存,例如 32 位的系统,可以简单得认为我就使用了 232 个 byte,也就是 4G 的内存。这肯定是不可能的嘛,那么具体是怎么实现的,就是本文尝试探索的问题。

概述

前言中说到每个进程都可以认为自己使用了所有的内存,这在操作系统中叫做虚拟内存,也就是说进程可以认为自己使用了所有的虚拟内存空间,32 位系统的 4G 空间。那么为什么操作系统要这么实现呢?这是有原因的:

  1. 可以当作进程和磁盘之间的缓冲,根据需要从磁盘中读取和写回数据
  2. 可以为进程提供一致的地址空间,这样进程的操作就简化了
  3. 可以保护每个进程的内存数据不会被其他进程破坏

硬件相关

MMU

在通常使用 Linux 系统时,我们可能接触到的内存地址类型有三种:

它们之间的关系是:

仲裁器

虽然多核 CPU 逻辑上可以并发访问内存,但是对于内存来说,读写操作都是串行的,所以对于内存操作,有个内存仲裁器,它决定某一时刻,哪个 CPU 可以操作内存。

实模式和保护模式

虚拟内存和物理内存的关系

我们日常经常了解到的内存往往都是物理内存,也就是我们去电脑城购买的内存条的内存。事实上,以现在的平均水平来说,可能大部分人的电脑都有 4G 以上的物理内存,但是,这对于程序运行来说,远远不够,因为即使对于 32bit 系统来说,它的内存空间也是 4G,所以,如果我们实打实得将物理内存的地址直接映射到进程的内存空间,那么我们也就只够运行一个进程,啥都做不了。

基于这种情况,所以就出现了虚拟内存的概念,虚拟内存可以让进程有占有整个进程空间的幻觉,但是,当真正反映到物理内存的时候,只是用到了其中的一部分。同时,这就引出了一个问题,进程如何将虚拟内存地址(VA)转换成真正的物理内存地址(PA)呢?这个就得依赖于 CPU 上的一个小硬件 —— 内存管理单元(Memory Management Unit),它可以将一个虚拟地址转换为物理地址,这个操作也叫做地址翻译(Address Translation)

虚拟内存管理

从功能性的角度来说,我们可以将虚拟内存当作是磁盘与CPU 之间的缓存,既然是缓存,那么就存在几种情况:

同时,因为是缓存,那么怎么缓存也是个问题。我们可以想象一下,如果是单字节为单位或者4字节为单位进行缓存,那么 MMU 的转换逻辑或者说是转换效率会不会有问题?这些都是确定的,所以,为了解决这些问题,虚拟内存的管理是以页(Page) 为单位进行。虚拟内存以页为单位进行内存分配,假设一页的大小为 4KB,那么我们 4G 的虚拟内存就可以划分为 220 个页,定位一个页也就只需要 20 bit。

为了转换 VA 和 PA,在 MMU 上存在一张页表,其实也就是一个数组,数组里面每个元素都是一个页表条目(Page Table Entry, PTE),每个 PTE 里面都存放着类似这样的结构:

这张图可能乍看之下不太明白什么意思,所以先来个小实例看看:

假设进程 A 先要访问地址:0xFF3F 的数据,首先到 MMU,因为这里的页表的大小是 8(23),所以取前三位,就是 0x7, 所以就找 PTE7,PTE7 的有效位是有效的,所以就拿到 PTE7 中的物理页号: 0x8345,然后跟虚拟地址的后几位拼接起来就构成了物理地址:0x8345FF9F1

这里在 MMU 上能够找 PTE 的情况就叫做页命中,那要是不能找到 PTE 的情况会怎样呢?当不能找到有效的 PTE 时,就会引发 缺页,从而触发了一个缺页异常缺页异常调用内核中的缺页异常处理函数,该程序会选择一个牺牲页,用于存放要加载的页。可以看到,这里就和 OS 关联起来了。

Ref