8Linux 内核设计与实现(第3版)

内存管理

虚拟内存

虚拟内存页表slab分配器页面回收

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第8章 内存管理(Memory Management)

一、导读

1.1 本章学习目标

理解虚拟内存的概念及其在现代操作系统中的核心地位

掌握 Linux 多级页表(PGD→PUD→PMD→PTE)的结构和工作原理

深入理解 buddy 系统(伙伴算法)的物理页面分配机制

掌握 slab 分配器的设计思想和对象缓存机制

了解页面回收算法:LRU 链表、活跃/非活跃链表和页面回收流程

理解缺页中断(Page Fault)的处理流程和按需分页的实现

了解高端内存(High Memory)的概念及其在 32 位系统上的处理方式

掌握 vmalloc 和 kmalloc 的区别及适用场景

1.2 为什么本章如此重要

内存管理是操作系统最核心、最复杂的子系统之一。它负责管理计算机最宝贵的资源——物理内存,同时为每个进程提供独立、连续的虚拟地址空间抽象。内存管理的效率直接影响系统的整体性能:页面分配的速度影响系统调用和中断处理的延迟;页面回收的效率决定系统在内存压力下的表现;缺页中断的处理速度影响程序的实际运行速度。

Linux 的内存管理采用分层设计:最底层是 buddy 系统,管理物理页面的分配和回收;中间层是 slab 分配器(以及较新的 slub/slob),为内核对象提供高效的内存分配;上层是虚拟内存子系统,管理进程的地址空间和页面映射。这种分层设计使得每一层可以独立优化,同时通过清晰的接口协作。

对于内核开发者而言,理解内存管理是编写高效、正确代码的基础。选择错误的内存分配方式(如在中断上下文中使用可能睡眠的分配)会导致内核崩溃;不理解虚拟内存机制会导致错误的指针操作;不了解页面回收机制则无法优化内存密集型应用的性能。

1.3 核心问题引导

为什么需要虚拟内存?直接使用物理地址有什么问题?

多级页表相比单级页表有什么优势?为什么 Linux 使用 4 级(或 5 级)页表?

buddy 系统如何避免外部碎片?它的"伙伴"是什么意思?

slab 分配器解决了什么问题?它如何减少小对象分配的开销和碎片?

缺页中断的处理流程是什么?按需分页如何提高内存利用率?

LRU 页面回收算法的基本思想是什么?活跃链表和非活跃链表如何协作?

32 位系统上的高端内存问题是什么?为什么 64 位系统没有这个问题?

kmalloc() 和 vmalloc() 的区别是什么?各自适用于什么场景?


二、核心概念详解

8.1 页表与虚拟地址映射

虚拟内存的核心思想是:每个进程拥有独立的虚拟地址空间,通过页表(Page Table)将虚拟地址映射到物理地址。在 x86-64 架构上,虚拟地址为 48 位(或 57 位,取决于配置),物理地址为 52 位。页面大小通常为 4 KB。

Linux 采用多级页表来管理映射关系。以标准的 4 级页表为例:虚拟地址被分为多个字段,分别索引页全局目录(PGD)、页上级目录(PUD)、页中间目录(PMD)和页表(PTE)。每一级目录包含指向下一级目录或最终物理页面的指针。多级页表的优势在于稀疏性——如果某个虚拟地址范围未被使用,对应的目录项可以为空,不需要分配下级目录页面。

页表的每一项(PTE)不仅包含物理页面的帧号,还包含各种标志位:存在位(Present)、可写位(Read/Write)、用户位(User/Supervisor)、 accessed 位、dirty 位等。这些标志位由 MMU(Memory Management Unit)硬件在地址转换时检查,实现内存保护。

每个进程有自己的页表,存储在 mm_struct 结构体中。进程切换时,内核需要切换页表(通过修改 CR3 寄存器),这会导致 TLB(Translation Lookaside Buffer)失效,增加后续内存访问的延迟。TLB 是一种硬件缓存,存储最近的虚拟地址到物理地址的映射,避免每次都遍历页表。

8.2 Buddy 系统(伙伴算法)

Buddy 系统是 Linux 内核的物理页面分配器,负责管理系统中的所有物理页面。它的核心思想是按 2 的幂次(1 页、2 页、4 页、8 页...)组织空闲页面块。每个"阶"(order)对应一个空闲链表,存储该大小的空闲块。例如,order-0 链表存储单个空闲页面,order-3 链表存储 8 页的空闲块。

当请求分配 N 页内存时,buddy 系统找到最小的 order k 使得 2^k >= N。如果该 order 的空闲链表不为空,取出一个块返回;如果为空,向更高 order 借一个块,将其分裂为两个 2^(k-1) 的"伙伴"(buddy),返回其中一个,将另一个加入 order k-1 的空闲链表。

