4. 内核中的 struct page 与页分配:页分配器、alloc_pages、__get_free_pages、page 结构体详解

物理内存是 Linux 驱动与硬件交互的底层基础。在编写 mmap 驱动时,驱动开发者必须理解内核如何管理物理页、如何分配连续物理内存,以及如何通过 struct page 描述每一页的状态。本章深入剖析内核页分配器的核心机制、三个关键分配接口的差异,以及 struct page 结构体的关键字段,为后续的物理内存映射实战打下坚实基础。

4.1 内核页分配器(Page Allocator)概述

Linux 内核使用伙伴系统(Buddy System)作为物理页分配器的核心算法。它将物理内存划分为 2order 个连续页的块(order 从 0 到 MAX_ORDER-1),通过合并与分裂来减少外部碎片。

关键概念:页分配器管理的最小单位是物理页帧(Page Frame),每个物理页帧对应一个 struct page 实例。分配器维护 11 个链表(order 0~10),每个链表挂载对应大小的空闲块。
伙伴系统页分配器结构图 伙伴系统页分配器结构 order 链表 order 0 4KB order 1 8KB order 2 16KB order 3 32KB ... 直到 order 10 (4MB) 分配流程示例(order=1) 请求 order=1 块 (8KB 连续物理页) 检查 order=1 链表 有空闲块? 有:直接分配 无:从 order=2 分裂 链表头 空闲块 请求 决策 结果
驱动开发者提示:在 mmap 驱动中,通常使用 order=0(单页)或较小的 order 值分配物理内存。大块连续物理内存(order≥4)在长时间运行的系统上容易分配失败,建议在驱动初始化时预分配。

4.2 核心分配接口:alloc_pages 与 __get_free_pages

内核提供两套主要的物理页分配接口,它们最终都调用伙伴系统的核心函数 __alloc_pages_nodemask(),但返回类型和适用场景不同。

接口 返回类型 物理地址连续性 适用场景
alloc_pages(gfp_mask, order) struct page * 连续 需要直接操作 struct page 的场景(如 mmap、DMA、页表操作)
__get_free_pages(gfp_mask, order) unsigned long(内核虚拟地址) 连续 仅需内核虚拟地址访问的场景(如内核缓冲区、临时数据)
get_zeroed_page(gfp_mask) unsigned long 连续(单页) 需要清零的单页分配

4.2.1 alloc_pages 接口详解

alloc_pages 返回 struct page * 指针,这是内核中描述物理页的核心结构。驱动通过该指针可以获取页的物理地址、设置页属性、建立页表映射。

#include <linux/gfp.h>
#include <linux/mm.h>

struct page *page;
int order = 2;  // 分配 2^2 = 4 个连续物理页(16KB)

// 分配物理页,GFP_KERNEL 表示可睡眠等待
page = alloc_pages(GFP_KERNEL, order);
if (!page) {
    pr_err("alloc_pages failed\n");
    return -ENOMEM;
}

// 获取物理地址(页帧号 PFN)
unsigned long pfn = page_to_pfn(page);
phys_addr_t phys_addr = page_to_phys(page);
pr_info("Allocated 4 pages, PFN: %lu, Phys: %pa\n", pfn, &phys_addr);

// 获取内核虚拟地址(若需要)
void *kaddr = page_address(page);
pr_info("Kernel virtual addr: %p\n", kaddr);

4.2.2 __get_free_pages 接口详解

__get_free_pages 直接返回内核虚拟地址(线性映射区),适合驱动只需要在 CPU 上读写内存、不需要操作 struct page 的场景。

unsigned long vaddr;
int order = 1;  // 分配 2 个连续物理页(8KB)

vaddr = __get_free_pages(GFP_KERNEL, order);
if (!vaddr) {
    pr_err("__get_free_pages failed\n");
    return -ENOMEM;
}

// 直接使用虚拟地址
memset((void *)vaddr, 0, PAGE_SIZE * (1 << order));
pr_info("Allocated 2 pages at vaddr: 0x%lx\n", vaddr);

// 如果需要物理地址,需要转换
struct page *page = virt_to_page(vaddr);
phys_addr_t phys = page_to_phys(page);
pr_info("Physical addr: %pa\n", &phys);
重要区别:__get_free_pages 返回的虚拟地址位于内核线性映射区(通常为 3GB~4GB 或 4GB 以上,取决于架构)。在 64 位系统上,该区域与物理内存有固定偏移关系。驱动在 mmap 时,必须使用 struct page * 或物理地址,不能直接使用该虚拟地址。

4.3 struct page 结构体深度解析

struct page 是内核中最重要的数据结构之一,每个物理页帧对应一个实例。它占用约 40~64 字节(取决于架构和配置),通过联合体(union)复用字段以节省内存。

struct page 结构体布局图 struct page 核心字段布局 struct page { ... } flags (unsigned long) 页状态标志位 mapping (struct address_space *) 所属地址空间/匿名页 index (pgoff_t) 文件内偏移 lru (struct list_head) LRU 链表节点 _refcount (atomic_t) 页引用计数 _mapcount (atomic_t) 页表映射计数 private (unsigned long) 私有数据(驱动使用) virtual (void *) 内核虚拟地址(若映射) slab_cache / freelist slab 分配器复用 说明:struct page 使用大量 union 复用字段,不同使用场景下字段含义不同 驱动 mmap 时最常操作 flags、_refcount、private、virtual 字段

