庖丁解vmCore · 第五篇
骨相之辨:内核内存的解剖学
相马者,先相骨,次相肉。骨定则形定,形定则性可知。
骨不辨而逐毛色以相之,终是盲人摸象。
目录
一、从 call trace 走向内存:本篇的位置
【第一层 · 辨位置】
二、地址即身份:内核内存的地理骨架
2.1 内核为何要划分内存区域
2.2 各区职能与辨位置的用法
2.3 读图避坑:两种架构的前缀不可混用
2.4 由此得出的通用原则:三轴定位
三、辨页之骨:zone 与 struct page
3.1 zone:物理内存的能力分区
3.2 kmem -i:内存全局骨相的第一张片子
3.3 struct page:每一页的户籍档案
四、辨器之骨:SLUB 分配器的形态
4.1 为何需要三层结构
4.2 freelist:嵌入对象体内的链表
4.3 在 crash 中读 slab 骨相
【第二层 · 辨状态】
五、冻结时刻的活体解剖:struct 与指针追踪
5.1 从地址到结构体
5.2 指针追踪的四步方法论
5.3 container_of:从成员反查宿主
5.4 p 命令:直取全局符号
【第三层 · 辨生死】
六、内存经济的崩溃:OOM killer 的死因鉴定
6.1 OOM 的触发路径
6.2 oom_badness:受害者的裁决逻辑
6.3 从 vmcore 读取三类死亡证据
6.4 死因分型
七、慢性出血:slab 泄漏的病灶定位
7.1 识别出血的 cache
7.2 泄漏与正常增长的判据
7.3 从 cache 追溯到分配路径
7.4 借力内核的自检机制
八、地质层的化石:内存损坏的痕迹辨认
8.1 use-after-free:0x6b 毒化模式
8.2 栈溢出:STACK_END_MAGIC
8.3 double-free:自引用 freelist
8.4 越界写入:redzone 破坏
九、辨骨全程:从 call trace 到根因的完整路径
十、小结:三层辨骨,一以贯之

