3、内核崩溃(Kernel Panic)分析:Panic产生原因、Oops信息解读、通过kdump捕获vmcore、使用crash工具分析

内核崩溃,说白了就是Linux内核遇到了它无法处理的致命错误。这时候系统会直接停摆,屏幕上刷出一堆信息,然后死在那。我刚开始做驱动开发那会儿,第一次看到Panic,心里是慌的。后来见得多了,反而觉得它是个好东西——至少它把问题摆在了明面上,比那些莫名其妙的内存踩踏好定位多了。

3.1 Panic产生原因

为什么会Panic?原因其实就几类,我列一下:

  • 空指针解引用:最常见。内核里访问了NULL地址,直接触发缺页异常。嗯,驱动里最常见的就是kmalloc失败没检查返回值。
  • 内核态非法指令:比如执行了未定义的CPU指令,或者代码段被篡改。我在项目中遇到过一例,是DMA写穿了缓冲区,把代码段覆盖了。
  • 内核栈溢出:递归调用太深,或者局部变量太大。内核栈默认只有8KB或16KB,你想想看,一个4KB的局部数组就能撑爆它。
  • 死锁检测触发:内核有lockdep机制,检测到潜在死锁会主动Panic。这其实是保护你,免得系统挂得更难看。
  • 硬件错误:比如ECC校验失败、PCIe链路断开。这种最头疼,因为往往是硬件问题,但背锅的总是驱动。

核心要点:Panic不是随机发生的。每一次Panic背后,都有一个明确的触发点。我们的任务就是找到它。

3.2 Oops信息解读

Oops是Panic的“前奏”。有时候系统只是Oops一下,还能继续跑(虽然很不稳定)。但大多数情况下,Oops之后紧接着就是Panic。我个人习惯把Oops信息当作Panic的“现场报告”来读。

一个典型的Oops信息长这样:

