gfp_mask 转 zonelist 是 Linux 内核页面分配里最容易被忽略、却直接影响分配结果的一步。当你调用alloc_pages(GFP_KERNEL, 0)时,内核拿到的只是一个 32 位或 64 位的标志位,而 buddy 分配器真正开始翻内存列表之前,必须先把这个值翻译成一个 zone 类型,再在对应 NUMA 节点的 zonelist 上从某个位置开始按优先级往下遍历。
这个链路解决了什么问题?它决定了你的分配请求优先使用哪个 ZONE、能不能 fallback 到 DMA/DMA32、能不能跨 NUMA 节点、要不要进 ZONE_MOVABLE,甚至短时间内分配失败时该继续回收还是直接返回。对 Linux 内核学习者、嵌入式驱动开发、长期做内存调优的人来说,理解 gfp_mask 到 zonelist 的转换,比单纯背几个 GFP 标志有意义得多。
下面按实际源码阅读顺序拆一遍。
1. 一次页面分配请求,gfp_mask 如何进入 zone 组
1.1 入口:alloc_pages 到 __alloc_pages_nodemask
最常见的分配入口是:
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);它最终会进入__alloc_pages_nodemask。在 4.x 到 6.x 内核对这条主线的处理已经比较稳定,步骤大致是:
alloc_pages内部把 gfp_mask 和 order 传给__alloc_pages_nodemask。__alloc_pages_nodemask先调用prepare_alloc_pages或类似逻辑,构造一个struct alloc_context。- 然后调用
get_page_from_freelist走快速分配路径。 - 如果快速路径失败,再考虑内存回收、compaction、OOM 等慢路径。
很多初学者直接跳到get_page_from_freelist看循环,结果看半天不知道循环里那个high_zoneidx是哪来的。实际上它来自 gfp_mask,而且是在进入循环之前就转换好了。
1.2 alloc_context:分配现场的上下文包
struct alloc_context是个关键中间结构。不同内核版本字段名略有差异,但核心信息基本一致:
zonelist:当前请求要遍历的 zonelist。high_zoneidx/highest_zoneidx:从 gfp_mask 解析出来的“允许访问的最高 zone 类型”。preferred_zoneref:遍历的起点。nodemask:允许访问的节点集合。migratetype:分配页面的迁移类型,比如 MIGRATE_MOVABLE、MIGRATE_RECLAIMABLE。
我建议先理解这段伪代码:
ac->zonelist = node_zonelist(preferred_nid, gfp_mask); ac->high_zoneidx = gfp_zone(gfp_mask); ac->preferred_zoneref = first_zones_zonelist( ac->zonelist, ac->high_zoneidx, ac->nodemask);到这里已经回答了一半标题问题:gfp_mask 先通过gfp_zone转成 zone 类型,再通过node_zonelist找到对应的 zonelist,最后用first_zones_zonelist找到遍历起点。
1.3 为什么不能直接用 gfp_mask 当下标
有人会问:为什么内核不直接把 gfp_mask 当作 zone 数组下标?因为 gfp_mask 的语义太杂了。
GFP_KERNEL里面可能包含__GFP_IO、__GFP_FS、__GFP_RECLAIM等行为位,这些和内存放在哪个 ZONE 没关系。真正参与 zone 选择的只有少数几位,通常由__GFP_DMA、__GFP_DMA32、__GFP_HIGHMEM、__GFP_MOVABLE等组合成GFP_ZONEMASK。gfp_zone做的就是把这个 bit 组合翻译成内核的enum zone_type。
zonelist 又是按 zone 优先级排好的连续数组,必须知道从哪个位置开始遍历。翻译成 zone 枚举后,内核才能在“允许的最高 zone 类型”和“实际物理页分布”之间建立对应关系。
2. gfp_zone 的映射细节:不是简单的 if-else
2.1 先看 zone 相关低位
include/linux/gfp.h里对 GFP 标志有清晰分组。和 zone 映射强相关的主要是这些位:
__GFP_DMA:要求低端 DMA 内存。__GFP_DMA32:要求 32 位地址可达的 DMA 内存。__GFP_HIGHMEM:允许使用高端内存。__GFP_MOVABLE:允许使用可移动页面,通常映射到 ZONE_MOVABLE。
不同架构下这些 bit 的编号可能不同,所以不能跨平台硬编码。GFP_ZONEMASK的作用就是把它们组合成一个掩码,gfp_zone只关心flags & GFP_ZONEMASK这部分。
GFP_KERNEL通常包含__GFP_IO | __GFP_FS | __GFP_RECLAIM,但这些都不会影响 zone 选择。影响 zone 的往往是那些“隐含的默认值”,比如普通内核分配默认走 ZONE_NORMAL,不会主动去 HIGHMEM。
2.2 常见标志到 zone 的映射
以常见 64 位配置、没有 HIGHMEM、支持 DMA/DMA32/ZONE_MOVABLE 为例,可以列成这样:
| 常用标志或位组合 | 典型含义 | gfp_zone 返回的起点 |
|---|---|---|
| GFP_KERNEL | 内核态常规分配 | ZONE_NORMAL |
| GFP_ATOMIC | 原子上下文分配 | ZONE_NORMAL |
| GFP_HIGHUSER | 用户态页缓存,偏向可移动 | ZONE_MOVABLE 或 ZONE_NORMAL |
| GFP_DMA | DMA 设备低端内存 | ZONE_DMA |
| GFP_DMA32 | 32 位 DMA 设备内存 | ZONE_DMA32 |
| __GFP_MOVABLE | 可迁移页面 | 与基础 zone 组合成 MOVABLE |
这个表只能在特定配置下参考。真实环境里,GFP_HIGHUSER在不同内核版本可能带不同的__GFP_MOVABLE组合,所以我的建议是不要背表,而是直接打开当前内核源码的gfp.h,看GFP_ZONE_TABLE相关注释和宏定义。
2.3 __GFP_MOVABLE 带来的特殊映射
ZONE_MOVABLE不是一个简单的物理内存区域,它更像是一种分配策略:允许页面在将来通过内存规整或迁移被搬走。很多 64 位系统里,ZONE_MOVABLE 是从普通物理内存里规划出来的,zone_type 排在 ZONE_NORMAL 之后。
如果 gfp_mask 带__GFP_MOVABLE,且没有特殊 DMA 要求,gfp_zone可能直接返回 ZONE_MOVABLE。这意味着分配器会先尝试可移动页面,不够再 fallback 到普通页面。这个顺序是为了保护不可移动内存:用户态页缓存通常可迁移,放在 MOVABLE 区更合理;内核对象页通常不可迁移,不应该用__GFP_MOVABLE。
这一点直接影响嵌入式驱动代码。如果驱动给 DMA 描述符申请内存时顺手加了__GFP_MOVABLE,就可能被分配到可移动区,后续被迁移时容易引入额外的生命周期问题。
2.4 新内核为什么用查表
老版本内核的gfp_zone可能是一连串 if-else 判断。新内核更多使用查表方式:把flags & GFP_ZONEMASK作为索引,从GFP_ZONE_TABLE里取出 zone 类型。
原因是组合太多。DMA、DMA32、HIGHMEM、MOVABLE 之间有多种合法组合,if-else 很难兼顾 32 位和 64 位差异。查表把“位组合”和“zone 枚举”解耦,让同一套逻辑可以适配不同配置。
但这也带来一个阅读门槛:不要试图只靠函数名猜测逻辑。我一般会同时看GFP_ZONE_TABLE、GFP_ZONES_SHIFT和.c文件里的注释,才能确认当前内核版本到底把哪几种 bit 组合映射到了哪个 zone。
3. zonelist 的构建与优先级来源
3.1 pgdat 里挂了两张 zonelist
每个内存节点struct pglist_data内部有一个数组:
struct zonelist node_zonelists[MAX_ZONELISTS];MAX_ZONELISTS通常是 2。一张是常规 fallback 表,叫ZONELIST_FALLBACK;另一张是不允许跨节点 fallback 的表,叫ZONELIST_NOFALLBACK。
struct zonelist内部不维护链表,而是使用struct zoneref数组:
struct zoneref { struct zone *zone; int zone_idx; };数组末尾用 EOF 项结束。这样遍历起来非常快,连续内存、顺序访问,没有链表指针跳转。
3.2 FALLBACK 与 NOFALLBACK 怎么选
node_zonelist(nid, gfp_mask)大致是这样工作的:
static inline struct zonelist *node_zonelist(int nid, gfp_t flags) { return NODE_DATA(nid)->node_zonelists + gfp_zonelist(flags); }gfp_zonelist判断是否带__GFP_THISNODE。带了这个标志时,分配请求被限制在当前节点,内核会使用 NOFALLBACK 表。普通分配使用 FALLBACK 表,所以在 NUMA 机器上,一个节点内存不足时,会按距离顺序去别的节点找内存。
这也是很多人调 NUMA 时容易踩坑的地方:你以为加了__GFP_THISNODE只是“更倾向本节点”,实际上它直接把跨节点 fallback 关掉了。本节点没内存时,结果就是分配失败,而不是去远端节点。
3.3 构建顺序:ZONE order 还是 NODE order
zonelist 的构建函数通常叫build_zonelists。它有几种构建模式,常见的是:
ZONELIST_ORDER_ZONE:先把所有节点的高优先级 zone 排在一起,再往低优先级 zone 排。ZONELIST_ORDER_NODE:先排完一个节点的所有 zone,再排下一个节点。
无论哪种模式,单个节点内部的 zone 排列基本遵循从高 zone 类型到低 zone 类型的顺序。比如普通 64 位机器启动后,本节点内常见顺序是:
| 位置 | zone 类型 | 说明 |
|---|---|---|
| 0 | ZONE_NORMAL | 最优先 |
| 1 | ZONE_DMA32 | 低端内存 |
| 2 | ZONE_DMA | 最低端内存 |
| EOF | 结束标记 | 遍历终止 |
这意味着GFP_KERNEL的请求会先看 NORMAL,再看 DMA32,最后才看 DMA。大部分情况下 DMA zone 不应该被普通分配消耗掉,所以这种排序很重要。
3.4 一个直观的 zone 组示例
假设一个支持 HIGHMEM 的 32 位嵌入式系统,用户态分配使用GFP_HIGHUSER,gfp_zone返回 ZONE_HIGHMEM,那么遍历顺序可能是:
ZONE_HIGHMEM -> ZONE_NORMAL -> ZONE_DMA32 -> ZONE_DMA
如果驱动错误使用普通GFP_KERNEL分配大块缓冲区,起点是 ZONE_NORMAL,即使 HIGHMEM 还有很多空闲也不会去碰,因为high_zoneidx不允许往 HIGHMEM 方向走。这是很多“内存够但分配失败”问题的来源。
4. get_page_from_freelist 是怎么按序搜索的
4.1 从 first_zones_zonelist 开始
get_page_from_freelist是页面分配器的“快路径主循环”。它不会从头到尾遍历整个 zonelist,而是先通过high_zoneidx定位起点。
相关宏在include/linux/mmzone.h和mm/page_alloc.c里:
first_zones_zonelist:找到第一个满足条件的zoneref。next_zones_zonelist:继续找下一个满足条件的zoneref。for_each_zone_zonelist_nodemask:封装成循环。
本质上,它做的是:从 zonelist 起点开始,逐个取zoneref -> zone,跳过不允许的 zone 类型和不在nodemask里的节点。
4.2 每个 zone 要检查哪些条件
循环体不会一到 zone 就直接rmqueue。现代内核会按顺序检查几个条件:
- cpuset 是否允许当前进程访问该 node/zone。
- 某些 alloc_flags 下,是否要求 spread_dirty_pages,并通过 dirty ratio 检查。
- 水位检查,比如
zone_watermark_fast,看当前 zone 的空闲页是否能满足 order。 - 如果之前经历过回收或 compaction,可能还有额外的返回条件。
- 最后调用
rmqueue从 buddy 里摘页。
每个检查不过就continue,跳到下一个 zone。优先级最高的 zone 不一定成功,失败以后并不立刻回收,而是先看低一档的 zone。这种设计是为了维持高频分配路径的速度。
4.3 快路径失败后怎么进入慢路径
如果整个 zonelist 遍历完都没有拿到页,说明当前所有候选 zone 都处于水位不足状态。这时进入慢路径,通常包括:
- 唤醒对应节点的 kswapd。
- 调用 shrink_zones 做内存回收。
- 必要时做内存 compaction。
- 再次调用
get_page_from_freelist。 - 还失败才进入 OOM 流程。
注意,慢路径重新调用快路径时,high_zoneidx并没有变,变的只是部分 zone 的空闲页状态。所以“按优先级遍历 zone 组”这个行为会重复执行,而不是只执行一次。
4.4 一个贴近真实行为的简化循环
为了便于理解,可以看这段伪代码:
zonelist = node_zonelist(preferred_nid, gfp_mask); high_zoneidx = gfp_zone(gfp_mask); z = first_zones_zonelist(zonelist, high_zoneidx, nodemask); while (z) { zone = zonelist_zone(z); if (!cpuset_zone_allowed(zone, gfp_mask)) goto next; if (!zone_watermark_ok(zone, order, mark, high_zoneidx, alloc_flags)) goto next; page = rmqueue(zone, order, gfp_mask, migratetype); if (page) return page; next: z = next_zones_zonelist(z, high_zoneidx, nodemask); } return NULL;真实代码里还有zone_dirty_ok、should_reclaim_retry等细节,但主线就是上面这样。理解这个循环之后,再看mm/page_alloc.c会比直接翻源码效率高很多。
5. 验证与调试:从源码到运行时的链路
5.1 读源码时先看哪几个位置
如果要在当前内核里验证这套逻辑,我建议按以下顺序找:
include/linux/gfp.h:找gfp_zone、gfp_zonelist、GFP_ZONE_TABLE。include/linux/mmzone.h:找struct zonelist、struct zoneref、ZONELIST_FALLBACK、ZONELIST_NOFALLBACK。mm/page_alloc.c:找node_zonelist、prepare_alloc_pages、get_page_from_freelist、build_zonelists。mm/mmzone.c或mm/page_alloc.c里找next_zones_zonelist的具体实现。
很多网上的旧文章会直接说“ZONE_NORMAL 在 zonelist 前面”,这种说法往往忽略了配置差异。在看源码时,你要以当前环境的CONFIG_ZONE_DMA、CONFIG_ZONE_DMA32、CONFIG_HIGHMEM、CONFIG_ZONE_MOVABLE为准。
5.2 运行时怎么观察
启动 Linux 系统后,下面这些入口可以帮助观察:
/proc/zoneinfo:每个 zone 的水位、空闲页、活动页等信息。/proc/buddyinfo:每个 zone 按 order 分布的空闲块。/proc/pagetypeinfo:每个 zone 各迁移类型的自由页数量。- ftrace 或 tracefs 下的事件,比如内存分配相关事件。
比如你想知道某次分配到底落到了哪个 zone,单靠/proc文件不够直接,更准确的是用 tracepoint 或者在内核调试版本里加临时打印。GFP_KERNEL分配是否真的落在 NORMAL,从zoneinfo的计数变化能看出来,但需要多次采样。
5.3 三个容易误判的点
第一个误判:以为GFP_DMA的请求还能 fallback 到 NORMAL。实际上如果gfp_zone返回 ZONE_DMA,分配起点是 DMA zone,遍历方向是往更低或者跨节点的 DMA zone 走,不会向上跳到 NORMAL。NORMAL 内存再多也帮不上忙。
第二个误判:以为GFP_KERNEL会优先使用 HIGHMEM。没有__GFP_HIGHMEM时,high_zoneidx就在 NORMAL 这一档,HIGHMEM 根本不会出现在候选集合里。
第三个误判:以为 zonelist 的顺序是每次分配时动态排出来的。实际上一旦内核启动,build_zonelists构建完成后,zonelist 基本只读。分配路径里不会重新排序,只会按构建好的顺序遍历。
6. 边界场景与经验提醒
6.1 嵌入式平台上 DMA/DMA32 的优先级陷阱
嵌入式设备经常只有少量物理内存,而且某些控制器只能访问低端地址。这时 zone 布局很可能只包含 DMA/DMA32 和 NORMAL。如果你的驱动在分配 DMA 描述符时误用普通 GFP_KERNEL,可能把本应留给外设的 DMA 内存占用掉。
正确做法是明确设备地址限制:
- 需要低 16MB 或 ISA DMA 时用
GFP_DMA。 - 需要 32 位地址可达时用
GFP_DMA32。 - 没有特殊地址限制的普通内核缓冲区用
GFP_KERNEL。
不要每个分配都加GFP_DMA以求“保险”,这会让低端内存提前耗尽,而且会改变遍历起点,导致分配器完全跳过普通移动页区的空闲内存。
6.2 ZONE_MOVABLE 和 CMA 常见的错误预期
__GFP_MOVABLE不是“这块内存可以丢”,而是“这块内存可以通过迁移方式腾挪”。可移动页面数量多并不等于可以随意分配和释放,它依赖内存规整和回收机制。给 DMA 缓冲加__GFP_MOVABLE,很可能让缓冲区落在 CMA 或 MOVABLE 区域,后续在分配大页时被迁移,带来不必要的拷贝开销。
如果要分配的页在生命周期内不允许被搬走,就应该保持默认不可移动。移动标志是给页缓存、匿名页这类能重建或迁移的数据用的,不是通用优化开关。
6.3 想调分配优先级,先改 GFP 而不是改 zonelist
有些同学遇到“分配不够快”时,第一反应是把 zonelist 顺序改了。实际上生产环境里最不应该动的就是node_zonelists构建逻辑,因为它是全局共享的,影响所有进程的分配路径。
更稳妥的顺序是:
- 想清楚这个分配请求应该从哪个 zone 开始,选择正确的 gfp zone 位。
- 需要跨节点时明确是否允许 fallback,是否设置
__GFP_THISNODE。 - 调水位或内存碎片阈值,而不是调顺序。
- 确实需要内存策略时,使用 mempolicy 机制,而不是手写 zonelist。
最后给一个阅读内核源码的个人习惯:我会先抓住三个函数名,gfp_zone、node_zonelist、get_page_from_freelist,按这条线走一遍。这条线走通了,gfp_mask 到 zone 组的转换逻辑就只剩细节了。实际线上排查时,很多分配异常并不是函数逻辑不对,而是你选的 GFP 标志和实际设备的地址限制、节点策略没有对上。