释放时,buddy 系统检查被释放块的伙伴是否也空闲。如果是,将两个伙伴合并为一个更高 order 的块,并递归检查更高 order 的伙伴。这种合并机制有效减少了外部碎片。

Buddy 系统的优点是分配和释放的时间复杂度为 O(order),即 O(log N),非常高效。缺点是内部碎片——请求 5 页但分配 8 页,浪费 3 页。对于小对象的分配,buddy 系统的碎片问题严重,因此内核使用 slab 分配器来补充。

8.3 Slab 分配器

Slab 分配器是内核级的对象缓存系统,专门用于高效分配频繁使用的小对象(如 task_struct、inode、dentry 等)。它的核心思想是:预先分配一大块内存(一个或多个物理页面),将其划分为固定大小的对象槽,缓存起来供后续使用。

Slab 分配器解决了几个问题:第一,减少 buddy 系统的碎片——小对象使用 buddy 分配会浪费大量空间(如请求 100 字节但分配 4096 字节的一个页面);第二,减少分配开销——从预分配的缓存中取一个对象比从 buddy 系统分配新页面快得多;第三,提高缓存局部性——相同类型的对象在内存中连续排列,提高 CPU 缓存命中率。

每个 slab 缓存(kmem_cache)管理一种特定类型的对象。缓存有三种状态:完全空(所有对象空闲)、部分使用、完全满。分配时优先从部分使用的 slab 中取对象;释放时将对象返回原来的 slab。当 slab 完全空且缓存中有过多空闲 slab 时,多余的 slab 被释放回 buddy 系统。

较新的内核中,slab 分配器有多个实现变体:SLUB(默认,简化了 slab 的管理,减少了内存开销)、SLAB(传统实现)、SLOB(用于嵌入式系统,内存占用最小)。SLUB 是目前大多数发行版的默认选择。

8.4 缺页中断与按需分页

按需分页(Demand Paging)是虚拟内存的核心机制:进程的虚拟地址空间在创建时并不立即分配物理页面,而是在进程实际访问该地址时才触发缺页中断(Page Fault),由内核分配物理页面并建立映射。

缺页中断的处理流程:CPU 访问一个虚拟地址,MMU 在页表中发现该页面对应的 PTE 的存在位(Present bit)为 0,触发缺页异常;CPU 跳转到内核的缺页处理程序 do_page_fault();内核分析缺页原因:可能是合法的按需分页(分配物理页面)、可能是写时复制(复制物理页面)、也可能是非法访问(发送 SIGSEGV 信号终止进程);对于合法的缺页,内核通过 buddy 系统分配一个物理页面,更新页表,将 PTE 的存在位置 1;返回用户空间,重新执行触发缺页的指令,这次 MMU 可以成功完成地址转换。

按需分页的优势在于:程序只分配实际使用的内存,未使用的虚拟地址不占用物理内存;多个进程可以共享同一个文件的页面(如共享库),通过页缓存机制减少内存使用;系统的物理内存可以被更有效地利用。


三、深入分析与讨论

3.1 页面回收与 LRU 算法

当系统物理内存不足时,内核需要回收不活跃的页面以释放内存。Linux 使用基于 LRU(Least Recently Used,最近最少使用)的页面回收算法。每个内存区域(zone)维护两条 LRU 链表:活跃链表(active list)和非活跃链表(inactive list)。

新分配的页面加入活跃链表的头部。当页面被访问时,其 accessed 位被 MMU 硬件设置。内核的页面回收守护进程(kswapd)定期扫描活跃链表,检查每个页面的 accessed 位:如果 accessed 位被设置,说明页面最近被访问过,将其移到活跃链表头部并清除 accessed 位;如果 accessed 位未被设置,说明页面近期未被访问,将其移到非活跃链表尾部。

当需要回收页面时,从非活跃链表尾部取出页面。如果页面是"脏"的(dirty,被修改过),需要先写回磁盘再释放;如果页面是干净的,可以直接释放。被释放的页面加入空闲链表,供后续分配使用。

这种双链表设计的好处是:活跃链表中的页面受到保护,不会被立即回收;只有经过一段时间确认不再活跃的页面才会被移到非活跃链表并最终回收。这避免了频繁访问的页面被错误回收导致的性能下降(thrashing)。

3.2 高端内存问题

在 32 位系统上,虚拟地址空间为 4 GB。Linux 将这 4 GB 分为两部分:用户空间(通常 3 GB)和内核空间(通常 1 GB)。内核空间需要直接映射所有物理内存,但如果物理内存超过 896 MB(1 GB 减去 128 MB 用于虚拟地址映射的其他用途),超出的部分就无法在内核空间中被永久映射。这部分内存称为"高端内存"(High Memory)。

