关于操作系统内存管理的总结

历史

许多年以前,当人们还在使用 DOS 或是更古老的操作系统的时候,计算机的内存还非常小。随着应用程序的规模逐渐膨胀,一个难题出现在程序员的面前,那就是应用程序太大以至于内存容纳不下该程序。

通常解决的办法是把程序分割成许多称为 覆盖块(overlay)的片段。覆盖块 0 首先运行,结束时他将调用另一个覆盖块。虽然覆盖块的交换是由操作系统完成的,但是必须先由程序员把程序先进行分割,这是一个费时费力的工作,而且相当枯燥。

人们必须找到更好的办法从根本上解决这个问题。不久人们找到了一个办法,这就是虚拟存储器(virtual memory)。虚拟存储器的基本思想是程序、数据、堆栈的总的大小可以超过物理存储器的大小,操作系统把当前使用的部分保留在内存中,而把其他未被使用的部分保存在磁盘上。

比如,对一个 16 MB 的程序和一个内存只有 4 MB 的机器,操作系统通过选择,可以决定各个时刻将哪 4 MB 的内容保留在内存中,并在需要时在内存和磁盘间交换程序片段。而这个 16 MB 的程序在运行前不必由程序员进行分割

  • $1K = 2^{10}\ (10\ bits) = 1,024$
  • $1M = 2^{20}\ (20\ bits) = 1,048,576$
  • $1G = 2^{30}\ (30\ bits) = 1,073,741,824$
  • $1T = 2^{40}\ (40\ bits) = 1,099,511,627,776$

注意:要区分 寻址能力内容大小,且寻址能力和内存大小没什么关系,而是与地址总线有关。每个地址表示一个 Byte(大写 B 表示字节,小写 b 表示位),32 位寻址能力为 $2^{32} = 2^2 \times 2^{30} = 4 \times G = 4G$(没有 B),可表示的内容大小为 $4G \times 1\ Byte = 4 GB$。

虚拟内存管理

在任何时候,计算机上都存在一个程序能够产生的地址集合,我们称之为 地址范围。这个范围是我们的程序能够产生的地址范围,如一个 32 位的 CPU,地址范围是 0 ~ 0xFFFFFFFF。我们把这个地址范围称为 虚拟地址空间,该空间中的某个地址叫做 虚拟地址(virtual address)。

其实应该使用「逻辑地址」,后文会详细介绍「虚拟地址」的来源和概念,上面这段话使用这个概念只是方便与物理地址做对应,而虚拟地址实际上代表的是「偏移量」。

与虚拟地址空间和虚拟地址相对应的则是 物理地址空间物理地址(physical address),大多数时候系统所具备的物理地址空间只是虚拟地址空间的一个子集。比如,对于一台内存为 256 MB 的 32 位 x86 主机来说,它的虚拟地址空间范围是 0 ~ 0xFFFFFFFF(4G),而物理地址空间范围是 0 ~ 0x0FFFFFFF(256M,即 2^28)

256 MB 内存可以存放 256 MB 大小的内容,表示这些内容需要的地址空间为 256 MB / 1 Byte = 256M。

这里有一个虚拟内存(virtual memory)的概念,是对整个内存(不要和机器上插的那条对上号)的抽象描述,并不与实际的物理内存一一对应。有了这样的抽象,一个程序就能使用比真实物理地址大得多的地址空间,甚至多个进程能使用相同的地址,因为相同的虚拟地址转换后的物理地址并不一定相同。

物理地址中很大一部分是留给内存条中的内存本身,但也常被映射到其他存储器上(显存、BIOS 等)。在没有使用虚拟存储器的机器上,虚拟地址被直接送到内存总线上,使具有相同地址的物理存储器被读写;而在使用了虚拟存储器的情况下,虚拟地址不是被直接送到内存地址总线上,而是送到存储器管理单元 MMU(Memory Management Unit),把虚拟地址映射为物理地址。

进程使用虚拟地址,由操作系统协助相关硬件,把它转换成真正的物理地址。这个 转换,是所有问题讨论的关键,通常包括 段式内存管理分页内存管理

段式内存管理

注意:读到这里,请读者将前文的 虚拟地址 换成 逻辑地址,下文的 虚拟地址 将是不同的概念。段式内存管理的任务是进行 逻辑地址物理地址 的相互转换。

逻辑地址是访内指令给出的地址,也叫 相对地址,就是机器语言指令中用来指定一个操作数或是一条指令的地址。但是,它并不是我们平时写代码中遇到的类似 0xFFFF4B1C 的 线性地址