一、从 call trace 走向内存:本篇的位置
前四篇建立的能力是读懂"事发瞬间"——panic header、寄存器转储、call trace 骨架。走完上述流程,通常能定位到崩溃发生在哪个函数、哪条指令、收到了什么参数。
但很多问题到这一步便无法继续。call trace 指出崩溃在 kmem_cache_alloc,却没有说明这个 slab 缓存的状态为何是错的;call trace 显示 oom_kill_process 被触发,却没有说明内存究竟被谁耗尽;call trace 顶部是 list_del 崩溃,却没有说明这条链表是何时、被谁破坏的。
这些问题的答案不在调用栈里,而在内存里。 一份 vmcore 的绝大部分内容不是崩溃那一瞬间的寄存器,而是崩溃时整个系统的内存全貌:数以百万计的对象、页帧、缓存,各安其位或已然错乱。
本篇要做的,是学会"相骨"。中医相骨知命,看的不是皮相,而是骨骼的形态、位置与连接方式,由此推断体质与病灶。读 vmcore 的内存同理:面对一串 ffff888012345678,成熟的分析者会先辨它的"骨相"——它在内存地图上处于什么位置、是什么类型的对象、此刻是活是死、是完好还是已被破坏。
什么样的 call trace 会把你带到本篇
以下是几类典型信号。看到它们,说明调用栈提供的信息已不足以定位根因,必须转入内存分析:
| call trace 特征 | 说明问题在哪 | 本篇对应章节 |
|---|---|---|
崩溃点在 kmem_cache_alloc / kfree / __slab_free | slab 分配器状态异常 | 四、七、八 |
出现 out_of_memory / oom_kill_process | 内存压力触发 OOM | 六 |
出现 page_fault 但故障地址是 0x6b6b6b6b6b6b6b6b | 访问了已释放的对象 | 八 |
崩溃点在 list_del / list_add,参数指针异常 | 链表被破坏 | 五、八 |
BUG_ON 断言在 mm/ 目录下的文件中 | 内存子系统不变量被打破 | 五、八 |
栈底 STACK_END_MAGIC 被覆盖 | 内核栈溢出 | 八 |
此处需澄清一点,以免误读上表:并非只有特定的 vmcore 才"有骨可相"。 任何一份 vmcore 都完整保存了崩溃瞬间的全部内存——task_struct、slab 对象、页帧档案一应俱全,骨头始终在那里,与崩溃属于哪一类无关。
真正的区别是:不是特定的 vmcore 才有骨,而是特定的问题才需要相骨。 若 call trace 本身已经指明了根因(例如崩溃点是一处明确的空指针解引用,且该指针的来源在调用链上一望可知),分析到此即可收束,不必深入内存。只有当调用栈提供的信息不足以解释"这个值为什么是错的"时,才需要转入本篇。上表列出的,正是这类"调用栈已无法回答、必须转入内存"的信号,而非"可以查看内存"的全部场景。
换言之,这是分析深度的选择,不是 vmcore 类型的限制。手中任何一份 vmcore,只要问题需要,都可以相骨。
辨骨的三个层次
本篇的主线,是三个递进的层次:
Layer 1: LOCATE
What is this address? Which memory region? Which allocator?
-> Ch.2 address layout, Ch.3 zone & page, Ch.4 SLUB
|
v
Layer 2: INSPECT
What state is this object in? Fields valid? Pointers intact?
-> Ch.5 struct parsing & pointer chasing
|
v
Layer 3: DIAGNOSE
How did the system die? Memory pressure or corruption?
-> Ch.6 OOM, Ch.7 slab leak, Ch.8 corruption
辨清这三层,任意一个内存地址都能在第一眼给出它的"身份证"与"病历"。这是"骨相之辨"的全部含义,也是本篇的编排逻辑。
【第一层 · 辨位置】
二、地址即身份:内核内存的地理骨架
辨骨的第一步,是回答"这根骨头长在何处"。面对 call trace 中出现的任意一个地址,在解析它的内容之前,先要知道它属于内核内存版图的哪一块——这决定了它是什么类型的对象、由谁分配、该用什么命令解析。
何时需要用到这一层? 只要手中有一个来历不明的内核地址,就从这里起步。它可能来自崩溃函数的参数、callee-saved 寄存器中的值、或某个结构体字段。辨位置是所有后续分析的前提:位置未定,解析方式就无从选择,甚至可能用错误的命令读出一堆看似合理实则毫无意义的数据。
2.1 内核为何要划分内存区域
第三篇已厘清 0xffff... 属内核空间、0x0000... 属用户空间这一大分界。辨位置的第一步,是在这个分界之内再深入一层。
要理解内核为何要把自己的地址空间再切成若干区,需要先看清一个矛盾:内核既需要高效访问物理内存(虚拟地址与物理地址之间简单加减即可换算,无需查页表),又需要分配大块的虚拟连续内存(物理上却可以不连续,否则大内存申请极易因碎片失败)。这两个需求彼此冲突,无法用同一套机制满足。
内核的解法是分区治理,各区承担不同职责:
Kernel virtual address space (conceptual layout)
+-- Direct/Linear mapping : linear map of all physical RAM
| slab / kmalloc objects live here
|
+-- vmalloc / ioremap : virtually contiguous, physically scattered
| vmalloc(), VMAP_STACK, driver ioremap
|
+-- vmemmap : the contiguous array of all struct page
|
+-- Kernel image : kernel text, global variables
|
+-- Module area : loadable module code and data
直接映射区解决第一个需求:虚拟地址减去一个固定偏移即得物理地址,换算代价极低,因此 kmalloc 这类高频小对象分配全部走这里。vmalloc 区解决第二个需求:牺牲换算效率(必须查页表),换取"虚拟连续、物理可散"的能力,因此大块内存与内核栈走这里。
这一设计对分析的直接价值在于:区域即身份。 一个地址落在哪个区,就基本决定了它是什么类型的对象、由谁分配、该用什么命令解析。
2.2 各区职能与辨位置的用法
| 区域 | 存放内容 | 分配来源 | 辨位置后的下一步动作 |
|---|---|---|---|
| 直接/线性映射 | slab 对象、kmalloc 结果、struct 实例 | kmalloc、kzalloc、slab | 用 kmem <addr> 查所属 cache |
| vmalloc / ioremap | 大块内存、内核栈、ioremap 映射 | vmalloc、vzalloc | 物理不连续,不可当 slab 解析 |
| vmemmap | struct page 本身 | 启动时静态建立 | 这是"页的档案",非页内数据 |
| 内核镜像 | 内核函数、全局变量 | 编译链接期静态分配 | 用 sym <addr> 反查符号名 |
| 模块区 | 模块代码/数据 | module_alloc | 用 mod 查属于哪个驱动 |
举例说明这张表的用法。call trace 中崩溃点的某个参数是 ffff888012345678,前缀落在直接映射区,说明它多半是一个 slab 对象,下一步执行 kmem ffff888012345678 确认它属于哪个 cache、对象起点在何处。若参数是 ffffea0004000000,前缀落在 vmemmap 区,说明它是一个 struct page 指针,下一步执行 struct page ffffea0004000000 读它的 flags 与引用计数。凭前缀即可确定区域,分析方向随之明确——这是辨位置最实用的价值。
读图避坑(内核栈归属依赖编译配置): 上表将内核栈归入 vmalloc 区,前提是
CONFIG_VMAP_STACK=y;该选项为n时,内核栈改由页分配器分配,地址落在直接映射区。它虽为default y,却有一条易被忽略的依赖depends on !KASAN || KASAN_HW_TAGS || KASAN_VMALLOC——开启 generic KASAN 而未开KASAN_VMALLOC的调试内核会自动关闭它。此时照搬上表,会把内核栈误判为 slab 对象,对其执行kmem <addr>的结果亦无意义。确认方法:任取一个任务读出task_struct.stack,看它落在哪个区。
2.3 读图避坑:两种架构的前缀不可混用
需要单独警示的一点是:"看前缀识身份"这个技巧,在 x86_64 与 arm64 上是两套不同的坐标系。 以 x86 的前缀经验去读 arm64 的 vmcore,判断结果几乎必然出错。
分析任何 vmcore 之前,先确认架构与基本配置:
crash> sys # MACHINE field: x86_64 or aarch64
crash> mach # page size, VA bits, kernel region boundaries
下表是两种架构在 48 位 VA、典型配置下的前缀对照。仅供快速参考,实战一律以 mach 读到的实际边界为准:
| 区域 | x86_64 前缀 | arm64 前缀(48 位 VA,5.4 及以后) |
|---|---|---|
| 直接/线性映射 | ffff8880... | ffff0000... ~ ffff7fff... |
| vmalloc 区 | ffffc900... | ffff8000_8... 之上 |
| vmemmap 区 | ffffea00... | fffffc00... ~ fffffdff... |
| 内核镜像 | ffffffff8... | ffff8000_8...(紧贴模块区之上) |
| 模块区 | ffffffffc0...(KASLR 实测常见) | ffff8000_0000... |
三个最容易导致误判的差异必须点明。
其一,arm64 的内存布局在 5.4 发生过整体翻转。 Linux 5.4 合入的 arm64: mm: Flip kernel VA space 将线性映射区与 vmalloc/模块区的位置完全对调:
arm64, 48-bit VA -- layout flipped in Linux 5.4
Before 5.4 (e.g. 4.19 LTS):
ffff0000_0000_0000 .. ffff7fff_ffff_ffff -> modules + vmalloc
ffff8000_0000_0000 .. (top) -> linear map (PAGE_OFFSET)
Since 5.4:
ffff0000_0000_0000 .. ffff7fff_ffff_ffff -> linear map (PAGE_OFFSET)
ffff8000_0000_0000 .. (top) -> modules + kernel image + vmalloc
这意味着上表在 5.4 之前的内核上会给出完全相反的判断——不是"不够精确",而是双向判反。国产发行版(麒麟、统信等)大量基于 4.19 LTS,遇到此类 vmcore 时,若照搬上表,ffff8ad6... 会被误判为 vmalloc(实为线性映射),ffff3ab9... 会被误判为线性映射(实为 vmalloc/模块区),整条分析路径随之偏离。动手之前先看内核版本。
其二,arm64 的内核代码不在高端固定区。 x86_64 上内核代码符号形如 ffffffff81xxxxxx,这是许多人的经验直觉。但 5.4 之后 arm64 的内核镜像嵌在 vmalloc 区最低端(源码中 KIMAGE_VADDR = MODULES_END),_text、_stext 等符号的运行时地址形如 ffff8000_8xxxxxxx。若用 x86 的前缀判断"这个地址是否为内核代码",在 arm64 上会得到错误结论,在符号还原环节尤其容易导致整条分析路径偏离。
其三,arm64 的内核镜像与 vmalloc 前缀重叠。 内核镜像坐落于 vmalloc 区最低端,二者同为 ffff8000_8...,单凭前缀无法区分。此时必须用 vtop <addr> 反查映射,或用 mach 读取实际边界来判定。
arm64 前缀强依赖内核版本与配置(5.4 前后布局翻转、VA 位宽 39/48/52 位、页大小 4K/16K/64K、KASLR 开关),上表仅为其中一种组合。可靠的做法始终是
mach读边界、vtop查归属,而非依赖前缀记忆。
2.4 由此得出的通用原则:三轴定位
前面两处避坑(架构前缀差异、5.4 布局翻转、VMAP_STACK 依赖)指向同一个结论:内核的内存视图不是一张固定的图,而是由三个正交因素共同决定的。
Any kernel memory fact = f( ARCH , VERSION , BUILD CONFIG )
ARCH x86_64 vs aarch64
-> region base addresses, page size options,
zone composition, register conventions
VERSION which kernel release
-> arm64 VA layout flipped in 5.4
-> arm64 ZONE_DMA introduced in 5.5, dynamic since 5.11
-> slab page metadata split into struct slab in 5.17
BUILD CONFIG what was compiled in
-> CONFIG_VMAP_STACK : where kernel stacks live
-> CONFIG_ZONE_DMA : whether the zone exists at all
-> CONFIG_SLUB_DEBUG : whether poison/redzone exist
-> CONFIG_SLAB_FREELIST_HARDENED : freelist readable or not
-> CONFIG_ARM64_VA_BITS, PAGE_SHIFT, KASAN, KASLR ...
三轴中任何一轴取值不同,结论都可能不同,甚至完全相反(5.4 的布局翻转就是双向判反的实例)。本篇后续每一处"避坑"提示,本质上都是在标注某条结论受哪一轴约束。
因此,本篇所有前缀、边界、特征值,都应视为"某一组三轴取值下的实例",而非普适常量。 落到操作上就是一句话:动手之前先用 sys 与 mach 把三轴的实际取值读出来,再决定套用哪套判断。 前缀是快速线索,实测是最终裁决。
三、辨页之骨:zone 与 struct page
辨位置的第二步,从虚拟地址下沉到物理内存本身。物理内存并非一片均质的资源,它有自己的"行政区划"(zone)与"户籍档案"(struct page)。
何时需要读到这一层? 当 call trace 中出现 __alloc_pages_slowpath、out_of_memory、kswapd 等页分配与回收路径的函数时,问题已经从"某个对象出错"上升到"整个内存供给出了问题",必须从 zone 与 page 的层面查看。
3.1 zone:物理内存的能力分区
内核按物理内存的能力与用途将其划分为若干 zone,分配请求优先由能力匹配的 zone 满足:驱动申请 DMA 缓冲区时指定 GFP_DMA,内核从低端 zone 分配;普通 kmalloc 则从 ZONE_NORMAL 分配。
但 zone 的构成本身就是架构相关的,且 arm64 上还随内核版本变化——这是与 2.3 节布局翻转性质相同的另一处版本陷阱。
x86_64 的 zone 构成(长期稳定):
| zone | 范围 | 用途 |
|---|---|---|
ZONE_DMA | 0 ~ 16MB | 传统 ISA 设备(24 位寻址)的遗留区 |
ZONE_DMA32 | 16MB ~ 4GB | 32 位 DMA 寻址设备 |
ZONE_NORMAL | 4GB 以上 | 常规内核分配,绝大多数对象在此 |
ZONE_MOVABLE | 可选 | 可迁移页,内存热插拔/大页使用 |
ZONE_DEVICE | 可选 | 持久内存 PMEM、设备内存 |
arm64 的 zone 构成(随版本演进):
| 内核版本 | 低端 zone 构成 |
|---|---|
| < 5.5(含 4.19 LTS) | 不存在 ZONE_DMA;只有 ZONE_DMA32(0 ~ 4GB)与 ZONE_NORMAL |
| 5.5 ~ 5.10 | 引入 ZONE_DMA,早期实现将 zone_dma_bits 固定为 30(即 1GB) |
| ≥ 5.11 | ZONE_DMA 上界改为动态推导:设备树平台取 dma-ranges 的最低约束,ACPI 平台取 IORT |
arm64 引入 ZONE_DMA 的直接动因是树莓派 4——其 PCIe、网卡、xHCI 只能寻址 30 位(1GB),而当时 arm64 没有任何低于 4GB 的 zone 可满足此类设备。
但版本只是三轴中的一轴。 按 2.4 节的三轴原则,某个 zone 在一台机器上是否真实存在、是否有页可用,须由三个因素共同决定:
| 因素 | 作用 | 具体表现 |
|---|---|---|
| 内核版本 | 决定该架构"能不能有"这个 zone | arm64 的 ZONE_DMA 需 ≥ 5.5 |
| 编译配置 | 决定这次编译"有没有"编进去 | 见下表的 Kconfig 差异 |
| 运行时布局 | 决定编进去之后"是不是空的" | 物理内存起始地址、dma-ranges/IORT 推导出的 zone_dma_bits、kernelcore= 等启动参数 |
低端 zone 的 Kconfig 控制方式,两个架构并不相同:
| 选项 | x86_64 | arm64 |
|---|---|---|
CONFIG_ZONE_DMA | default y,EXPERT 下可关闭 | default y,EXPERT 下可关闭(5.5 起才有此选项) |
CONFIG_ZONE_DMA32 | def_bool y,64 位下强制开启,不可关闭 | default y,EXPERT 下可关闭 |
由此可得两条推论。其一,即便内核版本已达 5.5,若该内核编译时 CONFIG_ZONE_DMA=n,arm64 上依然没有 ZONE_DMA——版本达标不等于 zone 存在。其二,ZONE_MOVABLE 情况特殊,它没有对应的 Kconfig 选项,完全由 kernelcore=/movablecore= 启动参数或内存热插拔策略在运行时划出,因此在 vmcore 中它的有无只能实测。
arm64 低端 zone 的两个实战陷阱。
陷阱一:旧内核上根本没有 ZONE_DMA。 4.19 等 5.5 之前的内核,arm64 的低端只有 ZONE_DMA32。在这类 vmcore 上寻找 ZONE_DMA 的水位是徒劳的,kmem -z 中不存在这一项。国产发行版大量基于 4.19,遇到时须留意。
陷阱二:新内核上 ZONE_DMA32 可能是空的。 5.11 之后,若平台上不存在窄位宽 DMA 设备,zone_dma_bits 被推导为 32,此时 ZONE_DMA 独自覆盖 0 ~ 4GB,ZONE_DMA32 页数为零:
zone_dma_bits = 32 (typical server, no restrictive device)
ZONE_DMA : 0 ~ 4GB <- covers the entire low range
ZONE_DMA32 : EMPTY <- zero pages
ZONE_NORMAL : 4GB and up
zone_dma_bits = 30 (e.g. RPi4, a 30-bit device present)
ZONE_DMA : 0 ~ 1GB
ZONE_DMA32 : 1GB ~ 4GB
ZONE_NORMAL : 4GB and up
若按 x86 经验只盯 ZONE_DMA32,在这类机器上只会看到一个恒为空的 zone,从而误判"低端内存没有压力"——实际该关注的是 ZONE_DMA。
还需注意,两个架构的 ZONE_DMA 同名而不同物:x86_64 的是 0 ~ 16MB 的 ISA 遗留区,几十年未变;arm64 的是为现代窄位宽设备而设,上界动态。排查时不可套用彼此的经验值。
因此,arm64 上判断"低端内存耗尽",第一步不是看某个 zone 的水位,而是先用
kmem -z确认这台机器实际存在哪几个 zone、边界在哪、各有多少页。三者缺一,都可能把死因判错。这一差异在第六章辨 OOM 时会再次出现。
3.2 kmem -i:内存全局骨相的第一张片子
辨骨自整体内存状态入手,kmem -i 是第一张 X 光片:
crash> kmem -i
PAGES TOTAL PERCENTAGE
TOTAL MEM 16777216 64 GB ----
FREE 6291456 24 GB 37% of TOTAL MEM
USED 10485760 40 GB 62% of TOTAL MEM
SHARED 131072 512 MB 0% of TOTAL MEM
BUFFERS 65536 256 MB 0% of TOTAL MEM
CACHED 3145728 12 GB 18% of TOTAL MEM
SLAB 4194304 16 GB 25% of TOTAL MEM
TOTAL HUGE 0 0 0%
这张片子里,SLAB 行的占比是最关键的一根骨头。多数通用业务下 slab 占比大致落在 5%~15% 区间,本例的 25% 偏高,提示 slab 泄漏的可能(第七章详辨)。
这个区间只是启发式参照,不可作为硬判据。slab 的合理占比随业务类型差异极大:文件服务器、元数据密集型负载会缓存大量
dentry与inode,长期占据 30% 以上也属正常;而计算密集型业务的 slab 占比可能长期低于 5%。判断是否异常,应结合业务类型,并以第七章的三条判据交叉验证,而非只看这一个数字。
各 zone 的水位线由 kmem -z 查看。MIN、LOW、HIGH 三条水位线决定了内存回收与 OOM 的触发时机:
free >= HIGH -> normal; kswapd sleeps
LOW <= free < HIGH -> normal; kswapd keeps running if already awake
free < LOW -> wake kswapd, async reclaim starts
free < MIN -> direct reclaim; allocation blocks and reclaims itself
reclaim exhausted -> OOM killer triggered
三条水位线的作用可以这样理解:LOW 是"预警线",跌破即唤醒 kswapd 在后台异步回收,分配本身不受阻;MIN 是"底线",跌破后分配路径必须自己下场做直接回收(direct reclaim),分配因此阻塞;HIGH 是"解除线",回收到此即可停手,kswapd 重新休眠。只有当直接回收也无法把水位拉回 MIN 之上时,OOM killer 才登场。
这三条线是第六章辨"内存死因"时的关键刻度。
3.3 struct page:每一页的户籍档案
物理内存的每一个页帧,内核都为其维护一份 struct page。整个 struct page 数组连续铺展于 vmemmap 区,构成一张以物理页帧号(PFN)为索引的巨型户籍册。
两级换算:只有第一级依赖页大小。 从物理地址找到对应的 struct page,需要经过两次转换。混淆这两级、误以为整条链路都受页大小影响,是跨架构套用经验值时最常见的出错点。
第一级:物理地址 → PFN,依赖 PAGE_SHIFT。
PFN = physical_address >> PAGE_SHIFT
x86_64 : PAGE_SHIFT = 12 (4KB), hardcoded in arch header, no Kconfig
arm64 : PAGE_SHIFT = 12 / 14 / 16 (4KB / 16KB / 64KB)
selected by CONFIG_ARM64_4K_PAGES / _16K_PAGES / _64K_PAGES
同一个物理地址,在不同页大小配置下算出的 PFN 并不相同。须特别注意:x86_64 的 2MB / 1GB 大页属于页表映射层面的优化,并不改变内存管理的 base page 大小,PAGE_SHIFT 仍是 12——不要因为"x86 支持 2MB 页"就推断这里可以取 21。
第二级:PFN → struct page,不依赖页大小。 在 SPARSEMEM_VMEMMAP 配置下(现代 x86_64 与 arm64 的默认选择),它只是一次数组寻址:
page_addr = vmemmap_base + PFN * sizeof(struct page)
参与缩放的是 sizeof(struct page)(其值随内核版本与配置而变),PAGE_SIZE 不在这个公式之内。
完整链路。 分析时不应由地址数值直接猜测其所属的 page 或 zone,而应沿下列路径逐级换算:
virtual address
-> page table
-> physical address
-> PFN
-> struct page
-> zone / node / flags
crash、drgn 等工具已封装上述全部换算逻辑,实测一律以目标内核
vmlinux的解析结果为准,不必手工推算。
crash> kmem -p 0x100000000
PAGE PHYSICAL MAPPING INDEX CNT FLAGS
ffffea0004000000 100000000 0 0 1 ...
crash> struct page ffffea0004000000
struct page {
flags = 0x200000000,
_refcount = { counter = 1 },
mapping = 0x0,
...
}
flags 字段是辨页状态信息密度最高的一处,按位编码了这一页此刻的处境:
| 标志名 | 含义 | 辨骨时的意义 |
|---|---|---|
PG_locked | 页被锁定(I/O 进行中) | 死锁分析:等待的进程可能在等这页 |
PG_slab | 属于 slab 分配器 | 该页由 slab 管理,不可当普通数据页解析 |
PG_compound | 复合页(大页组成部分) | 解析时须找到 compound_head |
PG_reserved | 保留页(内核/固件) | 访问此页通常意味着越界 |
PG_dirty | 脏页(有未回写数据) | 内存压力分析时关注 |
PG_active | 处于 active LRU 链表 | 内存回收决策依据 |
版本提醒: Linux 5.17 起,slab 页的元数据已从
struct page中拆分出来,独立为struct slab(commitd122019bf061等一系列改动),PG_slab的语义与呈现随之调整。在 5.17 及以后的内核上排查 slab 页元数据时,应直接使用struct slab <addr>而非struct page <addr>,否则会因字段已迁出而找不到freelist、inuse、slab_cache等成员。判断某页是否属于 slab,最稳妥的方式仍是交给kmem <addr>判定,由 crash 按目标内核的实际结构处理。
kmem -p 与 struct page 配合,是从物理地址溯源至"这一页是什么、处于什么状态"的标准路径。
四、辨器之骨:SLUB 分配器的形态
辨位置的最后一步,是认识内核对象最主要的容器——slab 分配器。内核大多数运行时对象(task_struct、inode、sk_buff 及各类驱动私有结构)都由 slab 分配。
何时需要读到这一层? call trace 崩溃点落在 kmem_cache_alloc、kmem_cache_free、kfree、__slab_free 这类函数上时,问题就在 slab 分配器内部。理解其骨架,是后续辨对象状态、辨内存损坏的前提。
现代 Linux 默认采用 SLUB。其内部结构与相关 crash 命令在两种架构上一致,无差异(唯一影响是前述页大小决定单个 slab 的容量)。
4.1 为何需要三层结构
理解 SLUB 的三层结构,先要理解它要解决什么问题:内核对象的分配与释放极其频繁(每秒可达数十万次),若每次都加锁访问全局链表,锁竞争会成为系统瓶颈,在多核机器上尤为严重。
SLUB 的解法是分层缓存,用空间与复杂度换取无锁的快速路径:
kmem_cache (cache descriptor)
| describes ONE object class: size, alignment, ctor, name
|
+-- kmem_cache_cpu (per-CPU fast path)
| active slab page + freelist head pointer
| lock-free: pop object directly from freelist
| ~99% of allocations take this path
|
+-- kmem_cache_node (per-NUMA-node)
partial : slab pages with free objects available
full : slab pages fully occupied
refills per-CPU freelist when it runs dry
per-CPU 层是快速路径:每颗 CPU 持有自己的活跃 slab,分配时直接从 freelist 弹出对象,无需任何锁。仅当 per-CPU freelist 耗尽时,才向 per-node 层的 partial 链表申请补充,这一步才需要加锁。约 99% 的分配走快速路径,锁竞争因此被压到极低。
这个设计对分析的意义:当你怀疑某个 cache 有问题时,per-CPU 与 per-node 两层的状态需要分别检查——对象可能滞留在某颗 CPU 的 freelist 里,也可能堆积在 node 的 partial 链表上,两者的排查手段不同。
4.2 freelist:嵌入对象体内的链表
SLUB 有一个对辨骨极其关键的设计:freelist 不是独立的链表节点,而是复用空闲对象自身的内存。 空闲对象的头部(或 offset 字节处)存放下一个空闲对象的指针,形成一条嵌入在对象体内的单链表。
Free object layout in SLUB:
+------------------+ <- object start
| next free ptr | (at offset 0, or at kmem_cache->offset)
+------------------+
| (unused space) |
+------------------+
|
v points to next free object in the same slab page
+------------------+
| next free ptr |
+------------------+
这一设计的辨骨意义,按机制、判据、限制三层来描述。
机制。 freelist 指针存放在空闲对象体内,具体位置由 kmem_cache->offset 决定——可能在对象起始处,也可能在对象中部。分析前先确认该 cache 的实际偏移,不可假定为 0:
crash> struct kmem_cache.offset ffff888200b24800
offset = 8
判据。 因此,当某个本应存放业务数据的字段读出一个内核地址时,存在两种解释:该对象是活的,这是正常的指针成员;或该对象已被释放,这是 freelist 指针的遗留值。区分二者不能只看"是否长得像地址",需要旁证——该地址是否指向同一 slab 页内的另一个对象(freelist 链的典型特征),以及 kmem <addr> 报告的该对象分配状态。
限制,也是最关键的一点。 以上判据仅在未启用 CONFIG_SLAB_FREELIST_HARDENED 时成立。该选项在现代主流发行版内核中默认开启,启用后 freelist 指针不再以裸地址存放,而是与 per-cache 随机值及自身存放地址异或混淆之后再落盘:
stored_value = real_ptr XOR s->random XOR swab(&object->freeptr)
此时读出的是一串无规律的值,而不是 ffff... 开头的内核地址。若照搬"看到内核地址即疑似 freelist 指针"的经验,在这类内核上不仅失效,还可能因见到"随机值"而误判为内存损坏。判断该选项是否启用,可利用 random 字段仅在启用时存在这一特点:
crash> struct kmem_cache -o | grep random
有此字段即说明 hardening 已开启,此时对象是否空闲应改由 kmem <addr> 的分配状态判定,不可依赖指针外观;遍历空闲链表也应交给 kmem -S <cache>,由 crash 完成解混淆,而非手工用 rd 逐个读取。
辨清这一点,即可区分"活对象的正常数据"与"已释放对象的残留"——这是第八章辨内存损坏的核心线索之一。
4.3 在 crash 中读 slab 骨相
crash> kmem -s
CACHE OBJSIZE ALLOCATED TOTAL SLABS SSIZE NAME
ffff888200b24800 1024 24580 25088 392 32k sock_inode_cache
ffff888200a14400 256 8388608 8389120 32770 8k kmalloc-256
ffff888100c34000 4096 42 64 4 32k task_struct
crash> kmem -S kmalloc-256
# 深入某个 cache,显示每个 slab page 的 inuse/total
crash> kmem ffff888012345678
SLAB: ffff888200a14400 NAME: kmalloc-256
OFFSET: 0x678
OBJECT: ffff888012345000
kmem <addr> 是辨位置三层的收口动作:给它一个裸地址,它返回该地址属于哪个 cache、对象起点在何处、在 slab 内偏移多少。位置辨清,下一步即是辨这个对象的状态。
【第二层 · 辨状态】
五、冻结时刻的活体解剖:struct 与指针追踪
位置辨清之后,进入辨骨的第二层:这个对象此刻处于什么状态?
何时需要走到这一层? 从 call trace 中拿到一个可疑的对象地址(通常是崩溃函数的参数,或 callee-saved 寄存器中的值)之后,就需要展开这个对象、检查它的字段是否合理、指针是否完好。这是从"崩溃在哪"走向"为什么崩溃"的必经环节。
vmcore 是崩溃瞬间的内存快照。自这一刻起,所有指针都已冻结——它们在崩溃那一刻是有效的,完全可以追踪。但需格外警觉:其中部分指针可能指向被修改到一半的对象,或指向已被释放的内存。 辨状态,本质是在这具冻结的躯体上做活体解剖,判断每根骨头是完好、错位,还是已经断裂。
辨状态的方法在两种架构上通用。唯一的架构相关点是:追踪指针前的区域归属判断须依据第二章对应架构的前缀表。
5.1 从地址到结构体
从 call trace 中拿到一个地址(例如崩溃函数的参数 ffff888200b24800),或经 kmem <addr> 确认它属于某个 cache 之后,它仍然只是一串数字。赋予它含义的第一步,是套用内核对应的 C 结构体模板展开它。
先完整展开这个 kmem_cache 实例:
crash> struct kmem_cache ffff888200b24800
struct kmem_cache {
cpu_slab = 0xffff8881f3c03a80,
flags = 1073741824,
size = 1024,
object_size = 1016,
offset = 8,
name = 0xffff888100a3bc10 "sock_inode_cache",
node = {0xffff888100b28000, ...}
}
展开后确认了两件事。其一,name 字段表明这是 sock_inode_cache,即套接字 inode 的专用缓存。其二,拿到了两条可继续追踪的线索:offset = 8 说明该 cache 的 freelist 指针位于对象内偏移 8 字节处(第 4.2 节机制的实例印证);node 数组的首元素指向管理该 cache 物理页的 kmem_cache_node 结构。
若只需确认单个字段,可用点号语法直接读取,避免被完整输出淹没:
crash> struct kmem_cache.name ffff888200b24800
name = 0xffff888100a3bc10 "sock_inode_cache"
沿 node 指针继续追踪,展开该 cache 在 NUMA 节点上的宏观状态:
crash> struct kmem_cache_node 0xffff888100b28000
struct kmem_cache_node {
nr_partial = 12,
partial = { next = 0xffffea0012345000, ... },
nr_slabs = 2057,
total_objects = 527616,
}
nr_partial = 12 表示该节点当前持有 12 个部分使用的 slab 页,partial.next 是这些页的链表头,total_objects = 527616 是该节点管理的对象总数。至此,一个原本无意义的地址被还原为"该 cache 在此节点持有 12 个半满 slab 页、共管理约 52 万个对象"的完整画像。
这就是逐层套用结构体、沿指针追踪的基本方法。下一节将其归纳为可复用的步骤。
5.2 指针追踪的四步方法论
追踪指针需要有明确方向,而非漫无目的地跳转。四个步骤,每一步回答一个具体问题:
第一步:确认这根骨头长在何处。 按所在架构的前缀表判断地址处于直接/线性映射、vmalloc 还是 vmemmap。落在直接映射区的,用 kmem <addr> 确认所属 slab;落在 vmalloc 区的,是 vmalloc 对象;落在 vmemmap 区的,是 struct page 本身。
第二步:检查这根骨头的状态。 用 struct 解析,重点关注引用计数、状态标志、链表指针、名称/类型字段。引用计数异常(负数或超大值)通常是 use-after-free 的早期迹象——对象已被释放,计数却仍在被操作。
第三步:验证骨与骨的连接是否完好。 Linux 内核大量使用侵入式双向链表(list_head)。健康的链表满足 next->prev == current;该等式不成立即说明链表这处关节已经错位或断裂:
crash> struct list_head.next ffff888012345678
next = 0xffff888012346000
crash> struct list_head.prev 0xffff888012346000
prev = 0xffff888012345678
上例中返回值与出发点一致,关节完好。若不一致,则链表已损坏,需转入第八章辨认损坏类型。
第四步:明确何时终止追踪。 并非每根骨头都值得追。指针为 NULL(0)时直接停止;指向内核地址范围之外的值(如 0xdeadbeef、0x6b6b6b6b)属于已知的哨兵值,应停止并记录——这类值本身通常即是内存损坏的直接证据,是第八章要专门辨认的化石。
5.3 container_of:从成员反查宿主
内核广泛使用侵入式数据结构。若从 call trace 中拿到一个 list_head 地址(一根肋骨),要找到它所在的那具 struct task_struct(整具骨架),需减去成员偏移。
第一步,查该字段在结构体内的偏移:
crash> struct task_struct -o | grep -w tasks
[1000] struct list_head tasks;
第二步,用 eval 完成减法。crash 的命令行不支持在参数位置直接书写算术表达式,须先算出结果再代入:
crash> eval ffff888012346000 - 1000
hexadecimal: ffff888012345c18
...
crash> struct task_struct ffff888012345c18
若目的只是遍历某条内核链表,list 命令更为直接——它内置了偏移换算,无需手工计算:
crash> list -H init_task.tasks -s task_struct.comm,pid
读图避坑(偏移的架构差异): 字段偏移在 x86_64 与 arm64 上可能因结构体对齐与配置而不同(上例
1000仅为示意)。不可跨架构或跨内核版本套用偏移常量,每次均以struct <type> -o实测。这是辨状态环节最容易出错之处:将某次分析中记住的偏移值应用于其他架构或版本,结果会全盘偏离。
5.4 p 命令:直取全局符号
全局变量是辨状态的快捷通道,无需知道地址,直接以变量名访问:
crash> p jiffies # current jiffies value
crash> p init_task # init process task_struct
crash> p vm_event_states.pgfault # read a struct field directly
【第三层 · 辨生死】
六、内存经济的崩溃:OOM killer 的死因鉴定
位置辨清、状态辨明,进入辨骨的最高层:系统究竟是如何死亡的? 内存类死亡有两大类死因——被内存压力压垮(OOM),或被内存损坏破坏(第七、八章)。本章辨前者。
何时需要走到这一章? call trace 中出现 out_of_memory、oom_kill_process、select_bad_process,或 dmesg 中出现 Out of memory: Kill process,即进入本章的范畴。
当内存确实耗尽,内核不会无声崩溃,而是启动"牺牲其一、保全其余"的机制。辨清该机制的运作方式,才能从 vmcore 中读出准确的死亡时间线。
6.1 OOM 的触发路径
OOM 并非在"内存归零时触发",而是在分配路径上多次失败后被动触发:
Kernel allocation request (kmalloc, alloc_pages, ...)
|
v
Fast path fails (no free page/object available)
|
v
__alloc_pages_slowpath()
|
+-- wake kswapd, try reclaim
+-- direct reclaim
+-- memory compaction
|
v (all attempts failed)
out_of_memory()
|
v
select_bad_process() <- pick the victim
|
v
oom_kill_process() <- send SIGKILL
辨骨的关键推论:OOM 发生时,并不意味着所有内存真的用尽。 有可能是某个特定 zone 耗尽,或 GFP 标志限制了分配只能来自特定区域。这一点在 arm64 上尤需注意——按第三章所述,arm64 的低端 zone 构成随内核版本而变(5.5 之前无 ZONE_DMA,5.11 之后 ZONE_DMA32 可能为空),且窄位宽设备会把 ZONE_DMA 上界压到 1GB 一级,使得"总内存充足却因低端 zone 耗尽而 OOM"的情形比 x86_64 更易出现。读 vmcore 时必须区分全局 OOM 与 zone 局部 OOM,否则会将死因判错。
同理,在容器化环境中还存在第三种可能:cgroup 内存限额触发的 OOM。此时全局内存与各 zone 水位可能都完全健康,见 6.4 节。
6.2 oom_badness:受害者的裁决逻辑
被 OOM killer 选中的进程由 oom_badness() 计算的分数决定,分数最高者被终止:
raw_score = RSS + swap_pages + pgtable_pages (in pages)
+ oom_score_adj * total_pages / 1000
该原始分值的单位是页,量级通常在数十万至数百万,并非直接可读的小数字。/proc/<pid>/oom_score 是它经缩放后的呈现,而缩放公式本身随内核版本变化——这是 2.4 节三轴中"版本"轴的又一处体现:
Older kernels:
points = badness * 1000 / totalpages
-> processes with adj = 0 fall in [0, 1000]
(i.e. per-mille of total memory used)
Newer kernels (after the oom_badness rework):
points = (1000 + badness * 1000 / totalpages) * 2 / 3
-> full range [0, 2000]
-> processes with adj = 0 fall in [666, 1333]
新公式多出 +1000 与 * 2 / 3 两步,是因为改版后的 oom_badness() 允许返回负值(oom_score_adj 为负时),须先平移到非负区间再压缩;内核注释亦说明其目标是把分值映射到 [0, 2000]。
由此可得两条实用推论。
其一,流传甚广的"oom_score 取值 0–1000"并非错误,而是有前提的:它描述的是旧公式下、或 oom_score_adj = 0 时的情形。新内核上叠加正向 adj 后可达 2000,旧内核则确实止于 1000 一线。
其二,也是更关键的一条——新公式下一个 adj 为 0 的普通进程,分值起点就是 666。因此见到 score 892 不应理解为"接近满分",它在 [666, 1333] 区间内只是中位偏上。
版本提醒: dmesg 中 OOM 消息的格式同样随版本变化。较老内核打印
... score <N> or sacrifice child;较新内核的 kill 消息已改为输出oom_score_adj与各类 RSS 明细,不再打印score。分析时应以 RSS、total-vm、oom_score_adj这些原始量为准——score的绝对大小在不同版本间不可横向比较。
oom_score_adj 由用户配置(-1000 至 +1000):-1000 表示完全豁免、永不被杀(通常用于关键系统守护进程);0 表示正常参与;+1000 表示最易被选中。
6.3 从 vmcore 读取三类死亡证据
OOM 事件在 vmcore 中留下三处证据,共同构成完整的死亡鉴定。
证据一:dmesg ring buffer,死亡通告。
crash> log | grep -A30 "Out of memory"
[12345.678] Out of memory: Kill process 9821 (python3) score 892 ...
[12345.679] Killed process 9821 (python3) total-vm:18874368kB, anon-rss:15728640kB, ...
[12345.680] oom_reaper: reaped process 9821 (python3), now anon-rss:0kB, ...
这几行给出:被杀进程的 pid 与名称、score 值,以及各类内存明细。其中 score = 892 是经 6.2 节公式缩放后的结果。若该内核用的是新公式,adj 为 0 的进程起点即为 666,892 只是落在 [666, 1333] 的中位偏上,并非"接近满分";不同版本间该值也不可横向比较。真正可靠的判据是后一行的原始量:total-vm 约 18GB、anon-rss 约 15GB——该进程确属内存大户,被选中合理。分析 OOM 时应以 RSS 与 total-vm 为准。
证据二:zone 水位快照,死亡现场的刻度。
crash> log | grep -A5 "Normal free"
Normal free:12288kB min:16384kB low:20480kB high:24576kB
此处 free < min,说明 ZONE_NORMAL 的空闲内存已跌破最低水位线——这正是触发 OOM 而非继续回收的直接原因,与第三章的水位线模型对应。arm64 上排查时,须先确认该内核实际存在哪几个低端 zone(见 3.1 节的版本差异),再逐一检查其水位,而非只看 Normal,也不可假定 DMA 与 DMA32 一定同时存在。
证据三:进程表残局,死者的遗留。 若 vmcore 在 SIGKILL 发出后很快生成,进程列表中可能仍留有被杀进程:
crash> ps | grep 9821
> 9821 1234 3 ffff888034ab1000 ZO 15360000 python3
ZO 即 Zombie 状态。若 oom_reaper 线程可见,通常处于等待(IN)状态:
crash> ps | grep oom
45 2 0 ffff888000b2c000 IN 0 oom_reaper
oom_reaper 是内核 4.6 引入的异步回收线程,负责快速回收被 OOM 终止进程的匿名内存,无需等待进程完全退出。在 vmcore 中见其处于等待状态属正常,说明其工作已完成。
6.4 死因分型
| 现象 | 死因 | 鉴定方向 |
|---|---|---|
| log 中出现 “Out of memory”,全局 zone 水位跌破 min | 全局内存耗尽(System OOM) | 查看哪个进程/缓存占用最多 |
| 特定 zone 耗尽(含 arm64 低端 zone) | zone 局部 OOM | 查看 zone 分布,未必是总内存不足 |
| log 中出现 “Memory cgroup out of memory”,全局 zone 水位正常 | cgroup 限额触发(Memcg OOM) | 全局内存充足不代表没有 OOM,须查该 cgroup 的 memory.max 与实际用量 |
| 进程 RSS 总和远小于总内存 | slab / page cache 异常占用 | 用 kmem -s 排查 slab 泄漏(第七章) |
| 被杀进程 oom_score 很低 | oom_score_adj 配置不当 | 检查关键服务的 adj 值 |
容器场景提醒: 在 Kubernetes 等容器化环境中,OOM 相当一部分并非全局内存耗尽,而是容器超出 cgroup 的
memory.max限额所致。此类现场的显著特征是:全局总内存与各 zone 水位完全健康,甚至有大量空闲内存,而 dmesg 提示Memory cgroup out of memory: Kill process ...。若只盯着 zone 水位查看,会陷入"内存明明充足为何 OOM"的困惑。判别方法很直接:先看 dmesg 里 OOM 消息的措辞——含Memory cgroup即为 memcg OOM,随后应转向该 cgroup 的限额与用量,而非全局内存。容器场景的完整分析方法见第九篇。
七、慢性出血:slab 泄漏的病灶定位
OOM 是急性死亡,slab 泄漏则是慢性出血——它不会立即致命,而是使某个 cache 的对象数量持续攀升,在数小时乃至数日后逼出 OOM。此时 vmcore 记录的是结果(已经 OOM 或濒临 OOM),而非过程,需从现状反推出血点。
何时需要走到这一章? 第六章的死因分型中,若发现"进程 RSS 总和远小于总内存",或第三章 kmem -i 中 SLAB 占比明显偏高,即进入本章的范畴。这类问题的特点是:死状(OOM)在明处,病灶(泄漏)在暗处。
7.1 识别出血的 cache
crash> kmem -s
CACHE OBJSIZE ALLOCATED TOTAL SLABS SSIZE NAME
ffff888200a14400 256 8388608 8389120 32770 8k kmalloc-256
ALLOCATED / TOTAL 接近 100% 且 SLABS 数量异常庞大,是出血的直接特征。本例中 kmalloc-256 分配了约 800 万个对象。
7.2 泄漏与正常增长的判据
看到某个 cache 的对象数量很大,并不能立即判定为泄漏——某些业务本身就需要大量对象。区分二者需要判据。以下三条,任一成立即可提高泄漏的置信度:
判据一:数量超出业务合理上限。 将对象数量与系统中该类型对象的合理上限对比。例如 task_struct 出现 10 万个,而 ps 显示系统进程总数只有数百——差异无法用正常业务解释,泄漏成立。这条判据对有明确对应关系的 cache(task_struct、inode_cache、dentry)最为有效。
判据二:增长与业务负载不匹配。 若有崩溃前的监控快照可比对,直接看 kmem -s 中 ALLOCATED 的增量。正常业务的对象数量应随负载起伏;持续单调增长而不回落,即是泄漏特征。增长最快的 cache 即第一嫌疑。
判据三:占比与系统规模不匹配。 回到 kmem -i 的 SLAB 占比。一个内存充裕、负载平稳的系统,slab 长期占据高位而 page cache 却被挤压,说明 slab 中存在只进不出的对象。
三条判据中,判据一最直接但适用范围有限(需要对象类型与业务实体有明确对应);判据二最可靠但需要历史数据;判据三最容易获得但只能提示方向。实战中通常自判据三入手,以判据一确认,判据二作为佐证。
7.3 从 cache 追溯到分配路径
确认泄漏的 cache 之后,下一个问题是:这些对象是谁分配的、为何没被释放?
crash> kmem -S kmalloc-256 | head -20
# inspect slab pages of this cache
crash> rd ffff888012000100 8
ffff888012000100: ffff888013456789 0000000000000042 ...
这一步的难点在于: 内核的 kmalloc 调用本身不携带"我是谁"的信息,它只知道大小。kmalloc-256 是所有 256 字节以内分配共享的 cache,其中混杂着数十种不同用途的对象。要区分哪些属于泄漏,只能靠内容推断——观察对象内是否有特征指针(如指向某个已知 slab 对象的地址)、魔数,或特征字符串,据此反推这些对象由谁分配。
若泄漏的是专用 cache(如 sock_inode_cache、dentry),则问题简单得多:cache 名称本身就指明了对象类型,直接对照该类型的分配与释放路径审查即可。
若问题可以复现,则有更直接的手段。 SLUB 的 slub_debug 提供了对象级的分配/释放调用栈追踪:在内核启动参数中加入 slub_debug=UFP(U 记录分配与释放的调用栈,F 启用完整性校验,P 启用毒化),SLUB 会在每个对象尾部追加两份 struct track,分别记录分配与释放时的调用栈。复现后取得的 vmcore 中,可直接读出这些对象由哪条代码路径分配:
crash> kmem -S kmalloc-256
# with slub_debug enabled, crash prints the alloc/free stack per object
对于通用 kmalloc-N 这类混杂多种用途的 cache,这是定位泄漏来源最有效的方式——它把"靠内容特征猜测"变成了"直接读出分配栈"。
两点前提: 其一,
slub_debug必须在崩溃发生时已经启用;若手中的 vmcore 采集时未带此参数,对象尾部没有track数据,该方法不可用,只能退回内容推断。因此这是一种"为下次复现做准备"的手段,而非分析既有 vmcore 的手段。其二,该参数会显著增加每个对象的内存开销并降低分配性能,生产环境应限定到具体 cache(如slub_debug=UFP,kmalloc-256,逗号后接 cache 名称)而非全局开启。
7.4 借力内核的自检机制
若 vmcore 来自启用了 CONFIG_DEBUG_KMEMLEAK 的内核,kmemleak 会在内存中维护一张可疑泄漏对象的清单,遍历该链表即可找到被标记为"无引用者"的对象——相当于内核自己给出了一份嫌疑名单,可大幅缩短第 7.3 节的推断过程。
此外,SLUB 的统计计数器中,活跃对象数若远超业务预期,也可作为佐证。
八、地质层的化石:内存损坏的痕迹辨认
辨生死的最后一类,是内存损坏。不同类型的损坏会留下不同的特征模式,如同地质层中的化石。辨认这些化石,在没有 KASAN 等运行时检测的情况下,也能对损坏类型作出定性。
何时需要走到这一章? call trace 中出现以下任一特征时:故障地址是 0x6b6b6b6b6b6b6b6b 等哨兵值;崩溃点在 list_del/list_add 且指针异常;BUG_ON 断言位于 mm/slub.c;dmesg 中出现 SLUB: double free or corruption。
本章涉及的毒化值与魔数均为内核常量,在两种架构上一致,不随架构改变。
8.1 use-after-free:0x6b 毒化模式
启用 CONFIG_SLUB_DEBUG 并在运行时开启毒化后,SLUB 在释放对象时以特征值填充,并在分配时校验:
| 模式 | 十六进制 | 含义 |
|---|---|---|
POISON_INUSE | 0x5a(字符 Z) | 对象使用中,填充未初始化区域 |
POISON_FREE | 0x6b(字符 k) | 对象已释放,以此值填充 |
POISON_END | 0xa5 | 对象末尾的边界标记 |
看到被 0x6b6b6b6b6b6b6b6b 填满的内存,基本可判定为 use-after-free:对象已被释放(SLUB 将其毒化为 0x6b),却仍有代码访问它并读到此值;更严重的情形是向其中写入数据破坏了毒化模式,触发分配时的校验报错。这是最清晰的一类化石。
crash> rd 0xffff888012345000 4
ffff888012345000: 6b6b6b6b6b6b6b6b 6b6b6b6b6b6b6b6b
ffff888012345010: 6b6b6b6b6b6b6b6b a56b6b6b6b6b6ba5
上例中前三行全为 0x6b,说明对象已释放;末尾的 0xa5 是边界标记,标识对象末端。
前提提醒(生产环境尤其重要): 毒化需
CONFIG_SLUB_DEBUG编译开启且运行时启用(slub_debug=P,或该 cache 带SLAB_POISON),而 RHEL、Ubuntu 等主流发行版的生产内核默认不启用。这意味着多数生产 vmcore 中根本不会出现0x6b模式,已释放对象保留的是原有数据或 freelist 指针。因此看不到毒化痕迹不等于没有 use-after-free,只说明这条线索不可用,应改判其他化石:引用计数为负或异常大、链表指针自引用或指向已释放区、对象内魔数/类型字段被改写。查struct kmem_cache.flags <addr>,含SLAB_POISON即已启用。
与 call trace 的对应关系: 若 panic header 中的故障地址是 0x6b6b6b6b6b6b6b6b,说明代码从一个已释放对象中读出了毒化值,并将其当作有效指针解引用——这是 use-after-free 在启用毒化的内核上最典型的呈现形式。
8.2 栈溢出:STACK_END_MAGIC
Linux 在每个任务的内核栈底部写入魔数 0x57AC6E9D(STACK_END_MAGIC,定义于 include/uapi/linux/magic.h),用于检测栈溢出。arm64 与 x86_64 的内核栈大小默认值可能不同(受 THREAD_SIZE 与页大小影响),但魔数一致:
crash> struct task_struct.stack 0xffff88803a4b1000
stack = 0xffffc900001a4000
crash> rd 0xffffc900001a4000 1
ffffc900001a4000: 0000000057ac6e9d
该魔数完好即为正常。若被覆盖,说明栈已溢出——递归过深,或某个栈上数组越界写穿了栈底。
与 call trace 的对应关系: 栈溢出的 call trace 通常表现为帧数异常深(正常内核栈很少超过 30 帧),或同一函数反复出现(无限递归)。见到这类特征,即应检查 STACK_END_MAGIC。
8.3 double-free:自引用 freelist
SLUB 检测到 double-free 时直接 panic,dmesg 中有明确信息(形如 SLUB: double free or corruption,并指出对象地址)。vmcore 中的现场表现:RIP/PC 指向 slub.c 内部的检查函数,call trace 中可见 kmem_cache_free → slab_bug 的路径。
出事对象的 freelist 指针往往形成自引用(next == self)或指向无效地址——这是 double-free 最典型的化石形态。理解这一点需要回到第 4.2 节:freelist 指针就嵌在对象体内,同一对象被释放两次,就会在 freelist 链上出现两次,链表因此自我缠绕。
须结合 4.2 节的限制条件: 在启用
CONFIG_SLAB_FREELIST_HARDENED的内核上(现代主流发行版默认开启),freelist 指针经异或混淆后存储,自引用不会呈现为字面上的next == self,用rd直接观察也看不出规律。此时应以 dmesg 中 SLUB 自身的报错信息为准——内核在解混淆后完成校验,报错内容是可信的;手工观察指针值则不可行。
8.4 越界写入:redzone 破坏
slab 越界写入通常不会立即 panic,而是悄然破坏相邻对象。其特征是:相邻 slab 对象(地址等于出事对象加对象大小)的前若干字节包含不属于它的数据。
启用 CONFIG_SLUB_DEBUG 并开启红区校验后,SLUB 在对象尾部放置一段 redzone 作为警戒线:未使用/已释放(inactive)的对象填 0xbb(SLUB_RED_INACTIVE),使用中(active)的对象填 0xcc(SLUB_RED_ACTIVE)。越界写入会覆盖红区,在下次分配/释放校验时被检测到。排查越界时,相邻对象多处于 inactive 状态,故常见被破坏的是 0xbb 红区。
crash> struct kmem_cache.flags 0xffff888200a14400
flags = SLAB_POISON|SLAB_RED_ZONE|...
flags 中含 SLAB_RED_ZONE 即说明该 cache 有红区保护,可据此排查;不含则与 8.1 节的毒化同理——生产内核默认不开,这条线索不可用。
与 call trace 的对应关系: 越界写入的隐蔽之处在于,崩溃往往不发生在越界的那一刻,而发生在相邻对象被正常使用时。因此 call trace 顶部的函数通常是受害者而非肇事者——这也是这类问题难以定位的根本原因。
九、辨骨全程:从 call trace 到根因的完整路径
将三层辨骨串成一条实际的鉴定路径,起点是前四篇的 call trace 分析结论:
Step 0: Entry point - establish the three axes first (see Ch.2.4)
crash> sys # AXIS 1+2: arch and kernel version
crash> mach # page size, VA bits, region boundaries
crash> kmem -z # AXIS 3: which zones actually exist here?
crash> bt # crash point + suspicious pointer args
-> Which chapter to enter? See the table in Ch.1
-> arm64 traps: VA layout flipped in 5.4; no ZONE_DMA before 5.5
============ Layer 1: LOCATE ============
Step 1: Global memory picture
crash> kmem -i # is SLAB ratio abnormal for this workload?
crash> kmem -z # any zone below watermark?
Step 2: Locate the suspicious address
crash> kmem <addr> # which cache? object start? offset?
-> address prefix tells the region (Ch.2 table, arch- and version-specific)
============ Layer 2: INSPECT ============
Step 3: Parse and chase
crash> struct <type> <addr> # refcount? state flags? list pointers?
crash> struct list_head.next/prev <addr> # verify next->prev == self
============ Layer 3: DIAGNOSE ============
Step 4: Fossil identification
crash> struct kmem_cache.flags <cache> # SLAB_POISON / SLAB_RED_ZONE set?
crash> rd <addr> 32 # 0x6b poison? 0xbb redzone? sentinel?
crash> rd <task_stack_base> 1 # STACK_END_MAGIC intact?
Step 5: OOM forensics (if applicable)
crash> log | grep -A30 "Out of memory" # System OOM or Memcg OOM?
crash> kmem -s | sort -k3 -rn | head -20 # leaking cache?
每一步的输出决定下一步的方向,不预设终点。内存问题往往具有层级性: 表面死状是 OOM,根因却是 slab 泄漏;表面死状是 BUG_ON,根因却是越界写入破坏了链表;call trace 顶部的函数看似有问题,实则只是受害者。
三层辨骨的价值,正在于穿透表面死状,直抵真正病灶。
十、小结:三层辨骨,一以贯之
本篇以"辨骨"为主线,建立了内核内存分析的完整框架,并将其接入前四篇的 call trace 分析体系。
入口(第一章): 当 call trace 走到 slab 分配器、OOM 路径、链表操作或哨兵值故障地址时,说明答案不在调用栈里,而在内存里,此时转入本篇的三层辨骨。
辨位置(第二至四章): 本层的核心产出是 2.4 节的三轴原则——内核的内存视图由架构、内核版本、编译配置三个正交因素共同决定,任一轴取值不同,结论都可能不同甚至完全相反。据此,内核虚拟地址按区域赋予地址以身份——直接/线性映射、vmalloc、vmemmap、内核镜像、模块区,任意地址一眼可知其类型。但这套坐标系既分架构、也分版本:x86_64 与 arm64 的区域基址截然不同,而 arm64 自身在 5.4 还经历过线性映射与 vmalloc 的整体翻转,旧内核上照搬新布局会双向判反。因此必须以 mach 实测为准;arm64 上内核镜像与 vmalloc 前缀重叠时,须靠 vtop 区分。物理内存以 zone 加 struct page 为基本单元——zone 的构成正是三轴的典型体现:版本决定"能不能有"(arm64 的 ZONE_DMA 需 ≥ 5.5),编译配置决定"这次有没有"(CONFIG_ZONE_DMA 可关),运行时布局决定"是不是空的"(5.11 后 ZONE_DMA32 常为零页);同理,内核栈落在 vmalloc 还是直接映射区,取决于 CONFIG_VMAP_STACK(5.17 后 slab 页元数据另独立为 struct slab);SLUB 的三层结构(kmem_cache → per-CPU 快速路径 → per-node partial)是为无锁分配而设计,也决定了排查时需分层检查。
辨状态(第五章): struct 解析加指针追踪,核心是"在冻结时刻读取活的数据结构"。四步方法论明确何时追、何时停、如何验证链表关节的完整性;偏移量不可跨架构套用,算术换算须经 eval 或交由 list 命令处理。
辨生死(第六至八章): OOM 是急性死亡,vmcore 中的三类证据(dmesg、zone 水位、进程残局)共同还原死亡时间线;须区分全局 OOM、zone 局部 OOM 与容器场景下的 memcg OOM,后者的显著特征是全局水位健康而 cgroup 限额告罄。slab 泄漏是慢性出血,三条判据区分泄漏与正常增长,slub_debug 的分配栈追踪是复现场景下的最强手段。内存损坏则留下可辨认的化石——0x6b 毒化、STACK_END_MAGIC 损坏、自引用 freelist、redzone 破坏;但其中依赖 CONFIG_SLUB_DEBUG 的几类线索在生产内核上默认不可用,看不到痕迹不等于没有问题。
三层层层递进:辨清位置才能辨状态,辨明状态才能辨生死。 面对任意一个内存地址,走完这三层,它的身份、病历、死因便一览无余。
下一篇《众生之相》,我们将把本篇的 struct 解析方法论,应用于最重要的内核数据结构——task_struct——之上,解剖进程与线程在内核中的真实面目。

94

被折叠的 条评论
为什么被折叠?