访问高端内存需要临时映射:内核使用 kmap() 函数将高端页面映射到内核虚拟地址空间的一个特殊区域(固定映射区),使用完毕后通过 kunmap() 解除映射。由于固定映射区的大小有限,同时只能映射有限数量的高端页面。

在 64 位系统上,虚拟地址空间足够大(48 位或更多),可以永久映射所有物理内存,因此不存在高端内存问题。这也是 64 位系统在大内存场景下性能更好的原因之一。


四、实践案例

4.1 案例一:分析 /proc/meminfo 的内存统计

通过 cat /proc/meminfo 可以查看系统的内存使用情况。分析输出中的各项指标:MemTotal(总物理内存)、MemFree(完全空闲的内存)、MemAvailable(可供新应用使用的内存,包括可回收的页面缓存)、Buffers(块设备缓冲区占用的内存)、Cached(页面缓存占用的内存)、Slab(slab 缓存占用的内存)、AnonPages(匿名页面,即进程的堆和栈)。理解这些指标有助于诊断内存问题和优化系统配置。

4.2 案例二:观察缺页中断的行为

编写一个程序,使用 mmap() 映射一个大文件到虚拟地址空间,但不立即访问。通过 /proc/[pid]/status 观察进程的 VmRSS(常驻集大小)和 VmSize(虚拟内存大小)。然后逐页访问映射区域,同时通过 /proc/[pid]/status 或 perf stat 观察缺页中断的数量。可以看到,每次访问新的页面都会触发一次缺页中断,VmRSS 逐渐增加直到覆盖所有访问过的页面。


五、常见误区与注意事项

误区:虚拟内存越大越好。虚拟内存只是地址空间的抽象,实际的物理内存是有限的。如果进程使用的内存超过物理内存,频繁的页面换入换出(thrashing)会导致系统性能急剧下降。

误区:kmalloc 和 vmalloc 可以互换使用。kmalloc() 分配物理和虚拟地址都连续的内存,适用于 DMA 操作和需要连续物理地址的场景;vmalloc() 分配虚拟地址连续但物理地址可以不连续的内存,适用于大块内存分配。vmalloc() 的开销更大(需要修改页表),且不能用于 DMA。

误区:slab 缓存中的对象释放后物理内存立即归还系统。释放的对象只是标记为空闲,仍在 slab 缓存中,以便下次快速分配。只有当系统内存压力很大时,slab 收缩器(shrinker)才会将空闲 slab 释放回 buddy 系统。

注意:GFP 标志的选择影响内存分配行为。GFP_KERNEL 是普通分配,可能睡眠等待内存释放;GFP_ATOMIC 用于中断上下文,不能睡眠;GFP_DMA 分配 DMA 可用的低地址内存。选择错误的 GFP 标志可能导致内核崩溃或死锁。

注意:内存泄漏在内核中比用户空间更严重。用户进程退出时,内核会自动回收其所有资源。但内核模块的内存泄漏会持续累积,最终耗尽系统内存。使用 kmemleak 工具可以检测内核内存泄漏。


六、本章小结

本章深入讲解了 Linux 内核的内存管理机制。虚拟内存通过页表将虚拟地址映射到物理地址,为每个进程提供独立的地址空间抽象。多级页表(4 级或 5 级)通过稀疏性优化了页表的内存占用。Buddy 系统按 2 的幂次管理物理页面分配,通过伙伴合并减少外部碎片。Slab 分配器为内核对象提供高效的缓存分配,减少了小对象分配的开销和碎片。缺页中断实现了按需分页,只在进程实际访问时才分配物理页面,提高了内存利用率。LRU 页面回收算法通过活跃/非活跃双链表机制,智能地回收不活跃的页面。理解这些机制对于内核开发和性能调优至关重要。


七、思考题与练习

计算:在 4 级页表、4 KB 页面大小的配置下,每级页表各需要多少位索引?一个完整的页表占用多少内存?

画出 buddy 系统分配和释放的过程:请求 3 页、释放 3 页、请求 5 页,标注每一步的链表变化。

分析 slab 分配器如何减少内部碎片。如果 task_struct 大小为 8 KB,buddy 系统和 slab 分配器分别如何处理?

编写一个程序,通过 mmap() 映射一块大内存区域,测量缺页中断对访问延迟的影响。

研究 /proc/buddyinfo 的输出,分析系统的物理内存碎片情况。

比较 SLAB、SLUB 和 SLOB 三种分配器的设计差异和适用场景。

解释为什么 64 位系统不需要高端内存机制。64 位系统的内核虚拟地址空间有多大?

分析页面回收中的"抖动"(thrashing)现象:什么情况下会发生?如何避免?

8/10内存管理
0%