在 Intel 32 位平台下,逻辑地址(logical address)是由 段标识符(selector)和 段内偏移(offset)组成。段标识符是段寄存器(CS、DS、SS、ES 等)的值,其中前 13 位为索引信息,后 3 位是硬件信息;段内偏移是 IP、EIP 寄存器的值。通过 段标识符 去 GDT(全局描述符表)里取得 段基址(segment base address)然后加上 段内偏移,这就得到了 线性地址(linear address)。如果不再使用页式内存管理,线性地址也就是 物理地址

  • 逻辑地址 = 段标识符 : 段内偏移
  • 段标识符 → 段基址
  • 线性地址 = 段基址 + 段内偏移

关于寄存器可参考:关于 C 语言编译流程 PCAL 的总结

虚拟地址究竟是什么👻?

参考知乎上的一个回答(文末有链接):

问题来了,为什么没提到虚拟地址,这是个什么东西?其实在 Intel IA-32 手册里并没有提到这个术语,但是在内核的确是用到了这个概念,比如 __va 和 __pa 这两个宏定义。看似神秘的虚拟地址究其本质就是程序里面使用的地址比如一个指针值,指针的本质就是 EIP 寄存器里的值,说直白点,虚拟地址就是 EIP 寄存器的值。你会发现我们上面说过,逻辑地址由段标识符和段内偏移两部分组成,其中段内偏移也是 EIP 寄存器的值,所以结论为:逻辑地址的段内偏移正是虚拟地址,它俩是一个东西

逻辑地址 = 段标识符 : 段内偏移(虚拟地址)

既然搞明白了逻辑地址和虚拟地址的关系,那么我们再来看下,线性地址和虚拟地址是什么关系。在上面讲到的段式内存管理中,Linux 内核会将段基址设成 0,于是就有 线性地址 = 0 + 段内偏移,又因为虚拟地址就是段内偏移,所以算出的线性地址在数值上等于虚拟地址,注意,仅仅是数值上等于。

在 Linux(Windows 也是)下:线性地址 = 0 + 段内偏移(虚拟地址)

网上很多资料认为逻辑地址是虚拟地址的别名,其实它们不是一个东西。还有很多资料把线性地址当作虚拟地址的别名,其实它们也不是一个东西,只是 Linux 在 x86 下将它们弄得数值相等而已,虽然值相等但是本质不同。

到这里,三者之间的关系就讲明白了,虽然逻辑地址的概念很清晰,但是虚拟地址和线性地址依然可以不作区分,因为区分了也没什么用,内核里这俩概念是通用的。不过,知道点区别还是不至于在某些时候把自己搞晕,尤其是有些书和教程里面这两个词不说缘由就混着用。

这三者关系为何如此混乱?

按照 Intel 的设计,段式内存管理中的段类型分为三种:代码段、数据段、系统段(TSS 之类的),实在是太麻烦了。我们只靠页式内存管理就已经可以完成 Linux 内核需要的所有功能,根本不需要段映射,但是段映射这玩意儿又关不掉,那就只能上点手段了。于是,Linux 内核将所有类型的段的段基址都设成 0,段限长都设成最大(具体数值不展开讲了,涉及到段描述符结构,很麻烦,这里理解成地址总线的最大寻址限度即可),那么这样一来所有段都重合了,也就是不分段了,此外由于段限长是地址总线的寻址限度,所以这也相当于所有段跟整个线性空间重合了。

虚拟地址本来是在段内的偏移量,现在段就是整个线性空间,所以虚拟地址就成了在整个线性空间内的偏移量,这和线性地址的概念一样,所以内核开发者都已经将虚拟地址和线性地址当作一个东西了。像是《Understand The Linux Kernel》这本书里面为了避免混淆,除了在开头和术语表中引用了虚拟地址这个术语之外,其他地方全是用的线性地址。

页式内存管理

如果再把线性地址分成四段,用前三段分别作为索引去 PGD(page global directory)、PMD(page middle directory)、PT(page table)里查表,最终就会得到一个 页表项(page table entry),里面存着的值是一页物理内存的起始地址,把它加上线性地址中第四段的内容(页内偏移)就得到了最终的 物理地址

大多数使用虚拟存储器的系统都采用这种称为 分页(paging)的机制。虚拟地址空间被划分成 (page)单位,而相应的物理地址空间也被进行划分,单位是 页桢(frame)。页和页桢的大小必须相同,因为内存和外围存储器之间的传输总是以页为单位的。比如,在前文内存为 256 MB 的例子中,页的大小为 4 KB,其对应 4G 的虚拟地址空间和 256M 的物理地址空间,它们分别包含了 1M 个页和 64K 个页桢。

$1G = 1M \times 1K$

参考