10.2 当内核决定放弃治疗——Panic 机制全解
要征服这只野兽,你得先懂它。
我们上一节把环境搭好了,现在,让我们亲手把它引爆。
从 panic() 开始:内核的最后遗言
内核处理 Panic 的核心代码位于 kernel/panic.c,心脏就是 panic() 函数。这个函数接收一个 printf 风格的格式化字符串(以及对应的参数),打印出最后的挣扎,然后让系统彻底停摆。
// kernel/panic.c
/**
* panic - halt the system
* @fmt: The text string to print
*
* Display a message, then perform cleanups.
* This function never returns.
*/
void panic(const char *fmt, ...)
{ [...]
显然,这玩意儿不能随便乱调。一旦调用,意味着内核已经处于一种「无法修复、不可救药」的状态。系统会立刻停止一切有效运作。
让我们来亲手制造一场混乱。
第一次引爆:写个模块让它崩溃
为了实证(当然是在我们的测试 VM 里),我们写个最简单的模块,直接调用 panic()。
代码非常直白,不需要复杂的初始化逻辑:
// ch10/letspanic/letspanic.c
static int myglobalstate = 0xeee;
static int __init letspanic_init(void)
{
pr_warn("Hello, panic world\n");
panic("whoa, a kernel panic! myglobalstate = 0x%x",
myglobalstate);
return 0; /* success */
}
module_init(letspanic_init);
既然我们调用 panic() 是为了死机,那写清理函数(cleanup callback)就没意义了,系统永远不会走到那一步。
编译,插入模块。
我是在我那台信任的 x86_64 Ubuntu 20.04 LTS 虚拟机上操作的(内核是自定制的 5.10.60-prod01),通过 SSH 连上去:
$ sudo insmod ./letspanic.ko
[... <panicked, and hung> ... ]
插进去的瞬间——世界安静了。
SSH 终端没任何输出,虚拟机的图形界面也僵住了。系统显然 Panic 了,但问题来了:我们瞎了。
为了调试它,我们至少得看到内核吐出来的那些诊断信息。这些信息跟我们在第 7 章里讲过的 Oops 日志格式很像(那一章如果你忘了,现在是个好机会回去翻翻)。
既然本地控制台和 SSH 都看不到日志(因为内核调度器已经停了,甚至可能中断都关了),我们该怎么办?
答案是用 netconsole!但在那之前,先介绍个不用写代码就能制造 Panic 的「后门」。
不写代码也能炸:Magic SysRq
有时候你就是想测试一下 Panic 流程,但不想写模块。内核留了个后门:Magic SysRq。
配合 panic_on_oops 参数,三行命令就能让内核躺平:
echo 1 > /proc/sys/kernel/panic_on_oops
echo 1 > /proc/sys/kernel/sysrq
echo c > /proc/sysrq-trigger
翻译一下这三步在干什么:
- 设定心态:告诉内核,只要遇到 Oops,直接升级为 Panic,别犹豫。
- 打开后门:确保 Magic SysRq 功能是开着的(为了安全,有些发行版默认关了)。
- 引爆:通过 SysRq 触发一个崩溃(
c代表 Crash)。
什么是 Magic SysRq?
你可以把它理解为一个给上帝准备的键盘快捷键。
它允许系统管理员(或者开发者)强制内核走某些平时不可能走的代码路径。这在系统假死、调试 Hang 的时候非常有用——它是直通内核后门的隧道。
这个功能必须在编译时开启(CONFIG_MAGIC_SYSRQ=y)。出于安全考虑,你可以通过写 /proc/sys/kernel/sysrq 这个伪文件来微调它的权限:
- 写
0:完全关闭。 - 写
1:开启所有功能(上帝模式)。 - 写位掩码:开启部分功能组合。
默认值通常由内核配置项 CONFIG_MAGIC_SYSRQ_DEFAULT_ENABLE 决定,一般是 1。
它能干的事非常「激进」:
c:强制触发崩溃(也就是sysrq-trigger里用的)。b:冷重启,不听解释。o:强行关机。f:召唤 OOM Killer 杀进程。s:紧急 Sync,把内存数据强行甩硬盘。u:紧急卸载所有文件系统。
在调试视角下,它的信息收集功能才是核心:
l:显示所有 CPU 上活动任务的堆栈。p:显示 CPU 寄存器。q:显示内核定时器。w:显示被阻塞的任务。z:Dump 所有 ftrace 缓冲区。
怎么用?两种方式:
- 交互式:直接砸键盘。x86 上是
Alt + SysRq + <命令键>。注意有些键盘上SysRq和Print Screen是同一个键。 - 非交互式:也就是刚才用的,往
/proc/sysrq-trigger里写字符。
如果你往里写个 ?,内核会给你列出一张 cheat sheet:
echo ? > /proc/sysrq-trigger
官方文档在这里,建议读一遍:Linux Magic System Request Key Hacks。
真正的救命稻草:netconsole
回到刚才那个「瞎了」的问题。我们要看日志,但系统僵死导致本地终端写不进去(或者刷新不出来)。
这时候轮到 netconsole 出场了。
如果你还记得第 7 章(我们在讲 ARM 上的 Oops 时用过),netconsole 能把内核所有的 printk 通过网络实时扔到另一台机器上。
这里简单复现一下配置过程。
我现在的环境是:
- 发送端:那个跑着
letspanic模块的 VM。 - 接收端:宿主机(或者局域网里的另一台机器)。
接收端先动起来(监听 UDP 6666 端口):
netcat -d -u -l 6666 | tee -a klog_from_vm.txt
netcat 会阻塞等待数据包,来一个显示一个,顺便存进 klog_from_vm.txt。
发送端加载 netconsole 模块:
参数格式稍微有点反直觉,长这样:
netconsole=[+][src-port]@[src-ip]/[<dev>],[tgt-port]@<tgt-ip>/[tgt-macaddr]
我的命令(请替换成你自己的 IP 和网卡名):
sudo modprobe netconsole netconsole=@192.168.1.20/enp0s8,@192.168.1.101/
这一句的意思是:把本机所有 printk 从 enp0s8 网卡发出去,目标是 192.168.1.101 的 6666 端口。
现在,万事俱备。再次执行 insmod ./letspanic.ko。
这一次,虽然虚拟机的屏幕依然是死的,但你的接收端窗口上会疯狂滚动出内核最后的遗言。
这感觉太棒了——我们终于看清了它是怎么死的。
逐行解读:Panic 的最后时刻
既然拿到了日志,我们把它拆开看看。它的格式其实跟 Oops 非常像。
首先映入眼帘的是最显眼的那行报错头:
Kernel panic - not syncing: whoa, a kernel panic! myglobalstate = 0xeee
这行信息的优先级是最高的 KERN_EMERG,会试图广播到所有控制台。
这里有几个关键点:
-
"not syncing"(不同步): 这是内核 Panic 里的经典短语。它的意思是:我知道内存里有一堆数据还没刷到硬盘,但我故意不刷了。 为什么?因为这时候系统状态已经乱了,强行写磁盘可能会把文件系统搞挂,导致数据损坏。两害相权取其轻,放弃同步。
-
自定义消息: 后面那段
whoa, a kernel panic! ...就是我们传给panic()的参数。
接下来是一大段系统状态快照:
- 进程上下文:这里肯定是
insmod挫手了。 - 污染标志:内核是否被非 GPL 模块污染等。
- 内核版本。
- 硬件详情。
- 调用栈:如果开启了
CONFIG_DEBUG_BUGVERBOSE(通常都是开的),内核会调用dump_stack()打印出它是怎么一路走过来死掉的。这是最重要的线索。 - 寄存器与指令指针:RIP 值、机器码、CPU 寄存器快照。
- KASLR 偏移量:如果你的内核开了地址随机化(这是现代内核的标配),这里会告诉你内核镜像加载时的偏移量。
- 结尾报错:
---[ end Kernel panic - not syncing: whoa, a kernel panic! myglobalstate = 0xeee ]---
这些细节的深度解读我们在第 7 章的 Devil in the details – decoding the oops 里已经讲过了,这里不赘述。
深入 panic() 函数内部
我们现在看一眼源码。这些输出来自哪里?在 5.10.60 内核中,panic() 函数的开头部分是这样的:
void panic(const char *fmt, ...)
{
static char buf[1024];
va_list args;
[...]
pr_emerg("Kernel panic - not syncing: %s\n", buf);
[...]
}
这是个导出符号,所以我们的模块能直接调用它。
逻辑很清晰:先把传进来的格式化字符串解析到 buf 里,然后立马用最高优先级 pr_emerg 把它吼出去。
吼完之后呢?内核会陷入一个死循环。
但在死循环前,它还得做点「临终关怀」。
1. 紧急信息转储 (panic_print_sys_info)
内核有一个叫 panic_print 的引导参数,它是一个位掩码。默认是 0,意味着除了基本 Panic 信息外,什么都不额外打印。
但你可以调它。比如,你想看所有任务状态、内存信息、Timer 状态、锁状态等,可以在启动参数里加上 panic_print=0x3f(具体每一位的含义见表 10.2)。这对调试某些特殊的死锁问题非常有帮助。
2. 视觉警报 (panic_blink)
代码的最后,内核会停在一个无限循环里,但在那个循环里,它会周期性地调用一个架构相关的函数 panic_blink()。
在 x86 上,这东西连到了键盘驱动(drivers/input/serio/i8042.c)。它会让键盘上的 LED 灯疯狂闪烁。
目的很简单:如果你正在用图形界面(X Window),屏幕定格了,你分不清是显卡挂了还是内核真的 Panic 了。看到键盘灯闪,你就知道:是内核死了,不是显示器坏了。
下面是 panic() 函数尾部的真实代码,就在那个结束信息打印之后:
pr_emerg("---[ end Kernel panic - not syncing: %s ]---\n", buf);
/* Do not scroll important messages printed above */
suppress_printk = 1;
local_irq_enable();
for (i = 0; ; i += PANIC_TIMER_STEP) {
touch_softlockup_watchdog();
if (i >= i_next) {
i += panic_blink(state ^= 1);
i_next = i + 3600 / PANIC_BLINK_SPD;
}
mdelay(PANIC_TIMER_STEP);
}
注意这里有个 suppress_printk = 1。这是为了防止后面的任何日志把刚才打印出来的关键诊断信息给滚屏滚没了。这时候系统都死透了,你肯定没法往上翻页看日志,所以必须把屏幕锁死在最重要的那个画面上。
3. 其他的逃生通道
panic() 的默认行为是死机,但它还有几条「岔路」:
-
kexec/kdump: 如果你在内核配置里启用了 kexec,并且配置了崩溃内核,Panic 发生时,内核不会傻傻地死循环,而是会立刻启动另一个备用的内核。那个备用内核的唯一任务就是把当前主内存里的数据 Dump 下来存盘。这是服务器领域标准的崩溃捕获方案。
-
Panic Notifier Chain: 内核允许其他模块注册一个「恐慌通知链」。当 Panic 发生时,这些回调函数会被触发,执行一些特定的清理或记录操作。下一节我们会自己玩玩这个。
-
panic=n 参数: 如果你在启动参数里传了
panic=10,意思是内核 Panic 10 秒后自动重启。这在无人值守的嵌入式设备上很常见——死机了就赶紧重启,看能不能自愈。
内核参数与配置速查表
最后,为了方便查阅,这里列出了影响 Panic 行为的主要参数和配置。
表 10.1 – Panic 相关的内核参数、Sysctl 调优旋钮及配置宏
这里只列几个最关键的:
| 参数/文件 | 类型 | 说明 |
|---|---|---|
panic | 启动参数 | 设定 Panic 后多少秒自动重启。0 表示永远挂起。 |
panic_on_oops | 启动/Sysctl | 设为 1 时,任何 Oops 都会直接升级为 Panic。这在某些关键业务系统上很有用(宁可死也不许错)。 |
panic_print | 启动参数 | 控制 Panic 时打印多少额外信息的位掩码(见下表)。 |
panic_on_unrecovered_nmi | Sysctl | 遇到不可恢复的 NMI(不可屏蔽中断)是否 Panic。 |
panic_on_io_nmi | Sysctl | IO 产生的 NMI 是否触发 Panic。 |
panic_on_warn | Sysctl | 慎用。如果设为 1,内核任何 WARN_ON() 触发都会导致 Panic。这对开发阶段极其有用,能帮你抓到那些被忽略的隐患。 |
表 10.2 – panic_print 位掩码详解
这个参数决定了除了堆栈信息外,你还想看什么。
| Bit | 宏定义 | 打印内容 |
|---|---|---|
| 2 | PANIC_PRINT_ALL_CPU_BT | 所有活跃 CPU 上的堆栈回溯(不仅是当前 CPU)。 |
| 4 | PANIC_PRINT_TASK_INFO | 打印所有任务的状态(就像按下了 SysRq-t)。 |
| 16 | PANIC_PRINT_TIMER_INFO | 打印内核定时器信息。 |
| 32 | PANIC_PRINT_LOCK_INFO | 打印所有锁的状态(如果持有锁的信息可用的话)。 |
小试牛刀:验证 panic_print
现在原理懂了,动手验证一下。
练习 10.1:调整 panic_print,重新加载 letspanic 模块,看看日志里多了什么?
- 修改你的内核启动参数,加上
panic_print=0x3f(或者你想试的任何位)。 - 重启虚拟机。
- 确保
netconsole接收端已经准备好。 insmod ./letspanic.ko。
你应该能在接收端看到一大堆额外的系统状态信息,这往往是推断死机原因的关键线索。
好了,既然我们完全理解了 Panic 时的内核行为,接下来该玩点高级的——如何在 Panic 发生时,插入我们自己的代码(比如通知外部硬件)。
下一节,我们来实现一个自定义的 Panic 处理器。