页框管理:struct page结构体、页框分配器alloc_pages、伙伴系统算法原理

大家好,我是你们的Linux内核讲师。今天我们来聊聊内存管理中最基础、也最核心的一块——页框管理。说白了,就是内核怎么管好物理内存里那些4KB大小的页框。

我刚开始接触这部分时,觉得不就是分配释放内存嘛,能有多复杂?后来在嵌入式项目里踩过坑才明白,这里面的门道深着呢。你想想看,系统里成百上千个进程同时申请内存,内核得保证分配得快、碎片少、还不冲突——这活儿真不简单。

struct page:物理内存的身份证

每个物理页框在内核里都有一个对应的 struct page 结构体。你可以把它想象成页框的身份证,记录着这个页框的所有信息。

struct page {
    unsigned long flags;        // 页框状态标志
    atomic_t _refcount;         // 引用计数
    atomic_t _mapcount;         // 映射计数
    unsigned long private;      // 私有数据
    struct address_space *mapping; // 映射地址空间
    pgoff_t index;              // 页框索引
    struct list_head lru;       // LRU链表节点
    void *virtual;              // 内核虚拟地址
};

这里我重点说几个关键字段:

  • flags:用位图表示页框状态,比如是否被锁定、是否脏页、是否在交换缓存中。我在调试内存泄漏时,经常通过查看flags来定位问题。
  • _refcount:引用计数,表示有多少用户在使用这个页框。当计数降到0时,内核就可以回收它了。
  • _mapcount:映射计数,表示这个页框被映射到了多少个进程的页表中。这个值在写时复制(COW)场景下特别重要。
  • lru:LRU链表节点,用于页面回收算法。内核通过LRU链表来管理哪些页框是活跃的、哪些是可以回收的。

重要提示:struct page 结构体的大小是固定的,通常为40-56字节(取决于架构)。内核用一个全局数组 mem_map 来管理所有页框的struct page实例。每个物理页框对应一个struct page,所以 mem_map 的大小 = 物理内存页框数 × sizeof(struct page)。

页框分配器:alloc_pages 家族

内核提供了 alloc_pages 系列函数来分配物理页框。我个人习惯用 alloc_pages(gfp_mask, order),其中 order 表示分配2的order次方个连续页框。

// 分配一个页框
struct page *page = alloc_pages(GFP_KERNEL, 0);

// 分配4个连续页框(2^2)
struct page *pages = alloc_pages(GFP_KERNEL | __GFP_DMA, 2);

// 从特定内存节点分配
struct page *page = alloc_pages_node(node_id, GFP_KERNEL, 0);

这里有个坑,我曾经在项目中遇到过:GFP_KERNEL 分配可能会睡眠,所以不能在中断上下文使用。如果你在中断里调用,系统会直接崩溃。正确的做法是用 GFP_ATOMIC

GFP标志 使用场景 能否睡眠
GFP_KERNEL 进程上下文,普通分配 可以
GFP_ATOMIC 中断上下文,不能睡眠 不能
GFP_DMA 需要DMA访问的物理内存 取决于组合
__GFP_HIGHMEM 分配高端内存 取决于组合

经验之谈:我建议你在写驱动时,尽量用 alloc_pages 而不是 __get_free_pages。因为前者返回struct page指针,方便后续做页框映射和DMA操作。后者返回虚拟地址,虽然用起来简单,但灵活性差很多。

伙伴系统算法:内存分配的基石

伙伴系统是内核管理物理内存的核心算法。它的设计思想很巧妙——把内存按2的幂次方分成块,分配时找大小合适的块,释放时合并相邻的空闲块。

为什么会用这种设计?你想想看,如果每次分配都去扫描整个内存,效率太低了。伙伴系统用了一种折中方案:

  • 内存被分成11个链表(order 0 到 order 10),每个链表管理特定大小的空闲块
  • order 0:管理2^0 = 1个页框(4KB)
  • order 1:管理2^1 = 2个连续页框(8KB)
  • ... 以此类推,直到 order 10:管理2^10 = 1024个连续页框(4MB)

核心逻辑:分配时,如果对应order的链表有空闲块,直接分配;如果没有,就去更高order的链表找,找到后把大块拆成两个小块(称为伙伴),一个分配出去,另一个放回低order链表。释放时正好相反,检查释放块的伙伴是否空闲,如果是就合并成更大的块。

我画了一张图,帮你理解伙伴系统的分配过程:

伙伴系统分配过程示例 order 2 (4页框 = 16KB) 空闲块 A (16KB) ↓ 请求分配 1 个页框 (order 0) order 2 被拆分成两个 order 1 伙伴 B (8KB) 伙伴 C (8KB) ↓ 伙伴 B 继续拆分成两个 order 0 order 0 (1页框 = 4KB) 已分配 空闲 伙伴 C (8KB) 未拆分 图例: 已分配 空闲 待拆分

嗯,这里要注意:伙伴系统虽然高效,但有一个致命弱点——外部碎片。虽然它通过合并机制缓解了碎片问题,但在长时间运行的系统中,还是会出现无法分配连续大块内存的情况。

避坑指南:我曾经在一个视频采集卡驱动里,需要分配8MB的连续DMA缓冲区。系统跑了几天后,alloc_pages(GFP_KERNEL, 11) 总是返回NULL。后来排查发现,是伙伴系统无法找到连续的1024个页框。解决方案是用vmalloc或者提前在系统启动时预留内存。

伙伴系统的核心数据结构

每个内存节点(NUMA架构下)都有一个 struct free_area 数组,管理着11个order的空闲链表:

struct zone {
    ...
    struct free_area free_area[MAX_ORDER];
    ...
};

struct free_area {
    struct list_head free_list;  // 空闲块链表
    unsigned long nr_free;       // 空闲块数量
};

分配时,内核从 free_area[order] 的链表中取一个块。如果链表为空,就尝试 free_area[order+1],以此类推。这个过程叫「向上搜索,向下拆分」。

释放时,内核会检查释放块的伙伴是否也在同一个 free_area 链表中。如果是,就把两个块合并,放到更高order的链表中。这个过程叫「伙伴合并」。

怎么判断两个块是不是伙伴?很简单:两个块的物理地址必须连续,且大小相同。更准确地说,如果块A的起始地址是 addr,块B的起始地址是 addr ^ (1 << (order + PAGE_SHIFT)),那么它们就是伙伴。

实用技巧:在调试内存分配问题时,你可以通过 /proc/buddyinfo 查看当前伙伴系统的状态。这个文件会显示每个zone、每个order的空闲块数量。如果某个order的nr_free长期为0,说明你的系统可能存在内存碎片问题。

好了,关于页框管理的内容就讲到这里。struct page是物理内存的身份证,alloc_pages是分配接口,伙伴系统是背后的算法支撑。这三者环环相扣,构成了Linux内核物理内存管理的基础。


公众号:蓝海资料掘金营,微信deep3321