页框管理: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_KERNELvsGFP_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个连续页框),伙伴系统会:
- 先查order 3链表有没有空闲块。有就直接拿走。
- 没有?去order 4链表借一个16页框的块。
- 把它拆成两个8页框的块。一个给请求者,另一个挂到order 3链表。
- 如果order 4也没有,继续往上借,直到order 10。
释放时正好相反:
- 释放一个8页框的块。
- 检查它的“伙伴”(相邻且大小相同的块)是否空闲。
- 如果空闲,合并成16页框的块,挂到order 4链表。
- 继续向上合并,直到无法合并为止。
关键点:伙伴系统保证了分配和释放的时间复杂度都是O(1)。但代价是内部碎片——你申请5个页框,它给你8个,多出来的3个就浪费了。
我画了一张图,帮你理解伙伴系统的核心逻辑:
注意:伙伴系统有一个隐藏问题——外部碎片。虽然它通过合并机制缓解了,但长时间运行后,内存还是可能被拆得七零八落。这时候就需要内存规整(compaction)来救场了。这个我们后面章节再细聊。
三个层次如何协作?
咱们把整个流程串起来:
- 驱动调用
alloc_pages(GFP_KERNEL, 3)申请8个连续页框。 - 页框分配器解析
gfp_mask,确定从哪个内存域(ZONE_NORMAL、ZONE_DMA等)分配。 - 伙伴系统从对应order的链表中取出一个空闲块。如果没有,就向上拆分。
- 分配器拿到
struct page指针,返回给调用者。 - 调用者通过
page_address()获取虚拟地址,开始读写。 - 使用完毕后,调用
__free_pages()释放,伙伴系统尝试合并。
你看,整个过程环环相扣。struct page 是描述,分配器是接口,伙伴系统是算法。缺一不可。
个人经验:我调试过一个网络驱动,它每次收包都调用 __get_free_pages(GFP_ATOMIC, 0)。高负载下,order 0 链表被迅速耗尽,系统开始频繁回收页面,性能直线下降。后来改成预分配内存池,问题就解决了。所以,频繁的小块分配,最好用 slab 分配器,别直接找伙伴系统。
好了,页框管理这块就聊到这儿。struct page 是每个页框的身份证,伙伴系统是分配回收的发动机,页框分配器是连接两者的桥梁。理解这三者的关系,你就掌握了物理内存管理的骨架。