页框管理: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链表。释放时正好相反,检查释放块的伙伴是否空闲,如果是就合并成更大的块。
我画了一张图,帮你理解伙伴系统的分配过程:
嗯,这里要注意:伙伴系统虽然高效,但有一个致命弱点——外部碎片。虽然它通过合并机制缓解了碎片问题,但在长时间运行的系统中,还是会出现无法分配连续大块内存的情况。
避坑指南:我曾经在一个视频采集卡驱动里,需要分配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