庖丁解 vmCore · 第六篇 · 众生之相:进程与线程的生死现场

庖丁解 vmCore 十篇 · 第六篇 —— 众生之相:进程与线程的生死现场

解牛者,刀游骨缝,非割肉也。 读进程者,眼见状态,非枚举字段也。


目录

  • 一、从一条 call trace 到一屋子进程
    • 1.1 一个 bt 看不出问题的现场
    • 1.2 什么样的现场需要横向看进程
    • 1.3 本篇的分析动线
    • 1.4 承上:三轴原则在本篇的落点
  • 二、task_struct:进程的身份档案
    • 2.1 身份标识字段
    • 2.2 状态字段的编码方式
    • 2.3 内存视图:mm 与 active_mm
    • 2.4 调度相关字段
    • 2.5 进程亲属关系字段
  • 三、进程状态的法医学意义
    • 3.1 状态定义与调试价值对照
    • 3.2 crash ps 的输出解读
    • 3.3 时间维度:ps -m 与 ps -l
    • 3.4 D 状态:最高价值的分析目标
  • 四、内核栈:一个进程到底有几个栈
    • 4.1 进程内核栈的布局与定位
    • 4.2 x86_64 的多栈体系与 IST
    • 4.3 ARM64 的多栈体系
    • 4.4 thread_info 的位置演变
    • 4.5 STACK_END_MAGIC 溢出检测
  • 五、pt_regs:哪些切换会留下寄存器快照
    • 5.1 两类切换,两套保存机制
    • 5.2 会产生 pt_regs 的三种场景
    • 5.3 不产生 pt_regs 的场景
    • 5.4 在 crash 中查看 pt_regs
    • 5.5 栈切换与 pt_regs 的对应关系
    • 5.6 当 bt 读不到 pt_regs:三类失败场景
  • 六、调度上下文:睡眠进程的寄存器在哪里
    • 6.1 确认崩溃时哪个进程在 CPU 上
    • 6.2 x86_64:寄存器保存在内核栈上
    • 6.3 ARM64:寄存器保存在 cpu_context 里
  • 七、线程组、内核线程与僵尸进程
    • 7.1 pid 与 tgid:线程的双重身份
    • 7.2 内核线程的识别与特殊性
    • 7.3 僵尸进程的成因与现场特征
  • 八、实战方法论:四种典型场景
    • 8.1 定位触发崩溃的进程
    • 8.2 D 状态挂起分析
    • 8.3 僵尸进程堆积分析
    • 8.4 多进程参与的崩溃场景
  • 九、回到开篇:jbd2 现场的分析路径
    • 第 0 步:确认三轴
    • Q1:谁在现场
    • Q2:各自是什么姿态
    • Q3:谁牵连了谁
    • 跨 CPU 的共因判断
    • 这个现场留下的方法论
  • 十、架构差异速查
  • 十一、小结

在这里插入图片描述

一、从一条 call trace 到一屋子进程

1.1 一个 bt 看不出问题的现场

先看一份真实现场——这是第三篇用过的 ARM64 样本,一台基于 4.19 内核的国产服务器:

watchdog: BUG: soft lockup - CPU#79 stuck for 22s! [jbd2/sdc1-8:2896966]
...
Call trace:
  machine_kexec+0x44/0x3d8
  __crash_kexec+0x6c/0x100
  panic+0x130/0x310
  watchdog_timer_fn+0x304/0x308
  __hrtimer_run_queues+0xfc/0x318
  hrtimer_interrupt+0xf8/0x2d0
  arch_timer_handler_phys+0x38/0x58
  handle_percpu_devid_irq+0x90/0x248
  gic_handle_irq+0x6c/0x170
  el1_irq+0xb8/0x140                          <- 中断边界
  ext4_process_freed_data+0x258/0x530         <- 被中断时正在执行
  ext4_journal_commit_callback+0x50/0x120
  jbd2_journal_commit_transaction+0x164c/0x1a60
  kjournald2+0xd0/0x2b8
  kthread+0x134/0x138
  ret_from_fork+0x10/0x18

按第三篇的骨架模型读,这条 call trace 是清晰的:el1_irq 以上是 watchdog 中断路径,以下才是真正卡住的代码——jbd2 内核线程在 ext4_process_freed_data+0x258 处长时间未让出 CPU。