4.3.1 关键字段详解

  • flags:页状态标志位,使用 PG_* 宏定义。例如 PG_locked(页被锁定)、PG_dirty(脏页)、PG_uptodate(数据有效)、PG_reserved(保留页,驱动分配的内存通常设置此标志)。
  • _refcount:页的引用计数,内核使用 get_page()/put_page() 操作。当计数降为 0 时,页被释放回伙伴系统。
  • _mapcount:页表映射计数,表示该页被映射到多少个进程的页表中。mmap 驱动中,每次建立用户映射都会增加此计数。
  • mapping:指向 address_space 的指针。文件页指向文件的 address_space;匿名页最低位为 1 表示匿名;驱动分配的页通常为 NULL 或指向驱动自定义结构。
  • private:驱动私有数据字段。在 mmap 驱动中,常用来存储驱动自定义的上下文指针或标志位。
  • virtual:页的内核虚拟地址。对于高端内存(HIGHMEM)的页,此字段为 NULL,需要通过 kmap() 临时映射。

4.4 页分配标志(GFP Flags)详解

GFP(Get Free Pages)标志控制分配器的行为,包括是否可睡眠、从哪个内存区域分配、是否允许回收等。

GFP 标志 含义 驱动典型场景
GFP_KERNEL 普通内核分配,可睡眠,允许 I/O 和回收 大多数驱动分配场景(进程上下文)
GFP_ATOMIC 原子分配,不可睡眠,不允许 I/O 中断上下文、自旋锁保护区域
GFP_DMA 从 DMA 区域分配(低 16MB 或 4GB 以下) ISA DMA、某些 32 位 DMA 设备
GFP_DMA32 从 DMA32 区域分配(4GB 以下) 64 位系统上的 32 位 DMA 设备
GFP_HIGHUSER 从用户空间区域分配,可回收 用户态页分配(通常不用于驱动)
__GFP_ZERO 返回清零的页(与 GFP 标志按位或) 需要初始化数据的场景
// 驱动中常见的 GFP 组合示例

// 1. 进程上下文,可睡眠,需要清零
struct page *page = alloc_pages(GFP_KERNEL | __GFP_ZERO, 2);

// 2. 中断上下文,不可睡眠
unsigned long vaddr = __get_free_pages(GFP_ATOMIC, 0);

// 3. 需要 DMA 区域的物理内存(某些硬件要求)
struct page *dma_page = alloc_pages(GFP_KERNEL | GFP_DMA, 1);

// 4. 大块分配,允许使用紧急内存池
struct page *emergency_page = alloc_pages(GFP_KERNEL | __GFP_MEMALLOC, 4);
最佳实践:在 mmap 驱动中,推荐使用 alloc_pages(GFP_KERNEL | __GFP_ZERO, order) 分配物理页。清零可以避免向用户空间泄露内核敏感数据,同时确保页的 PG_reserved 标志被正确设置。

4.5 页释放接口

分配的内存必须正确释放,否则会造成内存泄漏。释放接口与分配接口成对使用。

分配接口 释放接口 说明
alloc_pages(gfp, order) __free_pages(page, order) 传入 struct page * 和 order
__get_free_pages(gfp, order) free_pages(addr, order) 传入虚拟地址和 order
get_zeroed_page(gfp) free_page(addr) 单页释放的快捷方式
// 释放示例
struct page *page = alloc_pages(GFP_KERNEL, 2);
if (page) {
    // ... 使用内存 ...
    __free_pages(page, 2);  // 释放 4 页
}

unsigned long vaddr = __get_free_pages(GFP_KERNEL, 1);
if (vaddr) {
    // ... 使用内存 ...
    free_pages(vaddr, 1);  // 释放 2 页
}
常见错误:释放时必须使用与分配时相同的 order 值。如果 order 不匹配,伙伴系统会破坏空闲链表,导致内核崩溃。此外,驱动在模块卸载时务必释放所有分配的物理页,否则系统内存泄漏会逐渐耗尽可用内存。

4.6 驱动 mmap 中的页分配实战模式

在 mmap 驱动中,物理页的分配通常遵循以下模式:

  1. 预分配模式:在驱动初始化(probe)时分配一大块连续物理内存,mmap 时直接映射已分配的页。适合固定大小的缓冲区。
  2. 按需分配模式:在 mmap 回调中,根据用户请求的大小动态分配物理页。适合大小可变的映射。
  3. 预留内存模式:通过设备树或内核参数预留专用物理内存区域,驱动直接管理该区域。适合对延迟和连续性要求极高的场景(如 GPU、VPU)。
驱动 mmap 页分配模式对比 驱动 mmap 页分配三种模式 模式1:预分配 probe() 时 alloc_pages 优点:mmap 延迟低,无分配失败风险 缺点:内存利用率低,固定大小 模式2:按需分配 mmap 回调时 alloc_pages 优点:内存按需分配,灵活 缺点:mmap 可能失败,延迟较高 模式3:预留内存 设备树预留 + ioremap 优点:确定性高,无碎片 缺点:配置复杂,灵活性低 选择建议:大多数驱动使用模式1(预分配)或模式2(按需分配) 模式3(预留内存)适用于实时性要求高或需要大块连续内存的场景

4.7 本章小结

  • 内核页分配器基于伙伴系统,管理 2order 大小的连续物理页块。
  • alloc_pages 返回 struct page *,适合 mmap 和 DMA 场景;__get_free_pages 返回虚拟地址,适合内核内部使用。
  • struct page 是物理页的描述符,驱动 mmap 时重点关注 flags、_refcount、private 和 virtual 字段。
  • GFP 标志控制分配行为,驱动中常用 GFP_KERNEL(进程上下文)和 GFP_ATOMIC(中断上下文)。
  • 分配和释放必须使用相同的 order 值,避免内存泄漏和内核崩溃。
  • 驱动 mmap 的页分配模式包括预分配、按需分配和预留内存,开发者应根据场景选择。