庖丁解 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 seconds | hung 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_INFO、CONFIG_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
| 字段 | 类型 | 含义 | 分析用途 |
|---|---|---|---|
pid | pid_t | 线程 ID,内核视角的唯一标识 | 与 panic header 的 PID 对应 |
tgid | pid_t | 线程组 ID,等于进程主线程的 pid | 用于判断是主线程还是子线程 |
comm | char[16] | 进程名(最长 15 字符 + 终止符) | 快速识别进程归属 |
flags | unsigned 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_UNINTERRUPTIBLE:TASK_WAKEKILL 不是一个独立状态,而是给 TASK_UNINTERRUPTIBLE 附加的一个行为修饰——允许致命信号将其唤醒。
二者在 ps 输出中外观完全相同(都显示 UN),差别只在等待行为的容错性:
纯 TASK_UNINTERRUPTIBLE | TASK_KILLABLE | |
|---|---|---|
收到 SIGKILL | 不唤醒,信号挂在 pending 上,等进程自己醒来才处理 | 立即唤醒,等待中止 |
| 典型场景 | 本地块设备 I/O | NFS、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
这两个字段揭示了进程的内存身份:
mm(struct mm_struct *): 进程的用户地址空间描述符。
- 用户进程:
mm指向有效的mm_struct,包含页表、VMA 链表等 - 内核线程:
mm = NULL。内核线程没有用户地址空间,这是判断是否为内核线程最可靠的方式
active_mm: 当前进程实际使用的内存描述符。
- 用户进程:
active_mm == mm(正常情况) - 内核线程:
active_mm借用前一个用户进程的mm_struct,避免频繁刷新 TLB
两个字段的组合含义:
| mm | active_mm | 进程类型 |
|---|---|---|
| 非 NULL | == mm | 普通用户进程 |
| NULL | 非 NULL | 内核线程(借用前一个用户进程的 mm) |
| NULL | NULL | 极少见,系统初始化阶段或特殊路径 |
# 深入 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_cpu | 1 = 当前正在某个 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_class | CFS,普通进程(6.6+ 内核可能是 EEVDF,符号名不变) |
rt_sched_class | 实时进程(SCHED_FIFO / SCHED_RR) |
dl_sched_class | Deadline 调度 |
idle_sched_class | idle 进程(swapper/N) |
stop_sched_class | migration/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 介入的场景下,PPID 与 real_parent 的 pid 可能不同,需要单独确认。
三、进程状态的法医学意义
3.1 状态定义与调试价值对照
不同状态揭示了进程在崩溃瞬间的不同处境,每种状态对应不同的分析方向:
这里有一个必须先厘清的层级问题:进程的"状态"分散在两个不同字段里。
struct task_struct {
unsigned int __state; /* TASK_* : 活着时的运行/睡眠状态 */
...
int exit_state; /* EXIT_* : 退出流程中的状态 */
};
TASK_* 与 EXIT_* 是两套独立的取值空间,分属两个字段,不能放在一张表里比较数值。crash 的 ps 在 ST 列把二者统一呈现,才造成了容易混淆的假象。
活着时的状态(__state 字段,TASK_*):
| 状态值 | crash 显示 | 内核宏 | 含义 | 调试意义 |
|---|---|---|---|---|
0x0000 | RU | TASK_RUNNING | 可运行或正在运行 | 崩溃时在 CPU 上,或在运行队列中等待调度 |
0x0001 | IN | TASK_INTERRUPTIBLE | 可中断睡眠 | 等待某个事件,可被信号唤醒;通常是正常等待 |
0x0002 | UN | TASK_UNINTERRUPTIBLE | 不可中断睡眠(D 状态) | 等待无法被信号打断的资源,是 hang 问题的核心目标 |
0x0004 | ST | __TASK_STOPPED | 被信号停止 | 收到 SIGSTOP 或调试器介入 |
0x0008 | TR | __TASK_TRACED | 被 ptrace 跟踪 | 正在被调试 |
0x0080 | — | TASK_DEAD | 已死亡,调度器内部过渡态 | 不会出现在 ps 的 ST 列,只存在于 do_task_dead() 到最终释放之间的极短窗口 |
退出流程中的状态(exit_state 字段,EXIT_*):
| 状态值 | crash 显示 | 内核宏 | 含义 |
|---|---|---|---|
0x0010(16) | DE | EXIT_DEAD | 正在被回收,父进程已 wait(),task_struct 即将释放 |
0x0020(32) | ZO | EXIT_ZOMBIE | 僵尸进程,已退出但父进程尚未 wait() |
三个易混值必须记清:
EXIT_DEAD是 16(0x10),EXIT_ZOMBIE是 32(0x20),二者同属exit_state;而TASK_DEAD是 128(0x80),属于__state,是调度器内部状态,ps里看不到。分析僵尸进程时,先确认自己读的是哪个字段,再对照取值。具体数值以目标内核include/linux/sched.h为准。
TASK_RUNNING(crash 显示 RU)有两种含义需要区分:
- 进程确实在 CPU 上运行(
on_cpu = 1) - 进程在运行队列(runqueue)中等待 CPU,还未被调度(
on_cpu = 0)
两者状态值相同,区别在 on_cpu 字段。大量 RU 而 on_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 上 |
TASK | task_struct 地址 | 直接传给 struct task_struct 命令 |
ST | 状态 | 重点关注 UN 和 ZO |
%MEM | RSS 占总内存比 | — |
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/O | io_schedule / blk_execute_rq | 磁盘 hang、存储路径问题 |
| mutex/rwsem | mutex_lock / down_write / __mutex_lock_slowpath | 锁竞争、死锁 |
| 等待任务完成 | wait_for_completion | 依赖的异步任务未完成 |
| 等待 page lock | wait_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。
栈的范围是 stack 到 stack + 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_RESCHED、TIF_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_64 | 4.9 | 率先切换 |
| ARM64 | 4.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,具体数值随版本变动,以目标内核头文件为准):
| 位 | 标志 | 含义 |
|---|---|---|
0x0002 | TIF_SIGPENDING | 有待处理信号 |
0x0004 | TIF_NEED_RESCHED | 需要重新调度 |
0x0008 | TIF_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.sp 或 cpu_context 里取。
5.2 会产生 pt_regs 的三种场景
场景一:用户态陷入内核。
系统调用、来自用户态的中断或异常,入口汇编会把用户态的完整寄存器压成一份 pt_regs,放在该进程内核栈的最底部(最高地址端):
stack + THREAD_SIZE +---------------------------+
| pt_regs (user context) | <- pushed at kernel entry
+---------------------------+
| kernel execution ... |
| | |
| v |
stack +---------------------------+
| 架构 | 入口 | 说明 |
|---|---|---|
| x86_64 | entry_SYSCALL_64 / asm_exc_* | ORIG_RAX 存系统调用号 |
| ARM64 | el0_sync / el0_irq | 由 kernel_entry 0 宏构造 |
这份 pt_regs 在整个系统调用期间常驻栈底,返回用户态时用于恢复现场。它是判断"这个进程从哪来"的依据——有它说明源自用户态,没有说明是内核线程。
场景二:内核态发生异常或中断。
缺页、BUG()、IRQ、NMI 等在内核态触发时,异常入口会再压一份 pt_regs,保存的是内核态当时的寄存器:
| 架构 | 入口 | crash 中的标记 |
|---|---|---|
| x86_64 | asm_exc_page_fault、asm_exc_invalid_op 等 | [exception RIP: ...] |
| ARM64 | el1_sync、el1_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 CS | 0x33 | 0x10 |
x86_64 RIP | 0x00007f... | 0xffffffff... |
x86_64 ORIG_RAX | 有效系统调用号 | -1(哨兵值) |
ARM64 pstate 的 EL 位 | EL0 | EL1 |
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_regs,bt 会输出三套寄存器,并以两条分隔线标出栈边界。逐套核对 CS 与 RIP 即可确定每套属于哪一层:[1] 是用户态,[2]、[3] 都是内核态。
几条实用推论:
-
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 节的总判据:执行流是否被打断。 -
内核线程的栈上只有一份不被使用的初始
pt_regs,若崩溃发生在它身上,bt只显示一套异常寄存器 -
睡眠进程的
bt完全没有pt_regs展开,只有从thread.sp重建的普通栈帧 -
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_irq → el1_irq → ext4_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 frame | crash_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)
TASK 与 THREAD_INFO 地址完全相同,说明 thread_info 就位于 task_struct 偏移 0 处——这实测确认了 4.4 节的结论:4.19 的 ARM64 内核已是新布局,不必去栈底找。这也是"实测优于版本推断"的一个具体例证。
六、调度上下文:睡眠进程的寄存器在哪里
6.1 确认崩溃时哪个进程在 CPU 上
panic 触发时,每个 CPU 上都有一个正在运行的进程。crash 启动摘要里的 PID 和 COMMAND 只是触发 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_asm(arch/x86/entry/entry_64.S)与 __switch_to(arch/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_to(arch/arm64/kernel/entry.S)把 callee-saved 寄存器 x19–x28、帧指针 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_64 | ARM64 |
|---|---|---|
| 保存位置 | 内核栈(经 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 分析中有三点特殊性:
- 没有用户地址空间,
vm命令无输出 active_mm借用前一个用户进程的mm_struct,但不真正使用其用户地址空间- 崩溃发生在内核线程时,
crash bt只输出一套寄存器(无用户态入口帧),原因见第三篇 9.3 节,机制见本篇 5.2 节
常见内核线程及其分析价值:
| 进程名 | 用途 | D 状态时的含义 |
|---|---|---|
kworker/N:M | 工作队列 worker | workqueue 中有任务阻塞 |
kswapd0 | 内存回收 | 内存压力极高,回收跟不上分配 |
jbd2/sdX-8 | ext4 日志提交 | 磁盘 I/O 或日志空间问题 |
khungtaskd | hung task 检测 | 该线程本身活跃说明检测机制在工作 |
migration/N | 进程迁移 | 调度类为 stop_sched_class |
oom_reaper | OOM 后异步回收 | 见第五篇 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 ← 用户内存已释放
单个僵尸进程占用的资源极少,但大量堆积(数百以上)意味着以下三种情况之一:
- 父进程持续 fork 而从不
wait()—— 父进程自身的逻辑缺陷,最常见 - 父进程卡住,无法执行
wait()—— 父进程可能处于 D 状态或死锁,需回到 3.4 节排查 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!"
PID 和 COMMAND 给出了触发进程。但要牢记:触发 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 -a或runq显示多个 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]
TASK 与 THREAD_INFO 地址相同,即 thread_info 位于偏移 0 处——新布局确认。不要因为"4.19 是老版本"就假定所有特性都是旧形态,各特性的分界点互不相同。
Q1:谁在现场
crash> ps | grep jbd2
两个 jbd2 内核线程,PPID 均为 2(kthreadd),分别服务两块磁盘:
| PID | CPU | COMMAND | TASK |
|---|---|---|---|
| 2896966 | 79 | jbd2/sdc1-8 | ffff8b5408058380 |
| 2896829 | 95 | jbd2/sdb1-8 | ffff8b540805c180 |
按第七章的判据(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 lockup | hung task | |
|---|---|---|
| 进程状态 | RU 且 on_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_RUNNING且on_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_transaction 或 ext4_* 路径上,即可确认它们是下游受害者,而非独立故障。
这一步的价值在于把现场分成"源头"与"被牵连"两类,避免在受害者身上浪费时间。注意与 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 # 结合源码定位
若该处确为锁操作,问题就转入第七篇的锁分析范畴。
这个现场留下的方法论
- 先分清 soft lockup 与 hung task —— 状态是 RU 还是 UN 决定了整个分析方向,问错问题会一路偏离
bt失败不等于分析中止 —— ARM64 上 crash 跨栈能力有限,dmesg 里内核自己的Call trace往往更完整- 留意
failed to stop secondary CPUs—— 见到它就说明部分 CPU 的现场天生不可信,不必在bt失败上纠结 - 多个进程卡在同一偏移,值得优先怀疑共享资源 —— 但需反汇编确认该处是锁或共享结构访问;热点循环、下游阻塞同样能造成这一现象
- 三轴要逐项实测 —— 同一个 4.19 内核上,状态字段名还是旧的,
thread_info却已是新的
十、架构差异速查
| 维度 | x86_64 | ARM64 |
|---|---|---|
| 内核栈大小(典型) | 16KB | 16KB |
| SP 对齐要求 | call 前 16 字节对齐(ABI 约定) | 始终 16 字节对齐(硬件强制) |
thread_info 迁入 task_struct 的版本 | 4.9 | 4.10 |
| 睡眠进程寄存器保存位置 | 内核栈(经 thread.sp) | thread.cpu_context 结构体 |
| 保存的寄存器集合 | rbp, rbx, r12-r15 + sp | x19-x28, x29(fp), x30(lr), sp |
| 切换点 PC 的获取 | 从栈上的返回地址推断 | cpu_context.pc 直接读取 |
| 上下文切换函数 | __switch_to_asm + __switch_to | cpu_switch_to |
| 汇编源码位置 | arch/x86/entry/entry_64.S | arch/arm64/kernel/entry.S |
| current 指针来源 | per-CPU 变量 current_task(经 gs) | sp_el0 寄存器 |
| 函数参数寄存器(普通 C 调用约定) | rdi, rsi, rdx, rcx, r8, r9 | x0–x7 |
| 系统调用参数寄存器 | rdi, rsi, rdx, r10, r8, r9(rcx 被 syscall 指令占用) | x0–x5,调用号在 x8 |
STACK_END_MAGIC | 0x57AC6E9D | 0x57AC6E9D(相同) |
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 在等资源,问"在等什么";RU 且 on_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_context,pc 与 sp 可直接读出。查看各 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 偏移处究竟在争抢什么,答案也在那一篇。

1629

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