但读到这里就停住了。 这条 bt 回答不了下面这些问题:

  • jbd2 卡了多久?22 秒只是 watchdog 的告警阈值,不是实际时长
  • 它在等什么?调用栈只给出位置,没给出等待对象
  • 只有它卡住,还是整台机器都卡了?
  • 另一颗 CPU(#95)上的 jbd2/sdb1-8 卡在同一个偏移,是巧合还是共因
  • 有多少业务进程被它拖累了
  • 这条链跨了几个栈?栈上有几份寄存器快照可读?

这些问题的共同点是:它们不在这条调用链上,而在这条链之外的其他进程身上。

1.2 什么样的现场需要横向看进程

第五篇的转向信号是「call trace 走到尽头,答案在内存里」。本篇的转向信号不同——不是 call trace 走不下去,而是一条 call trace 本身就不够

bt 给出的是一条线:某个进程的调用链。而下面这些场景需要的是一个:所有进程的状态分布。

现场特征为什么单条 bt 不够本篇对应章节
soft lockup / hard lockup栈顶全是 watchdog 中断路径,卡住的代码在中断边界之下,且不知道卡了多久三、七
INFO: task xxx blocked for more than 120 secondshung task 是状态检测的结果,不是崩溃点;须找出等待链源头三、七
崩溃进程的 bt 完全正常它可能只是受害者,肇事者是另一个进程
大量进程处于 D 状态需要判断谁是源头、谁是被牵连三、七
怀疑竞态 / 并发访问必须同时看多个 CPU 上的进程在做什么五、七
僵尸进程堆积问题在父进程,不在僵尸本身六、七
内存充足却 OOM,且非 memcg可能是某进程持续 fork,需看进程数量与关系

一句话概括二者的分工: 第五篇回答「这个值为什么是错的」,本篇回答「这件事跟谁有关」。

1.3 本篇的分析动线

vmcore 是一张冻结的底片。拍摄那一刻,系统上所有进程都定格在最后姿态——有人在 CPU 上奔跑,有人在等 I/O,有人在等锁,有人刚死去还没被收尸。

分析进程不是逐一查阅 task_struct 字段,而是根据这些姿态重建崩溃前后的人物关系图。本篇按三个递进的问题组织:

Q1: WHO  -- 谁在现场?
    task_struct identity: pid / tgid / comm / mm
    -> Ch.2 identity fields

Q2: HOW  -- 各自是什么姿态?
    state, how long stuck, which stack, which registers
    -> Ch.3 states / Ch.4 stacks / Ch.5 pt_regs / Ch.6 sleep context

Q3: WHY  -- 谁牵连了谁?
    parent-child, thread group, waiter-holder chains
    -> Ch.7 relationships, Ch.8 practical scenarios

回到 1.1 的现场:Q1 告诉我们 jbd2 是什么身份(内核线程,mm == NULL);Q2 告诉我们它卡了多久、跑在哪个栈上、栈上有几份寄存器快照;Q3 告诉我们哪些进程因它而卡。第九章会用这条动线把开篇现场走完。

1.4 承上:三轴原则在本篇的落点

第五篇的核心产出是三轴原则:内核的任何一项事实,都由「架构 × 内核版本 × 编译配置」三个正交因素共同决定,任一轴取值不同,结论都可能不同甚至完全相反。

task_struct 是这条原则最典型的验证场——它是内核中改动最频繁的结构之一,本篇涉及的每个关键字段几乎都落在某一轴上:

本篇知识点受哪一轴约束具体分界
状态字段名 state / __state版本5.14 前后改名
thread_info 在栈底还是嵌入 task版本 + 架构x86_64 于 4.9、ARM64 于 4.10 迁入
内核栈大小 THREAD_SIZE编译配置通常 16KB,可配为 8KB
睡眠进程寄存器的存放位置架构x86_64 在栈上,ARM64 在 cpu_context
current 指针的获取方式架构x86_64 用 per-CPU,ARM64 用 sp_el0
内核栈落在哪个内存区编译配置CONFIG_VMAP_STACK 决定

开篇那份 4.19 ARM64 样本就命中了其中一项:内核版本早于 5.14,状态字段名是 state 而非 __state——照搬新内核经验执行 struct task_struct.__state 会直接报字段不存在。另一方面,thread_info 在该版本上已经迁入 task_struct(ARM64 于 4.10 完成),这一项反而不必担心。

这正说明三轴不能靠记忆推断: 同为"4.19 这个老版本",有的特性还是旧形态,有的早已是新形态,分界点各不相同。可靠做法始终是实测。

因此,读本篇每条结论时先问一句:这条在我手上这份 vmcore 的三轴取值下成立吗? 动手之前,仍是那两条命令:

crash> sys        # 架构 + 内核版本
crash> mach       # 页大小、VA 位宽、区域边界

本篇凡涉及三轴差异处,均以「版本差异」或「架构差异」单独标注。


二、task_struct:进程的身份档案

task_struct 是内核中体积最大、字段最多的数据结构之一。它的具体大小是典型的三轴依赖项——随架构、内核版本、以及 CONFIG_SCHED_INFOCONFIG_SCHEDSTATS、cgroup、KASAN 等大量配置项剧烈浮动,本文不给具体数字,请直接实测:

crash> struct task_struct
...
SIZE: 9792                 # 该 vmcore 上的实际大小

输出末尾的 SIZE 即为当前内核的实际字节数。但从 vmcore 分析的角度,不需要通读全部字段,只需熟悉以下几个功能分组。

2.1 身份标识字段

crash> struct task_struct.pid,tgid,comm,flags ffff888034ab1000
  pid = 1234
  tgid = 1234
  comm = "nginx\000\000\000\000\000\000\000\000\000\000"
  flags = 0x400000
字段类型含义分析用途
pidpid_t线程 ID,内核视角的唯一标识与 panic header 的 PID 对应
tgidpid_t线程组 ID,等于进程主线程的 pid用于判断是主线程还是子线程
commchar[16]进程名(最长 15 字符 + 终止符)快速识别进程归属
flagsunsigned int进程标志位(PF_*判断特殊状态(如 PF_KTHREAD

flags 字段中 PF_KTHREAD 置位表示内核线程。本例 flags = 0x400000 对应 PF_WQ_WORKER(工作队列 worker),说明这是一个 workqueue 线程。

以上数值均为示例,务必实测。 PF_* 各标志位的数值在内核演进中经历过多次位重排与增删(如 PF_KTHREAD 在多数版本为 0x00200000,但不保证跨版本一致)。跨版本分析时不要凭记忆套用,以目标内核的 include/linux/sched.h 为准。这正是三轴原则中"版本轴"最琐碎也最容易翻车的一类。

2.2 状态字段的编码方式

进程状态字段在不同内核版本有不同名称,这是本篇第一处版本轴分界:

  • 内核 5.14 之前:字段名为 state,类型 long
  • 内核 5.14 及之后:字段名改为 __state,类型 unsigned int
# 5.14 之前的内核
crash> struct task_struct.state ffff888034ab1000
  state = 2     ← TASK_UNINTERRUPTIBLE

# 5.14 及之后的内核
crash> struct task_struct.__state ffff888034ab1000
  __state = 2

改名的动因是引入 READ_ONCE(p->__state) 的访问约定,防止编译器对状态读取做危险优化。对分析者而言只需记住:命令报"字段不存在"时,换另一个名字试一次,不要误以为 vmlinux 不匹配。

各状态值的定义(5.14+ 内核):

#define TASK_RUNNING            0x00000000
#define TASK_INTERRUPTIBLE      0x00000001
#define TASK_UNINTERRUPTIBLE    0x00000002
#define __TASK_STOPPED          0x00000004
#define __TASK_TRACED           0x00000008
#define TASK_DEAD               0x00000080

状态值可以是上述基础值的按位组合。最常见的是 TASK_KILLABLE = TASK_WAKEKILL | TASK_UNINTERRUPTIBLETASK_WAKEKILL 不是一个独立状态,而是给 TASK_UNINTERRUPTIBLE 附加的一个行为修饰——允许致命信号将其唤醒。

二者在 ps 输出中外观完全相同(都显示 UN),差别只在等待行为的容错性:

TASK_UNINTERRUPTIBLETASK_KILLABLE
收到 SIGKILL不唤醒,信号挂在 pending 上,等进程自己醒来才处理立即唤醒,等待中止
典型场景本地块设备 I/ONFS、FUSE 等可能长时间不返回的路径
运维影响kill -9 无效,进程无法清理kill -9 可生效

排查"进程 kill -9 都杀不掉"这类问题时,二者的区分是关键——但由于 ps 显示相同,须读 __state 的完整值才能分辨。

TASK_WAKEKILL 等扩展标志位的数值随版本有过调整,不建议跨版本硬记,以目标内核 include/linux/sched.h 为准。

2.3 内存视图:mm 与 active_mm

crash> struct task_struct.mm,active_mm ffff888034ab1000
  mm = 0xffff88802c4b1800
  active_mm = 0xffff88802c4b1800

这两个字段揭示了进程的内存身份:

mmstruct mm_struct *): 进程的用户地址空间描述符。

  • 用户进程:mm 指向有效的 mm_struct,包含页表、VMA 链表等
  • 内核线程:mm = NULL。内核线程没有用户地址空间,这是判断是否为内核线程最可靠的方式

active_mm 当前进程实际使用的内存描述符。

  • 用户进程:active_mm == mm(正常情况)
  • 内核线程:active_mm 借用前一个用户进程的 mm_struct,避免频繁刷新 TLB

两个字段的组合含义:

mmactive_mm进程类型
非 NULL== mm普通用户进程
NULL非 NULL内核线程(借用前一个用户进程的 mm)
NULLNULL极少见,系统初始化阶段或特殊路径
# 深入 mm_struct 查看地址空间信息
crash> struct mm_struct.total_vm,rss_stat 0xffff88802c4b1800
  total_vm = 4718592    ← 总虚拟内存页数
  rss_stat = ...        ← 各类型 RSS 统计

版本差异: mm_struct 中 RSS 的统计实现随版本反复调整过(计数器数组、per-CPU 缓存、percpu_counter 都出现过),字段名与层级并不稳定。不要凭记忆套用某一版的写法,直接 struct mm_struct <addr> 看完整输出,以目标内核的实际形式为准。

2.4 调度相关字段

crash> struct task_struct.prio,on_cpu,cpu,sched_class ffff888034ab1000
  prio = 120           ← 动态优先级(普通进程默认 120)
  on_cpu = 0           ← 当前是否在 CPU 上执行
  cpu = 3              ← 最后运行在 CPU 3 上
  sched_class = 0xffffffff82200e00    ← 调度类指针
字段含义分析价值
on_cpu1 = 当前正在某个 CPU 上执行崩溃时在 CPU 上的进程此字段为 1
cpu最后一次运行的 CPU 编号确认进程与 CPU 的对应关系
prio动态优先级(0-139,越小越高)100-139 为普通进程,0-99 为实时进程
sched_class指向调度类(fair/rt/dl/idle…)判断调度策略

sched_class 指针指向以下调度类之一,用 sym 反查即可确认:

crash> sym 0xffffffff82200e00
ffffffff82200e00 (D) fair_sched_class    ← CFS 调度(普通进程)

常见调度类:

符号含义
fair_sched_classCFS,普通进程(6.6+ 内核可能是 EEVDF,符号名不变)
rt_sched_class实时进程(SCHED_FIFO / SCHED_RR
dl_sched_classDeadline 调度
idle_sched_classidle 进程(swapper/N
stop_sched_classmigration/N 等最高优先级停机线程

版本差异: task_struct.cpu 字段在部分版本中被移入 thread_info(受 CONFIG_THREAD_INFO_IN_TASK 影响)。若直接读 .cpu 报字段不存在,改读 .thread_info.cpu

2.5 进程亲属关系字段

crash> struct task_struct.real_parent,group_leader,children ffff888034ab1000
  real_parent = 0xffff888012300000   ← 真实父进程
  group_leader = 0xffff888034ab1000  ← 线程组组长(等于自身说明是主线程)
  children = {
    next = 0xffff888098765438,
    prev = 0xffff888098765438
  }

group_leader == self:该进程是线程组的主线程(对用户态来说就是"进程"本身)。

group_leader != self:该进程是某个进程的子线程,group_leader 指向它所属的主线程。

此外还有一对易混字段:real_parent 保留真实的创建者parent 用于当前的子进程通知关系(接收 SIGCHLD 的进程)。二者通常相同,但在某些重新绑定父子关系的机制下会分离,最常见的是 ptrace——调试器附加后会成为 parent,而 real_parent 保持不变。

分析僵尸进程时要认准 real_parent,它才是那个应当调用 wait() 却没有调用的进程。顺带一提,crash ps 输出的 PPID 列显示的是 parent,因此在有 ptrace 介入的场景下,PPIDreal_parent 的 pid 可能不同,需要单独确认。


三、进程状态的法医学意义

3.1 状态定义与调试价值对照

不同状态揭示了进程在崩溃瞬间的不同处境,每种状态对应不同的分析方向:

这里有一个必须先厘清的层级问题:进程的"状态"分散在两个不同字段里。

struct task_struct {
    unsigned int  __state;      /* TASK_* : 活着时的运行/睡眠状态 */
    ...
    int           exit_state;   /* EXIT_* : 退出流程中的状态     */
};

TASK_*EXIT_* 是两套独立的取值空间,分属两个字段,不能放在一张表里比较数值。crash 的 psST 列把二者统一呈现,才造成了容易混淆的假象。

活着时的状态(__state 字段,TASK_*):

状态值crash 显示内核宏含义调试意义
0x0000RUTASK_RUNNING可运行或正在运行崩溃时在 CPU 上,或在运行队列中等待调度
0x0001INTASK_INTERRUPTIBLE可中断睡眠等待某个事件,可被信号唤醒;通常是正常等待
0x0002UNTASK_UNINTERRUPTIBLE不可中断睡眠(D 状态)等待无法被信号打断的资源,是 hang 问题的核心目标
0x0004ST__TASK_STOPPED被信号停止收到 SIGSTOP 或调试器介入
0x0008TR__TASK_TRACED被 ptrace 跟踪正在被调试
0x0080TASK_DEAD已死亡,调度器内部过渡态不会出现在 psST,只存在于 do_task_dead() 到最终释放之间的极短窗口

退出流程中的状态(exit_state 字段,EXIT_*):

状态值crash 显示内核宏含义
0x0010(16)DEEXIT_DEAD正在被回收,父进程已 wait()task_struct 即将释放
0x0020(32)ZOEXIT_ZOMBIE僵尸进程,已退出但父进程尚未 wait()

三个易混值必须记清: EXIT_DEAD16(0x10)EXIT_ZOMBIE32(0x20),二者同属 exit_state;而 TASK_DEAD128(0x80),属于 __state,是调度器内部状态,ps 里看不到。分析僵尸进程时,先确认自己读的是哪个字段,再对照取值。具体数值以目标内核 include/linux/sched.h 为准。

TASK_RUNNING(crash 显示 RU)有两种含义需要区分:

  • 进程确实在 CPU 上运行(on_cpu = 1
  • 进程在运行队列(runqueue)中等待 CPU,还未被调度(on_cpu = 0

两者状态值相同,区别在 on_cpu 字段。大量 RUon_cpu = 0 的进程堆积,本身就是 CPU 争抢或调度延迟的信号,可结合第 3.3 节的时间维度进一步确认。

3.2 crash ps 的输出解读

crash> ps
   PID    PPID  CPU       TASK        ST  %MEM     VSZ    RSS  COMM
      1       0   0  ffff888100400000  IN   0.0    125M    12M  systemd
    142       2   1  ffff888100b2c000  IN   0.0      0      0  kworker/1:1
   1234    1000   3  ffff888034ab1000  RU   2.1     18G   5.4G  nginx
   1235    1000   0  ffff888034bc2000  UN   0.0     18G   5.4G  nginx-worker
   1236    1234   2  ffff888034cd3000  ZO   0.0      0      0  nginx-child

上例中,nginx(RU)是崩溃时在 CPU 上的进程,nginx-worker(UN)处于 D 状态需要重点关注,nginx-child(ZO)是僵尸。

各列含义:

含义注意点
PID线程 ID对线程来说不等于 tgid
PPID父进程 PID显示的是 parent 而非 real_parent,ptrace 场景下二者可能不同(见 2.5 节)
CPU最后运行的 CPU不代表崩溃时一定在这个 CPU 上
TASKtask_struct 地址直接传给 struct task_struct 命令
ST状态重点关注 UN 和 ZO
%MEMRSS 占总内存比
VSZ虚拟内存大小
RSS常驻内存

常用过滤方式:

crash> ps | grep " UN "    # 只看 D 状态进程
crash> ps | grep " ZO "    # 只看僵尸进程
crash> ps <name>           # 按进程名过滤
crash> ps -G 1234          # 查看进程 1234 的整个线程组
crash> ps -k               # 只看内核线程
crash> ps -u               # 只看用户态进程

3.3 时间维度:ps -m 与 ps -l

上一节的 ps 输出回答的是"谁在哪、什么状态",属于空间维度。但排查 hang 类问题还需要时间维度:这个进程卡了多久?

crash 提供两个专门选项,实战价值很高却常被忽略:

crash> ps -m               # 显示每个进程距最后运行的时间差
crash> ps -l               # 按最后运行时间排序(最久未运行的排在最后)

ps -m 的输出形如:

crash> ps -m | head
[0 00:00:00.000] [RU]  PID: 1234   TASK: ffff888034ab1000  CPU: 3  COMMAND: "nginx"
[0 00:00:00.012] [IN]  PID: 1      TASK: ffff888100400000  CPU: 0  COMMAND: "systemd"
[0 00:03:47.221] [UN]  PID: 1235   TASK: ffff888034bc2000  CPU: 0  COMMAND: "nginx-worker"
[0 02:14:09.883] [UN]  PID: 1245   TASK: ffff888100d4f000  CPU: 1  COMMAND: "jbd2/sda1-8"

方括号内是 天 时:分:秒.百分秒,其准确含义是 距最后一次在 CPU 上运行的时间差(last-run delta),而非"阻塞了多久"。这个区分很重要:

  • 睡眠态UN / IN)进程,该值反映的是自最后一次运行以来经过的时间。在多数 hang 场景下它可以近似反映连续未运行时长,但不等同于阻塞时长——进程若在此期间被短暂唤醒后又重新入睡,计时会被重置
  • 运行态RU)进程,它一直在或刚在 CPU 上跑,时间差接近 0,完全无法反映它是否有问题

所以 ps -m 是 D 状态排查的利器,但对 soft lockup 这类 RU 卡死毫无帮助——开篇的 jbd2 就是后者,第九章会具体演示。

回到上面的输出:

  • nginx-worker 已 3 分 47 秒未被调度
  • jbd2/sda1-8 已 2 小时 14 分未被调度——阻塞得最深

一条常用线索,但不是判据: 排查等待链时,时间差最大的 D 状态进程往往更接近源头,因为上游阻塞会拖着下游一起卡,而源头卡得最早。但这只是启发式排序,反例不难遇到——上游进程若在阻塞期间被短暂唤醒过(例如超时重试、信号打断后重新入睡),它的 last-run 时间差反而可能小于下游。用它决定"先看谁",而不是用它下结论"就是它"。 真正的判定仍要靠调用栈与等待对象的持有关系(8.2 节)。

3.4 D 状态:最高价值的分析目标

D 状态(TASK_UNINTERRUPTIBLE)的进程处于内核路径上,正在等待一个无法被信号中断的资源。这类进程是 hang 问题、死锁分析和资源瓶颈定位的核心目标。

先明确一点:D 状态本身不是异常。 任何一次磁盘读写、页面换入都会让进程短暂进入 D 状态,在 I/O 密集负载下 ps 里出现若干 D 状态进程完全正常。

但"多久算异常"没有通用阈值。 常被引用的 120 秒来自 kernel.hung_task_timeout_secs 的默认值,它是内核打印告警的检测阈值,不是"业务可容忍上限"。二者混为一谈会造成双向误判:

  • 误判为正常: 数据库、低延迟交易类系统中,D 状态持续数秒就已构成严重故障,远未到 120 秒就该介入
  • 误判为异常: 慢速存储上一次大文件 fsync、iSCSI/NFS 链路抖动期间的重试,超过 120 秒仍可能属于预期行为

因此判断应基于该业务在正常时期的基线,而非固定数字。可用的参照方式:

参照维度做法
与同类进程横向比同批 worker 中只有个别卡住 → 可疑;整批都卡 → 更可能是共同的下游资源问题
与操作本身的量级比阻塞时长是否已远超该 I/O 或锁操作的正常耗时
hung_task 告警比dmesg 中出现 INFO: task ... blocked 说明已超阈值,属内核认定的异常;但没有告警不代表没问题
与自身历史比若有崩溃前的监控数据,对比该进程平时的 D 状态时长分布

ps -m 给出的 last-run 时间差是上述判断的输入,不是结论本身。

D 状态常见等待原因:

等待路径调用栈特征根因类型
块设备 I/Oio_schedule / blk_execute_rq磁盘 hang、存储路径问题
mutex/rwsemmutex_lock / down_write / __mutex_lock_slowpath锁竞争、死锁
等待任务完成wait_for_completion依赖的异步任务未完成
等待 page lockwait_on_page_locked / folio_wait_bit页面锁竞争
NFS / 网络文件系统nfs_wait_bit_killable服务端不可达
日志提交jbd2_log_wait_commit文件系统日志阻塞

批量查看:

# 所有 D 状态进程的调用栈
crash> foreach UN bt

# 在输出里搜索等待路径
crash> foreach UN bt 2>/dev/null | grep -E "mutex|down_write|io_schedule|wait_for"

四、内核栈:一个进程到底有几个栈

上一章确定了进程的状态,接下来要读它的执行现场。但在读栈之前,必须先回答一个容易被忽略的问题:一个进程在内核态执行时,用的到底是哪个栈?

答案不是"一个"。x86_64 与 ARM64 都为不同的执行上下文准备了独立的栈,bt 输出里的栈切换标记(第三篇 8.3 节讲过的 <IRQ><NMI>)正是跨栈的边界。搞不清有几个栈,就无法判断一段 bt 输出是否完整、pt_regs 应该出现在哪里。

4.1 进程内核栈的布局与定位

每个进程(线程)都有一个专属的内核栈,供普通内核态执行路径使用。通过 task_struct.stack 定位:

crash> struct task_struct.stack ffff888034ab1000
  stack = 0xffffc900001a4000    ← 内核栈的基地址(低地址端)

大小由 THREAD_SIZE 决定,典型值 16KB(4 页 @ 4KB),x86_64 与 ARM64 均是如此。这属于三轴中的编译配置轴,部分嵌入式配置会缩减到 8KB。

栈的范围是 stackstack + THREAD_SIZE栈向低地址增长,因此 stack 指向的是最低地址(栈底),初始栈顶在高地址端:

stack + THREAD_SIZE  +---------------------------+  <- initial SP (high address)
                     |                           |
                     |   kernel stack in use     |  grows downward
                     |                           |
       current SP    +---------------------------+  <- current stack top
                     |          ...              |
                     +---------------------------+
stack                |  STACK_END_MAGIC          |  <- guard value (low address)
                     +---------------------------+

编译配置差异: 内核栈落在哪个内存区,取决于 CONFIG_VMAP_STACK。开启时(现代内核默认)经 vmalloc 分配,x86_64 上地址形如 0xffffc9...;关闭时由页分配器直接分配,地址落在直接映射区。第五篇 2.2 节对此有详细说明——若照搬"内核栈在 vmalloc 区"的经验,在关闭该选项的调试内核上会判错。

4.2 x86_64 的多栈体系与 IST

x86_64 上,进程内核栈只是众多栈中的一个。硬件通过 IST(Interrupt Stack Table) 机制,允许特定异常向量强制切换到专用栈——这是为了应对"异常发生时当前栈本身就不可用"的场景(例如栈溢出、NMI 嵌套)。

x86_64 per-CPU stacks

  process kernel stack   : normal syscall / kernel execution path
                           per task, THREAD_SIZE (16K)

  IRQ stack              : hardware interrupt handling
                           per CPU, marked <IRQ> in bt output

  IST stacks (per CPU, switched by hardware via TSS):
    IST1  DOUBLEFAULT    : #DF, when exception handling itself faults
    IST2  NMI            : non-maskable interrupt
    IST3  DEBUG          : #DB, hardware breakpoints
    IST4  MCE            : machine check exception
    IST5  VC             : #VC, SEV-ES guest (newer kernels)

IST 栈的数量与用途随内核版本和功能配置增减——例如较早的内核没有 VC 栈,某些配置下条目数也不同。上表是当前主流内核的典型配置,实际以目标内核的 arch/x86/include/asm/page_64_types.h 为准。

IST 的关键性质:切换由硬件完成。 CPU 在分发这些异常向量时,直接从 TSS 中取出对应的栈指针装入 RSP,不依赖当前栈是否可用。这就是为什么栈溢出(当前栈已经写穿)仍能正常产生 #DF 处理和 vmcore——异常处理跑在另一块内存上。

bt 输出中,跨栈会以标记分隔:

crash> bt
PID: 1234   TASK: ffff888034ab1000  CPU: 3   COMMAND: "nginx"
 #0 [fffffe0000034e58] machine_kexec at ...
 #1 [fffffe0000034eb0] __crash_kexec at ...
 ...
--- <NMI exception stack> ---              <- 栈切换边界
 #5 [ffffc900001a3d40] native_queued_spin_lock_slowpath at ...
 ...
--- <IRQ stack> ---                        <- 又一次切换
 #9 [ffffc900001a7e58] ext4_writepages at ...

每一条 --- <...> --- 分隔线,都意味着一次栈切换,也意味着栈上存在一份 pt_regs——下一章会详细说明这个对应关系。

判断某个 SP 值属于哪个栈:

crash> help -m | grep -i stack        # 查看该架构的栈布局定义
crash> kmem <sp_value>                # 确认该地址所属的内存区域

4.3 ARM64 的多栈体系

ARM64 没有 x86_64 的 TSS/IST 硬件机制,但通过软件实现了类似的分栈:

ARM64 per-CPU stacks

  process kernel stack   : normal EL1 execution, per task, THREAD_SIZE

  IRQ stack              : per CPU, switched in software at el1_irq entry
                           introduced in v4.6, present in 4.19 LTS

  overflow stack         : per CPU, used when the guard page is hit
                           (CONFIG_VMAP_STACK)

  SDEI stacks            : per CPU, for firmware-first error handling
                           (CONFIG_ARM_SDE_INTERFACE, server platforms)

与 x86_64 最大的差异是:ARM64 的栈切换由软件在异常入口处完成,而非硬件自动。el1_irq 入口的汇编会显式判断并切到 IRQ 栈。

版本澄清: ARM64 的 per-CPU IRQ 栈自 v4.6 引入,并非新特性,4.19 LTS 上早已具备。(后来新增的通用 CONFIG_IRQ_STACKS 是把该机制抽象供更多架构复用的产物,不代表 ARM64 到那时才有。)

但"有没有独立 IRQ 栈"最终仍是编译配置决定的,属于三轴中的配置轴——绝大多数发行版内核都启用了,但不能假定必然存在。若目标内核未启用,中断就在被打断任务的内核栈上处理,也就不会出现跨栈现象。

这一点对开篇的 4.19 案例很关键:bt 之所以卡在栈边界,正是因为那台机器确实存在独立 IRQ 栈。

另一处差异是 bt 输出的呈现:ARM64 上 crash 是否打印 --- <IRQ stack> --- 这类分隔线,随 crash 版本而不同——较旧版本通常不打印,栈切换隐含在调用链中;部分较新版本已支持该标记。

因此在 ARM64 上,识别跨栈边界应以调用链中的 el1_irq / el1_sync 等异常入口符号为准,而不是依赖分隔线是否出现。开篇那份现场里的 el1_irq+0xb8/0x140 就是这样一个边界标志。这也是第三篇强调用异常入口符号作分界帧的原因。

4.4 thread_info 的位置演变

thread_info 存储线程的底层状态标志(TIF_* 系列,如 TIF_NEED_RESCHEDTIF_SIGPENDING),其位置经历过一次重要迁移,且两个架构的迁移时间点不同——这是三轴中版本轴与架构轴交叉的典型例子。

迁移之前: thread_info 位于内核栈的最低地址处,与栈共用同一块内存:

stack + THREAD_SIZE  +---------------------------+
                     |     kernel stack          |
                     |                           |
stack                +---------------------------+
                     |     thread_info           |  <- at the bottom of stack
                     +---------------------------+

访问方式是 (struct thread_info *)task->stack

迁移之后: thread_info 嵌入 task_struct 的第一个成员,不再与栈共享内存:

struct task_struct {
    struct thread_info thread_info;   /* embedded at offset 0 */
    unsigned int __state;
    void *stack;                      /* just a pointer to the kernel stack */
    ...
};

迁移时间点因架构而异,由 CONFIG_THREAD_INFO_IN_TASK 控制:

架构迁入版本说明
x86_644.9率先切换
ARM644.10紧随其后,仅隔一个版本

两个架构的切换时间点相近,因此 4.10 之后的绝大多数生产内核(含 4.19 LTS)都已是新布局thread_info 嵌在 task_struct 里。真正需要留意旧布局的,是 4.9 之前的老内核,以及至今仍未启用 CONFIG_THREAD_INFO_IN_TASK 的少数架构。

不要凭版本号推断,直接实测。 该选项由架构的 Kconfig select 决定,并非所有架构都跟进了。判断方式很直接——看 task_struct 的首个成员是不是 thread_info

crash> struct task_struct -o | head -3

是则为新布局,用 struct task_struct.thread_info <addr> 读取;否则为旧布局,须从 task->stack 处按 struct thread_info 解析。

这一变化的安全动因,是让 thread_info 中的敏感字段不再与可写的栈内存相邻,防止栈溢出直接覆盖它们——当年最关键的是 addr_limit 字段,覆写它可导致内核任意地址读写。

补充说明: addr_limit 及其配套的 set_fs() 机制已在 5.10 前后的一系列改动中从主流架构上彻底移除。在新内核里查不到这个字段属正常现象,上述动因应作为历史背景理解。

读取 thread_info(迁移后的内核):

crash> struct task_struct.thread_info ffff888034ab1000
  thread_info = {
    flags = 0x400,
    ...
  }

常见 TIF_* 标志位(x86_64,具体数值随版本变动,以目标内核头文件为准):

标志含义
0x0002TIF_SIGPENDING有待处理信号
0x0004TIF_NEED_RESCHED需要重新调度
0x0008TIF_SINGLESTEP单步调试中

TIF_NEED_RESCHED 置位而进程仍在运行,说明调度请求已发出但尚未完成切换。

这本身不构成异常判据。 在高负载系统上,"已标记需调度但还没切过去"是完全正常的瞬时状态——从置位到实际切换总有一段窗口。它真正有价值的场合是:当已知某个 CPU 长时间无调度进展时(例如 soft lockup 现场),该位置位可作为旁证,说明调度器确实想抢占但没能成功,从而支持"长循环未让出 / 抢占被禁用 / 自旋等待"一类的假设。单独看到这一位置位,不能推断出任何问题。

4.5 STACK_END_MAGIC 溢出检测

Linux 在每个进程内核栈的最低地址(即 task->stack 处)写入一个哨兵魔数,用于检测栈溢出。该值定义于 include/uapi/linux/magic.h

#define STACK_END_MAGIC 0x57AC6E9D

注意结尾是 9D。这个值在两个架构上完全一致,也不随版本变化,是少数可以放心记忆的常量之一。

crash> struct task_struct.stack ffff888034ab1000
  stack = 0xffffc900001a4000

crash> rd 0xffffc900001a4000 1
ffffc900001a4000:  0000000057ac6e9d        <- intact

若该位置被其他值覆盖,说明栈底哨兵已遭破坏,提示可能存在栈溢出或栈上越界写:

crash> rd 0xffffc900001a4000 1
ffffc900001a4000:  ffff888034ab1234        <- guard value destroyed

这条判据是单向的,两个方向都要留意:

magic destroyed  -> STRONG evidence of stack corruption / overflow
magic intact     -> does NOT rule out a stack overflow

魔数完好不能排除栈溢出。最典型的反例是开启 CONFIG_VMAP_STACK 时:栈下方紧邻一个未映射的 guard page,越界写会先触发缺页异常,此时魔数往往还没被覆盖,崩溃现场直接是 stack guard page was hit。此外,若溢出发生在栈的其他区域(例如某个中间帧被越界写破坏),栈底魔数同样可能完好无损。

因此这条检查适合作为辅助确认,不适合当作栈溢出的排除手段。

两点注意。 其一,该魔数只写在进程内核栈上,IRQ 栈、IST 栈、ARM64 的 overflow 栈都没有,不要拿这个方法去校验它们。其二,开启 CONFIG_VMAP_STACK 后,内核栈之后紧跟一个未映射的 guard page,溢出会立即触发缺页异常而非静默破坏内存,崩溃现场直接是 stack guard page was hit,比检查魔数更早暴露问题。魔数检查主要用于未开启该选项、或溢出方向异常的场景。


五、pt_regs:哪些切换会留下寄存器快照

第三篇 9.1 节给出过一条规律:bt 里的寄存器套数 = 该任务内核栈上 pt_regs 的份数。但那一篇没有回答:什么情况下会产生 pt_regs、什么情况下不会。这个问题属于进程执行上下文的范畴,在这里补全。

5.1 两类切换,两套保存机制

内核里有两类完全不同的"切换",很容易被混为一谈,但它们保存寄存器的方式截然不同:

Type A: PRIVILEGE / EXCEPTION LEVEL SWITCH        -> pt_regs
        user -> kernel, or kernel -> exception handler
        triggered by hardware (syscall, IRQ, fault)
        must save the COMPLETE register set,
        because the interrupted code knows nothing about it

Type B: TASK SWITCH (context switch)              -> NO pt_regs
        kernel -> kernel, same privilege level
        triggered by software (schedule())
        saves ONLY callee-saved registers,
        because it is just a function call following the ABI

判据只有一句话:执行流是被"打断"的,就需要 pt_regs;是主动"让出"的,就不需要。

这里必须先破除一个常见误解:pt_regs 与栈切换没有一一对应关系。 二者描述的是两个不同维度的事情:

pt_regs      describes an ENTRY EVENT
             (syscall / IRQ / fault saved the interrupted context)

stack switch describes WHICH STACK the code runs on
             (task stack -> IRQ stack / IST stack / etc.)

二者经常同时出现,但各自独立:用户态 syscall 陷入内核会产生 pt_regs却不发生栈切换(就在该任务自己的内核栈上);而普通的进程切换会更换内核栈,却不产生任何 pt_regs

因此判断有没有 pt_regs,依据始终是"是否发生了异常/中断/系统调用入口",不是"是否看到栈切换"

被打断的代码对此毫不知情,因此必须完整保存全部寄存器,将来才能原样恢复——这就是 pt_regs。而 schedule() 是当前代码主动调用的普通 C 函数,按 ABI 约定,caller-saved 寄存器由调用者自己负责,被调用方只需保存 callee-saved 那几个,压在栈上或存进 cpu_context 即可。

这也解释了第六章要讲的内容:睡眠进程没有 pt_regs,它的"最后姿态"必须从 thread.spcpu_context 里取。

5.2 会产生 pt_regs 的三种场景

场景一:用户态陷入内核。

系统调用、来自用户态的中断或异常,入口汇编会把用户态的完整寄存器压成一份 pt_regs,放在该进程内核栈的最底部(最高地址端):

stack + THREAD_SIZE  +---------------------------+
                     |  pt_regs (user context)   |  <- pushed at kernel entry
                     +---------------------------+
                     |  kernel execution ...     |
                     |          |                |
                     |          v                |
stack                +---------------------------+
架构入口说明
x86_64entry_SYSCALL_64 / asm_exc_*ORIG_RAX 存系统调用号
ARM64el0_sync / el0_irqkernel_entry 0 宏构造

这份 pt_regs 在整个系统调用期间常驻栈底,返回用户态时用于恢复现场。它是判断"这个进程从哪来"的依据——有它说明源自用户态,没有说明是内核线程。

场景二:内核态发生异常或中断。

缺页、BUG()、IRQ、NMI 等在内核态触发时,异常入口会再压一份 pt_regs,保存的是内核态当时的寄存器:

架构入口crash 中的标记
x86_64asm_exc_page_faultasm_exc_invalid_op[exception RIP: ...]
ARM64el1_syncel1_irq同上

若发生了栈切换(IRQ 栈、IST 栈),这份 pt_regs 通常压在切换前的原栈上,crash 以 --- <IRQ stack> --- 之类的分隔线标出边界。

场景三:新任务的初始 pt_regs。

fork() / kthread_create() 创建的新任务,其栈底有一份由内核构造的 pt_regs。对用户进程,它是父进程 pt_regs 的副本(RAX 改为 0 表示 fork 返回值);对内核线程,它是一份基本为零的占位结构。

新任务从 ret_from_fork 开始执行,用户进程最终会用这份 pt_regs 返回用户态;内核线程永远走不到那一步,它的这份初始现场全程不被使用。这也是内核线程崩溃时 bt 通常只有一套寄存器的原因(第三篇 9.3 节)。

不要把它记成"内核线程栈上必定有一份 pt_regs"。 新任务初始现场的构造方式是架构与版本相关的实现细节——不同架构在 copy_thread() 中的处理不同,内核线程与用户进程走的分支也不同,某些版本对内核线程只填充必要字段而非构造完整 pt_regs。可靠的做法是在目标 vmcore 上实测,而非套用通用结论。

5.3 不产生 pt_regs 的场景

场景一:进程切换(最重要)。

schedule()context_switch()__switch_to_asm / cpu_switch_to 这条路径不产生 pt_regs。它是同特权级的普通函数调用,只按 ABI 保存 callee-saved 寄存器。

这一点有个直接的实战后果:对睡眠进程执行 bt,看不到任何 [exception RIP] 标记,因为它是主动让出 CPU 的,没有任何异常发生。crash 展示的调用栈是从 thread.sp / cpu_context 重建的,起点固定是 __schedule 内部。

场景二:内核态的普通函数调用。

不必多说,这就是常规的栈帧压入,与 pt_regs 无关。

场景三:软中断(softirq)在同一栈上执行时。

x86_64 上 softirq 若在 IRQ 栈上直接接续执行,不会额外产生 pt_regs;只有真正切换到 ksoftirqd 线程时才发生进程切换(同样不产生 pt_regs)。

小结:

切换类型产生 pt_regs保存内容crash 中的表现
用户态 → 内核态全部寄存器(用户态)栈底一套,CS 为用户段
内核态异常 / 中断全部寄存器(内核态)[exception RIP] 标记
fork / kthread 创建是(构造)初始值内核线程的这份不被使用
进程切换 schedule()仅 callee-saved无标记,从 thread.sp 重建
内核态函数调用按 ABI普通栈帧

5.4 在 crash 中查看 pt_regs

方法一:bt 自动展开。

crash 遍历栈时识别出 pt_regs 结构,会自动展开并加 [exception RIP] 注记:

crash> bt
 #7 [ffffadfc4259bdc0] page_fault at ffffffffb1a00d0e
    [exception RIP: sysrq_handle_crash+18]
    RIP: ffffffffb1dc0152  RSP: ffffadfc4259be70  RFLAGS: 00010286
    ...
    ORIG_RAX: ffffffffffffffff  CS: 0010  SS: 0018

注意:[exception RIP] 是 crash 主动加的注记,不是内核 dmesg 的输出。这是第三篇 9.1 节强调过的一点。

方法二:bt -r 查看原始栈内容。

想确认 pt_regs 在栈上的确切位置,用 -r 打印整个栈的原始数据:

crash> bt -r

方法三:直接按结构体解析。

已知某个栈地址上是 pt_regs,可直接展开:

crash> struct pt_regs ffffadfc4259be70
struct pt_regs {
  r15 = 0x0,
  ...
  orig_ax = 0xffffffffffffffff,
  ip = 0xffffffffb1dc0152,
  cs = 0x10,
  flags = 0x10286,
  sp = 0xffffadfc4259be70,
  ss = 0x18
}

方法四:估算用户态那份 pt_regs 的位置。

在若干常见架构与入口路径下,用户态 pt_regs 位于内核栈高地址端附近,可按下式估算起点:

estimated address = task->stack + THREAD_SIZE - sizeof(struct pt_regs)

这不是跨架构通用公式,只是一个起手估算。 实际位置取决于该架构入口汇编的具体布局——是否额外预留了对齐间隙、是否在 pt_regs 之上还压了其他内容、THREAD_SIZE 与栈顶是否严格对齐,各版本处理并不一致。ARM64 与 x86_64 的入口路径差异尤其明显。

正确用法是:先估算,再用 struct pt_regs <addr> 验证内容是否合理CS/pstate 是否指向用户态、RIP/pc 是否落在用户地址区间、栈指针是否在用户空间)。若字段明显不合理,说明估算偏了,应回头查该内核的入口汇编确定实际布局,而不是直接采信算出来的地址。

crash> struct task_struct.stack ffff888034ab1000
  stack = 0xffffc900001a4000

crash> eval 0xffffc900001a4000 + 16384 - 168
hexadecimal: ffffc900001a7f58

crash> struct pt_regs ffffc900001a7f58

sizeof(struct pt_regs) 随架构与版本变化,用 struct pt_regs 不带地址即可查看其大小。对内核线程执行这一步,会读到那份全零的占位结构。

快速判别一套寄存器属于哪一层:

判据用户态 pt_regs内核态 pt_regs
x86_64 CS0x330x10
x86_64 RIP0x00007f...0xffffffff...
x86_64 ORIG_RAX有效系统调用号-1(哨兵值)
ARM64 pstate 的 EL 位EL0EL1
ARM64 pc低半区地址0xffff...

5.5 栈切换与 pt_regs 的对应关系

把第四章的多栈体系与本章的 pt_regs 规则合起来,就得到了完整的图景:

Scenario: a hardware IRQ hits while the task is in a syscall,
          and a page fault occurs inside the IRQ handler (x86_64)

  process kernel stack
    +------------------------------+
    |  pt_regs  (user context)     |  <- [1] pushed at syscall entry
    +------------------------------+
    |  vfs_write / ksys_write ...  |
    +------------------------------+
    |  pt_regs  (kernel context)   |  <- [2] pushed when IRQ arrives
    +------------------------------+
              |
              v  hardware switches RSP to IRQ stack
  IRQ stack
    +------------------------------+
    |  handle_irq / driver ISR     |
    +------------------------------+
    |  pt_regs  (kernel context)   |  <- [3] pushed by page fault
    +------------------------------+
    |  do_page_fault ...           |
    +------------------------------+

这个例子里栈上共有三份 pt_regsbt 会输出三套寄存器,并以两条分隔线标出栈边界。逐套核对 CSRIP 即可确定每套属于哪一层:[1] 是用户态,[2][3] 都是内核态。

几条实用推论:

  1. bt 中每出现一条 --- <...> --- 分隔线,其附近通常能找到一份 pt_regs,但二者不是一一对应关系。 准确的因果是:pt_regs 由异常/中断入口产生,栈切换是某些入口流程中的一步,二者常伴随出现而已(见 5.1 节的说明)。 尤其要注意:pt_regs 与栈切换的先后顺序不存在跨架构通用规则,它取决于架构、异常类型、以及是否使用 IST / IRQ stack:

    • x86_64 IST 异常:硬件先切栈(CPU 从 TSS 装载 RSP),入口代码随后在新栈上构造现场
    • x86_64 IRQ stack:由软件在入口流程中切换,pt_regs 的落点取决于该版本的入口汇编
    • ARM64:el1_irq 入口由软件判断并切栈,同样需看具体版本的 entry.S

    因此判断 pt_regs 在哪个栈上、位于什么位置,必须结合目标内核的入口汇编,不能靠通用规律推断。分析时的可靠依据仍是 5.1 节的总判据:执行流是否被打断。

  2. 内核线程的栈上只有一份不被使用的初始 pt_regs,若崩溃发生在它身上,bt 只显示一套异常寄存器

  3. 睡眠进程的 bt 完全没有 pt_regs 展开,只有从 thread.sp 重建的普通栈帧

  4. pt_regs 的份数反映了"嵌套深度" —— 三份以上说明在中断中又发生了异常,这类现场需格外小心,因为嵌套本身可能就是问题的一部分

5.6 当 bt 读不到 pt_regs:三类失败场景

前面讲的是理想情况。实战中经常遇到 bt 一份 pt_regs 都不展开,甚至连栈都走不完——开篇那份 jbd2 现场就是如此。

先看实际输出(CPU 79,触发 panic 的那颗):

crash> bt
PID: 2896966  TASK: ffff8b5408058380  CPU: 79  COMMAND: "jbd2/sdc1-8"
 #0 [ffffab54bf4dfbf0] __crash_kexec at ffff3ab9a98cf8a0
 #1 [ffffab54bf4dfd60] panic at ffff3ab9a9809cc4
 #2 [ffffab54bf4dfe40] watchdog_timer_fn at ffff3ab9a9906878
 #3 [ffffab54bf4dfea0] __hrtimer_run_queues at ffff3ab9a98aa070
 #4 [ffffab54bf4dff20] hrtimer_interrupt at ffff3ab9a98aaf14
 #5 [ffffab54bf4dff90] arch_timer_handler_phys at ffff3ab9aa0eec14
 #6 [ffffab54bf4dffb0] handle_percpu_devid_irq at ffff3ab9a9889c8c
 #7 [ffffab54bf4dfff0] generic_handle_irq at ffff3ab9a98820e8
 #8 [ffffab54bf4e0010] __handle_domain_irq at ffff3ab9a9882a04

没有任何 pt_regs 被展开,也没有栈切换标记,而且栈到 __handle_domain_irq 就断了。 对照第一章那份 dmesg,内核自己打印的调用链在这之后还有 gic_handle_irqel1_irqext4_process_freed_data 等六帧。

失败场景一:展开器无法跨越栈边界。

留意帧地址的走向:从 bf4dfbf0 逐帧升高,最后一帧 bf4e0010 已经越过 bf4e0000 这个整齐的边界——这正是 IRQ 栈的顶端。crash 走到栈尽头,需要解析 el1_irq 压入的那份 pt_regs 才能跳回任务栈继续,而它没能做到。

原因是两套展开器的能力差异:

展开器el1_irq 边界说明
内核 dump_backtrace内置 on_irq_stack() 判断,主动定位 pt_regs 跳栈
crash 的 ARM64 后端常失败依赖符号识别异常入口,旧版本与旧内核组合下经常断链

由此得到一条 ARM64 特有的实战经验:crash bt 断链时,回头去看 dmesg 里内核自己打的那份 backtrace,它往往更完整。log 命令即可取得:

crash> log | grep -A30 "Call trace"

x86_64 上这个问题轻得多——crash 对 asm_exc_* 系列入口的识别成熟,且会明确打印 --- <IRQ stack> --- 分隔线。

失败场景二:crash_notes 缺失,连起点都没有。

同一份 vmcore,换到 CPU 95:

crash> bt -c 95
PID: 2896829  TASK: ffff8b540805c180  CPU: 95  COMMAND: "jbd2/sdb1-8"
bt: WARNING: cannot determine starting stack frame for task ffff8b540805c180

一帧都没有。原因要回到第一篇讲的 kdump 机制——再看一眼这份 vmcore 的 dmesg 尾部:

SMP: stopping secondary CPUs
SMP: failed to stop secondary CPUs 0-78,80-95
------------[ cut here ]------------
Some CPUs may be stale, kdump will be unreliable.

CPU 95 从未响应停机 IPI,它的 crash_notes 区域根本没有被填写。on_cpu = 1 的进程,其寄存器快照正是从 crash_notes 取的(见 6.1 节)。没有寄存器就没有 SP、没有 PC,展开无从起步。

内核自己已经就此告警——kdump will be unreliable 这行的分量常被低估,它意味着这份 vmcore 里未停止 CPU 的现场全部不可信

判断方法:

crash> log | grep -i "failed to stop\|may be stale\|unreliable"

有这类输出,就要预期部分 CPU 的 bt 不可用,并且不能拿它们的状态做推论。

失败场景三:栈本身已损坏。

栈溢出、越界写入破坏了帧指针链,展开器读到非法地址后中止。特征是 bt 输出中出现明显不合法的地址,或帧数异常少。此时 bt -f(打印栈原始内容)与 4.5 节的 STACK_END_MAGIC 检查是替代手段。

三类失败的判别:

现象场景应对
栈走到一半停在中断入口附近展开器跨栈失败改看 dmesg 的 Call trace
cannot determine starting stack framecrash_notes 缺失查 dmesg 是否有 CPU 未停止;该 CPU 数据不可信
帧地址非法、帧数异常少栈损坏bt -f 读原始栈,查 STACK_END_MAGIC

回到开篇现场的结论。 jbd2 是内核线程,崩溃由 watchdog 中断触发,理论上栈上应有 el1_irq 压入的一份内核态 pt_regs。但实际 bt 一份也没展开——CPU 79 是展开器跨栈失败,CPU 95 是 crash_notes 缺失。

这恰恰印证了本章的核心:pt_regs 是否存在于栈上,与 crash 是否能把它读出来,是两个不同的问题。 前者由内核机制决定,后者还取决于工具能力、架构支持和 vmcore 采集质量。分析时必须把二者分开看,否则会因为"bt 里没有异常帧"而错误推断"没有发生异常"。

顺带一个可用作实测印证的细节。查看 CPU 95 的上下文:

crash> set -c 95
    PID: 2896829
COMMAND: "jbd2/sdb1-8"
   TASK: ffff8b540805c180  [THREAD_INFO: ffff8b540805c180]
    CPU: 95
  STATE: TASK_RUNNING (ACTIVE)

TASKTHREAD_INFO 地址完全相同,说明 thread_info 就位于 task_struct 偏移 0 处——这实测确认了 4.4 节的结论:4.19 的 ARM64 内核已是新布局,不必去栈底找。这也是"实测优于版本推断"的一个具体例证。


六、调度上下文:睡眠进程的寄存器在哪里

6.1 确认崩溃时哪个进程在 CPU 上

panic 触发时,每个 CPU 上都有一个正在运行的进程。crash 启动摘要里的 PIDCOMMAND 只是触发 panic 的那个 CPU 上的进程,其余 CPU 需要单独查看。

方法一:runq 命令(推荐)

crash 提供了专门的运行队列命令,比手工读 per-CPU 变量直观得多:

crash> runq
CPU 0 RUNQUEUE: ffff88803fc2ae40
  CURRENT: PID: 0      TASK: ffffffff82614940  COMMAND: "swapper/0"
  RT PRIO_ARRAY: ffff88803fc2b000
     [no tasks queued]
  CFS RB_ROOT: ffff88803fc2aee8
     [no tasks queued]

CPU 3 RUNQUEUE: ffff88803fd2ae40
  CURRENT: PID: 9012   TASK: ffff888034ab1000  COMMAND: "mysqld"
  CFS RB_ROOT: ffff88803fd2aee8
     [120] PID: 1234   TASK: ffff888034bc2000  COMMAND: "nginx"

每个 CPU 的 CURRENT 即该 CPU 运行队列中记录的当前任务,其下列出的是排队等待的进程。若某个 CPU 的就绪队列里堆积了大量进程,说明该 CPU 存在调度压力或被长时间占用

可信度限定: runq 读的是内存中的 runqueue 结构,它反映的是内核数据结构在 vmcore 落盘时的状态,与"该 CPU 崩溃瞬间的寄存器现场"是两码事。对于成功响应停机 IPI 的 CPU,二者一致,CURRENT 可作为可靠依据;对于未能停止的 CPU(dmesg 中的 failed to stop secondary CPUs),其内存结构仍可读,但可能已经陈旧或处于中间状态,只能作参考,不能直接当作崩溃瞬间的确定现场。分析前先按 5.6 节的方法确认哪些 CPU 成功停止。

配合时间戳查看:

crash> runq -t          # 显示每个进程的最后运行时间戳

方法二:bt -a

crash> bt -a
PID: 0      CPU: 0   COMMAND: "swapper/0"     <- CPU0 idle
PID: 1234   CPU: 1   COMMAND: "nginx"
PID: 5678   CPU: 2   COMMAND: "kworker/2:1"
PID: 9012   CPU: 3   COMMAND: "mysqld"        <- panic CPU

bt -a 在给出进程的同时输出各自的调用栈,适合直接进入分析;runq 则更适合看整体调度分布。二者互补。

判断单个进程是否在 CPU 上:

crash> struct task_struct.on_cpu ffff888034ab1000
  on_cpu = 1    ← 正在某 CPU 上执行

对于 panic 瞬间正在某 CPU 上执行的任务,其 CPU 寄存器现场通常来自该 CPU 保存到 crash_notes(体现为 vmcore 的 PT_NOTE 段)的快照,crash bt 据此展示完整现场。

on_cpu = 1 与"寄存器一定可读"不是一回事。 on_cpu 是内存里 task_struct 的一个字段,反映的是崩溃瞬间该任务的调度状态;而寄存器快照是否存在,取决于该 CPU 有没有成功走完 kdump 的停机保存流程。二者来源不同,可能不一致:

on_cpu = 1        -> task was running (from task_struct in memory)
crash_notes valid -> that CPU responded to the stop IPI and saved regs

开篇案例中的 CPU 95 正是二者背离的实例:on_cpu 显示它在运行,但它自己就是 soft lockup 的那颗,从未响应停机 IPI,crash_notes 为空——bt 因此完全失败(详见 5.6 节)。

上一章说明了睡眠进程没有 pt_regs——它是主动让出 CPU 的,没有任何异常发生。那么它的"最后姿态"保存在哪里?答案是上下文切换机制留下的那几个 callee-saved 寄存器,而保存位置因架构而异,这是本篇最重要的一处架构轴差异。

6.2 x86_64:寄存器保存在内核栈上

x86_64 的上下文切换由 __switch_to_asmarch/x86/entry/entry_64.S)与 __switch_toarch/x86/kernel/process_64.c)配合完成。前者将 callee-saved 寄存器压入当前进程的内核栈,并把切换后的栈指针保存到 task_struct.thread.sp

以下汇编与压栈顺序以某一版本的 __switch_to_asm 为例——保存哪些寄存器由 ABI 约定(callee-saved 集合稳定),但压栈顺序、是否额外保存其他内容属于实现细节,分析时应以目标内核的 __switch_to_asm 为准:

__switch_to_asm:                    /* arch/x86/entry/entry_64.S */
    pushq  %rbp
    pushq  %rbx
    pushq  %r12
    pushq  %r13
    pushq  %r14
    pushq  %r15

    movq   %rsp, TASK_threadsp(%rdi)    /* prev->thread.sp = rsp */
    movq   TASK_threadsp(%rsi), %rsp    /* rsp = next->thread.sp */

    popq   %r15
    popq   %r14
    popq   %r13
    popq   %r12
    popq   %rbx
    popq   %rbp
    jmp    __switch_to

栈向低地址增长,因此按上述顺序,rbp 最先压入、地址最高,r15 最后压入、地址最低。从 thread.sp 开始向高地址读取,顺序即为 r15 → r14 → r13 → r12 → rbx → rbp → 返回地址

若目标内核的压栈顺序不同,读出的对应关系也随之变化——可先反汇编确认:

crash> dis __switch_to_asm
# 找到睡眠进程保存的栈指针
crash> struct task_struct.thread.sp ffff888034bc2000
  thread.sp = 0xffffc900001b4ef0

# 从该地址读出被压入的 callee-saved 寄存器
crash> rd 0xffffc900001b4ef0 7
ffffc900001b4ef0:  ffff888034bc3ab0    <- r15
ffffc900001b4ef8:  0000000000000000    <- r14
ffffc900001b4f00:  0000000000000001    <- r13
ffffc900001b4f08:  ffff888098765000    <- r12
ffffc900001b4f10:  ffff8880a1b2c000    <- rbx
ffffc900001b4f18:  ffffc900001b4f60    <- rbp
ffffc900001b4f20:  ffffffff81234567    <- return address (into __schedule)

最后那个返回地址是关键:它指向 __schedule() 内部调用 context_switch() 之后的位置。这也解释了为什么 crash bt 能对睡眠进程展开调用栈——它从 thread.sp 出发,按普通栈展开的方式向上回溯,效果等同于"这个线程刚从 schedule() 返回"。

6.3 ARM64:寄存器保存在 cpu_context 里

ARM64 的做法完全不同:上下文切换函数 cpu_switch_toarch/arm64/kernel/entry.S)把 callee-saved 寄存器 x19x28、帧指针 x29、链接寄存器 x30 以及 SP 成对写入 task_struct.thread.cpu_context 结构体,而不是压栈:

