M01/M02 在谈"进程",这一关第一次谈"内存"——但只谈分配器这一层,不谈虚拟内存(那是 M04 的地盘)。核心问题是:内核的物理内存分配是两级生意——伙伴系统(buddy)管"以页为单位从哪来",slab 管"页里面怎么切、怎么复用小对象"。前者解决页的供给,后者解决页内的利用率和速度。
这一关的硬核点:buddy 公式的 XOR 本质(2^n 不是巧合)、slab 的三板斧(构造析构复用 / per-cpu freelist / node 亲和)、以及 NUMA 下"页自带出身"的机制。M01 的 task_struct 分配和 fork 的页表页,在这里第一次有了分配器视角的解释。
内核的物理内存管理分两层:
task_struct(数 KB)、inode、dentry 都是 slab 的客户。分工的理由是粒度错配:buddy 的最小单位是 4KB 页,而内核对象大多是几十到几百字节。直接用页分配 task_struct(约数 KB)——一页装不下一个、两页浪费一半。slab 把"整页批发"变成"对象零售",顺带解决复用和速度问题。
调用关系是单向的:slab 缺页时向 buddy 要整页(按 slab 的 order),buddy 不知道 slab 的存在。M01 fork 里 copy_page_range 复制的页表页,走的就是 buddy(页级分配)。
buddy 的核心 invariant:空闲块的大小恒为 2^n 页,且地址按 2^n 对齐。分配时:
伙伴的定位公式:
```c
buddy = addr XOR (PAGE_SIZE << order)
```
为什么 2^n?为什么是 XOR? 因为"能拼成对齐大块"当且仅当两个 order-n 块满足"第 n 位互补、低位相同":低地址块的第 n 位为 0,高地址块的第 n 位为 1,低 n 位都对齐为 0——两者拼起来正好是一个对齐的 order-(n+1) 块。XOR 翻转的正是第 n 位——2^n 不是巧合,是"地址对齐互补"的数学必然。任何非 2^n 的大小类都写不出这么干净的伙伴定位。
两种碎片,完全是两码事:
诊断对号入座:/proc/buddyinfo 看伙伴系统按 order 的空闲块数量——order-9 分配失败时看它,判断是"真没内存"还是"碎得拼不出大块"(外部碎片诊断);/proc/slabinfo 看对象缓存统计(每种 slab 的对象数、页数)——是对象层面的事。order-9 失败是页层面的事,看 buddyinfo,跟 slab 无关。
slab 解决三个问题,每个对应一板斧:
1. 亚页精度:对象按实际大小切分,终结"一页装不下、两页浪费一半"的内部碎片。
2. 构造/析构复用:对象的构造(初始化)和析构只在 slab 缓存增长/收缩时跑一次,对象在"使用 ↔ 空闲"之间流转时不走构造析构。task_struct 这种带复杂初始化的对象,复用省掉的是重复的初始化开销——不是"性能优化"四个字能概括的,要点名机制。
3. per-cpu freelist:每个 CPU 有自己的空闲对象链表,热路径上无锁摘链表头即可。速度的来源是"无锁 + CPU 本地",不是"缓存"这个模糊词。
综合题(Q10):task_struct 用 (a) 直接页分配 vs (b) slab?三个维度:
task_struct 数 KB——(a) 一页 4KB 装不下、两页 8KB 浪费近一半,内部碎片严重;(b) 按对象大小切分,亚页精度。三个维度全胜,选 (b)。这也是内核的实际选择——M01 的 dup_task_struct 走的就是 slab。
NUMA 机器上,"页属于哪个 node"不是查表查出来的——页自带出身:page_to_nid() / page_zonenum() 从 struct page 的 flags 位段直接解码出 node/zone。UMA 机器上这些位段宽度编译为 0——零开销,不为用不上的功能付代价。
buddy 的空闲链表按 zone 组织,zone 从属于 node(zone->zone_pgdat)。分配路径:启动时 build_zonelists() 按 NUMA 距离给每个 node 排好 zonelist(本地优先、远端兜底);alloc_pages_node(nid) 直接取该 node 的 zonelist。释放路径:page_zone(page) 读出页的出身,挂回所属 zone 的链表——合并只在同 zone 内发生,而 zone 不跨 node,所以合并天然不跨 node。
注意层级归属:这是 buddy 层的机制,不是 slab 层的。slab 的 node 亲和(3.4 的第三板斧)是"向 buddy 要页时指定 node",出身信息本身由 buddy 层维护。回答前先对齐问题所在的层。
zone 不只有 NUMA 的维度,还有用途的维度:ZONE_DMA、ZONE_DMA32、ZONE_NORMAL、ZONE_HIGHMEM(32 位)。老式 DMA 设备只能寻址低 16MB 物理内存——驱动给这种设备分配 DMA 缓冲区时必须用 GFP_DMA,限定在 ZONE_DMA 里找页。
"DMA 不可用"的意思是:如果 ZONE_DMA 的页耗尽(或系统根本没划这个 zone),GFP_DMA 分配失败——普通 zone 再有空闲页也救不了,因为设备硬件上就看不见高地址。zone 是分配的硬边界,跨 zone 借页在语义上不成立。
为什么大小类非得是 2^n?用 3、5、10 不行吗?
因为只有 2^n 能让"找伙伴"变成一条 XOR 指令。非 2^n 的大小类,伙伴关系没有地址位上的规律,定位要查表、合并条件要逐项判断——O(1) 的位运算退化成 O(n) 的搜索。2^n 是把"数据结构不变量"编译进地址编码里,这是内核里反复出现的设计品味:用数学结构代替运行时查找。
为什么 slab 的构造/析构只在缓存增长时跑,而不是每次分配都跑?
因为对象语义的初始化往往是贵的(task_struct 要填几十个字段、建链表头),而"分配→使用→释放→再分配"的循环里,对象的类型语义没变——变的只是"谁在用"。构造一次、复用 N 次,是把"类型不变式"的建立成本摊薄。这是对象缓存(slab)区别于"通用小块分配器"的本质:slab 缓存的是类型化的、已构造的对象。
为什么 per-cpu freelist 能无锁?
因为"每个 CPU 只碰自己的链表"——单生产者单消费者(同一 CPU 上的分配和释放),天然不需要锁。跨 CPU 的 slab 收缩走慢路径加锁。设计模式是:把并发拆成"本地无锁快路径 + 全局加锁慢路径",快路径覆盖 99% 的流量。这是 3.4 里速度的真正来源。
copy_page_range 复制的页表页是页级分配,走 buddy——"1300 万 PTE"的另一面是"几千个页表页"的 buddy 分配。init_task 静态定义——连 slab 都没走,因为启动早期分配器还没起来。分配器有它自己的出生顺序:buddy 先于 slab。do_wp_page、do_anonymous_page)走 buddy;mm_struct、vm_area_struct 这些元数据走 slab——"页内容"和"描述页的结构"分属两层。出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。
因为粒度错配:buddy 的最小单位是 4KB 页,而内核对象大多几十到几百字节。buddy 管"页从哪来"(以页为单位的供给、回收、合并,对抗外部碎片);slab 管"页里怎么切"(把页切成小对象、对象复用、per-cpu 无锁快路径)。调用关系单向:slab 缺页时向 buddy 要整页,buddy 不知道 slab 的存在。
内部碎片是分配出去的内存里用不掉的部分(如要 100 字节给了 128 字节槽,28 字节浪费在分配单元内部)。不是 padding——padding 是对齐手段,内部碎片是"给了但用不上"的经济学概念。外部碎片是空闲总量够但太散、拼不出连续大块。内部碎片是"分多了浪费",外部碎片是"散了拼不出"——一个向内看(分配单元内部),一个向外看(空闲块的连续性)。
因为只有 2^n 能让"找伙伴"变成一条 XOR 指令:buddy = addr XOR (PAGE_SIZE << order)——两个 order-n 块能拼成对齐的 order-(n+1) 块当且仅当低地址块第 n 位为 0(第 n 位互补、低位相同),XOR 翻转的正是这一位。改成 3 的倍数,伙伴关系在地址位上没有规律,定位要查表、合并条件逐项判断——O(1) 位运算退化成 O(n) 搜索。失去的是"把数据结构不变量编译进地址编码"的设计。
因为"性能优化"是结论不是机制。必须点名:(1) 构造/析构复用——对象的构造只在 slab 缓存增长时跑一次,"使用 ↔ 空闲"流转不走构造析构,省掉的是重复初始化开销(如 task_struct 几十个字段的初始化);(2) per-cpu freelist——热路径无锁摘链表头,速度来自"无锁 + CPU 本地"。只说"快"不说"为什么快",等于没答。
buddy = addr XOR (PAGE_SIZE << order)。两个 order-n 块拼成对齐的 order-(n+1) 块,当且仅当"第 n 位互补、低位相同":低地址块第 n 位为 0、高地址块第 n 位为 1,低 n 位都对齐为 0——拼起来正好是一个 2^(n+1) 对齐的大块。XOR 翻转的正是第 n 位,所以公式一次算出唯一的伙伴地址。注意公式里是 PAGE_SIZE << order(块大小),不是 PAGE_SIZE——漏了 order 就错了。
看 /proc/buddyinfo。order-9 是 512 个连续页的分配——页层面的事,跟 slab 无关。buddyinfo 按 order 列出伙伴系统的空闲块数量,能判断失败原因是"真没内存"还是"碎得拼不出 512 页连续块"(外部碎片诊断)。slabinfo 看的是对象缓存统计(每种 slab 的对象数、页数),是对象层面的——拿它诊断页分配失败是层级错位。
必须用 GFP_DMA 限定在 ZONE_DMA 里分配。"DMA 不可用"指 ZONE_DMA 的页耗尽(或系统没划这个 zone)时 GFP_DMA 分配失败——普通 zone(ZONE_NORMAL)再有空闲页也救不了,因为设备硬件上就看不见高 16MB 以上的地址。zone 是分配的硬边界:页的"出身"决定了谁能用,跨 zone 借页在语义上不成立。
页自带出身:page_to_nid() / page_zonenum() 从 struct page 的 flags 位段直接解码出 node/zone——不是查表查出来的。UMA 下这些位段宽度编译为 0,零开销。分配:build_zonelists() 启动时按 NUMA 距离给每 node 排好 zonelist(本地优先、远端兜底),alloc_pages_node(nid) 取该 node 的 zonelist。释放:page_zone(page) 读出出身,挂回所属 zone 的空闲链表——合并只在同 zone 内,zone 不跨 node,故合并天然不跨 node。这是 buddy 层的机制,注意别答成 slab 层。
PTE 在页中的偏移有固定意义:硬件按索引查表,偏移即语义——第 i 个 PTE 固定对应第 i 个虚拟页,MMU 用"页基址 + 索引 × 表项大小"直接定位。随意挪动 PTE 会破坏"偏移 = 索引"的对应关系,硬件翻译就错了。所以页表页的内容布局是硬件 ABI 的一部分,不是内核可以自由安排的数据。
选 (b),三个维度:
task_struct 数 KB——(a) 一页 4KB 装不下、两页 8KB 浪费近一半,内部碎片严重;(b) 按对象大小切分,亚页精度,终结内部碎片。task_struct 尽量落在运行它的 CPU 所在的 node;(a) 页落点听天由命,跨 node 访问调度器高频读写的数据结构,吃持续的延迟税。三个维度全胜——这也是内核的实际选择:dup_task_struct 走 slab。只答"选 (b)"不给三维度论证,等于没答这道题。
__alloc_pages、__free_pages、build_zonelistsGFP_DMA 等分配标志、zone 选择kmalloc/kfree/kmem_cache_* 的声明入口:看 API 先看头文件kmalloc(sizeof(task_struct)) / dentry / inode …(小对象)
└─ SLAB 分配器:解决"页里怎么切、怎么复用"
│ 对象级缓存:构造/析构一次,多次复用
└─ 缺页时 ──▶ BUDDY 分配器:解决"页从哪来"
│ 最小单位 = 页
└─ 物理内存
调用单向:slab → buddy → 物理页(反向不成立)
记住分工线:buddy 的最小单位是页,解决"页从哪来";
slab 的客户是 task_struct/inode/dentry 这种小对象,解决"页里怎么切、
怎么复用"。调用单向:slab 缺页时向 buddy 要整页。
buddy = addr XOR (PAGE_SIZE << order) └─ XOR 翻转的正是第 n 位 → 伙伴块地址 = 一条位运算 分裂:order n 的空闲块 → 两个 order n-1 的伙伴 合并:两个空闲伙伴 → 合成一个 order n 的大块 2^n 不是巧合:只有 2^n 对齐能让"找伙伴"变成位运算—— 把数据结构不变量编译进了地址编码
buddy = addr XOR (PAGE_SIZE << order) —— XOR 翻转的正是第 n 位。
2^n 不是巧合:只有 2^n 能让"找伙伴"变成一条位运算指令,
这是把数据结构不变量编译进地址编码的设计。对应 Q3 和 Q5。
在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。
点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。