内存管理:Brendan's Memory Management Guide(译文)
本文译自 OSDev Wiki 上的一篇文章,原文链接为 Brendan’s Memory Management Guide。
概述
内存管理是所有操作系统开发者迟早都需要处理的问题。这个主题非常广泛;起初看起来似乎很简单,但往往会演变成比最初预想的更复杂的内容。这就容易导致人们在操作系统规模较小时做出一些看似合理(并且最初运行良好)的设计决策,但这些决策在系统变大后可能会成为严重的问题。
为了减少这种设计决策在后期演变为重大问题的可能性,“内存管理”应当被拆分为三个独立的概念:
“堆”(动态内存管理)
虚拟内存管理
物理内存管理
请注意,这仅仅是一个指南,旨在帮助初学者意识到各种潜在问题,并为应对最终涉及的复杂性做好准备。它并不是一份必须严格遵循的规范,也不是涵盖所有内存管理实现方式的完整文档。
堆(动态内存管理)
这是最高层的内存管理层,也是普通软件通常使用的那一层。
一般来说,不同的进程会使用与其编程语言相匹配的内存管理器(例如,C 语言程序使用简单的 “malloc/free”,Java 使用垃圾回收系统等)。因此,“堆”通常与操作系统本身无关,而是属于编程语言运行时的一部分。 不过,在某些特殊情况下(例如需要极致性能或效率时),进程可能会绕过语言自带的通用内存管理系统,自行实现针对该应用的数据结构和使用模式定制的内存管理机制。
此外,进程往往会非常频繁地进行内存的分配和释放;如果每次分配或释放都调用内核的 API,这是非常低效的。为此,进程通常会从内核的虚拟内存管理器那里一次性申请较大块的虚拟内存,然后根据需要自行划分成更小的部分。
本质上,这样做的目的是允许进程以任何自己喜欢的方式管理自身的内存,同时避免频繁调用内核 API 带来的性能开销。
内核本身也可能提供用于通用动态内存管理的机制(例如 “kmalloc()” 和 “kfree()”,它们模仿了 C 标准库中的 “malloc/free”);但内核也可能为特定的数据结构设计定制的分配器,或者针对不同用途使用多个不同的内存管理器。然而,这与普通进程中的内存管理并没有本质区别——内核的“堆”同样是从虚拟内存管理器那里申请较大的区域,然后再自行划分使用。
使这种内存管理变得复杂的因素包括:
- 满足调用方的对齐(alignment)要求
- 使用多个“内存池”(例如每个线程、每个 CPU 一个内存池,或针对不同类型数据的不同内存池)以提升缓存局部性、可扩展性和效率
- 不同的分配策略属性(例如:“快速分配,不关心碎片,因为很快会释放” vs. “花更多时间最小化碎片,因为这块内存将长期使用”)
- 提供更高层次的接口,用于支持内存映射文件或“内存属性”等功能
- 为调试和检测常见编程错误(例如重复释放同一块内存)提供额外信息
虚拟内存管理(Virtual Memory Management)
虚拟内存管理的主要目的是管理虚拟地址空间,包括创建和销毁虚拟地址空间,以及在其中映射或取消映射各种“对象”。
在最初阶段,虚拟内存管理器可以非常简单——例如,当某个进程(或内核)的“堆”请求将某段虚拟地址空间变为“可用 RAM”时,虚拟内存管理器只需找到该区域中尚未映射为“可用 RAM”的部分,通过物理内存管理器分配物理内存并将其映射进去;而当“堆”请求将某个区域设为“不可用 RAM”时,虚拟内存管理器则取消映射并通过物理内存管理器释放相应的物理内存。
然而,虚拟内存管理器还负责实现许多“技巧”(tricks),以减少内存占用、提升性能或提供额外功能。这些技巧包括:
- 在初始时假装某块区域是“可用 RAM”,但并未真正分配物理内存,只有在该区域被实际访问时才分配(避免浪费未使用的 RAM);
- 假装某个文件已被加载到内存中,但实际上仅在访问时才真正读取(减少文件 I/O、提高性能、节省内存);
- 通过交换空间(swap space)在物理内存和存储设备之间转移数据,从而“假装”系统拥有比实际更多的内存,并尽量保持最可能被访问的数据驻留在内存中;
- 改进进程间通信的效率,例如共享内存区或在进程间直接移动整个页表。
请注意,这些技巧不仅对普通进程有用,对内核本身同样有益。例如,我在内核中经常使用“先假装区域可用但尚未分配物理内存”的方法。
虚拟内存管理器还必须确保系统安全——防止软件访问未授权的内容,确保进程释放的内存不会在下一次被错误访问等。
使虚拟内存管理器变得复杂的因素(除了这些技巧和安全问题)包括:
- 性能与可扩展性(如无锁或无阻塞的 O(1) 算法);
- 延迟的 TLB 失效机制(降低多核系统开销);
- 支持多种页大小;
- 页/缓存着色及 NUMA 优化(减少缓存未命中代价);
- 压缩内存作为一级交换空间(例如在 4 GiB 物理 RAM 中存储 5 GiB 的数据);
- 当内存空闲且磁盘负载较低时,预取文件或交换空间中的数据(以减少未来访问延迟);
- 统计信息追踪(每个进程的内存使用量、磁盘页调入速率、各页访问频率等,用于估算“最可能被使用的数据”);
- 当系统内存紧张时通知其他软件(如文件系统缓存、浏览器缓存等)主动释放缓存,以减少交换区使用;
- 支持内存映射 I/O(例如将显存映射到虚拟地址空间供显卡驱动访问);
- 支持不同的缓存策略(write-through、write-combining 等),并通过 PAT 补足缺乏 MTRR 的设备;
- 为设备驱动提供更高层接口(如分配“物理连续”的内存或低地址内存)。
一个常见的初学者错误值得特别指出——那就是直接将整个物理地址空间“按原样”映射进内核空间。这会让内核失去虚拟内存管理器所有的优化与技巧优势,同时浪费内存(用于无意义的页表项),导致缓存局部性差、增加安全风险(例如内核漏洞或 CPU 缺陷如 Meltdown 可导致任意内存泄露),并且在物理地址空间大于内核空间时会彻底失效,迫使系统采用丑陋的权宜之计。遗憾的是,早年确有某位初学者犯下此错,他的内核后来发展到难以修复,成为一个著名的开源操作系统,至今仍被人拿来“学习内存管理”。
物理内存管理(Physical Memory Management)
物理内存管理的职责是管理物理地址空间,就像虚拟内存管理负责虚拟地址空间一样。它既要管理空闲的物理 RAM,也要管理非 RAM 区域(例如 PCI 设备的内存映射区、MTRR 等)。
最常见、也是最需优化的场景,是分配与释放单个物理页,而不关心具体的物理地址。我强烈建议为此专门实现基于“快速/O(1) 空闲页栈”的分配器。
但物理内存管理器还必须处理一些特殊请求(通常来自设备驱动),这些请求可能要求分配物理上连续的内存块、位于某个特定地址以下的内存等。最棘手的情况是为传统 ISA DMA 控制器分配缓冲区——这些缓冲必须低于 0x01000000、物理上连续且不得跨越 64 KiB 边界。满足这种请求需要使用较慢的分配器。因此,建议将“可用物理内存”划分为多个区域(zone): 例如,将 0x01000000 以下的内存用专为特殊请求设计的分配器管理,而其他区域则用优化的页级分配器以应对频繁的单页分配/释放。
这些分配器无需访问空闲页内的数据——它们仅负责分配与释放,因此没有必要将空闲页映射到任何虚拟地址空间。
对于非 RAM 的物理地址区域,需要维护一张物理地址空间映射表,以指明各区域的用途。例如,在初始化新的 PCI 设备时,为配置其 BAR 寻找未被占用且非 RAM 的区域。这些信息还包括缓存属性(cacheability),用于配置和管理 CPU 的 MTRR。此外,物理地址空间映射表还应包含 RAM 的属性信息,例如是否为易失性内存、是否支持热插拔等。
使物理内存管理复杂化的因素包括:
- 性能与可扩展性(无锁/O(1) 算法);
- 支持多种页大小;
- 页/缓存着色与 NUMA 优化;
- 统计信息跟踪(各 zone 的已用/空闲 RAM 数量);
- 容错性(检测并避开损坏的内存);
- 电源管理(将频繁访问的数据集中在部分内存芯片上,以便其他芯片节能休眠);
- 热插拔内存(RAM 的移除与插入);
- 支持虚拟内存“气球”(ballooning)机制,以便虚拟机管理程序能回收未使用的内存。