☰
Linux内核gfp_mask到zonelist转换:页面分配优先级与陷阱
2026/10/5 2:31:46 网站建设 项目流程

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 内核对这条主线的处理已经比较稳定,步骤大致是:

  1. alloc_pages内部把 gfp_mask 和 order 传给__alloc_pages_nodemask。
  2. __alloc_pages_nodemask先调用prepare_alloc_pages或类似逻辑,构造一个struct alloc_context。
  3. 然后调用get_page_from_freelist走快速分配路径。
  4. 如果快速路径失败,再考虑内存回收、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_DMADMA 设备低端内存ZONE_DMA
GFP_DMA3232 位 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 类型说明
0ZONE_NORMAL最优先
1ZONE_DMA32低端内存
2ZONE_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。现代内核会按顺序检查几个条件:

  1. cpuset 是否允许当前进程访问该 node/zone。
  2. 某些 alloc_flags 下,是否要求 spread_dirty_pages,并通过 dirty ratio 检查。
  3. 水位检查,比如zone_watermark_fast,看当前 zone 的空闲页是否能满足 order。
  4. 如果之前经历过回收或 compaction,可能还有额外的返回条件。
  5. 最后调用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构建逻辑,因为它是全局共享的,影响所有进程的分配路径。

更稳妥的顺序是:

  1. 想清楚这个分配请求应该从哪个 zone 开始,选择正确的 gfp zone 位。
  2. 需要跨节点时明确是否允许 fallback,是否设置__GFP_THISNODE。
  3. 调水位或内存碎片阈值,而不是调顺序。
  4. 确实需要内存策略时,使用 mempolicy 机制,而不是手写 zonelist。

最后给一个阅读内核源码的个人习惯:我会先抓住三个函数名,gfp_zone、node_zonelist、get_page_from_freelist,按这条线走一遍。这条线走通了,gfp_mask 到 zone 组的转换逻辑就只剩细节了。实际线上排查时,很多分配异常并不是函数逻辑不对,而是你选的 GFP 标志和实际设备的地址限制、节点策略没有对上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询