cpu_switch_to:                      /* arch/arm64/kernel/entry.S */
    mov    x10, #THREAD_CPU_CONTEXT
    add    x8, x0, x10              /* x8 = &prev->thread.cpu_context */
    mov    x9, sp
    stp    x19, x20, [x8], #16      /* store callee-saved registers */
    stp    x21, x22, [x8], #16
    stp    x23, x24, [x8], #16
    stp    x25, x26, [x8], #16
    stp    x27, x28, [x8], #16
    stp    x29,  x9, [x8], #16      /* fp and sp */
    str    lr,      [x8]            /* pc = lr (return address) */
    ...

对睡眠中的 ARM64 进程,直接读结构体即可,无需解析栈:

crash> struct task_struct.thread.cpu_context ffff888034bc2000
  cpu_context = {
    x19 = 0xffff888098abc000,
    x20 = 0x0000000000000003,
    x21 = 0xffff888034bc2000,
    x22 = 0x0000000000000000,
    x23 = 0x0000000000000000,
    x24 = 0x0000000000000001,
    x25 = 0xffff888098765432,
    x26 = 0x0000000000000000,
    x27 = 0x0000000000000000,
    x28 = 0x0000000000000000,
    fp  = 0xffff800009abc890,    <- x29, frame pointer: stack unwind starts here
    sp  = 0xffff800009abc870,    <- stack pointer
    pc  = 0xffff8000080a765c     <- resume address (where it will continue)
  }

