庖丁解vmCore · 第五篇·骨相之辨:内核内存的解剖学

庖丁解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_freeslab 分配器状态异常四、七、八
出现 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 实例kmallockzalloc、slabkmem <addr> 查所属 cache
vmalloc / ioremap大块内存、内核栈、ioremap 映射vmallocvzalloc物理不连续,不可当 slab 解析
vmemmapstruct page 本身启动时静态建立这是"页的档案",非页内数据
内核镜像内核函数、全局变量编译链接期静态分配sym <addr> 反查符号名
模块区模块代码/数据module_allocmod 查属于哪个驱动

举例说明这张表的用法。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 的布局翻转就是双向判反的实例)。本篇后续每一处"避坑"提示,本质上都是在标注某条结论受哪一轴约束。

因此,本篇所有前缀、边界、特征值,都应视为"某一组三轴取值下的实例",而非普适常量。 落到操作上就是一句话:动手之前先用 sysmach 把三轴的实际取值读出来,再决定套用哪套判断。 前缀是快速线索,实测是最终裁决。


三、辨页之骨:zone 与 struct page

辨位置的第二步,从虚拟地址下沉到物理内存本身。物理内存并非一片均质的资源,它有自己的"行政区划"(zone)与"户籍档案"(struct page)。

何时需要读到这一层? 当 call trace 中出现 __alloc_pages_slowpathout_of_memorykswapd 等页分配与回收路径的函数时,问题已经从"某个对象出错"上升到"整个内存供给出了问题",必须从 zone 与 page 的层面查看。

3.1 zone:物理内存的能力分区

内核按物理内存的能力与用途将其划分为若干 zone,分配请求优先由能力匹配的 zone 满足:驱动申请 DMA 缓冲区时指定 GFP_DMA,内核从低端 zone 分配;普通 kmalloc 则从 ZONE_NORMAL 分配。

但 zone 的构成本身就是架构相关的,且 arm64 上还随内核版本变化——这是与 2.3 节布局翻转性质相同的另一处版本陷阱。

x86_64 的 zone 构成(长期稳定):

zone范围用途
ZONE_DMA0 ~ 16MB传统 ISA 设备(24 位寻址)的遗留区
ZONE_DMA3216MB ~ 4GB32 位 DMA 寻址设备
ZONE_NORMAL4GB 以上常规内核分配,绝大多数对象在此
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.11ZONE_DMA 上界改为动态推导:设备树平台取 dma-ranges 的最低约束,ACPI 平台取 IORT

arm64 引入 ZONE_DMA 的直接动因是树莓派 4——其 PCIe、网卡、xHCI 只能寻址 30 位(1GB),而当时 arm64 没有任何低于 4GB 的 zone 可满足此类设备。

但版本只是三轴中的一轴。 按 2.4 节的三轴原则,某个 zone 在一台机器上是否真实存在、是否有页可用,须由三个因素共同决定:

因素作用具体表现
内核版本决定该架构"能不能有"这个 zonearm64 的 ZONE_DMA 需 ≥ 5.5
编译配置决定这次编译"有没有"编进去见下表的 Kconfig 差异
运行时布局决定编进去之后"是不是空的"物理内存起始地址、dma-ranges/IORT 推导出的 zone_dma_bitskernelcore= 等启动参数

低端 zone 的 Kconfig 控制方式,两个架构并不相同:

选项x86_64arm64
CONFIG_ZONE_DMAdefault yEXPERT 下可关闭default yEXPERT 下可关闭(5.5 起才有此选项)
CONFIG_ZONE_DMA32def_bool y,64 位下强制开启,不可关闭default yEXPERT 下可关闭

由此可得两条推论。其一,即便内核版本已达 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 的合理占比随业务类型差异极大:文件服务器、元数据密集型负载会缓存大量 dentryinode,长期占据 30% 以上也属正常;而计算密集型业务的 slab 占比可能长期低于 5%。判断是否异常,应结合业务类型,并以第七章的三条判据交叉验证,而非只看这一个数字。

各 zone 的水位线由 kmem -z 查看。MINLOWHIGH 三条水位线决定了内存回收与 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(commit d122019bf061 等一系列改动),PG_slab 的语义与呈现随之调整。在 5.17 及以后的内核上排查 slab 页元数据时,应直接使用 struct slab <addr> 而非 struct page <addr>,否则会因字段已迁出而找不到 freelistinuseslab_cache 等成员。判断某页是否属于 slab,最稳妥的方式仍是交给 kmem <addr> 判定,由 crash 按目标内核的实际结构处理。

kmem -pstruct page 配合,是从物理地址溯源至"这一页是什么、处于什么状态"的标准路径。


四、辨器之骨:SLUB 分配器的形态

辨位置的最后一步,是认识内核对象最主要的容器——slab 分配器。内核大多数运行时对象(task_structinodesk_buff 及各类驱动私有结构)都由 slab 分配。

何时需要读到这一层? call trace 崩溃点落在 kmem_cache_allockmem_cache_freekfree__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)时直接停止;指向内核地址范围之外的值(如 0xdeadbeef0x6b6b6b6b)属于已知的哨兵值,应停止并记录——这类值本身通常即是内存损坏的直接证据,是第八章要专门辨认的化石。

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_memoryoom_kill_processselect_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-vmoom_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,也不可假定 DMADMA32 一定同时存在。

证据三:进程表残局,死者的遗留。 若 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_structinode_cachedentry)最为有效。

判据二:增长与业务负载不匹配。 若有崩溃前的监控快照可比对,直接看 kmem -sALLOCATED 的增量。正常业务的对象数量应随负载起伏;持续单调增长而不回落,即是泄漏特征。增长最快的 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_cachedentry),则问题简单得多:cache 名称本身就指明了对象类型,直接对照该类型的分配与释放路径审查即可。

若问题可以复现,则有更直接的手段。 SLUB 的 slub_debug 提供了对象级的分配/释放调用栈追踪:在内核启动参数中加入 slub_debug=UFPU 记录分配与释放的调用栈,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_INUSE0x5a(字符 Z对象使用中,填充未初始化区域
POISON_FREE0x6b(字符 k对象已释放,以此值填充
POISON_END0xa5对象末尾的边界标记

看到被 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 在每个任务的内核栈底部写入魔数 0x57AC6E9DSTACK_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_freeslab_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)的对象填 0xbbSLUB_RED_INACTIVE),使用中(active)的对象填 0xccSLUB_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——之上,解剖进程与线程在内核中的真实面目。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

硅基岭

好大一块肉

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值