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

好,咱们今天来聊聊内存管理里最基础、也最核心的一块——页框管理。

你想想看,整个物理内存被内核划分成一个个4KB大小的页框。每个页框长什么样?谁在用?怎么分配?怎么回收?这些问题,都得靠一套精密的机制来搞定。

我个人习惯把页框管理拆成三个层次来看:描述单元(struct page)、分配器接口、底层算法(伙伴系统)。咱们一层层剥开。

struct page:每个页框的身份证

每个物理页框,内核都用一个 struct page 结构体来描述。这个结构体不大,但信息量惊人。

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

这里我重点说几个字段:

  • flags:记录页框是脏页、锁定页、还是正在回写。每个bit代表一种状态。
  • _refcount:引用计数。为0时表示空闲,可以分配。
  • _mapcount:映射计数。表示这个页被多少个进程的页表映射了。
  • lru:LRU链表节点。页面回收时,内核靠它来管理活跃/非活跃页。

核心要点:struct page 是物理内存管理的基石。每个4KB页框对应一个struct page,整个数组叫 mem_map。内核通过 pfn_to_page()page_to_pfn() 在页框号和结构体之间来回转换。

我在项目中遇到过一个问题:某个驱动模块疯狂申请内存,但从来不释放。结果 _refcount 一直不为0,伙伴系统回收不了这些页框。系统跑着跑着就OOM了。嗯,排查起来很痛苦,但从此我对引用计数格外敏感。

页框分配器:谁来找内核要内存?

内核里有很多模块需要物理页框:进程的匿名页、文件系统的缓存页、驱动申请的DMA缓冲区……总不能都直接操作 struct page 吧?

所以内核提供了几个分配接口:

接口 功能 返回
alloc_pages(gfp_mask, order) 分配2^order个连续页框 struct page *
__get_free_pages(gfp_mask, order) 分配连续页框,返回虚拟地址 unsigned long
alloc_page(gfp_mask) 分配单个页框 struct page *
__free_pages(page, order) 释放连续页框 void

这里有个关键参数——gfp_mask。它告诉分配器:

  • 能不能睡眠等待?(GFP_KERNEL vs GFP_ATOMIC
  • 从哪里分配?(__GFP_DMA__GFP_HIGHMEM
  • 要不要零初始化?(__GFP_ZERO

避坑指南:我曾经在中断上下文里用了 GFP_KERNEL,结果内核直接报 scheduling while atomic。记住:中断、软中断、自旋锁保护的区域,只能用 GFP_ATOMIC

伙伴系统算法:内存分配的幕后英雄

页框分配器拿到请求后,真正干活的是伙伴系统。说白了,它解决的是连续物理页框的分配与合并问题。

核心思想很简单:

  • 所有空闲页框按2的幂次方分组,形成11个链表(order 0 ~ order 10)。
  • order 0 链表里每个节点是1个页框(4KB)。
  • order 1 链表里每个节点是2个连续页框(8KB)。
  • ……
  • order 10 链表里每个节点是1024个连续页框(4MB)。

分配时,如果请求order为3(8个连续页框),伙伴系统会:

  1. 先查order 3链表有没有空闲块。有就直接拿走。
  2. 没有?去order 4链表借一个16页框的块。
  3. 把它拆成两个8页框的块。一个给请求者,另一个挂到order 3链表。
  4. 如果order 4也没有,继续往上借,直到order 10。

释放时正好相反:

  1. 释放一个8页框的块。
  2. 检查它的“伙伴”(相邻且大小相同的块)是否空闲。
  3. 如果空闲,合并成16页框的块,挂到order 4链表。
  4. 继续向上合并,直到无法合并为止。

关键点:伙伴系统保证了分配和释放的时间复杂度都是O(1)。但代价是内部碎片——你申请5个页框,它给你8个,多出来的3个就浪费了。

我画了一张图,帮你理解伙伴系统的核心逻辑:

伙伴系统分配与合并流程 order 0 (4KB) 蓝色=已分配 灰色=空闲 order 1 (8KB) 红色=已分配 灰色=空闲 order 2 (16KB) 紫色=已分配 灰色=空闲 请求 order 1 (8KB) 从 order 2 拆分 分配 空闲 释放 order 1 检查伙伴并合并 合并到 order 2 分配时向上拆分,释放时向下合并 伙伴:地址相邻且大小相同的两个空闲块

注意:伙伴系统有一个隐藏问题——外部碎片。虽然它通过合并机制缓解了,但长时间运行后,内存还是可能被拆得七零八落。这时候就需要内存规整(compaction)来救场了。这个我们后面章节再细聊。

三个层次如何协作?

咱们把整个流程串起来:

  1. 驱动调用 alloc_pages(GFP_KERNEL, 3) 申请8个连续页框。
  2. 页框分配器解析 gfp_mask,确定从哪个内存域(ZONE_NORMAL、ZONE_DMA等)分配。
  3. 伙伴系统从对应order的链表中取出一个空闲块。如果没有,就向上拆分。
  4. 分配器拿到 struct page 指针,返回给调用者。
  5. 调用者通过 page_address() 获取虚拟地址,开始读写。
  6. 使用完毕后,调用 __free_pages() 释放,伙伴系统尝试合并。

你看,整个过程环环相扣。struct page 是描述,分配器是接口,伙伴系统是算法。缺一不可。

个人经验:我调试过一个网络驱动,它每次收包都调用 __get_free_pages(GFP_ATOMIC, 0)。高负载下,order 0 链表被迅速耗尽,系统开始频繁回收页面,性能直线下降。后来改成预分配内存池,问题就解决了。所以,频繁的小块分配,最好用 slab 分配器,别直接找伙伴系统

好了,页框管理这块就聊到这儿。struct page 是每个页框的身份证,伙伴系统是分配回收的发动机,页框分配器是连接两者的桥梁。理解这三者的关系,你就掌握了物理内存管理的骨架。


专注资料整理