关于 cpu_context.pc,有一处极易误读,务必厘清: 它存的是切换时的 lr(链接寄存器),语义是该任务下次被调度恢复时将从哪里继续执行,而不是"崩溃瞬间这个进程的 PC 停在哪里"。

cpu_context.pc  ==  RESUME address (where it will continue)
                !=  the exact PC at the moment of the crash

举例说明这个区别的实际后果:读到 pc = schedule+0x2a8,正确的理解是"这个任务被切出时留下的恢复入口在 schedule 内部",而不是"崩溃时它正在执行 schedule+0x2a8"——事实上崩溃发生时,这个 CPU 上跑的是完全另一个任务。睡眠进程根本没有"崩溃瞬间的 PC"这回事,它早已不在 CPU 上。

这一点与 x86_64 栈上那个返回地址语义完全对等(都指向 __schedule 内部的恢复点),差别只在 ARM64 无需读栈即可直接获得

两个架构的对照:

维度x86_64ARM64
保存位置内核栈(经 thread.sp 定位)thread.cpu_context 结构体
访问方式rd <thread.sp> 逐字读栈struct ... .thread.cpu_context 直接读
保存的寄存器rbp, rbx, r12-r15(压栈)x19-x28, x29(fp), x30(lr), sp
栈展开起点thread.sp 展开cpu_context.fp / sp 展开
切换点 PC需从栈上的返回地址推断cpu_context.pc 直接可读

