10.3 编写自定义内核恐慌处理程序
上一节,我们像拆弹专家一样,拆解了内核恐慌发生时的标准流程——从打印最后的遗言到决定是否重启。我们甚至还学会了通过 panic_print 这个调音旋钮,控制内核在临死前吐出多少信息。
这很像是在黑盒子里观察:你知道它死了,你也拿到了死亡报告。
但作为一个正在死磕系统的工程师,你不免会想:我能不能在它死的那一瞬间,插一手?
比如,在嵌入式设备上,内核恐慌意味着系统已经不可控了。这时候,你可能需要立刻关闭一个正在运转的电机,或者点亮一个红灯告诉操作员「系统挂了」,甚至通过备用通道向监控服务器发一个最后的警报。这些事情,内核自己的 panic() 函数是不会做的——它只管自己死,不管你的业务逻辑。
要做到这一点,我们需要一种机制,能让我们把自己的代码「挂」到内核的死亡钩子上。
Linux 内核为此提供了一个强大的基础设施:通知器链。
先退一步:理解通知器链机制
别急着写代码,让我们先理解这个机制是怎么一回事。你肯定熟悉发布-订阅模式——这不是什么高深的概念,就像你订阅了 YouTube 频道,UP 主一更新你就能收到通知。
内核里的通知器链就是干这个的。
- 发布者:内核里某个发生了关键事件的地方(比如网络接口上线了、系统要重启了,或者内核崩溃了)。
- 订阅者:对这些事件感兴趣的模块(比如驱动程序)。
- 订阅动作:把一个回调函数注册到一条链表上。
当事件发生时,发布者会遍历这条链表,依次调用所有注册好的回调函数。甚至,你还可以通过设置优先级,来决定是先收到通知(VIP 席),还是排在后面。
但真实情况没那么简单——四种链表
虽然直觉上很简单,但内核对上下文非常敏感。你的回调函数是在什么上下文里跑的?这决定了通知器链的类型。
内核定义了四种通知器链,区别在于能不能睡眠以及用了什么锁:
- Atomic(原子链):
回调运行在原子上下文,绝对不能阻塞,也不能睡眠。内部用自旋锁保护。如果你尝试在这里调用
kmalloc(..., GFP_KERNEL)或者用信号量,系统会立刻崩得更惨。 - Blocking(阻塞链): 回调运行在进程上下文,允许睡眠。内部用了读写信号量来实现阻塞行为。
- Sleepable RCU(SRCU,可睡眠 RCU): 这也是运行在进程上下文,允许睡眠。但它用了更高级的 RCU 机制来实现无锁语义。它适用于「回调极其频繁,但极少有人注销」的场景。
- Raw(原始链): 上下文不明,可能睡眠也可能不睡眠。没有任何限制保护,完全由调用者自己负责安全。
⚠️ 类比时刻:公告板 你可以把通知器链想象成办公楼大厅的公告板(Publish/Subscribe)。
- Atomic 公告板:你是经过的特种兵,只能在跑步经过时扫一眼,不能停下来,不能蹲下系鞋带。
- Blocking 公告板:你可以停下来,甚至搬把椅子坐下慢慢看,哪怕看一个小时也没人管你。
- SRCU 公告板:这就像是一个高科技电子板,只有在你真的盯着看的时候它才耗电,读的时候可以发呆,但注销的时候很麻烦。
回到现实:我们现在要打交道的是
panic_notifier_list。你可以猜一下,内核崩溃时,系统处于极度不稳定状态,中断可能关了,调度可能停了——你觉得这是哪一种?答案是 Atomic。在这种时候,如果你敢睡眠,系统连给你报错的机会都没有。
既然要挂钩点,就得先看「钩子」长什么样
我们要用的是内核预定义的一个原子通知器链:panic_notifier_list。
它在内核代码里的定义是这样的:
// kernel/panic.c
ATOMIC_NOTIFIER_HEAD(panic_notifier_list);
EXPORT_SYMBOL(panic_notifier_list);
ATOMIC_NOTIFIER_HEAD 这个宏帮你生成了一个链表头,而且导出了符号,意味着我们的内核模块可以直接操作它。
注册与注销:搭档原则
要把自己挂上去,得用这个 API:
int atomic_notifier_chain_register(
struct atomic_notifier_head *nh,
struct notifier_block *nb
);
它其实是一个封装,内部用 spin_lock_irqsave 把你的注册动作保护了起来。参数很简单:
nh:指向我们要注册的链表头——这里就是&panic_notifier_list。nb:指向一个notifier_block结构体,这就是你的「身份证」。
当然,有注册就得有注销。虽然在 Panic 场景下,模块的 cleanup 函数大概率不会被调用(因为系统都停了),但作为良好的编码习惯,我们还是要在模块退出时调用注销函数:
int atomic_notifier_chain_unregister(
struct atomic_notifier_head *nh,
struct notifier_block *n
);
核心数据结构:struct notifier_block
这是整个机制的灵魂。它的定义非常干净:
// include/linux/notifier.h
struct notifier_block {
notifier_fn_t notifier_call;
struct notifier_block __rcu *next;
int priority;
};
notifier_call:函数指针。这是你要写的回调函数。内核发生了 Panic,就会跳到这里来执行。next:链表的下一个节点。初始化时置NULL,内核框架会帮你处理。priority:优先级。数字越大,越早被调用。如果你设为INT_MAX,你就能拿到头香。通常不管它,默认为 0 就行。
回调函数长什么样?
你的回调函数必须符合这个签名:
typedef int (*notifier_fn_t)(
struct notifier_block *nb,
unsigned long action,
void *data
);
这三个参数都是内核传给你的线索:
nb:指向你自己的notifier_block。这在你的回调需要处理多个注册时有用,通常用来找回包含这个结构体的宿主结构体(用container_of)。action(或叫val):这是一个无符号长整型,告诉我们「为什么」触发。对于 Panic 链来说,它通常是架构相关的enum die_val里的一个值。你可以根据这个值来判断,是普通的 Oops 导致的 Panic,还是 NMI 不可屏蔽中断导致的。// arch/x86/include/asm/kdebug.henum die_val {DIE_OOPS = 1,DIE_INT3, DIE_DEBUG, DIE_PANIC, DIE_NMI,// ...};data:这是一个指向struct die_args的指针。这里面全是干货:通常大家最关心的就是// include/linux/kdebug.hstruct die_args {struct pt_regs *regs; // CPU 寄存器快照const char *str; // 也就是 panic() 里的那个字符串!long err;int trapnr;int signr;};data里的那个str——也就是触发 Panic 的具体原因描述。
结束语:怎么告诉内核你干完了?
你的回调函数必须返回一个特定的宏,告诉内核框架你的处理结果:
NOTIFY_OK:处理完毕,一切正常(最常用的返回值)。NOTIFY_DONE:处理完毕,我不关心后续通知了。NOTIFY_STOP:处理完毕,停止调用链上后续的其他回调(这是个霸道的选择)。NOTIFY_BAD:出错了,不要继续通知其他人了(相当于一票否决)。
对于 Panic 处理器来说,乖乖返回 NOTIFY_OK 就行了。
实战:编写我们的 Panic 监控模块
理论铺垫完了,现在我们把它们拼起来。我们要写一个模块,当内核崩溃时,它会尖叫。
1. 定义模块头部和 notifier_block
注意一下许可证的选择。因为 atomic_notifier_chain_register 这个 API 是 GPL 导出的,如果你的模块只声明了 MIT 或 Proprietary,内核会拒绝你这种「白嫖」行为,导致符号未定义的报错。用 Dual MIT/GPL 或者 GPL 就能解决问题。
// ch10/panic_notifier/panic_notifier_lkm.c
/* 必须包含合适的 LICENSE,否则无法使用 GPL-only 的 API */
MODULE_LICENSE("Dual MIT/GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A custom kernel panic handler demo");
/* 定义我们的通知块 */
static struct notifier_block mypanic_nb = {
.notifier_call = mypanic_handler,
/* .priority = INT_MAX 如果你想抢在所有人前面跑,解开这行 */
};
2. 编写回调函数
这是我们的核心逻辑。这里我们既要打印信息,又要预留位置给实际硬件操作。
/* 模拟一个硬件动作:比如拉响警报 */
/* 在真实产品中,这里可能是写寄存器关电机、开继电器等 */
static void dev_ring_alarm(void)
{
pr_emerg("!!! ALARM !!!\n");
}
/* 实际的回调处理函数 */
static int mypanic_handler(
struct notifier_block *this,
unsigned long val,
void *data)
{
struct die_args *args = (struct die_args *)data;
/* pr_emerg 是最高优先级的 printk,通常能刷到控制台 */
pr_emerg("\n************ Panic : SOUNDING ALARM ************\n"
"val = %lu\n"
"data(str) = \"%s\"\n",
val,
(args && args->str) ? args->str : "unknown");
/* 执行我们的硬件保护逻辑 */
dev_ring_alarm();
return NOTIFY_OK;
}
这里有个细节需要注意:data 可能是 NULL(虽然 Panic 时很少见),所以访问 args->str 之前最好做个防御性检查。这里为了简洁,我们在 pr_emerg 里用了三元运算符做了判断。
3. 模块初始化与退出
最后,把钩子挂上去。
static int __init panic_notifier_lkm_init(void)
{
if (!atomic_notifier_chain_register(&panic_notifier_list, &mypanic_nb)) {
pr_info("Custom panic handler registered.\n");
return 0;
}
return -EFAULT; /* 注册失败极少见,但以防万一 */
}
static void __exit panic_notifier_lkm_exit(void)
{
atomic_notifier_chain_unregister(&panic_notifier_list, &mypanic_nb);
pr_info("Custom panic handler unregistered.\n");
}
module_init(panic_notifier_lkm_init);
module_exit(panic_notifier_lkm_exit);
上号!:验证我们的假设
代码写完了,现在我们需要人为制造一次 Panic 来看看效果。
⚠️ 警告 接下来的操作会让你的虚拟机立刻崩溃。请确保你已经保存了所有重要工作,并且不要在生产环境的机器上尝试。
步骤 1:准备好网络接收端
因为系统崩溃时本机屏幕可能来不及显示或者根本没屏幕,我们依然使用上一节配置好的 netconsole 和 netcat 来接收日志。
确保你在宿主机(或者另一台机器)上正在监听:
# 接收端命令
nc -d -u -l 6666
步骤 2:在虚拟机中加载我们的模块
把模块编译并插入虚拟机:
# 在虚拟机里
insmod ./panic_notifier_lkm.ko
# 检查日志确认注册成功
dmesg | tail
你应该能看到 Custom panic handler registered. 的字样。
步骤 3:引发灾难
我们写了一个小脚本来做这件事。它的逻辑是:
- 把
panic_on_oops设为 1(让 Oops 升级为 Panic)。 - 开启 SysRq。
- 通过 SysRq 触发一次 Crash。
$ cat ../cause_oops_panic.sh
sudo sh -c "echo 1 > /proc/sys/kernel/panic_on_oops"
sudo sh -c "echo 1 > /proc/sys/kernel/sysrq"
sync; sleep .5
sudo sh -c "echo c > /proc/sysrq-trigger"
运行这个脚本。
结果:看见死亡
如果一切正常,你的虚拟机会瞬间死机。回到你的接收端终端,你应该能看到 netcat 喷涌而出的一大堆日志。
在那些熟悉的乱码和堆栈信息的海洋里,你应该能清楚地找到我们留下的痕迹:
[ ...] ************ Panic : SOUNDING ALARM ************
val = 1
data(str) = "SysRq : Trigger a crash"
[ ...] Kernel panic - not syncing: SysRq : Trigger a crash
[ ...] ---[ end Kernel panic - not syncing: SysRq : Trigger a crash ]---
看到了吗?val = 1 对应的正是 DIE_OOPS(或者是 DIE_PANIC,取决于架构的枚举定义),而 data(str) 正是我们触发的命令字符串。
这说明在内核彻底咽气之前,我们的 mypanic_handler 成功地被执行了。
最后的忠告:千万别搞砸了
虽然我们可以插入自定义代码,但有一个非常重要的限制:保持极简。
Panic 发生时,内核处于极其脆弱的状态。自旋锁可能已经被持有、调度器已经停止工作、中断可能被关闭。如果你的 Panic 处理器试图获取一个已经被持有的锁,或者试图睡眠,那么系统可能会卡死在你的处理函数里,连 kdump(崩溃转储)都生成不了,导致你连死因都查不到。
内核源码里的注释已经警告过这一点了:
// kernel/panic.c:panic()
/*
* 注意:因为某些 panic_notifier 可能会让崩溃的内核更加不稳定,
* 这也会增加 kdump 失败的风险。
*/
所以,在这个回调里,只做那些绝对必要的事情:记下几个寄存器的值、切换一个 GPIO 的状态(比如关掉激光)、或者向一个特殊的硬件寄存器写入重启命令。不要做复杂的字符串处理,不要申请内存,不要等待互斥锁。
我们现在的目标已经达成:我们不仅知道内核死了,还给它装了一个「遗言录音笔」。下一节,我们将面对另一种更隐蔽的死亡形式——系统没崩溃,但它卡死了。