从这一章起进内存管理.地基是物理内存怎么被组织和记账:每个物理页一张"户口页"struct page,按 zone 分区,按 node 分组,底层再用 SPARSEMEM 把不连续的物理内存缝成一张虚拟连续的账本.这篇把这些结构在板上逐个读出来,包括一个出乎意料的 zone 分布,和 1.4 那场"常量错位"事故的续集.
这篇回答几件事:
- struct page 是什么,整本"户口簿"要花多少内存
- zone 怎么分,为什么板上的分布和直觉相反
- SPARSEMEM 和 vmemmap 是什么关系
- memblock 在启动早期扮演什么角色
先看户口本.内核不直接管理字节,管理的是页:物理内存按 4KB 一格切开,每格配一个 struct page,这一整本数组叫 memmap.它自己就是一笔不小的开销,实测数据:
sizeof(struct page) = 64 字节
totalram_pages = 508954 (1988 MB)
memmap 开销 = 508954 × 64B = 31 MB
2GB 内存,31MB 拿去记账,约 1.56% — 这是无法关闭的固定税.struct page 里塞了什么:引用计数,映射计数,flags(脏,活跃,LRU 归属...),以及一大坨按用途复用的 union — 单独一页的"状态"居然要 64 字节,因为它身兼数职:可以是文件页,匿名页,slab 页,空闲页,每个角色要记的信息都挤在这一个结构里.这也是近两年 folio 改造的动机之一:把"至少一页"和"恰好一页"的概念分开,让大页不必伪装成一大群小页.
memmap 数组本身放在哪,引出 SPARSEMEM.老的 FLATMEM 假设物理内存连续,memmap 就是一根直线;真实 SoC 的内存洞多得是,还有内存热插拔,于是有了 SPARSEMEM:物理地址切成 mem_section 段,每段独立的 memmap,存在才登记.板上配置 SPARSEMEM_VMEMMAP=y,这是它的高级形态:把所有段的 memmap 再映射进一段虚拟连续区域,struct page 指针又能当数组用了 — 代价是 pfn_to_page 依赖一个固定的虚拟基址 vmemmap.
然后是 1.4 那场事故的续集.当时发现编译期 PAGE_OFFSET(52 位布局)和运行期线性映射(48 位)错位,模块里不能手拼地址.这次做 pfn 和 struct page 的往返,又撞上同款:vmemmap 的编译期常量 VMEMMAP_START 是 0xffffc1ffc0000000,运行期实测数组基址在 0xfffffdffc0000000 — 又错位.解法同款自校准:vmalloc_to_page 给 struct page 指针,vmalloc_to_pfn 给页号,一减就是运行期真基址.用它做往返验证,alloc_page 拿到 pfn 70514,自校准的 pfn_to_page 算出的指针和真实 struct page 差为零 — 自校准闭环.这条线的教训在模块开发里通用:这种"配置 52 运行 48"的内核上,布局常量一概不信,导出函数现算.
zone 是物理页的第二层组织,按"用途约束"分区.直觉上 2GB 内存应该落在 DMA32(4GB 以下),实测反直觉:
Node 0, zone DMA: managed 508954 (全部!)
Node 0, zone DMA32: managed 0
Node 0, zone Normal: managed 0
全在 DMA.原因是 arm64 的 zone 上界跟平台走:ZONE_DMA 的边界取自设备实际能 DMA 到达的范围,RK3399 的设备 32 位全能摸到,内核就把整个 2GB 划进 DMA,DM A32 和 Normal 空着. x86 上 DMA 只有 16MB 是历史 ISA 的锅,arm 这边同名不同命 — 读 zoneinfo 别带 x86 的成见.分配器按 zone 从高往低降级:Normal 没有,要 GFP_HIGHUSER 的分配自动落 DMA32,再落 DMA;ZONE_MOVABLE 是给内存热插拔和巨型页预留的"可搬家区",没开就空.
node 是第三层,NUMA 的单位.板上没编 NUMA,/sys/devices/system/node 目录都不存在,但 zoneinfo 依然以 "Node 0" 组织 — 单节点是 NUMA 退化为一点的特殊情形,数据结构同一套.真 NUMA 机器上每个 node 一份 pglist_data,各自挂着 zone 链,跨 node 访存有距离税,调度器和分配器都有整套对策,这里立个牌子,后文碰到再说.
启动早期还有个临时工:memblock.内核刚起来时 buddy 和 memmap 都还没建,memblock 用最朴素的 region 数组把"固件报的内存"和"要保留的内存"(内核映像,DTB,initrd,固件保留区)先记下来 — 冷启动日志里 "Memory: 2026404K/2095104K available" 那行就是 memblock 完成使命前的对账.随后 memblock_free_all 把没被保留的区域整批灌进 buddy,自己功成身退,只留 dmesg 里的几行痕迹.调试启动期内存问题(比如固件偷偷保留了一大块)时,memblock=debug 参数能让它把每笔账吐出来.
最后留几个容易忽略的细节:
- struct page 64 字节是"无 CONFIG 的裸价",开 KASAN,页表检查之类的调试配置它会膨胀,生产内核和调试内核的 memmap 开销差着好几倍.
- vmemmap 的存在让 struct page 数组虚拟连续,但物理上不必连续 — 每个 mem_section 的 memmap 是独立分配再映射进来的,这正是 SPARSEMEM_VMEMMAP 的精髓.
- zone 的 present 和 managed 是两个数:present 是物理上有的,managed 是 buddy 管得着的,差值被预留页吃掉(页表本身的 memmap 就是预留的一部分).
- pfn 是物理页号,PFN_PHYS 一乘就是物理地址,它和 struct page 的换算全程无锁,靠的都是这些"某地址算术"约定,改布局常量等于改 ABI.
- ZONE_MOVABLE 的"可搬家"依赖页迁移,而页迁移又依赖大多数页可迁移 — 大量不可迁移分配(mlock,某些硬件 pin 页)会把这区的承诺打折.
- memblock 的保留区在 /proc/iomem 能看个大概,但对不上 dmesg 的 reserve_memory 顺序时,直接上 memblock=debug,别猜.
- totalram_pages 在 6.18 已经变成函数访问,老代码里当变量用的编译直接报错 — API 老化在内存子系统里特别常见,抄十年前的模块注意.