七、线程组、内核线程与僵尸进程

7.1 pid 与 tgid:线程的双重身份

Linux 内核用同一个 task_struct 描述进程和线程,靠 pid / tgid 区分角色:

  • pid 内核分配的唯一线程 ID,每个 task_struct 各不相同
  • tgid 线程组 ID,等于该线程组主线程的 pid,同一进程的所有线程共享

从用户态视角看,二者的对应关系是:

用户态调用返回的内核字段
getpid()tgid(进程 ID)
gettid()pid(线程 ID)

这个错位是许多混淆的根源:ps 命令默认显示的 PID 其实是 tgid,而 crash 的 ps 显示的是内核 pid(即线程 ID)。在多线程程序的 vmcore 里,二者数值不同属正常现象。

# 查看一个进程的线程组
crash> ps -G 1234
   PID    PPID  CPU       TASK        ST  COMM
   1234    1000   3  ffff888034ab1000  IN  nginx      <- main thread (tgid == pid)
   1238    1000   0  ffff888034bc2000  UN  nginx      <- tgid=1234, pid=1238
   1239    1000   1  ffff888034cd3000  IN  nginx      <- tgid=1234, pid=1239

# 验证
crash> struct task_struct.pid,tgid ffff888034bc2000
  pid = 1238    ← 线程自身的 ID
  tgid = 1234   ← 等于主线程的 pid

