1. 引言
1.1 背景
内存管理(Memory Management, MM)子系统是 Linux 内核的基石之一。它负责管理系统的核心硬件资源——物理内存(RAM),为内核其他组件以及用户空间进程提供必要的内存抽象和分配服务。其职责范围广泛,涵盖虚拟内存实现、按需分页、内核与用户空间的内存分配、文件到进程地址空间的映射等诸多关键功能。高效的内存管理对于整个系统的性能、稳定性和可扩展性至关重要。
1.2 数据结构的重要性
Linux 内存管理子系统的复杂性和强大功能,在很大程度上依赖于一系列精心设计的核心数据结构。这些结构体不仅用于表示物理内存的状态、进程的虚拟地址空间布局,还用于组织内存分配策略(如 NUMA 和内存区域划分)以及优化常用内核对象的分配效率。可以说,理解这些核心数据结构是深入掌握 Linux 内存管理机制的关键。
1.3 报告目的与范围
本报告旨在提供一份专家级的技术分析,深入探讨 Linux 内核内存管理子系统中最核心的几个数据结构,包括:
struct page
:物理页帧的描述符。struct vm_area_struct
(VMA):进程虚拟内存区域的描述符。struct mm_struct
:进程地址空间的整体描述符。struct zone
与struct pglist_data
(或struct node
):物理内存组织结构(NUMA 节点和内存区域)。struct kmem_cache
:Slab 分配器中的缓存描述符。
报告将详细分析这些结构体的设计目的、关键字段、它们在物理内存管理、虚拟内存映射和内核对象缓存中的具体作用,并阐述它们之间的相互关系和协作方式。
1.4 目标读者
本报告面向具有一定操作系统基础的读者,特别是 Linux 内核开发者、系统工程师、以及对操作系统内存管理有深入研究兴趣的高级学生和研究人员。
2. 核心数据结构概览
Linux 内存管理涉及多个层面,其核心功能由一组相互关联的数据结构支撑。这些结构体共同构成了管理物理内存、虚拟内存以及内核对象分配的基础。
struct page
: 作为最基础的结构,它代表系统中的每一个物理页帧,跟踪其状态和使用情况。struct vm_area_struct
(VMA): 在虚拟内存层面,VMA 用于描述进程地址空间内的一个连续的、具有相同属性(如权限、后备存储)的虚拟内存区域。struct mm_struct
: 该结构封装了一个进程(或共享地址空间的线程组)的完整地址空间信息,包括 VMA 列表(或树)以及指向该进程页表的指针。struct zone
和struct pglist_data
: 这两个结构用于物理内存的组织。pglist_data
代表一个 NUMA 节点(内存 Bank),而zone
则将节点内的内存根据硬件限制或用途(如 DMA、NORMAL、HIGHMEM)划分为不同的区域。struct kmem_cache
: 这是 Slab 分配器的核心结构,用于管理特定类型内核对象的缓存,以实现高效的分配和回收。
这些结构并非孤立存在,而是构成了一个复杂的交互网络。例如,mm_struct
通过 VMA 列表管理虚拟地址空间,VMA 通过页表与具体的 struct page
关联,实现虚拟地址到物理地址的映射。同时,struct page
也与其所属的物理 zone
相关联,参与物理内存的分配与回收。后续章节将详细剖析每个结构体及其相互作用。
3. 物理页表示:struct page
struct page
是 Linux 内核中用于描述物理内存的基本单元。系统中的每一个物理页帧(通常为 4KB)都有一个对应的 struct page
实例,用于跟踪该页帧的状态、使用情况和属性。这种一对一的映射关系是内核管理物理内存的基础,无论采用哪种内存模型(如 FLATMEM 或 SPARSEMEM),内核都提供了 pfn_to_page()
和 page_to_pfn()
助手函数来进行物理页帧号(PFN)和 struct page
指针之间的转换。
存放这些 struct page
描述符的数组(通常称为 mem_map
)一般位于低端内存区域(ZONE_NORMAL)。在 SPARSEMEM 模型下,这些描述符可能分布在不同的内存区段(section)中,并可能通过 vmemmap 技术进行虚拟地址映射优化。
3.1 struct page
的关键字段分析
struct page
结构体包含了众多字段,其中一些核心字段对于理解其功能至关重要。值得注意的是,为了最大限度地节省内存(因为系统中 struct page
的数量可能非常庞大,达到数百万甚至数十亿),struct page
的设计体现了高度的优化和字段复用。许多字段的含义取决于页面的当前状态(通过 flags
字段判断)。
-
flags
(unsigned long):- 描述: 这是一个极其重要的位掩码字段,用于编码页面的当前状态和多种属性。每一位代表一个特定的标志。
- 关键标志示例:
PG_locked
: 页面被锁定(通常在进行 I/O 操作时),防止被交换或修改。PG_error
: 页面相关的磁盘 I/O 操作发生错误。PG_referenced
: 页面最近被访问过,用于 LRU 算法。PG_uptodate
: 页面包含从磁盘读取的有效数据。PG_dirty
: 页面内容已被修改,需要写回后备存储。PG_lru
: 页面位于 LRU (Least Recently Used) 链表中,是页面回收的候选者。PG_active
: 页面位于活跃 LRU 链表中,表示是热点页面。PG_slab
: 页面由 Slab 分配器管理。PG_buddy
: 页面是伙伴系统中的空闲块。PG_reserved
: 页面被保留,不能被换出或用于普通分配(如内核代码、引导内存)。PG_highmem
: 页面位于高内存区域(仅特定 32 位架构)。
- 分析:
flags
字段的设计体现了对空间效率的极致追求。通过位掩码,可以用一个unsigned long
存储大量的状态信息。内核提供了丰富的宏(如PageLocked()
,SetPageDirty()
,PageLRU()
等)来安全地测试、设置和清除这些标志位,隐藏了底层的位操作细节。这个字段是理解页面当前角色的关键,因为它决定了其他复用字段(如lru
,private
)的含义。
-
_refcount
(atomic_t count):- 描述: 原子引用计数器,跟踪有多少个内核子系统或数据结构正在使用(引用)这个页面。
- 用法: 当页面被映射到页表、被 DMA 操作锁定(pin)、或被内核其他部分使用时,引用计数会增加。当使用者释放对页面的引用时,计数减少。只有当
_refcount
降至 0 时,页面才真正变为空闲状态,可以被伙伴系统回收。 - 分析: 这是管理页面生命周期的核心机制,确保正在使用的页面不会被意外释放。由于多核并发访问,其原子性至关重要。
-
mapping
(struct address_space *):- 描述: 如果页面属于文件映射(页缓存)或交换缓存(swap cache),此字段指向关联的
address_space
对象。address_space
通常与文件的inode
相关联。 - 用法: 对于文件页,
mapping
指向文件的inode->i_mapping
。对于匿名页(不由文件支持),如果已被换出到交换空间并再次读入内存(位于交换缓存中),mapping
指向全局的swapper_space
。对于尚未被换出的匿名页或不由address_space
管理的特殊页面(如 Slab 页),此字段通常为NULL
。 - 分析: 该字段将物理页面与其逻辑上的后备存储(文件或交换空间)联系起来,是页缓存管理和 Swap 机制的基础。
- 描述: 如果页面属于文件映射(页缓存)或交换缓存(swap cache),此字段指向关联的
-
index
(unsigned long):- 描述: 表示页面在其
mapping
内的偏移量。 - 用法: 对于文件页,
index
是以页面大小(PAGE_SIZE
)为单位的文件内偏移量。对于交换缓存页,它是页面在交换空间(swapper_space
)中的偏移量。当页面块通过伙伴系统释放时,该字段会被临时用来存储释放块的阶数(order)。 - 分析: 提供了页面在其逻辑后备存储中的位置信息。字段的复用再次体现了内存优化的设计思路。
- 描述: 表示页面在其
-
lru
(struct list_head):- 描述: 用于将页面链接到各种双向链表中的链表头。
- 用法: 其主要用途是链接到 LRU 链表(
active_list
或inactive_list
),用于页面回收算法。然而,当页面处于空闲状态并由伙伴系统管理时(PG_buddy
标志置位),该字段被复用来链接相同阶数的空闲块。对于ZONE_DEVICE
类型的页面,它可能被用于反向引用宿主设备/驱动。 - 分析: 这是字段复用的一个典型例子。内核通过检查
flags
字段(如PG_lru
或PG_buddy
)来确定lru
字段当前的具体含义。这种设计节省了为每种链表单独设置指针字段所需的内存。
-
private
(unsigned long):- 描述: 一个通用的私有数据字段,其确切含义取决于页面的上下文。
- 用法: 在伙伴系统中,当页面代表一个空闲块的首页时,
private
用于存储该空闲块的阶数(order)。它也可能被缓冲区头(buffer heads)或 Slab 分配器用于存储特定信息。 - 分析: 提供了额外的灵活性,允许不同子系统在
struct page
中存储少量自定义数据,但同样需要结合flags
来正确解释。
-
_mapcount
(atomic_t):- 描述: 原子计数器,跟踪有多少个页表项(PTE)映射到了这个页面。它与
_refcount
不同。 - 用法: 一个物理页面可以被映射到多个进程的地址空间(共享内存)或同一进程的多个虚拟地址。
_mapcount
记录了这种映射的数量。_mapcount == -1
通常表示该页面不是页缓存的一部分(例如,匿名页或特殊映射)。 - 分析: 对于管理共享内存和区分不同类型的页面映射非常重要。
- 描述: 原子计数器,跟踪有多少个页表项(PTE)映射到了这个页面。它与
-
virtual
(void *):- 描述: 指向该物理页面在内核虚拟地址空间中的地址。这通常只对低端内存(
ZONE_NORMAL
)中的页面有效,因为它们有永久的内核映射。对于高内存(ZONE_HIGHMEM
)页面,只有在被临时映射(如通过kmap()
)时,该字段才会被设置并指向临时的内核虚拟地址。此字段的存在取决于内核配置(CONFIG_HIGHMEM
或WANT_PAGE_VIRTUAL
)。 - 分析: 提供了内核直接访问页面内容的便捷方式(如果可用)。对于处理高内存至关重要。
- 描述: 指向该物理页面在内核虚拟地址空间中的地址。这通常只对低端内存(
struct page
:高度优化、上下文相关的核心
struct page
的设计深刻地反映了在管理海量物理页面时对内存效率的极致追求。由于系统中存在数百万甚至数十亿个 struct page
实例,每个实例的微小内存开销都会被显著放大。因此,内核设计者采用了字段复用(如 lru
, index
, private
)的策略。一个页面在不同时刻可能扮演不同角色(空闲块、LRU 候选页、Slab 页、文件缓存页等),但通常只处于一种主要状态。通过 flags
字段来标记页面的当前状态,内核得以在运行时确定复用字段的正确含义。这种设计以增加代码理解和访问的复杂性为代价,换取了宝贵的内存空间,是针对这一基础核心数据结构的必然权衡。struct page
不仅仅是一个状态容器,更是连接物理内存、虚拟内存、文件系统和各种内存管理策略(分配、回收、缓存)的枢纽。
表 1: struct page
关键字段摘要
字段名 | 数据类型 | 主要目的 | 示例用法/相关标志 |
flags | unsigned long | 存储页面状态和属性的位掩码 | PG_locked , PG_dirty , PG_lru , PG_active , PG_slab , PG_buddy , PG_reserved , PG_uptodate , PG_referenced , PG_error , PG_highmem 等 |
_refcount | atomic_t | 页面引用计数 | >0 表示页面在使用中,0 表示可回收。 |
_mapcount | atomic_t | 页面被页表映射的次数 | 用于跟踪共享映射。 |
mapping | struct address_space * | 指向关联的地址空间(文件/交换) | 文件页指向 inode->i_mapping ,交换缓存页指向 swapper_space ,匿名页通常为 NULL 。 |
index | unsigned long | 在 mapping 内的偏移量 | 文件页:文件内页偏移;交换页:交换区内偏移;伙伴系统释放时:临时存储块阶数。 |
lru | struct list_head | 用于链接到各种链表 | 主要用于 LRU 链表 (PG_lru );伙伴系统空闲链表 (PG_buddy );ZONE_DEVICE 反向引用。 |
private | unsigned long | 子系统私有数据 | 伙伴系统:空闲块首页存储块阶数;Buffer Heads;Slab 分配器等。 |
virtual | void * | 页面的内核直接映射虚拟地址 | 仅对低内存页或临时映射的高内存页有效 (需 CONFIG_HIGHMEM 或 WANT_PAGE_VIRTUAL )。 |
4. 进程虚拟内存区域:struct vm_area_struct
(VMA)
struct vm_area_struct
(通常称为 VMA) 是 Linux 内核用来描述进程虚拟地址空间中一个连续区域的核心数据结构。每个 VMA 定义了一段具有相同属性(如起止地址、访问权限、后备存储等)的虚拟内存。这些区域对应着进程内存布局中的逻辑段,例如代码段、数据段、堆、栈、内存映射文件(如共享库)或匿名内存区域。
当用户进程通过 mmap()
系统调用请求内存映射时,内核会创建一个新的 VMA 来代表这个映射区域。一个进程的所有 VMA 都被组织在其对应的 struct mm_struct
(进程地址空间描述符)中。为了高效管理,这些 VMA 通常通过两种方式组织:一个按地址排序的双向链表(mm_struct->mmap
)和一个树形结构(早期是红黑树 mm_struct->mm_rb
,现代内核常用 Maple Tree mm_struct->mm_mt
)。链表便于线性遍历(例如 /proc/<pid>/maps
的实现),而树结构则支持快速(对数时间复杂度)查找给定虚拟地址所属的 VMA,这对于处理页面错误(page fault)至关重要。如果可能,内核还会尝试合并具有相同属性且地址相邻的 VMA。
4.1 VMA 关键字段分析
struct vm_area_struct
包含多个字段,用于详细描述虚拟内存区域的特征和管理信息。
-
vm_start
,vm_end
(unsigned long):- 描述: 分别定义了 VMA 所覆盖的虚拟地址范围的起始地址(包含)和结束地址(不包含)。区域的大小即
vm_end - vm_start
。 - 分析: 这两个字段界定了 VMA 的管辖范围,是地址查找和范围检查的基础。修改这些字段需要持有适当的锁(如 mmap 写锁或 VMA 写锁)以保证一致性。
- 描述: 分别定义了 VMA 所覆盖的虚拟地址范围的起始地址(包含)和结束地址(不包含)。区域的大小即
-
vm_mm
(struct mm_struct *):- 描述: 指向拥有该 VMA 的进程地址空间描述符 (
struct mm_struct
)。 - 分析: 明确了 VMA 的归属,将其与特定的进程内存上下文关联起来。
- 描述: 指向拥有该 VMA 的进程地址空间描述符 (
-
vm_page_prot
(pgprot_t):- 描述: 页保护属性(Page Protection Flags),包含了用于设置该 VMA 对应页表项(PTE)的硬件相关的保护位(如读、写、执行权限)。该值通常根据
vm_flags
计算得出。 - 分析: 这是将 VMA 层面定义的访问权限策略转化为底层硬件(MMU)能够理解和执行的页表级权限控制的关键。
- 描述: 页保护属性(Page Protection Flags),包含了用于设置该 VMA 对应页表项(PTE)的硬件相关的保护位(如读、写、执行权限)。该值通常根据
-
vm_flags
(unsigned long):- 描述: 一个重要的位掩码,描述了 VMA 的属性和行为。常见的标志包括:
VM_READ
,VM_WRITE
,VM_EXEC
: 控制区域的读、写、执行权限。VM_SHARED
: 区域是共享映射(修改对所有映射进程可见),否则是私有映射(通常采用写时复制 Copy-on-Write, COW)。VM_ANONYMOUS
: 区域是匿名映射(不由文件支持)。VM_GROWSDOWN
: 区域向下扩展(用于栈)。VM_LOCKED
: 区域内存已被mlock()
系统调用锁定,不能被换出。VM_IO
: 区域映射了设备的 I/O 内存,通常禁止交换。VM_PFNMAP
: 区域直接映射物理页帧号(PFN),绕过struct page
管理。
- 分析:
vm_flags
定义了 VMA 的核心特性和访问规则,是页面错误处理、权限检查和内存管理策略(如 COW)的基础。
- 描述: 一个重要的位掩码,描述了 VMA 的属性和行为。常见的标志包括:
-
vm_file
(struct file *):- 描述: 如果 VMA 是文件映射,此字段指向对应的
struct file
对象。对于匿名映射,此字段为NULL
。struct file
是 VFS (Virtual File System) 层表示打开文件的结构。 - 分析: 这是区分文件支持映射和匿名映射的关键字段。页面错误处理程序根据
vm_file
是否为NULL
来决定是从文件读取数据还是分配匿名页面(如零页)。它建立了内存管理和文件系统之间的联系。
- 描述: 如果 VMA 是文件映射,此字段指向对应的
-
vm_pgoff
(unsigned long):- 描述: 对于文件映射,表示 VMA 的起始地址
vm_start
对应于vm_file
文件内的偏移量(以页面大小为单位)。对于VM_PFNMAP
类型的 VMA,它可能直接存储物理页帧号(PFN)。 - 分析: 精确指定了文件映射的内容来源或直接映射的物理地址范围。
- 描述: 对于文件映射,表示 VMA 的起始地址
-
vm_ops
(const struct vm_operations_struct *):- 描述: 指向一个包含 VMA 特定操作函数指针的结构体。这些函数(回调)通常由创建映射的文件系统或设备驱动提供。
- 关键方法:
open()
: VMA 创建时调用。close()
: VMA 销毁时调用。fault()
: 处理发生在此 VMA 内的页面错误的核心函数。map_pages()
: (较新内核)用于批量映射页面。page_mkwrite()
: 在对只读映射进行写时复制(COW)时调用,允许文件系统等执行必要操作。
- 分析:
vm_ops
提供了 VMA 行为的定制机制,是实现按需分页(demand paging)、写时复制以及特定设备内存管理的关键。fault()
处理程序尤其重要,它封装了如何为该 VMA 获取和映射物理页面的逻辑。
-
vm_private_data
(void *):- 描述: 一个供创建 VMA 的子系统(如设备驱动或文件系统)使用的私有数据指针。
- 分析: 允许将特定于驱动或文件系统的信息附加到 VMA 上,便于在
vm_ops
回调函数中使用。
-
链接字段 (
vm_next
,vm_prev
,vm_rb
/shared.rb
):- 描述: 用于将 VMA 链接到
mm_struct
的链表 (mmap
) 和树 (mm_rb
/mm_mt
) 中的字段。shared.rb
用于将文件支持的 VMA 链接到address_space->i_mmap
的区间树(interval tree)中。 - 分析: 这些链接字段是 VMA 组织和高效查找的基础。
- 描述: 用于将 VMA 链接到
VMA:虚拟内存的策略层
VMA 在 Linux 内存管理中扮演着至关重要的“策略层”角色。进程在虚拟地址空间中操作,而内核需要一种方式来管理这些虚拟地址的属性和行为规则。VMA 正是这种管理机制的体现:vm_start
/vm_end
定义了区域范围,vm_flags
/vm_page_prot
规定了访问权限,vm_file
/vm_pgoff
确定了后备存储(是文件还是匿名内存),而 vm_ops
则定义了针对该区域的操作逻辑,尤其是页面错误处理。
相比之下,页表(将在下一节与 mm_struct
一起讨论)提供了虚拟地址到物理地址转换的底层 机制。当 MMU 硬件因为页表项缺失或权限不足而触发页面错误时,内核介入。内核首先通过查找 VMA(策略查找)来确定发生错误的虚拟地址属于哪个内存区域及其规则。然后,内核依据 VMA 的信息(特别是 vm_flags
和 vm_ops->fault
)来决定如何解决这个错误(机制执行)——是分配一个新页面、从文件读取数据、执行写时复制,还是因为权限不足而发送 SIGSEGV
信号。因此,VMA 定义了虚拟内存区域的“合同”或“策略”,指导着底层页表机制的建立和异常处理行为。
表 2: struct vm_area_struct
关键字段摘要
字段名 | 数据类型 | 描述 (在定义虚拟区域中的作用) |
vm_start | unsigned long | 虚拟区域的起始地址(包含)。 |
vm_end | unsigned long | 虚拟区域的结束地址(不包含)。 |
vm_mm | struct mm_struct * | 指向所属的进程地址空间描述符。 |
vm_flags | unsigned long | 描述区域属性和权限的位掩码 (如 VM_READ , VM_WRITE , VM_EXEC , VM_SHARED , VM_ANONYMOUS , VM_LOCKED )。 |
vm_page_prot | pgprot_t | 用于设置页表项的硬件保护位,由 vm_flags 派生。 |
vm_file | struct file * | 如果是文件映射,指向对应的 struct file ;匿名映射则为 NULL 。 |
vm_pgoff | unsigned long | 文件映射时,表示在文件内的页偏移量;VM_PFNMAP 时可能为 PFN。 |
vm_ops | const struct vm_operations_struct * | 指向包含 VMA 特定操作(如 fault 处理)的函数指针结构体。 |
5. 进程地址空间描述符:struct mm_struct
struct mm_struct
是 Linux 内核中代表一个进程(或共享同一地址空间的线程组)完整内存上下文的核心数据结构。它不仅聚合了该进程所有的虚拟内存区域(VMAs),还持有指向该进程页表的根指针,并记录了相关的内存使用统计和管理信息。对于内核线程(没有用户空间的任务),其 task_struct->mm
字段通常为 NULL
,除非它们需要显式地使用某个用户地址空间(例如 khelper
线程)。
mm_struct
的生命周期与使用它的进程/线程紧密相关。它在创建新进程(如 fork
)时被分配(通常是复制父进程的 mm_struct
,或者创建一个新的)。由于线程共享地址空间,同一进程的所有线程共享同一个 mm_struct
实例。内核通过两个引用计数器 mm_users
和 mm_count
来管理其生命周期。当没有任何用户进程使用该地址空间时(mm_users
降为 0),相关的用户空间资源可以被清理。当没有任何内核引用指向该结构时(mm_count
降为 0),mm_struct
结构本身才会被最终释放(通过 mmdrop()
-> __mmdrop()
-> free_mm()
)。
5.1 mm_struct
关键字段分析
mm_struct
包含了管理进程地址空间所需的各种信息:
-
mmap
(struct vm_area_struct *):- 描述: 指向该地址空间中按地址排序的 VMA 双向链表的头节点。
- 分析: 支持对进程所有内存区域进行线性扫描,例如用于生成
/proc/pid/maps
文件内容。
-
mm_rb
(struct rb_root) /mm_mt
(struct maple_tree):- 描述: 指向用于组织 VMAs 的树形结构的根节点(早期内核使用红黑树
rb_root
,现代内核倾向于使用 Maple Treemaple_tree
)。 - 分析: 提供了对 VMA 的高效查找能力(对数时间复杂度)。这对于快速定位包含特定虚拟地址的 VMA 至关重要,尤其是在处理页面错误时。
- 描述: 指向用于组织 VMAs 的树形结构的根节点(早期内核使用红黑树
-
pgd
(pgd_t *):- 描述: 指向该进程的页全局目录(Page Global Directory, PGD)的指针。PGD 是多级页表的顶级目录。
- 分析: 这是硬件 MMU 进行地址转换的入口点,也是内核进行软件页表遍历(page table walk)的起点。每个进程(或共享
mm_struct
的线程组)都有自己独立的页表结构(尽管内核空间部分是共享的),pgd
字段指向用户空间部分的顶级页表。
-
使用计数器 (Usage Counters):
- 描述:
mm_struct
包含多个字段用于跟踪内存使用情况,例如:total_vm
: 进程虚拟地址空间的总大小(以页为单位)。locked_vm
: 被mlock()
锁定的页面数。pinned_vm
: 被pin_user_pages()
等函数固定的页面数。data_vm
,exec_vm
,stack_vm
: 分别跟踪数据段、代码段、栈所占用的虚拟内存大小。rss_stat
: 通常是一个结构体,详细记录了驻留集大小(Resident Set Size, RSS),即进程当前实际占用物理内存的大小,可能还会区分文件支持页和匿名页的数量。
- 分析: 这些计数器提供了重要的资源使用信息,可用于系统的资源管理、调度决策、内存限制(如 cgroups)以及通过
/proc
文件系统向用户空间报告进程内存占用情况。
- 描述:
-
mm_users
(atomic_t):- 描述: 原子计数器,记录当前有多少个用户态进程(线程)正在使用这个
mm_struct
。fork()
时递增,exit()
时递减。 - 分析: 这是判断一个地址空间是否仍被用户进程使用的主要依据。当
mm_users
降为 0 时,表示没有活动的用户线程关联到这个地址空间。
- 描述: 原子计数器,记录当前有多少个用户态进程(线程)正在使用这个
-
mm_count
(atomic_t):- 描述: 原子的二级引用计数器,除了跟踪
mm_users
外,还包括内核对mm_struct
的临时引用。例如,在上下文切换期间、页面错误处理期间或内核线程临时使用某个用户地址空间时,内核可能会增加mm_count
。 - 分析:
mm_count
管理mm_struct
结构本身的生命周期。只有当mm_count
降为 0 时,内核才确信没有任何部分(用户进程或内核自身)持有对该结构的引用,此时才会调用mmdrop()
最终释放mm_struct
。
- 描述: 原子的二级引用计数器,除了跟踪
-
mmap_lock
(struct rw_semaphore):- 描述: 一个读写信号量,用于保护
mm_struct
中的 VMA 列表和树结构,以及相关的内存使用计数器等字段。读锁允许多个线程并发地查找或遍历 VMA(例如处理缺页中断时的 VMA 查找),而写锁则是互斥的,在修改 VMA 结构(如mmap
,munmap
,mprotect
,mremap
操作)时必须持有。 - 分析: 这是保证 VMA 集合在多线程环境下安全访问和修改的关键同步机制。获取和释放该锁(如通过
mmap_read_lock()
,mmap_write_lock()
)是许多内存管理系统调用的标准操作。
- 描述: 一个读写信号量,用于保护
-
page_table_lock
(spinlock_t):- 描述: 一个自旋锁,用于保护进程页表本身的修改(主要是 PTE 级别的操作)。
- 分析: 提供了比
mmap_lock
更细粒度的锁,专门用于保护页表项的原子更新。在页面错误处理等场景下,内核可能在持有mmap_lock
(通常是读锁) 定位到 VMA 后,再获取page_table_lock
来安全地修改 PTE。
-
context.ctx_id
(unsigned long):- 描述: 地址空间上下文标识符。在支持 ASID(Address Space ID)的硬件架构上,该字段用于存储分配给该
mm_struct
的 ASID。 - 分析: ASID 可以帮助硬件区分不同进程的 TLB (Translation Lookaside Buffer) 条目。在上下文切换时,如果目标进程的 ASID 仍然有效(未被重用),则可能无需刷新整个 TLB,从而提高性能。
- 描述: 地址空间上下文标识符。在支持 ASID(Address Space ID)的硬件架构上,该字段用于存储分配给该
mm_struct
:进程内存身份的体现
mm_struct
在 Linux 中扮演着定义进程内存身份的核心角色。操作系统通过为每个进程提供独立的虚拟地址空间来实现进程隔离。mm_struct
正是这个独立空间的具体体现,它包含了该空间内所有有效虚拟地址区域的完整描述(通过 VMA 列表/树)以及将这些虚拟地址转换为物理地址的机制入口(通过 pgd
指向的页表)。
同一进程内的所有线程共享同一个 mm_struct
,这意味着它们共享相同的内存视图,这是 Linux 线程模型的基础。当内核在不同进程间进行上下文切换时,一个关键步骤就是切换当前活动的 mm_struct
,这通常涉及将目标进程的 mm_struct->pgd
加载到 CPU 的页表基址寄存器(如 x86 的 CR3)。相比之下,内核线程通常没有自己的 mm_struct
,它们在执行时要么使用前一个进程的地址空间上下文(通过 active_mm
跟踪),要么在纯粹的内核地址空间中运行。
因此,mm_struct
不仅仅是一个数据容器,它本质上定义了一个进程的内存视角,是其区别于其他进程的关键标识。它的生命周期通过引用计数与使用它的进程/线程紧密绑定,确保了地址空间的正确管理和释放。
表 3: struct mm_struct
关键字段摘要
字段名 | 数据类型 | 描述 (在定义进程地址空间中的作用) |
mmap | struct vm_area_struct * | 指向 VMA 链表的头节点,按地址排序。 |
mm_rb / mm_mt | struct rb_root / struct maple_tree | VMA 树结构的根,用于快速查找 VMA。 |
pgd | pgd_t * | 指向进程的页全局目录(顶级页表)。 |
Usage Counters | various (e.g., unsigned long , atomic_long_t , struct rss_stat ) | 跟踪虚拟内存总量 (total_vm )、驻留集大小 (rss_stat )、锁定的页面 (locked_vm ) 等统计信息。 |
mm_users | atomic_t | 使用该地址空间的用户进程(线程)数量。 |
mm_count | atomic_t | 对 mm_struct 的总引用计数(包括内核引用)。 |
mmap_lock | struct rw_semaphore | 保护 VMA 列表/树及相关字段的读写信号量。 |
page_table_lock | spinlock_t | 保护页表项修改的自旋锁。 |
6. 物理内存组织:struct zone
和 struct pglist_data
为了有效管理物理内存,特别是在具有非一致性内存访问(NUMA)特性的现代多处理器系统中,Linux 内核采用了一种分层的物理内存组织模型。该模型主要依赖于两个核心数据结构:struct pglist_data
(通常用 typedef pg_data_t
引用)和 struct zone
(通常用 typedef zone_t
引用)。
这个模型将物理内存首先划分为 节点 (Nodes),每个节点由一个 pglist_data
结构描述。节点通常对应于 NUMA 架构中的一个内存 Bank,其中的内存对于关联的 CPU 具有较低的访问延迟。然后,每个节点内的内存被进一步划分为 区域 (Zones),每个区域由一个 struct zone
描述。区域的划分基于内存的物理地址特性或硬件访问限制。
6.1 struct pglist_data
(节点描述符 - pg_data_t
)
- 目的: 代表系统中的一个 NUMA 节点,即一块具有特定访问延迟特性的物理内存。在 NUMA 系统中,每个内存 Bank 对应一个节点。为了保持代码一致性,即使在 UMA(一致性内存访问)系统中,整个物理内存也被视为一个单独的节点,由静态分配的
contig_page_data
结构表示。 - 关键字段:
node_id
(int): 节点的唯一标识符 (NID),从 0 开始。node_mem_map
(struct page *): 在 FLATMEM 或 SPARSEMEM Vmemmap 模型下,指向该节点对应的struct page
数组的起始地址。这是 PFN 到struct page
转换的基础。node_start_pfn
(unsigned long): 该节点管理的第一个物理页帧号 (PFN)。node_spanned_pages
(unsigned long): 该节点地址范围覆盖的总页数(可能包含物理内存空洞)。node_present_pages
(unsigned long): 该节点实际存在的物理页帧数量。node_zones
(struct zone): 包含该节点内所有struct zone
描述符的数组。nr_zones
(int):node_zones
数组中实际填充的区域数量。node_zonelists
(struct zonelist): 定义了该节点进行内存分配时的首选区域以及备用区域的回退顺序列表。这个列表包含了系统中所有节点的区域,并根据 NUMA 距离和区域类型进行了排序。这是实现 NUMA 感知内存分配策略的核心。node_states
(nodemask_t array reference): 内核维护一个全局的node_states
位掩码数组,用于跟踪所有节点的各种状态(如N_POSSIBLE
,N_ONLINE
,N_MEMORY
- 表示节点是否有内存)。pglist_data
结构通常不直接包含此数组,而是通过节点 ID 访问全局数组。内核提供了node_state()
等宏来查询特定节点的状态。bdata
(struct bootmem_data *): 指向引导期间使用的引导内存分配器(boot memory allocator)的相关数据结构。主要在系统初始化早期阶段使用。
- NUMA 策略:
pglist_data
结构及其包含的 NUMA 节点信息是 Linux NUMA 内存策略的基础。系统调用如set_mempolicy()
和mbind()
允许进程指定内存分配模式(如MPOL_BIND
- 强制在指定节点集分配,MPOL_PREFERRED
- 优先在指定节点分配,MPOL_INTERLEAVE
- 在指定节点集间交错分配)。内核的页面分配器会根据这些策略以及pglist_data
中的信息(特别是node_zonelists
)来选择最合适的节点和区域进行内存分配,力求将内存分配到距离请求 CPU 最近的节点上(本地分配原则)。
6.2 struct zone
(区域描述符 - zone_t
)
- 目的: 代表一个 NUMA 节点内具有特定属性或地址限制的连续物理内存范围。通过将内存划分为不同的区域,内核可以独立管理具有不同约束条件的内存。
- 区域类型 (Zone Types):
ZONE_DMA
: 适用于旧式 ISA 设备 DMA 的低端内存(如 x86 架构的 < 16MB)。ZONE_DMA32
: 适用于只能访问 32 位地址空间(< 4GB)的设备进行 DMA 的内存。ZONE_NORMAL
: 内核可以直接映射和访问的“普通”内存。在 32 位 x86 上通常是 16MB 到 896MB 之间的内存;在 64 位系统上,它通常覆盖了大部分可用 RAM。这是最常用也是性能最关键的区域。ZONE_HIGHMEM
: “高内存”,指超出内核直接映射范围的物理内存(如 32 位 x86 上的 > 896MB)。内核访问这些内存需要建立临时映射(如使用kmap()
)。64 位系统通常没有ZONE_HIGHMEM
,因为它们拥有足够大的虚拟地址空间来直接映射所有物理内存。ZONE_MOVABLE
: 包含可以被迁移(其物理位置可以改变而不影响虚拟地址)的页面的区域。这对于减少大页(HugeTLB)分配的碎片和支持内存热插拔非常有用。ZONE_DEVICE
: 用于表示位于外部设备(如持久内存 PMEM 或 GPU 显存)上的内存。这些内存虽然也可能通过struct page
进行管理,但其特性(如缓存行为、访问方式)与普通 RAM 不同。
- 关键字段:
name
(const char *): 区域的名称字符串(如 "DMA", "Normal", "HighMem")。lock
(spinlock_t): 保护zone
结构内部数据(特别是free_area
)的自旋锁,确保在并发分配和释放操作中的数据一致性。free_area
(struct free_area): 这是该区域伙伴系统(Buddy System)分配器的核心。它是一个数组,每个元素free_area[order]
管理大小为 2<sup>order</sup> 页的空闲内存块。struct free_area
: 包含一个free_list
(类型为struct list_head
),用于链接该阶数的所有空闲块(通过复用空闲块首页的page->lru
字段实现链接)。还包含一个nr_free
字段,记录该阶数空闲块的数量。
- 水印 (Watermarks) (
pages_min
,pages_low
,pages_high
): 这些是以页面数为单位的阈值,用于触发内存回收机制。当区域的空闲页面数低于pages_low
时,会唤醒kswapd
线程进行后台异步回收。当空闲页面数进一步下降到pages_min
以下时,内存分配请求(如果允许阻塞,如GFP_KERNEL
)可能会触发同步直接回收。pages_high
是kswapd
停止回收的目标水位。这些水印是动态调整的。 managed_pages
,spanned_pages
,present_pages
: 分别记录该区域管理的页面总数、地址范围跨越的页面数(包括空洞)、实际存在的物理页面数。zone_start_pfn
: 该区域的起始物理页帧号。zone_pgdat
(struct pglist_data *): 指向该区域所属的 NUMA 节点 (pglist_data
) 的指针。vm_stat
(atomic_long_t): 一个数组,用于存储该区域的各种虚拟机统计信息(如NR_FREE_PAGES
,NR_FILE_PAGES
,NR_SLAB_RECLAIMABLE
等)。这些统计信息可以通过/proc/vmstat
查看。
- 区域列表 (Zonelists):
- 目的: 如前所述,
pglist_data->node_zonelists
定义了内存分配的回退策略。当一个内存分配请求无法在最优先选择的区域(通常是本地节点的ZONE_NORMAL
或基于 GFP 标志选择的特定区域)中满足时,内核会按照node_zonelists
中定义的顺序依次尝试其他区域。 - 构建:
node_zonelists
在系统引导期间由build_all_zonelists()
函数构建。构建过程会考虑 NUMA 距离(优先尝试本地节点的所有适用区域,然后是距离最近的远程节点,以此类推)和区域类型本身的优先级(例如,通常会优先尝试ZONE_NORMAL
而不是ZONE_DMA
,除非请求明确指定GFP_DMA
)。Linux 默认采用节点优先(Node ordered)的回退策略,即先尝试完本地节点的所有合格区域,再尝试远程节点。
- 目的: 如前所述,
NUMA/Zone 层次结构:核心分配策略框架
NUMA/Zone 层次结构不仅仅是对物理内存的静态描述,它构成了 Linux 内存分配策略的核心框架。现代计算机系统普遍存在 NUMA 特性,访问本地内存远快于远程内存。同时,物理内存本身也可能具有不同的硬件能力限制(如 DMA 可达性)。pglist_data
结构捕获了内存的局部性 (Locality) 特征,而 struct zone
则体现了内存的能力 (Capability) 或约束。
当内核需要分配物理页面时(通过 alloc_pages
等函数),请求通常会附带 GFP (Get Free Pages) 标志,指明分配的上下文、优先级以及对内存区域类型的要求(如 GFP_DMA
, GFP_KERNEL
, GFP_ATOMIC
)。内存分配器利用 pglist_data
和 zone
结构来定位合适的内存来源。而 pglist_data
中的 node_zonelists
则明确地编码了分配策略:它规定了在首选区域无法满足请求时,应该按照怎样的顺序(综合考虑 NUMA 距离和区域适用性)去尝试其他备选区域。这种设计使得页面分配器能够在满足分配请求约束的同时,尽可能地优化内存访问性能(优先本地分配)和资源利用(保护特殊区域如 ZONE_DMA
)。因此,Node/Zone 结构及其关联的 Zonelist 是 Linux 实现高效、NUMA 感知内存分配的关键所在。
表 4: Linux 内存区域类型与特性
区域类型 | 典型地址范围/约束 (x86 示例) | 内核访问方式 | 主要用途 |
ZONE_DMA | < 16MB | 直接映射 | 兼容旧式 ISA 设备的 DMA。 |
ZONE_DMA32 | < 4GB | 直接映射 | 兼容 32 位地址限制的设备的 DMA。 |
ZONE_NORMAL | 16MB - 896MB (32位) / 大部分 RAM (64位) | 直接映射 | 内核最常用的内存区域,用于大多数内核数据结构和页缓存。 |
ZONE_HIGHMEM | > 896MB (仅特定 32 位架构) | 临时映射 (kmap ) | 用于超出内核直接映射范围的用户空间内存和页缓存。 |
ZONE_MOVABLE | 可配置 | 直接映射 | 存放可迁移页面,用于减少碎片、支持大页分配和内存热插拔。 |
ZONE_DEVICE | 设备特定 | 特定驱动/映射 | 管理位于外部设备(如 PMEM, GPU)上的内存,提供 struct page 支持。 |
7. 内核对象缓存:Slab 分配器结构 (struct kmem_cache
)
为了提高内核中频繁分配和释放的小型、固定大小对象的效率,Linux 引入了 Slab 分配器。相比于直接使用基于页面的伙伴系统分配器来满足这些小额内存请求,Slab 分配器具有显著优势:
- 减少内部碎片: 伙伴系统按页(或页的幂次方)分配,对于远小于页面大小的对象会造成大量浪费。Slab 分配器将从伙伴系统获取的大块内存(称为 slab,通常包含一或多个页面)进一步细分成适合对象大小的单元,显著减少了内部碎片。
- 缓存常用对象: Slab 分配器为每种类型的对象维护一个缓存。当对象被释放时,它通常不会立即归还给伙伴系统,而是保留在 Slab 缓存中,并可能保持其初始化状态(通过构造函数
ctor
实现)。下次请求分配同类型对象时,可以直接从缓存中获取,避免了重新分配和初始化的开销。 - 提高硬件缓存利用率: Slab 分配器可以通过对齐对象(
SLAB_HWCACHE_ALIGN
标志)和 slab 着色(coloring)技术,使得不同 slab 中的对象倾向于使用不同的 CPU 缓存行,从而减少缓存冲突,提高访问速度。
Slab 分配器位于伙伴系统(页面分配器)之上。它通过调用 alloc_pages()
向伙伴系统申请一块或多块连续的物理页面作为 slab,然后在这块内存上管理具体对象的分配和释放。
Linux 内核历史上和现在存在多种 Slab 分配器的实现,最著名的是 SLAB、SLUB 和 SLOB。SLOB 是为内存极其有限的嵌入式系统设计的简化版本。SLAB 是较早的经典实现。SLUB 是目前大多数 Linux 发行版的默认实现,它设计更简洁,旨在提高性能和可伸缩性,尤其是在大型多核和 NUMA 系统上。尽管内部实现细节不同,它们都提供了相似的外部 API,如 kmem_cache_create()
, kmem_cache_alloc()
, kmem_cache_free()
以及通用的 kmalloc()
和 kfree()
接口。本节将重点介绍通用的 struct kmem_cache
结构,并结合 SLUB 实现中的特定结构进行说明。
7.1 struct kmem_cache
(缓存描述符)
struct kmem_cache
是 Slab 分配器的核心控制结构,它描述了一个特定类型对象的缓存池。每个需要使用 Slab 机制进行管理的内核对象类型(如 task_struct
, inode
, dentry
, vm_area_struct
等)或者 kmalloc
使用的通用大小缓存,都会有一个对应的 kmem_cache
实例。该结构在调用 kmem_cache_create()
时创建,并在不再需要时通过 kmem_cache_destroy()
销毁。
- 关键字段 (综合 SLAB/SLUB):
name
(const char *): 缓存的可读名称,用于调试和/proc/slabinfo
输出。object_size
(unsigned int): 缓存管理的对象本身的大小(不含元数据)。size
(unsigned int): 在 SLUB 中,通常指包含元数据在内的每个对象的实际占用空间(可能等于object_size
)。在 SLAB 中,可能包含对齐后的对象大小。flags
(unsigned long): 缓存创建时设置的标志,控制缓存行为。例如:SLAB_HWCACHE_ALIGN
: 请求对象按硬件缓存行对齐。SLAB_ACCOUNT
: 将此缓存的内存使用计入内核内存 cgroup。SLAB_CACHE_DMA
: 请求从ZONE_DMA
分配 slab 页面。SLAB_RED_ZONE
: (调试用) 在对象前后添加“红区”以检测越界写。SLAB_POISON
: (调试用) 用特定模式填充已分配和已释放的对象以检测使用已释放内存等错误。
ctor
(void (*)(void *)): 指向对象构造函数的指针。当对象首次从 slab 中分配出来时(或者根据实现,每次分配时)调用,用于初始化对象。dtor
(void (*)(void *)): 指向对象析构函数的指针(不常用)。通常在整个 slab 被销毁时,对其中的所有对象调用。oo
(struct kmem_cache_order_objects
) /gfporder
(unsigned int): 定义了为该缓存分配一个 slab 时,需要向伙伴系统请求的页面数量(以 2 的幂次方表示,即阶数 order)。SLUB 使用oo
结构紧凑地存储了阶数和每个 slab 容纳的对象数量。min_partial
(unsigned int) (SLUB): 每个 NUMA 节点上最少保留的部分填充(partial)slab 数量,即使它们是空的也暂时不释放,用于加速后续分配。cpu_slab
(struct kmem_cache_cpu __percpu *) (SLUB): 指向一个 per-CPU 数据结构的指针。这是 SLUB 实现高性能的关键,每个 CPU 维护自己当前活跃的 slab 和空闲对象列表,极大地减少了锁竞争。node
(struct kmem_cache_node *) (SLUB/SLAB): 指向一个 per-NUMA 节点数据结构的数组。用于管理该节点上处于部分填充、全满或空闲状态的 slab 列表。list
(struct list_head): 用于将该kmem_cache
结构链接到全局的slab_caches
链表中,方便内核遍历所有活动的 Slab 缓存。
7.2 SLUB 特有结构
SLUB 分配器为了追求极致的性能和可伸缩性,引入了更精简的 per-CPU 和 per-Node 结构。
-
struct kmem_cache_cpu
: 这是与每个kmem_cache
相关联的 per-CPU 结构。- 作用: 提供 CPU 本地(CPU-local)的快速分配路径。
- 关键字段:
freelist
(void **
): 指向当前 CPU 活跃 slab 中的下一个可用空闲对象的指针。空闲对象内部通常也存储着指向下一个空闲对象的指针,形成链表。slab
(struct slab *
/struct page *
): 指向当前 CPU 正在使用的活跃 slab 页面的指针。分配请求首先尝试从这里获取对象。tid
(unsigned long
): 事务 ID,用于同步和调试。partial
(struct slab *
/struct page *
) (可选,CONFIG_SLUB_CPU_PARTIAL
): 指向一个 CPU 本地的部分填充 slab 链表。当活跃 slab 用尽时,可以快速从这里获取新的 slab,进一步减少全局锁竞争。
- 优势: 通过将活跃 slab 和空闲列表与 CPU 绑定,极大地减少了访问共享数据结构所需的锁操作,提高了 SMP 环境下的性能。
-
struct kmem_cache_node
: 这是与每个kmem_cache
相关联的 per-NUMA 节点结构。- 作用: 管理不属于任何 CPU 本地缓存的 slab,并考虑 NUMA 局部性。
- 关键字段:
partial
(struct list_head): 链接该节点上所有部分填充(有空闲对象但未满)的 slab 的链表。nr_partial
(unsigned long):partial
链表中 slab 的数量。list_lock
(spinlock_t): 保护partial
链表访问的自旋锁。
- 交互: 当一个 CPU 的
kmem_cache_cpu
中的freelist
和partial
列表(如果启用)都为空时,它会尝试从其所属 NUMA 节点的kmem_cache_node
的partial
链表中获取一个新的部分填充 slab。如果partial
链表也为空,则需要分配一个全新的 slab(调用页面分配器)。
7.3 通用缓存 (kmalloc
)
内核提供了一组通用的内存分配接口 kmalloc()
和 kfree()
。kmalloc()
实际上是 Slab 分配器的一个前端。内核预先创建了一系列 kmem_cache
实例,用于管理不同大小(通常是 2 的幂次方或接近的几何级数大小,如 32, 64, 96, 128,..., 4MB)的通用内存块。当调用 kmalloc(size, flags)
时,内核会根据请求的 size
选择一个最合适的预定义缓存(例如,请求 70 字节会使用 size-96 或 size-128 的缓存),然后从该缓存中分配一个对象。kfree()
则需要能够根据传入的指针找到其所属的 kmem_cache
,然后将对象释放回对应的缓存。
Slab 分配器:伙伴系统之上的分层缓存
Slab 分配器是 Linux 内核内存管理中一个典型的分层设计范例。底层的伙伴系统负责管理物理页面(通常 4KB 或更大)的分配和释放,它高效地处理大块连续内存,但对于频繁的小对象分配存在内部碎片和初始化开销问题。Slab 分配器构建在伙伴系统之上,通过 kmem_cache
结构为特定大小的对象创建专门的缓存池。它向伙伴系统申请页面(slabs),然后在这些页面内部精细地管理对象的分配与释放,并通过缓存空闲对象(通常保持初始化状态)来加速后续分配。现代实现如 SLUB 进一步引入了 per-CPU (kmem_cache_cpu
) 和 per-Node (kmem_cache_node
) 缓存层级,以适应多核(SMP)和 NUMA 架构,最大限度地减少锁竞争和远程内存访问。因此,Slab 分配器可以被视为一个建立在基础页面分配机制之上的、具有多级缓存(对象级缓存、CPU 级 slab 缓存、节点级 slab 管理)的复杂而高效的内存分配解决方案。
表 5: struct kmem_cache
关键字段摘要 (侧重 SLUB)
字段名 | 数据类型 | 描述 (在缓存管理中的作用) |
name | const char * | 缓存的可读名称。 |
object_size | unsigned int | 缓存对象的大小(不含元数据)。 |
size | unsigned int | 对象占用总空间(含 SLUB 元数据)。 |
flags | unsigned long | 缓存创建标志 (如 SLAB_HWCACHE_ALIGN , SLAB_ACCOUNT , SLAB_RED_ZONE )。 |
ctor | void (*)(void *) | 对象构造函数指针。 |
oo | struct kmem_cache_order_objects | 紧凑存储 slab 的阶数 (order) 和每个 slab 的对象数。 |
min_partial | unsigned int | 每个 NUMA 节点保留的最小部分填充 slab 数。 |
cpu_slab | struct kmem_cache_cpu __percpu * | 指向 per-CPU 数据的指针,包含活跃 slab 和 freelist。 |
node | struct kmem_cache_node * | 指向 per-NUMA 节点数据的指针数组,管理节点的部分/满/空闲 slab 列表。 |
8. 结构间的关系与交互
前面几节分别剖析了 Linux 内存管理中的几个核心数据结构。然而,这些结构并非孤立运作,而是紧密地相互关联,协同工作以实现复杂的内存管理功能,如虚拟地址转换、物理内存分配与回收、以及内核对象缓存。本节将重点阐述这些结构之间的动态交互过程。
8.1 虚拟地址到物理页面的转换流程
这是内存管理中最核心的操作之一,它将应用程序或内核使用的虚拟地址转换为 CPU 可以访问的物理地址。这个过程完美地展示了 mm_struct
, vm_area_struct
, 页表, 和 struct page
之间的协作。
- 进程访问虚拟地址 (VA): 进程中的代码执行,需要访问某个虚拟地址。
- MMU 介入: CPU 的内存管理单元 (MMU) 捕获这次访问。它首先需要知道当前进程的页表基址。这个基址存储在 CPU 的一个特殊寄存器中(如 x86 的 CR3),该寄存器在进程上下文切换时由内核加载,其值来源于当前进程
task_struct->mm
指向的mm_struct->pgd
。 - 页表遍历 (Page Table Walk): MMU 使用
pgd
作为起点,根据虚拟地址 VA 的不同位段作为索引,依次查找页全局目录 (PGD)、页上级目录 (PUD,如果存在)、页中间目录 (PMD,如果存在) 和页表项 (PTE)。内核在进行软件页表遍历时(例如在follow_page()
或缺页处理中),会使用pgd_offset()
,pud_offset()
,pmd_offset()
,pte_offset()
等宏来模拟这个过程。 - 找到有效 PTE (转换成功): 如果遍历过程顺利,最终找到一个有效 (present) 的 PTE。该 PTE 中包含了目标物理页帧的物理页帧号 (PFN) 以及访问权限位(读/写/执行)。MMU (或内核) 将 PFN 提取出来,左移
PAGE_SHIFT
位得到物理页帧的基地址,然后加上 VA 中的页内偏移量,最终得到完整的物理地址 (PA)。此时,内核可以通过pte_page()
宏从 PTE 得到对应的struct page
指针。硬件 MMU 将使用 PA 访问物理内存。 - 页面错误 (Page Fault): 如果在页表遍历过程中遇到无效的条目(例如,PGD/PUD/PMD 条目不存在,或者 PTE 标记为不存在 (not present)),或者访问权限不匹配(例如,尝试写入一个只读页面),MMU 会中止当前的内存访问,并产生一个页面错误异常,将控制权交给内核的缺页异常处理程序。
- 内核缺页处理: a. 上下文确定: 内核的缺页处理程序首先确定发生错误的虚拟地址 (faulting VA) 以及当前进程的
mm_struct
(通常通过current->mm
)。 b. VMA 查找: 内核需要在mm_struct
中查找覆盖了 faulting VA 的vm_area_struct
。它使用mm_struct
中的 VMA 树 (mm_rb
或mm_mt
) 进行快速查找。 c. VMA 策略检查: 找到对应的 VMA 后,内核检查vma->vm_flags
以确定访问是否合法(例如,是否尝试写入只读区域)。如果访问非法,内核会向进程发送SIGSEGV
信号,终止进程。 d. 调用 VMA 操作: 如果 VMA 存在且访问权限允许(但页面不在内存中或需要特殊处理),内核会调用该 VMA 注册的操作函数,通常是vma->vm_ops->fault()
。 e. 错误解决 (fault()
处理):fault()
函数负责将所需的页面加载到物理内存并建立映射。具体行为取决于 VMA 的类型: * 匿名页: 如果是匿名 VMA (vma->vm_file == NULL
),可能需要分配一个新的物理页面(通过调用alloc_pages()
,见下文)。首次访问时,可能会映射到一个全局的只读零页;发生写操作时(写时复制),则会分配一个新页面,填充零,然后建立写映射。如果页面之前被换出到交换空间,fault()
需要根据 PTE 中存储的交换信息找到页面在交换设备上的位置,分配一个物理页面,将数据从交换设备读回页面,然后更新 PTE。 * 文件页: 如果是文件支持的 VMA (vma->vm_file!= NULL
),fault()
需要根据vma->vm_pgoff
和 faulting VA 计算出所需页面在文件中的偏移量 (index
)。然后,它会在页缓存中查找该页面。如果页面不在缓存中,需要分配一个物理页面(alloc_pages()
),然后从对应的文件(vma->vm_file
)读取数据填充该页面。 f. 更新页表: 无论哪种情况,一旦物理页面准备好(分配并填充了数据),fault()
处理程序都需要更新进程页表(mm_struct->pgd
指向的层级结构)中的 PTE,将 faulting VA 映射到物理页面的 PFN,并设置正确的状态位(如 Present, Accessed, Dirty 等)。这通常涉及mk_pte()
和set_pte()
等函数。 g. 恢复执行: 缺页处理程序完成后,控制权返回到用户进程,之前导致错误的指令会被重新执行。此时,由于页表已经更新,MMU 翻译将成功,内存访问得以继续。
8.2 物理页面分配 (伙伴系统 -> Zone -> Node)
当内核需要分配物理页面时(例如在缺页处理、Slab 分配新 slab、或直接调用 kmalloc
/vmalloc
时),它会使用伙伴系统分配器。这个过程涉及 struct zone
和 struct pglist_data
。
- 分配请求: 内核代码调用
alloc_pages(gfp_mask, order)
或类似函数。gfp_mask
参数至关重要,它指定了分配的约束条件和行为:- 区域修饰符: 如
GFP_DMA
,GFP_HIGHMEM
(隐式或显式) 指示必须或优先从哪个区域分配。 - 行为修饰符: 如
GFP_KERNEL
(允许睡眠和 I/O,用于普通内核分配),GFP_ATOMIC
(不允许睡眠,用于中断上下文等高优先级分配,可以访问保留内存),GFP_NOWAIT
(不允许睡眠,但不保证成功),__GFP_RECLAIM
(允许直接或间接内存回收) 等。 order
参数指定了请求的页面数量(2<sup>order</sup> 个连续页)。
- 区域修饰符: 如
- 节点/区域选择: 分配器首先确定目标 NUMA 节点。默认情况下,它会尝试在运行当前代码的 CPU 所属的节点(本地节点,通过
numa_node_id()
获取)上分配。然后,根据gfp_mask
中指定的区域要求和分配上下文,它会查阅该节点的node_zonelists
。node_zonelists
提供了一个按优先级排序的区域列表(可能跨越多个节点),分配器会从这个列表中选择第一个符合gfp_mask
要求的区域开始尝试分配。 - 伙伴系统分配 (
__rmqueue
): 在选定的zone
中,分配器查找zone->free_area[order]
链表。如果该链表非空,表示有大小正好合适的空闲块,就从中取下一个块。 - 块分裂: 如果
free_area[order]
为空,分配器会查找更高阶的链表 (order+1
,order+2
,...,MAX_ORDER-1
)。如果找到一个更大的空闲块(例如在free_area[k]
找到,k > order),该块会被从链表中移除,然后递归地分裂成两个大小相等的“伙伴”块(大小为 2<sup>k-1</sup> 页)。其中一个伙伴被添加到free_area[k-1]
的空闲链表中,另一个则继续被处理(如果 k-1 仍然大于 order,则继续分裂)。这个过程一直持续,直到得到一个大小为 2<sup>order</sup> 的块。在分裂过程中,空闲块首页的page->private
字段用于记录块的阶数,而zone->free_area
中的位图 (map
) 用于跟踪伙伴的状态。 - 水印检查与回收: 在分配过程中(特别是当找不到合适的空闲块时),如果
gfp_mask
允许(例如包含__GFP_RECLAIM
),分配器会检查所选区域的水印。如果空闲页面低于pages_low
,可能会唤醒kswapd
进行后台回收。如果低于pages_min
,或者分配标志要求(如GFP_KERNEL
在低内存压力下),可能会触发同步的直接内存回收。回收过程会尝试释放一些页面(如清理页缓存、换出匿名页、收缩 Slab 缓存),然后重试分配。 - 返回结果: 如果分配成功,函数返回指向所分配块的第一个页面的
struct page
指针。如果经过所有尝试(包括遍历 zonelist 中的备用区域和可能的内存回收)仍然无法满足请求,则返回NULL
。
8.3 物理页面释放 (伙伴系统)
释放物理页面是分配的逆过程,同样由伙伴系统处理。
- 释放调用: 内核代码调用
__free_pages(page, order)
或类似函数,传入要释放块的首页指针和块的阶数。 - 伙伴查找与合并: 释放函数首先计算出传入块
page
的伙伴块的地址(通过 PFN 的异或运算)。然后,它检查这个伙伴块是否也处于空闲状态,并且具有相同的阶数order
。这个检查通常依赖于伙伴块首页的PG_buddy
标志、存储在page->private
中的阶数信息,以及zone->free_area[order].map
位图。 - 合并: 如果伙伴块确实是空闲且阶数相同,那么内核会将伙伴块从其所在的
free_area[order].free_list
中移除,然后将两个块合并成一个大小为 2<sup>order+1</sup> 的新块。这个合并过程会递归进行:将新合并的块视为当前块,继续在order+1
阶检查其伙伴并尝试合并,直到无法找到空闲伙伴或达到MAX_ORDER-1
阶为止。 - 添加到空闲链表: 当一个块无法再与其伙伴合并时(无论是初始块还是合并后的块),它会被添加到其对应阶数
order
的zone->free_area[order].free_list
链表中。同时,该区域的nr_free
计数会增加。
8.4 Slab 分配器与伙伴系统的交互
Slab 分配器作为伙伴系统的高层封装,它们之间存在清晰的交互模式。
- 缓存创建: 调用
kmem_cache_create()
时,Slab 分配器会根据对象大小、对齐要求和可选标志计算出最佳的对象打包方式,并确定每个 slab 需要包含多少个页面(即gfporder
)。 - Slab 分配: 当一个
kmem_cache
需要新的 slab 时(例如,某个 CPU 的本地缓存cpu_slab
用尽,且对应 NUMA 节点的kmem_cache_node->partial
列表也为空),它会调用页面分配器(如alloc_pages(cache->gfpflags, cache->gfporder)
)向伙伴系统申请所需数量的连续物理页面。 - Slab 管理: 伙伴系统返回分配的页面块(首页的
struct page
指针)。Slab 分配器接管这些页面:- 在页面描述符上设置
PG_slab
标志。 - 根据
kmem_cache
的配置(如ctor
)初始化 slab 中的对象。 - 使用 slab 内部的空间(或对象自身的空间)来维护空闲对象的链表(例如,SLUB 中空闲对象的前几个字节存储指向下一个空闲对象的指针)。SLUB 可能还会使用
page->freelist
指向 slab 中的第一个空闲对象。 - 将这个新的 slab 加入到
kmem_cache
的管理结构中(例如,放入某个 CPU 的cpu_slab
或某个节点的partial
列表)。
- 在页面描述符上设置
- Slab 释放: 当一个 slab 中的所有对象都被释放,并且 Slab 分配器决定收缩缓存时(例如,通过
kmem_cache_shrink()
或在内存压力下被动回收),组成该 slab 的物理页面会被归还给伙伴系统。这通过调用__free_pages()
完成。在释放前,页面的PG_slab
标志会被清除。
分层抽象与策略/机制分离
Linux 内存管理的整体设计体现了清晰的分层抽象和策略与机制分离的原则。
- 高层视图与策略 (
mm_struct
,vm_area_struct
): 这两个结构定义了进程的内存视图和规则。它们决定了哪些虚拟地址是有效的,这些地址具有什么权限,以及它们与后备存储(文件或匿名)的关系。它们是内存管理的策略层。 - 地址转换机制 (页表): 页表(以
mm_struct->pgd
为根)是实现虚拟地址到物理地址转换的底层机制,由硬件 MMU 直接使用。页表的填充和修改受到 VMA 定义的策略指导。 - 物理资源表示 (
struct page
):struct page
是对底层物理内存资源(页帧)的抽象表示,记录其状态和归属。 - 物理资源组织与分配 (
zone
,pglist_data
, 伙伴系统):zone
和pglist_data
根据硬件特性(NUMA, DMA)组织物理页面,并为伙伴系统(物理页面分配的机制)提供框架。分配策略(如 NUMA 亲和性)通过 Zonelist 在这个框架内实现。 - 专用分配器 (Slab): Slab 分配器为内核对象提供了一个专门的、基于缓存的分配机制,它本身又依赖于底层的伙伴系统机制来获取页面。
这种分层和分离使得系统的不同部分可以相对独立地演进和优化。例如,可以改进 Slab 分配器的内部算法(从 SLAB 到 SLUB)而不影响 VMA 的管理方式;可以调整 NUMA 分配策略而不改变页表遍历的基本逻辑。页面错误处理流程清晰地展示了这种交互:MMU(硬件机制)失败 -> 内核查找 VMA(策略)-> VMA 操作调用 alloc_pages
(物理分配机制)-> 内核更新页表(映射机制)。策略(VMA 定义了应该发生什么)与机制(页表、伙伴系统如何实现它)的分离是 Linux 内存管理设计的核心思想之一。
9. 结论
本报告深入分析了 Linux 内核内存管理子系统中的几个关键数据结构:struct page
、struct vm_area_struct
、struct mm_struct
、struct zone
、struct pglist_data
以及 struct kmem_cache
。这些结构体构成了 Linux 高效、灵活且功能强大的内存管理体系的基础。
struct page
: 作为物理页帧的通用描述符,通过高度优化的字段(特别是flags
)和上下文相关的字段复用,以极低的内存开销跟踪每个物理页面的状态、归属和使用情况。它是连接物理内存和上层抽象的桥梁。struct vm_area_struct
(VMA): 定义了进程虚拟地址空间中的连续区域,封装了该区域的地址范围、访问权限、后备存储信息以及操作行为(通过vm_ops
)。VMA 是内核实施内存访问控制、按需分页和写时复制等策略的核心。struct mm_struct
: 代表了一个进程的完整内存身份,聚合了所有的 VMA,持有指向进程页表的根指针 (pgd
),并维护内存使用统计和生命周期引用计数。它是进程隔离和内存上下文切换的基础。struct zone
和struct pglist_data
: 构建了物理内存的分层组织模型,根据 NUMA 节点的内存局部性和内存区域的硬件特性(如 DMA 可达性)对物理内存进行划分。它们为伙伴系统分配器提供了框架,并通过 Zonelist 实现了复杂的 NUMA 感知分配策略。struct kmem_cache
: Slab 分配器的核心,为特定大小的内核对象提供高效的缓存管理。它通过减少内部碎片、缓存已初始化对象和优化硬件缓存利用率,显著提高了常用内核对象的分配/释放性能,并构建在底层伙伴系统之上。
这些数据结构并非孤立存在,而是通过复杂的交互协同工作。虚拟地址的访问请求通过 mm_struct
指向的页表进行转换,页表项最终指向代表物理内存的 struct page
。当转换失败(页面错误)时,内核通过查找 mm_struct
中的 VMA 来确定处理策略,可能涉及调用 vm_ops->fault
,该函数又可能通过 alloc_pages
请求物理页面。alloc_pages
则依据 pglist_data
和 zone
定义的物理内存布局和 Zonelist 策略,使用伙伴系统在合适的区域分配 struct page
。而 Slab 分配器则利用伙伴系统获取页面,并在其上通过 kmem_cache
管理更小粒度的对象分配。
Linux 内存管理的设计体现了几个关键原则:
- NUMA 感知: 通过
pglist_data
和 Zonelist 明确考虑内存访问延迟,优化多节点系统性能。 - 性能优化: 大量使用缓存(页缓存、Slab 缓存、TLB)、惰性机制(按需分页、写时复制、Lazy TLB)和减少锁竞争(如 SLUB 的 per-CPU 缓存)来提高效率。
- 内存效率: 通过字段复用(如
struct page
)和精细管理(如 Slab 减少内部碎片)来最大限度地节省宝贵的内存资源。 - 灵活性与可扩展性: 支持多种内存区域类型、多种 Slab 实现、可配置的 NUMA 策略,适应不同的硬件平台和工作负载。
- 策略与机制分离: 清晰地划分了高层策略(VMA 定义规则)和底层机制(页表、伙伴系统、Slab 实现细节),使得系统更易于理解、维护和扩展。
综上所述,struct page
, vm_area_struct
, mm_struct
, zone
, pglist_data
, kmem_cache
这些核心数据结构及其精巧的交互机制,共同构成了 Linux 内存管理子系统的基石。正是这个复杂而高效的系统,使得 Linux 能够在各种硬件平台上为多样化的应用程序提供稳定、高性能的内存服务。对这些数据结构的深入理解,对于优化系统性能、调试内存相关问题以及进行内核开发都至关重要。