资讯动态

Linux内存分配核心:gfp_mask到zonelist的映射与zone遍历机制

发布时间:2026/9/2 6:52:15 来源:尧图企业网站定制
在 Linux 内核里kmalloc(GFP_KERNEL)几乎是每个驱动开发者都写过的一行代码。但很少有人会追问这行代码最终到底从哪块物理内存中拿页答案不是“从剩余内存里随便拿”而是要经历 gfp_mask 解析、zone 筛选、zonelist 遍历、水位检查等一系列步骤。整个过程的核心就是 gfp_mask 怎么映射到 zonelist以及内核如何按优先级遍历 zone 组。本文以 Linux 内核内存管理源码为主线拆解这条路径适合正在看内存管理源码、写内核驱动或者被“page allocation failure”困扰的读者。1. 一次内存分配背后的“分区决策”1.1 从 kmalloc 到 get_page_from_freelist在深入研究 gfp_mask 和 zonelist 之前我们先建立一条完整的调用链。当驱动调用kmalloc(GFP_KERNEL)时实际执行路径大致如下kmalloc() - kmalloc_slab() / __kmalloc() - kmalloc_large() 或 slab 分配 - __kmalloc_node_track_caller() - alloc_pages() - alloc_pages_nodemask() - __alloc_pages_nodemask() - get_page_from_freelist()在这条链上gfp_mask从入口一直传递到最底层的get_page_from_freelist()而zonelist则由内存节点node的pglist_data结构提供。get_page_from_freelist()的任务就是在给定的 zonelist 上按顺序找到第一个水位满足要求、且允许分配的 zone然后调用rmqueue()取出一页或一组连续页。这里有一个容易被忽视的事实get_page_from_freelist()并不是简单地从“空闲内存最多的地方”取内存而是遵循一套严格的优先级。这套优先级正是由 gfp_mask 决定的 zone 类型上限以及 zonelist 中 zone 的排列顺序共同决定。1.2 为什么需要 zone 分组物理内存不是一块完全均匀的大饼。在 x86 架构上由于历史原因和硬件约束不同地址范围的物理内存有不同的使用限制。早期 ISA 设备只能通过 DMA 访问 16MB 以下的物理内存对应ZONE_DMA。32 位设备无法访问超过 4GB 的地址空间对应ZONE_DMA32。普通内核映射和 slab 对象通常位于直接映射区域对应ZONE_NORMAL。用户态可迁移页面可以放入ZONE_MOVABLE目的是减少内存碎片。如果内核把所有内存混在一起分配那么一个只能做低地址 DMA 的设备可能永远拿不到可用内存。因此内存管理子系统把物理内存按地址范围和用途划分成多个 zone再通过 zonelist 把多个 zone 组织成一个“按优先级排列的候选列表”。1.3 读者能学到什么本文不会只停留在概念层面。接下来我们会一起分析gfp_zone()的位表映射原理看build_zonelists()如何构建 NUMA 下的 zone 顺序再深入get_page_from_freelist()的核心循环最后通过/proc/zoneinfo、tracepoint 等方式观察内核的真实分配行为。读完本文你应该能回答以下几个问题gfp_mask 中哪些位会影响 zone 选择GFP_KERNEL、GFP_DMA、GFP_HIGHUSER_MOVABLE分别会落到哪个 zoneNUMA 机器上 zonelist 的顺序是怎么排出来的为什么水位满足的 zone 不一定是第一个被选中的 zone2. gfp_mask 的结构与语义2.1 gfp_mask 的位域分类gfp_mask 不是一个简单的枚举值而是一个位图。它的每一位都表示一种分配行为约束。从逻辑上可以分成两大类一类是“动作修饰位”比如是否允许阻塞、是否允许发起 IO、是否允许执行文件系统操作。它们影响内核在分配过程中能不能进入睡眠、能不能回收页、能不能写回脏页。另一类是“zone 选择位”包括__GFP_DMA、__GFP_DMA32、__GFP_HIGHMEM、__GFP_MOVABLE。这些位直接参与映射到具体 zone 类型的计算。以include/linux/gfp_types.h中的定义为例常见的位有标志位含义__GFP_DMA只能从 ZONE_DMA 分配__GFP_DMA32只能从 ZONE_DMA32 及更低 zone 分配__GFP_HIGHMEM允许从高端内存分配__GFP_MOVABLE允许从可迁移区域 ZONE_MOVABLE 分配__GFP_RECLAIM允许回收内存唤醒 kswapd 或直接回收__GFP_IO允许发起块设备 IO__GFP_FS允许调用文件系统层代码__GFP_HARDWALL限制在 cpuset 允许的节点内分配理解这些位的意义在于gfp_mask 不仅告诉内核“我想要什么”还告诉内核“我允许你做什么”。如果驱动的分配请求不允许阻塞内核就不能在分配路径上调用schedule()如果不允许 IO内核就不能通过回收文件页的方式来凑内存。2.2 常用 GFP 组合的拆解内核把常用组合定义成宏方便开发者使用。比如#define GFP_KERNEL (__GFP_RECLAIM | __GFP_IO | __GFP_FS) #define GFP_ATOMIC (__GFP_HIGH | __GFP_ATOMIC | __GFP_KSWAPD_RECLAIM) #define GFP_NOWAIT (__GFP_KSWAPD_RECLAIM) #define GFP_DMA (__GFP_DMA) #define GFP_HIGHUSER (__GFP_HIGHMEM | __GFP_MOVABLE | __GFP_RECLAIM | __GFP_IO | __GFP_FS)以GFP_KERNEL为例它允许进入回收路径也允许 IO 和文件系统操作因此可以在进程上下文中安全使用。只要没有持有自旋锁、没有处在中断上下文驱动里的普通内存分配用GFP_KERNEL是首选。而在中断处理函数、自旋锁临界区、rcu_read_lock()临界区里不能睡眠更不能发起 IO因此要使用GFP_ATOMIC。它不能触发直接回收只能利用ALLOC_HARDER这类标志提高水位检查的容忍度或者依赖__GFP_HIGH从 reserve 页中借用内存。这里有一个很常见的误区GFP_ATOMIC并不是“原子操作”的意思而是“不允许睡眠”的意思。它强调的是分配过程中不能发生调度否则会破坏当前上下文的状态。2.3 迁移类型与 __GFP_MOVABLE 的特殊性__GFP_MOVABLE是一个比较特殊的标志。它不直接指向一个固定地址范围的 zone而是让页面可以被迁移。内核引入迁移类型migratetype的目的是为了缓解外碎片问题如果不同类型的页混在一起当需要分配高阶连续内存时很难找到连续空闲块。当 gfp_mask 带__GFP_MOVABLE时分配器会优先从ZONE_MOVABLE分配。这个 zone 里的页理论上都可以迁移因此可以配合内存迁移机制整理出大块连续区域也能支持内存热插拔。但要注意ZONE_MOVABLE并不存在于所有架构和配置下。在嵌入式内核中如果内存很小可能只有一个ZONE_NORMAL此时__GFP_MOVABLE的 zone 映射结果会退化到普通 zone迁移类型仍在伙伴系统的 free_area 内部生效。3. zone、node 与 zonelist 的组织关系3.1 zone 类型与布局在 64 位 x86 内核中常见的 zone 类型定义如下enum zone_type { ZONE_DMA, ZONE_DMA32, ZONE_NORMAL, ZONE_MOVABLE, __MAX_NR_ZONES };这个枚举顺序很重要因为它同时决定了 zone 在内存中的地址顺序ZONE_DMA地址最低ZONE_NORMAL通常在 4GB 以上。ZONE_MOVABLE的物理范围不固定它往往从上层 zone 中划出一部分作为可迁移页面的专用区域。每个 zone 都对应一个struct zone里面记录着水位、空闲页数量、伙伴系统的free_area[]、LRU 链表等信息。struct zone是内存管理中真正的“工作单元”伙伴系统的页块都挂在它下面。3.2 struct zonelist 与 zonerefzonelist 的结构很简洁真正核心的是_zonerefs数组struct zonelist { struct zoneref _zonerefs[MAX_ZONES_PER_ZONELIST 1]; }; struct zoneref { struct zone *zone; int zone_idx; };_zonerefs数组的结尾是一个 zone 为 NULL 的元素作为遍历终止标志。zone_idx记录的是该 zone 在enum zone_type中的索引主要用于快速比较当前 zone 是否超过允许的上限。MAX_ZONES_PER_ZONELIST的值与节点数和 zone 数有关。在 NUMA 系统里每个节点的 zonelist 可能包含多个节点的多个 zone因此这个数组需要预留足够空间。3.3 每个 node 有两份 zonelist每个内存节点struct pglist_data都有一个node_zonelists[]数组通常包含两个元素struct pglist_data { ... struct zonelist node_zonelists[2]; ... };数组下标使用枚举表示ZONELIST_FALLBACK允许跨节点 fallback 的 zonelist这是默认使用的版本。ZONELIST_NOFALLBACK不跨节点 fallback 的 zonelist在 cpuset 或 mempolicy 明确禁止跨节点时使用。为什么要维护两份因为有些分配场景不允许从其他节点拿内存。比如某些 NUMA 亲和性要求严格的实时任务如果内存落到远程节点访问延迟会显著升高。让分配器根据上下文选择不同 zonelist比在每次遍历时做复杂判断要高效得多。4. gfp_mask 到 zone 的映射gfp_zone()4.1 为什么需要 GFP_ZONE_TABLEgfp_zone()是 gfp_mask 到 zone 类型的核心转换函数。它接收 gfp_t 参数返回一个enum zone_type值表示本次分配允许的最高 zone 类型。如果只是简单的if (flags __GFP_DMA) return ZONE_DMA;这类逻辑不仅分支多而且难以处理组合标志的优先级。内核的做法是预编译一张位表GFP_ZONE_TABLE用 gfp_mask 中的 zone 选择位作为索引一次性查出对应 zone。4.2 查表法的实现思路真实内核中的gfp_zone()实现大致如下static inline enum zone_type gfp_zone(gfp_t flags) { enum zone_type z; int bit (__force int) (flags GFP_ZONEMASK); z (GFP_ZONE_TABLE (bit * GFP_ZONES_SHIFT)) ((1 GFP_ZONES_SHIFT) - 1); return z; }这里的关键是GFP_ZONEMASK它屏蔽了 gfp_mask 中所有非 zone 选择位。GFP_ZONES_SHIFT表示表示一个 zone 类型所需的位数一般是 2。位表GFP_ZONE_TABLE则是把所有可能的bit值对应的 zone 类型拼接在一个整数里。由于不同架构和内核版本的GFP_ZONE_TABLE构造不同这里不贴完整定义只展示它的核心思想#define GFP_ZONE_TABLE ( \ (ZONE_NORMAL 0 * GFP_ZONES_SHIFT) | \ (OPT_ZONE_DMA 1 * GFP_ZONES_SHIFT) | \ (OPT_ZONE_HIGHMEM 2 * GFP_ZONES_SHIFT) | \ ... )这种查表法的好处是性能稳定没有分支预测失败的风险。内存分配是非常高频的操作牺牲一点点可读性换来分配路径的确定性在核心路径上是值得的。4.3 常见掩码映射结果我们来看几个常见 gfp_mask 的映射结果。需要说明的是不同内核版本和架构下的结果可能有差异但主流 x86_64 内核的结论如下gfp_maskzone 选择位组合映射结果GFP_KERNEL无ZONE_NORMALGFP_DMA__GFP_DMAZONE_DMAGFP_DMA32__GFP_DMA32ZONE_DMA32GFP_HIGHUSER__GFP_HIGHMEM__GFP_MOVABLEGFP_HIGHUSER_MOVABLE__GFP_HIGHMEM__GFP_MOVABLEGFP_KERNEL没有设置任何 zone 选择位所以映射到ZONE_NORMAL。这并不意味着只能从 NORMAL 区分配而是把 NORMAL 作为“允许的最高 zone”。遍历 zonelist 时低于 NORMAL 的 DMA32、DMA 也同样是候选只是优先级排在后面。GFP_DMA则非常严格它映射到ZONE_DMA因此遍历时只会检查 DMA zone其他 zone 直接跳过。5. zonelist 的构建与 NUMA 排序5.1 build_zonelists 的整体逻辑zonelist 不是在每次分配时临时生成的而是在系统启动或内存热插拔时由build_zonelists()构建完成。它接收一个pgdat参数生成该节点对应的两份 zonelist。构建逻辑的核心有两步把本节点的所有 zone 按照从高到低的顺序放入 zonelist。遍历其他节点按 NUMA 距离从近到远把每个节点的 zone 依次追加到后续位置。这个过程保证了一个重要特性本地节点的ZONE_NORMAL一定优先于远程节点的ZONE_NORMAL远程节点的ZONE_NORMAL又优先于任何节点的ZONE_DMA。5.2 本节点 zone 的插入顺序build_zonelists_node()负责把单个节点的 zone 插入列表。它的顺序是从高位 zone 到低位 zone也就是先ZONE_MOVABLE再ZONE_NORMAL然后是ZONE_DMA32、ZONE_DMA。核心逻辑示意如下static int build_zonelists_node(pg_data_t *pgdat, struct zonelist *zonelist, int nr_zones) { struct zone *zone; enum zone_type zone_type; for (zone_type MAX_NR_ZONES - 1; zone_type 0; zone_type--) { zone pgdat-node_zones zone_type; if (populated_zone(zone)) { zoneref_set_zone(zone, zonelist-_zonerefs[nr_zones]); } } return nr_zones; }为什么要高位在前因为高位 zone 通常容量更大且没有低地址硬件约束。优先把大块普通内存分配出去可以尽量保留稀缺的 DMA 区域同时降低外碎片对高阶分配的影响。5.3 跨节点 fallback 排序在 NUMA 系统中一个节点的 zonelist 会包含其他节点的 zone。排序依据是node_distance()距离越近的节点越靠前。构建过程大致如下for_each_node_state(node, N_MEMORY) { if (node local_node) continue; if (node_distance(local_node, node) min_distance) continue; build_zonelists_node(pgdat, zonelist, nr_zones); }真实实现比这复杂因为要考虑 CPU-less 节点、内存less 节点、node_zonelist_order等多种因素但核心思想不变本地优先远程按距离分级。理解这一点对排查性能问题很有帮助。如果发现内存分配经常落到远程节点通常是本地节点内存不足或者 cpuset/mempolicy 限制了分配节点集合。这时候从/sys/devices/system/node/下的numastat可以观察到节点间的分配情况。6. 按优先级遍历 zone 组get_page_from_freelist6.1 zonelist 遍历的基本模式get_page_from_freelist()是内存分配快速路径的核心函数。它的主要结构是一个对 zonelist 的循环逐个检查 zone 是否满足条件。static struct page *get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags, const struct alloc_context *ac) { struct zoneref *z; struct zone *zone; for_next_zone_zonelist_nodemask(zone, z, ac-zonelist, ac-high_zoneidx, ac-nodemask) { if (!zone_watermark_fast(zone, order, mark, ac-high_zoneidx, alloc_flags)) continue; page rmqueue(ac-preferred_zoneref-zone, zone, order, gfp_mask, alloc_flags); if (page) return page; } return NULL; }这段代码是理解 zone 遍历的关键。宏for_next_zone_zonelist_nodemask做了三件事从 zonelist 中取出下一个候选 zone。如果 zone 的zone_idx高于ac-high_zoneidx则跳过。如果 zone 所属节点不在ac-nodemask中则跳过。ac-high_zoneidx就是前面gfp_zone()计算出的最高允许 zone 类型。比如GFP_DMA的 high_zoneidx 是ZONE_DMA那么遍历到 NORMAL 时会直接跳过。6.2 水位检查与分配标志遍历到候选 zone 后并不能直接取页还要检查水位。水位决定了一个 zone 是否“愿意”响应本次分配请求。内核使用zone_watermark_fast()做快速判断if (!zone_watermark_fast(zone, order, mark, ac-high_zoneidx, alloc_flags)) continue;这里的关键是mark它通常是WMARK_LOW或WMARK_MIN。不同分配场景会引入不同的放宽条件ALLOC_HARDER紧急分配允许更激进地使用内存。ALLOC_HIGH高优先级分配可以动用保留页。ALLOC_NO_WATERMARKS忽略水位检查通常只在 OOM 后的收尾阶段使用。如果水位满足就调用rmqueue()从伙伴系统取出页面。rmqueue()会依据迁移类型和 order 选择合适的 free_area必要时还会做高阶块拆分。这里有一个容易忽略的细节ac-preferred_zoneref-zone决定迁移类型等参数而真正分配时使用的zone是当前遍历到的 zone。即使最终从远程节点或低位 zone 分配到了内存分配器仍然会参考首选 zone 的迁移类型设置。6.3 快速路径到慢速路径的切换如果get_page_from_freelist()遍历完整个 zonelist 仍然没有找到合适页面分配请求会进入慢速路径__alloc_pages_slowpath()。慢速路径会做一系列努力唤醒 kswapd让后台回收线程开始异步回收。尝试直接内存回收direct reclaim同步扫描 LRU 并释放页。尝试内存压缩compaction整理碎片以获得高阶连续页。触发 OOM killer杀死占用内存最多的进程。在 OOM 之后以ALLOC_NO_WATERMARKS再次尝试。可见zonelist 遍历只是内存分配的第一关。真正复杂的回退机制都在慢速路径中。理解这一点可以帮助我们读dmesg中 “page allocation failure” 日志时知道内存不足只是一个表象背后的成因可能是碎片化、节点限制、内存 cgroup 限制或者进程数过多。7. 用实验观察内核怎么选 zone7.1 查看 /proc/zoneinfo 与 /proc/buddyinfo理论讲再多不如亲手观察。在 Linux 系统上执行cat /proc/zoneinfo输出非常长每个 zone 一节。重点看以下几个字段Node 0, zone DMA32 pages free 413306 boost 0 min 12334 low 15417 high 18500 spanned 1048441 ...free当前空闲页数量。min/low/high三个水位值。spanned/present/managedzone 的物理范围。再看伙伴系统的空闲块分布cat /proc/buddyinfo输出示例Node 0, zone DMA 0 1 1 0 1 1 1 0 0 1 0 Node 0, zone DMA32 2033 1127 518 253 111 42 10 2 1 0 0 Node 0, zone Normal 87134 63650 34664 13246 3120 654 102 18 2 0 0每一列对应 order 从 0 到 10 的空闲连续页块数量。如果高 order 列几乎全部为 0说明内存碎片化严重即使总空闲页很多也无法满足order 4的分配请求。7.2 用 tracepoint 追踪分配路径内核提供了mm_page_alloctracepoint可以追踪每次页面分配的结果。使用trace-cmd可以这样抓取trace-cmd record -e mm_page_alloc -e mm_page_free sleep 1 trace-cmd report如果系统没有trace-cmd也可以手动操作 tracefscd /sys/kernel/tracing echo mm_page_alloc set_event echo 1 tracing_on sleep 1 echo 0 tracing_on cat trace注意不同发行版挂载路径可能是/sys/kernel/debug/tracing或/sys/kernel/tracing需要根据实际情况调整。追踪结果中可以看到每次分配是在哪个 zone 完成的也能看到对应的进程名。7.3 用 bpftrace 统计分配热点如果需要统计哪个进程分配页最频繁可以用 bpftrace 挂载 kprobebpftrace -e kprobe:get_page_from_freelist { [comm] count(); }这个命令需要 root 权限并且内核需要开启 BTF 支持。运行一段时间后会输出进程名和触发次数的统计表对于定位内存分配热点很有帮助。如果想进一步观察 zone 参数可以尝试从get_page_from_freelist的入参中提取alloc_context但结构体字段在不同版本间差异较大建议优先使用已有的 tracepoint避免在版本迁移中出现兼容问题。8. 高频问题与工程建议8.1 常见排查场景表格问题现象常见原因解决思路GFP_DMA分配失败DMA zone 可用内存极少被大量驱动占用检查是否有驱动长时间持有 DMA 页减少非必要的 DMA 分配order:4分配失败内存碎片化严重空闲块不连续观察/proc/buddyinfo高 order 列用echo 1 /proc/sys/vm/compact_memory触发压缩NUMA 下访问延迟高分配落到远程节点查看numastat和nodemask设置考虑使用mbind()设置内存策略zone 明明有空闲却分配失败水位检查不通过或high_zoneidx限制查看zoneinfo中 free 与 low/high 的对比确认 gfp_mask 是否设置了过严格的 zone 标志分配路径长期卡在 direct reclaim内存回收速度跟不上分配速度调整vm.swappiness、vm.min_free_kbytes排查是否存在内存泄漏8.2 驱动与内核模块开发建议在内核驱动里选择 gfp_mask 时要养成“按上下文选标志”的习惯。中断上下文和自旋锁临界区使用GFP_ATOMIC普通进程上下文使用GFP_KERNEL。如果分配较大连续内存优先考虑alloc_pages()或kmalloc()配合__GFP_NOWARN避免在失败时刷屏。涉及 DMA 时要严格确认设备支持的地址宽度。老设备需要GFP_DMA64 位设备通常使用GFP_KERNEL | GFP_DMA32或直接使用GFP_KERNEL并配合 IOMMU 做地址映射。不要随意扩大GFP_DMA的使用范围因为 DMA zone 非常宝贵。8.3 性能优化与碎片化防治长时间运行的系统容易出现内存碎片尤其是大量分配和释放 order 较高的页面时。推荐从以下几个方面入手尽量避免频繁分配高阶连续内存改用dma_alloc_coherent()或kmem_cache等专用分配器。如果业务允许使用__GFP_MOVABLE让页面可迁移以便后续 compaction 整理出大块空闲区域。监控/proc/buddyinfo在碎片化趋势明显时提前处理。对延迟敏感的服务可以结合mlock()或 cpuset 限制内存节点减少 NUMA 跨节点访问。9. 总结与下一步阅读gfp_mask 到 zonelist 的映射是 Linux 内存分配路径上最关键的一环。通过gfp_zone()内核把分配请求的 zone 限制转换为一个枚举值通过build_zonelists()内核把多个节点、多个 zone 排列成一张有序的候选表最后get_page_from_freelist()用一次紧凑的循环完成了从候选到实际分配的闭环。建议你接下来阅读以下源码片段逐步加深理解include/linux/gfp_types.hgfp_mask 的位定义和常用组合。mm/page_alloc.c中的gfp_zone()与GFP_ZONE_TABLE。mm/page_alloc.c中的build_zonelists()。mm/page_alloc.c中的get_page_from_freelist()。如果手边有 NUMA 服务器可以顺手跑一下cat /proc/zoneinfo和cat /proc/buddyinfo再结合 bpftrace 统计一次真实负载下的 zone 选择分布。理论与实践结合之后你会对内核“按优先级遍历 zone 组”的每个决策有更直观的感知。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价