遍历同一线程组的所有线程:

crash> list -H ffff888034ab1000 -o task_struct.thread_group \
       -s task_struct.pid,comm

list 的参数形式在不同 crash 版本间存在差异,用前先 help list 确认。

7.2 内核线程的识别与特殊性

识别内核线程的三种方式,任一成立即可确认:

# 方式一:mm 为 NULL(最可靠)
crash> struct task_struct.mm ffff888100b2c000
  mm = 0x0

# 方式二:flags 含 PF_KTHREAD
crash> struct task_struct.flags ffff888100b2c000
  flags = 0x200000        ← PF_KTHREAD

# 方式三:ps -k 直接筛选
crash> ps -k

内核线程在 vmcore 分析中有三点特殊性:

  1. 没有用户地址空间,vm 命令无输出
  2. active_mm 借用前一个用户进程的 mm_struct,但不真正使用其用户地址空间
  3. 崩溃发生在内核线程时,crash bt 只输出一套寄存器(无用户态入口帧),原因见第三篇 9.3 节,机制见本篇 5.2 节

常见内核线程及其分析价值:

进程名用途D 状态时的含义
kworker/N:M工作队列 workerworkqueue 中有任务阻塞
kswapd0内存回收内存压力极高,回收跟不上分配
jbd2/sdX-8ext4 日志提交磁盘 I/O 或日志空间问题
khungtaskdhung task 检测该线程本身活跃说明检测机制在工作
migration/N进程迁移调度类为 stop_sched_class
oom_reaperOOM 后异步回收见第五篇 6.3 节

jbd2 线程阻塞是生产环境的高频场景。当某些文件系统操作需要等待尚未完成的 journal transaction commit 时,jbd2 的阻塞会形成下游等待,可能波及大量业务进程。

但影响范围并非"所有写操作",而取决于多个因素:该操作是否处于某个 journaling transaction 中、是否需要等待 commit 完成、等待的是哪一个 transaction、文件系统的 data 模式(journal / ordered / writeback)、以及当前的 handle 与 transaction 状态。因此实际现场里,有的写操作会卡住,有的可能不受影响。

判断是否被 jbd2 牵连,要看下游进程的调用栈是否确实落在 jbd2_log_wait_commit 这类等待路径上,而非仅凭"同一文件系统"推断。

7.3 僵尸进程的成因与现场特征

僵尸进程是已退出但父进程尚未调用 wait() 回收的进程。其 task_struct 与 PID 仍被占用,其余资源已释放。

crash> struct task_struct.exit_state,exit_code,mm ffff888034cd3000
  exit_state = 32      ← EXIT_ZOMBIE (0x20)
  exit_code = 0        ← 退出码(编码了 WTERMSIG / WEXITSTATUS)
  mm = 0x0             ← 用户内存已释放