BUG: unable to handle kernel NULL pointer dereference at 0000000000000010
IP: [<ffffffffa000e000>] my_driver_ioctl+0x30/0x50 [my_driver]
PGD 0 P4D 0 
Oops: 0002 [#1] SMP
CPU: 2 PID: 1234 Comm: test_app Tainted: P           O    4.18.0-xxx #1
RIP: 0010:my_driver_ioctl+0x30/0x50 [my_driver]
RSP: 0018:ffff880012345678 EFLAGS: 00010246
RAX: 0000000000000000 RBX: ffff880012345600 RCX: 0000000000000001
RDX: 0000000000000000 RSI: 0000000000000000 RDI: ffff880012345600
RBP: ffff880012345690 R08: 0000000000000000 R09: 0000000000000000
R10: 0000000000000000 R11: 0000000000000000 R12: 0000000000000000
R13: 0000000000000000 R14: 0000000000000000 R15: 0000000000000000

怎么读?我教你一个顺序:

  1. 看第一行:它告诉你是什么错误。上面是“NULL pointer dereference”,空指针。如果是“page fault”,那就是缺页异常。
  2. 看IP和RIP:这是出错的指令地址和函数名。上面显示是my_driver_ioctl+0x30/0x50,说明在my_driver_ioctl函数偏移0x30处出错了,这个函数总共0x50字节。
  3. 看Oops码0002 [#1]。这个数字有意义——最低位是0表示读操作,1表示写操作;第二位是0表示内核态,1表示用户态。上面是2,二进制是0010,表示内核态读操作。
  4. 看寄存器:RAX是0,说明很可能就是RAX被当作指针用了。结合第一行,就是解引用了RAX+0x10这个地址。

我的经验:拿到Oops信息,先别急着看代码。把RIP地址记下来,然后用objdump -d反汇编你的驱动模块,找到对应偏移。有时候一眼就能看出是哪行C代码出了问题。

3.3 通过kdump捕获vmcore

Oops信息虽然有用,但信息量有限。真正要深入分析,得靠vmcore。kdump就是干这个的——系统崩溃时,它用第二个内核(捕获内核)把崩溃时的内存快照保存下来。

配置kdump其实不复杂,我一般这么搞:

# 安装kdump工具
yum install kexec-tools crash

# 配置内核启动参数
# 在/etc/default/grub中追加 crashkernel=256M
# 然后更新grub
grub2-mkconfig -o /boot/grub2/grub.cfg

# 启动kdump服务
systemctl enable kdump
systemctl start kdump

# 验证是否生效
kdumpctl status

这里有个坑,我曾经踩过:crashkernel参数的大小要看你系统的内存大小。内存小于4GB的,给128M就够了;大于4GB的,建议给256M。给少了,捕获内核启动不起来,vmcore就抓不到。

触发一次Panic测试一下:

# 触发Panic(别在生产环境干这事!)
echo c > /proc/sysrq-trigger

系统会重启,重启后检查/var/crash目录,应该能看到类似127.0.0.1-2024-01-15-10:30:00这样的目录,里面就是vmcore文件。

注意:vmcore文件很大,跟你的系统内存大小差不多。一个8GB内存的机器,vmcore就有8GB。所以磁盘空间要预留够。我一般至少留两倍内存大小的空间。

3.4 使用crash工具分析

拿到vmcore之后,就该crash工具上场了。crash是一个交互式调试器,专门用来分析内核转储文件。我个人觉得,它比gdb更适合分析内核问题。

基本用法:

# 启动crash,需要vmcore和对应的vmlinux(带调试符号的内核镜像)
crash /usr/lib/debug/lib/modules/$(uname -r)/vmlinux /var/crash/127.0.0.1-2024-01-15-10:30:00/vmcore

# 进入crash交互界面后,常用命令:
crash> bt          # 查看崩溃时的调用栈
crash> log         # 查看内核日志(dmesg)
crash> ps          # 查看所有进程
crash> files       # 查看打开的文件
crash> vm          # 查看虚拟内存信息
crash> dis <addr>  # 反汇编指定地址
crash> rd <addr>   # 读取内存内容

我一般先跑bt看调用栈。比如:

crash> bt
PID: 1234   TASK: ffff880012345600  CPU: 2   COMMAND: "test_app"
 #0 [ffff880012345678] machine_kexec at ffffffff8105a0e0
 #1 [ffff880012345730] __crash_kexec at ffffffff8111b0e0
 #2 [ffff880012345800] panic at ffffffff8108b0e0
 #3 [ffff880012345880] oops_end at ffffffff8102b0e0
 #4 [ffff8800123458a0] no_context at ffffffff8106b0e0
 #5 [ffff8800123458f0] __bad_area_nosemaphore at ffffffff8106b1e0
 #6 [ffff880012345940] bad_area_nosemaphore at ffffffff8106b2e0
 #7 [ffff880012345950] __do_page_fault at ffffffff8106b3e0
 #8 [ffff8800123459a0] do_page_fault at ffffffff8106b4e0
 #9 [ffff8800123459b0] page_fault at ffffffff8106b5e0
    [exception RIP: my_driver_ioctl+0x30]
    RIP: ffffffffa000e030  RSP: ffff880012345a68  RFLAGS: 00010246
    RAX: 0000000000000000  RBX: ffff880012345600  RCX: 0000000000000001
    RDX: 0000000000000000  RSI: 0000000000000000  RDI: ffff880012345600
    RBP: ffff880012345a80   R8:  0000000000000000   R9:  0000000000000000
    R10: 0000000000000000  R11: 0000000000000000  R12: 0000000000000000
    R13: 0000000000000000  R14: 0000000000000000  R15: 0000000000000000
    ORIG_RAX: ffffffffffffffff  CS:  0010  SS:  0018

看到没?调用栈从page_fault一路回溯到my_driver_ioctl。这说明是用户态程序调用了驱动的ioctl,然后在内核态触发了缺页异常。

接下来,我习惯用log命令看完整的内核日志:

crash> log
[    0.000000] Linux version 4.18.0-xxx ...
...
[ 1234.567890] my_driver: my_driver_ioctl: invalid argument
[ 1234.567891] BUG: unable to handle kernel NULL pointer dereference at 0000000000000010
...

日志里往往有驱动打印的调试信息。上面就显示“invalid argument”,说明用户传了非法参数。嗯,这就是线索。

再深入一点,可以用struct命令查看数据结构:

crash> struct my_device ffff880012345600
struct my_device {
  dev_id = 0x1,
  name = "test_device\0",
  buffer = 0x0,    // 注意这里!buffer是NULL
  lock = {
    ...
  }
}

看到buffer = 0x0了吗?这就是问题所在。驱动在ioctl里直接用了这个NULL指针,没做检查。

分析结论:驱动在my_driver_ioctl中,没有检查my_device->buffer是否为NULL就直接解引用,导致空指针访问。修复方法是在使用前加if (!dev->buffer) return -EINVAL;

3.5 避坑指南

最后,分享几个我踩过的坑:

  • vmlinux版本必须匹配:crash分析时用的vmlinux,必须跟生成vmcore的内核是同一个版本。差一个patch都不行。我曾经因为内核小版本升级忘了更新vmlinux,分析了一整天发现全是错的。
  • vmcore可能被截断:如果磁盘空间不够,vmcore可能只保存了一部分。这时候分析结果不可靠。我建议在/etc/kdump.conf里设置core_collector makedumpfile -c -d 31,用压缩方式保存,能省不少空间。
  • 别忽略Tainted标志:Oops信息里的Tainted: P O表示内核被“污染”了。P表示有专有模块加载,O表示有外部模块。这些信息能帮你判断问题是不是出在第三方驱动上。
  • 多核系统要小心:Panic可能只发生在某个CPU上,其他CPU还在跑。crash的bt -a可以查看所有CPU的调用栈,有时候问题CPU并不是触发Panic的那个。

好了,这一章的内容就这些。内核崩溃分析是个熟能生巧的活,多分析几个vmcore,你就能一眼看出问题在哪。下一章我们会讲内存踩踏的定位方法,那也是个让人头疼的问题。