单个僵尸进程占用的资源极少,但大量堆积(数百以上)意味着以下三种情况之一:

  1. 父进程持续 fork 而从不 wait() —— 父进程自身的逻辑缺陷,最常见
  2. 父进程卡住,无法执行 wait() —— 父进程可能处于 D 状态或死锁,需回到 3.4 节排查
  3. wait() 回收逻辑存在缺陷 —— 例如只在特定分支回收、或回收数量跟不上创建速度

一处常见的错误归因:不要把"忽略 SIGCHLD"当作僵尸成因。 恰恰相反——Linux 对 signal(SIGCHLD, SIG_IGN)SA_NOCLDWAIT 有特殊语义:设置后子进程退出不会保留僵尸,由内核直接回收。因此"忽略 SIGCHLD"是消除僵尸的手段之一,而非产生僵尸的原因。

信号相关的因素中,真正可能导致僵尸堆积的是父进程的 SIGCHLD 处理函数本身有问题(例如 handler 中只 wait() 一次而未循环处理,无法应对信号合并的情况)。这属于"回收逻辑缺陷"的一种,排查时值得检查,但不能反过来说"忽略 SIGCHLD 导致僵尸"。

排查路径是找到父进程并检查它的状态:

# 找到僵尸进程的父进程(注意用 real_parent)
crash> struct task_struct.real_parent ffff888034cd3000
  real_parent = 0xffff888034ab1000

crash> struct task_struct.pid,comm,__state 0xffff888034ab1000
  pid = 1234
  comm = "nginx"
  __state = 0          ← TASK_RUNNING,父进程活着却没回收

# 查看父进程在做什么
crash> set 1234
crash> bt

若父进程 __state = 2(D 状态),则问题的性质就变了——不是"不肯回收",而是"没能力回收",应转入 D 状态分析流程。


八、实战方法论:四种典型场景

8.1 定位触发崩溃的进程

目标: 确认是哪个进程的哪个操作触发了 panic。

第一步:读 crash 启动摘要。

PID: 9012
COMMAND: "mysqld"
CPU: 3
PANIC: "kernel BUG at mm/slub.c:398!"

PIDCOMMAND 给出了触发进程。但要牢记:触发 panic 的进程不一定是有 bug 的进程,它可能只是恰好触碰到了某个已被他人破坏的内核对象。第五篇 8.4 节讲的越界写入就是典型——崩溃点的函数往往是受害者而非肇事者。

第二步:展开触发进程的完整状态。

crash> set 9012
crash> task              # 完整 task_struct,含内存、信号状态
crash> files             # 打开的文件描述符,I/O 类崩溃直接指向问题资源
crash> vm                # 内存映射,检查是否有异常 VMA

第三步:排除并发因素。

crash> runq              # 各 CPU 当前进程与就绪队列
crash> bt -a             # 各 CPU 的调用栈

若多个 CPU 上的进程处于同一内核路径(例如都在操作同一个 inode 或 socket),应转入 8.4 节的并发场景分析。

8.2 D 状态挂起分析

目标: 找出 D 状态进程在等什么、为什么等不到。

第一步:按时长排序,圈定源头。

crash> ps -m | grep UN
[0 00:03:47.221] [UN]  PID: 1235  ...  COMMAND: "nginx-worker"
[0 00:03:46.887] [UN]  PID: 1240  ...  COMMAND: "kworker/2:3"
[0 02:14:09.883] [UN]  PID: 1245  ...  COMMAND: "jbd2/sda1-8"    <- 最久

时间最长的那个通常离源头更近。本例中 jbd2 已 2 小时未被调度,而两个业务进程只有 3 分钟——提示 jbd2 可能先卡住、其余进程被它牵连,因此优先从 jbd2 入手排查。

这只是排序线索,不是结论。真正确认"谁牵连谁"要看第二、三步:业务进程的调用栈是否确实卡在依赖 jbd2 的路径上(如 jbd2_log_wait_commit),以及它们等待的对象是否由 jbd2 持有。若查下来发现二者并无依赖关系,就说明它们是两个独立故障,时间差只是巧合。

第二步:批量查看调用栈,找共同的等待点。

crash> foreach UN bt 2>/dev/null | \
       grep -B3 "mutex_lock\|down_write\|io_schedule\|wait_for_completion"

第三步:对目标进程深入,找出等待的具体对象。

crash> set 1245
crash> bt -f             # 展开栈帧内容,从中提取锁或队列的地址

以 mutex 为例,拿到地址后展开:

crash> struct mutex 0xffff888098abc000
  owner = {
    counter = 0xffff888034ab1001     ← 低位为标志位
  }
  wait_lock = ...
  wait_list = {
    next = 0xffffc900001b4e80,       ← 等待队列非空
    prev = ...
  }

owner 字段的低 3 位是标志位,须先清除低位才能得到有效的 task_struct 地址

/* include/linux/mutex.h */
#define MUTEX_FLAG_WAITERS  0x01
#define MUTEX_FLAG_HANDOFF  0x02
#define MUTEX_FLAG_PICKUP   0x04
#define MUTEX_FLAGS         0x07
crash> eval 0xffff888034ab1001 \& ~7
hexadecimal: ffff888034ab1000

crash> struct task_struct.pid,comm,__state ffff888034ab1000
  pid = 9012
  comm = "mysqld"
  __state = 0

版本提示: 上述三个标志位自 4.10 的 mutex 重写以来保持稳定,MUTEX_FLAGS 一直是 0x07。但既然是内核内部实现细节,仍建议以目标内核的 include/linux/mutex.h 为准;掩码若有变化,& ~7 需相应调整。此外 owner 的字段形式也随版本不同——较新内核是 atomic_long_t owner(如上例需读 .counter),更早的版本可能是裸指针。

第四步:判断是死锁还是单向等待。

crash> set 9012
crash> bt
  • 持有者正常运行(RU)→ 单向等待,可能只是持锁时间过长
  • 持有者已不存在 → 锁被泄漏,持有者退出前未释放
  • 持有者也处于 D 状态 → 这只是需要继续追查的信号,不能据此判定死锁

不要把"owner 处于 D 状态"直接等价于死锁。 D 状态只说明它在等某个不可中断的资源,可能是磁盘 I/O、可能是等另一个 completion,未必是在等锁;即使确实在等锁,也未必等的是本进程持有的那把。

判定死锁需要构造出闭合的等待图,缺一不可:

1. confirm owner's stack really sits in a lock-wait path
   (mutex_lock / down_write / rwsem slowpath ...)
2. identify WHICH lock object it is waiting on
3. find THAT lock's owner
4. repeat until the chain closes back to the starting task

只有第 4 步真正闭环,才能判定死锁。若追到某一环发现对方在等 I/O 而非等锁,那就不是死锁,而是一条以 I/O 阻塞为源头的单向等待链——处理方式完全不同。

第七篇会系统展开锁的等待链还原方法。

8.3 僵尸进程堆积分析

目标: 确认父进程为什么没有回收子进程。

# 统计僵尸数量
crash> ps | grep " ZO " | wc -l
127

# 归并父进程 PID,找出共同来源
crash> ps | grep " ZO " | awk '{print $2}' | sort | uniq -c | sort -rn
    127 1234           ← 全部来自 pid 1234

# 检查该父进程的状态
crash> ps -m | grep " 1234 "
crash> set 1234
crash> bt

判读要点:

  • 父进程 RU/IN 且调用栈正常 → 应用层逻辑问题,未调用 wait()
  • 父进程 UN → 它自己也被卡住,转 8.2 节
  • 父进程 PID 为 1 → systemd 未及时收割,检查 init 自身状态

8.4 多进程参与的崩溃场景

某些崩溃由多个进程协作触发(竞态、共享对象的并发访问)。其特征是:

  • panic header 中的进程本身逻辑正常,但操作了已被损坏的对象
  • bt -arunq 显示多个 CPU 上的进程处于相关的内核路径

分析流程:

# 1. 全局视图:各 CPU 在做什么
crash> runq
crash> bt -a

# 2. 圈定嫌疑:运行中的进程
crash> ps | grep " RU "

# 3. 逐个查看调用栈
crash> set <pid>
crash> bt

# 4. 找出它们共同访问的对象
#    对象地址通常存于 callee-saved 寄存器:
#    x86_64 -> RBX / R12-R15
#    ARM64  -> X19-X28
crash> struct <type> <addr>

第四步是关键:callee-saved 寄存器跨函数调用保持不变,因此常存放着上层函数持有的对象指针。若多个执行流的这类寄存器或栈帧指向同一个 slab 对象、inode 或 socket,可以确认它们之间存在共享对象关联

但"访问同一对象"不等于"正在竞争该对象"。 两个进程同时持有同一个 inode 指针,完全可能只是正常的并发读——共享本身是内核的常态,不是异常信号。

要确认存在竞争,还须进一步取证:

shared object address           -> only proves ASSOCIATION
+ lock / waiter / owner state   -> contention evidence
+ refcount anomaly              -> lifetime problem
+ object state inconsistency    -> corruption evidence
+ conflicting access paths      -> race candidate

换言之,共享对象地址只是把排查范围收敛到了这个对象上,它是调查的起点而非结论。接下来该做的是检查该对象上的锁状态、引用计数是否合理、各执行流的访问路径是否构成写-写或读-写冲突。确认对象身份与状态的方法见第五篇第五章。


九、回到开篇:jbd2 现场的分析路径

现在用 1.3 节的三问动线,把开篇那份 4.19 ARM64 现场走完。这个案例的价值在于:它几乎每一步都不顺利,恰好把本篇讲的各种坑集中演示了一遍。

第 0 步:确认三轴

crash> sys
      MACHINE: aarch64
      RELEASE: 4.19.90-23.42.v2101.ky10.aarch64

架构 aarch64、版本 4.19。据 1.4 节的表,这份 vmcore 上有两处需注意:状态字段名是 state 而非 __state(5.14 才改名);按第五篇 2.3 节,内存布局是 5.4 翻转之前的,线性映射在 ffff8000... 而非 ffff0000...

thread_info 已嵌入 task_struct(ARM64 于 4.10 完成迁移),可直接读取。实测印证:

crash> set -c 95
   TASK: ffff8b540805c180  [THREAD_INFO: ffff8b540805c180]

TASKTHREAD_INFO 地址相同,即 thread_info 位于偏移 0 处——新布局确认。不要因为"4.19 是老版本"就假定所有特性都是旧形态,各特性的分界点互不相同。

Q1:谁在现场

crash> ps | grep jbd2

两个 jbd2 内核线程,PPID 均为 2(kthreadd),分别服务两块磁盘:

PIDCPUCOMMANDTASK
289696679jbd2/sdc1-8ffff8b5408058380
289682995jbd2/sdb1-8ffff8b540805c180

按第七章的判据(PPID=2、mm == NULL、RSS 为 0),确认是内核线程。

Q2:各自是什么姿态

这一步立刻推翻了一个常见的先入之见。

crash> set -c 95
    PID: 2896829
COMMAND: "jbd2/sdb1-8"
    CPU: 95
  STATE: TASK_RUNNING (ACTIVE)

状态是 TASK_RUNNING (ACTIVE),不是 D 状态。crash 的 (ACTIVE) 标注意味着该任务不仅是 TASK_RUNNING,而且确实在某个 CPU 上执行(对应 on_cpu = 1)——这个补充信息很关键,因为按 3.1 节所述,TASK_RUNNING 本身还包含"在就绪队列上排队等调度"的情况。

由此可见 soft lockup 与 hung task 的根本差别:

soft lockuphung task
进程状态RUon_cpu = 1(确实在 CPU 上执行)UN(D 状态睡眠)
本质该 CPU 长时间未取得调度进展任务让出了 CPU,在等资源
检测者watchdog(CPU 侧)khungtaskd(任务侧)
要问的问题为什么不让出 CPU在等什么
常见成因自旋锁争抢、长循环缺 cond_resched()、活锁I/O 阻塞、mutex、completion

一处措辞上的严谨: 不要把 TASK_RUNNING 本身定义成"占着 CPU 不让出"。按 3.1 节,TASK_RUNNING 既可能是正在 CPU 上执行,也可能只是在就绪队列上等待调度,二者靠 on_cpu 区分。soft lockup 关注的是"该 CPU 长时间没有取得调度进展"这一现象,而本例中触发它的 jbd2 恰好是 TASK_RUNNINGon_cpu = 1

因此第 3.3 节的 ps -m 与第 8.2 节的等待链追踪,在本例中都不适用——它们是为睡眠态设计的。该任务持续在 CPU 上执行,ps -m 的 last-run 时间差接近 0,没有参考价值;它也没有在等待任何同步对象,追等待链无从谈起。

要回答"为什么不让出 CPU",只能看它在执行什么代码。这就需要完整的调用栈——而 bt 在这里失败了。

失败详情与两类根因已在 5.6 节完整分析:CPU 79 是展开器卡在 IRQ 栈边界,CPU 95 是 crash_notes 缺失。此处只记结论:

crash> bt                    # CPU 79:只到 __handle_domain_irq 就断,未跨栈
crash> bt -c 95              # CPU 95:cannot determine starting stack frame

改用 dmesg 中内核自己打印的调用链:

crash> log | grep -A20 "Call trace"

内核的展开器成功跨过了 el1_irq,给出了 crash 拿不到的那几帧:

  el1_irq+0xb8/0x140
  ext4_process_freed_data+0x258/0x530     <- 真正在执行的代码
  ext4_journal_commit_callback+0x50/0x120
  jbd2_journal_commit_transaction+0x164c/0x1a60
  kjournald2+0xd0/0x2b8

至此 Q2 有了答案:两个 jbd2 线程都卡在 ext4_process_freed_data+0x258,长时间未让出 CPU。

Q3:谁牵连了谁

crash> ps | grep " UN " | wc -l
crash> foreach UN bt 2>/dev/null | grep -E "jbd2|ext4|io_schedule" | sort | uniq -c | sort -rn

这一步查的是被牵连方——jbd2 卡住会阻塞该文件系统上所有需要日志提交的写操作,那些进程才是 D 状态。若大量业务进程卡在 jbd2_journal_commit_transactionext4_* 路径上,即可确认它们是下游受害者,而非独立故障。

这一步的价值在于把现场分成"源头"与"被牵连"两类,避免在受害者身上浪费时间。注意与 Q2 的区分:源头是 RU 状态的 jbd2,受害者才是 UN 状态。

跨 CPU 的共因判断

现场最关键的细节:CPU 79 与 CPU 95 上的两个 jbd2 卡在同一个函数的同一个偏移 +0x258。两块独立磁盘的日志线程同时卡在同一处,用巧合解释不合理,更可能是二者共同依赖的某个资源出了问题。

结合 Q2 得出的"soft lockup 要问为什么不让出 CPU",一个值得优先验证的假设是自旋锁争抢——若 ext4_process_freed_data+0x258 处持有或等待某个全局自旋锁,两个线程互相争抢可以解释同时卡死。

但"卡在同一偏移"本身不构成锁争抢的证据。 至少还有几种解释同样成立:该偏移处于一个耗时很长的循环内,两个线程只是先后进入了同一段热点代码;或该处调用的下游函数(如块层提交)阻塞,两个线程各自独立卡住,只是恰好都停在这个返回点上。只有反汇编确认该偏移确实是锁操作,假设才成立。 在此之前,它只是一条值得优先验证的线索。

验证方向:

crash> dis -l ext4_process_freed_data+0x258     # 该偏移处是什么指令
crash> sym ext4_process_freed_data              # 结合源码定位

若该处确为锁操作,问题就转入第七篇的锁分析范畴。

这个现场留下的方法论

  1. 先分清 soft lockup 与 hung task —— 状态是 RU 还是 UN 决定了整个分析方向,问错问题会一路偏离
  2. bt 失败不等于分析中止 —— ARM64 上 crash 跨栈能力有限,dmesg 里内核自己的 Call trace 往往更完整
  3. 留意 failed to stop secondary CPUs —— 见到它就说明部分 CPU 的现场天生不可信,不必在 bt 失败上纠结
  4. 多个进程卡在同一偏移,值得优先怀疑共享资源 —— 但需反汇编确认该处是锁或共享结构访问;热点循环、下游阻塞同样能造成这一现象
  5. 三轴要逐项实测 —— 同一个 4.19 内核上,状态字段名还是旧的,thread_info 却已是新的

十、架构差异速查

维度x86_64ARM64
内核栈大小(典型)16KB16KB
SP 对齐要求call 前 16 字节对齐(ABI 约定)始终 16 字节对齐(硬件强制
thread_info 迁入 task_struct 的版本4.94.10
睡眠进程寄存器保存位置内核栈(经 thread.spthread.cpu_context 结构体
保存的寄存器集合rbp, rbx, r12-r15 + spx19-x28, x29(fp), x30(lr), sp
切换点 PC 的获取从栈上的返回地址推断cpu_context.pc 直接读取
上下文切换函数__switch_to_asm + __switch_tocpu_switch_to
汇编源码位置arch/x86/entry/entry_64.Sarch/arm64/kernel/entry.S
current 指针来源per-CPU 变量 current_task(经 gssp_el0 寄存器
函数参数寄存器(普通 C 调用约定)rdi, rsi, rdx, rcx, r8, r9x0–x7
系统调用参数寄存器rdi, rsi, rdx, r10, r8, r9(rcxsyscall 指令占用)x0–x5,调用号在 x8
STACK_END_MAGIC0x57AC6E9D0x57AC6E9D(相同)

ARM64 的 sp_el0 机制: 在 ARM64 内核态(EL1)下,实际使用的栈指针是 sp_el1;而 sp_el0 作为用户态栈指针,在进入内核时其值已被保存进 pt_regs,该寄存器本身就处于闲置状态。内核正是利用这一点,把 sp_el0 复用为当前 task_struct 指针的存放处

这使得 current 的获取只需读一个寄存器,比 x86_64 通过 gs 段访问 per-CPU 变量更直接。

crash 已封装这一差异,日常使用 set / task / bt 无需关心。但在手工分析裸寄存器转储、或 crash 无法正确识别 current 时,知道该去哪里找 task 指针会很有用。


十一、小结

本篇的起点是一条读不下去的 call trace:jbd2 卡在 ext4_process_freed_data+0x258,位置清楚,但它为何不让出 CPU、跨了几个栈、牵连了谁,单条调用链一概答不了——更何况在真实的 vmcore 里,bt 连这条链都没能完整走出来。这正是从「线」转向「面」的时刻——第五篇解决「这个值为什么是错的」,本篇解决「这件事跟谁有关」。

围绕 WHO / HOW / WHY 三问,本篇建立了如下框架。

Q1 身份(第二章): pid / tgid / comm / flags 确定进程身份;mm == NULL 是内核线程最可靠的标志;group_leader == self 区分主线程与子线程。状态字段名在 5.14 前后不同(state / __state),是本篇第一处版本轴分界。

Q2 姿态(第三至五章): 进程状态不只是一个标志位,而是崩溃现场的行为快照。首先要分清两类卡死UN(D 状态)是让出了 CPU 在等资源,问"在等什么";RUon_cpu = 1 是持续在 CPU 上执行而无调度进展,问"为什么推进不下去"(注意 TASK_RUNNING 本身还包含就绪队列排队的情况,靠 on_cpu 区分)。开篇的 jbd2 属于后者——soft lockup 的任务状态是 TASK_RUNNING (ACTIVE),把它当 D 状态去追等待链会一路偏离。

对 D 状态问题,空间维度用 ps 看谁处于什么状态,时间维度用 ps -m 读 last-run 时间差。注意其准确语义是"距最后一次在 CPU 上运行经过了多久"——对睡眠态进程近似等于阻塞时长,对 RU 进程则毫无意义。时间差最大者通常离源头更近,但这是排序线索而非判据,真正的确认要靠调用栈与等待对象的持有关系。

同理,D 状态多久算异常没有通用阈值:120 秒是 hung_task 的检测阈值,不是业务容忍上限——数据库场景秒级即为故障,慢盘 fsync 超过两分钟也可能正常。判断应基于该业务的正常基线。

栈与 pt_regs 是这一层的另一半:一个进程有多个栈(进程栈、IRQ 栈、x86_64 的 IST 栈、ARM64 的 overflow 栈)。pt_regs 由异常/中断/系统调用入口产生,栈切换则描述执行栈的改变——二者常伴随出现,但不是一一对应关系(syscall 产生 pt_regs 却不切栈;进程切换换了栈却不产生 pt_regs)。但**“栈上有” 与 “crash 读得到” 是两回事**——5.6 节的三类失败(跨栈失败、crash_notes 缺失、栈损坏)在开篇现场占了两类,此时 dmesg 里内核自己打的 Call trace 反而更可靠。

内核栈经 task_struct.stack 定位,向低地址增长;STACK_END_MAGIC 的正确值是 0x57AC6E9D,两架构一致、不随版本变化。thread_info 的位置迁移在 x86_64 是 4.9、ARM64 是 4.10,二者仅隔一个版本,因此 4.10 之后的内核基本都是新布局;判断方式不是查版本号,而是看 struct task_struct -o 的首个成员。

睡眠进程的寄存器保存位置是两架构差异最显著之处:x86_64 压入内核栈、经 thread.sp 逐字读取;ARM64 存入 thread.cpu_contextpcsp 可直接读出。查看各 CPU 运行状况优先用 runq

Q3 关系(第六、七章): 线程组靠 tgid 归并;僵尸进程要顺 real_parent 上溯,注意 EXIT_ZOMBIE 的值是 32(0x20) 且属于 exit_state 字段,与 __state 里的 TASK_DEAD(128)分属两套取值空间;D 状态要顺 mutex 的 owner(清除低 3 位标志)追等待链。四类实战场景中反复出现同一条原则:崩溃现场的进程未必是肇事者——无论是越界写入的受害者,还是被上游阻塞牵连的下游进程,都需沿因果链继续上溯。

贯穿全篇的三轴提醒: 开篇那份 4.19 ARM64 样本上,状态字段名还是旧的 state,而 thread_info 已是新布局——同一个内核版本上,不同特性的新旧形态并存。这说明三轴不能靠"这是个老内核"之类的整体印象推断,每一项都要单独确认。分析开始前先 sys + mach 读出三轴取值,遇到具体字段再用 struct <type> -o 实测,是这套方法论的前置条件。

下一篇《结喉之处》,我们把进程分析延伸到锁层面——mutex、spinlock、rwsem 的内核状态如何在 crash 中读取,死锁的等待链如何还原,以及为什么找到锁的持有者只是分析的起点而非终点。开篇那个 +0x258 偏移处究竟在争抢什么,答案也在那一篇。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

不厚君

好大一块肉

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

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

打赏作者

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

抵扣说明:

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

余额充值