1. 一次内核崩溃引发的排查复盘
内核崩了。不是应用层那种抛个异常、打个日志就能定位的崩溃,是整台机器直接失联,SSH 断连,业务全挂,只能走带外管理去重启。这种场面但凡经历过一次,你就知道为什么内核排查和用户态排查完全是两码事——用户态崩了有 core dump,有堆栈,有日志;内核崩了,运气好给你留个 panic 日志,运气不好直接静默死机,连个遗言都没有。
这篇内容围绕的就是这类问题:Linux 内核层面的 bug 排查。我会把最近一段时间实际处理过的几个典型案例拆开来讲,包括内存越界、空指针解引用、锁竞争导致的死锁、引用计数泄漏这几类高频问题。适合谁看?如果你已经在做内核模块开发、驱动调试,或者运维过程中遇到过莫名其妙的宕机、OOM、soft lockup,那这篇内容应该能帮你少走一些弯路。如果你刚接触内核,也能从中了解到内核排查的基本思路和工具链,不至于面对一台挂掉的机器完全无从下手。
先说一个基本认知:内核 bug 排查的核心难点不在于"找到错误",而在于"复现错误"和"保留现场"。很多内核问题具有极强的时序依赖性和环境依赖性,同一个模块在 A 机器上跑三个月没事,在 B 机器上跑三天就崩,差别可能只是一个内存分配器的行为差异,或者一个 CPU 调度时机的不同。所以整个排查过程,本质上是在跟"不确定性"做斗争。
2. 内核排查的底层逻辑与工具选型
2.1 为什么内核排查比用户态排查难一个量级
用户态程序出问题,你有完整的虚拟内存空间,有操作系统提供的信号机制,有 gdb、valgrind、strace 这一整套成熟工具。进程崩了,core dump 文件往那一放,gdb 一挂,堆栈清清楚楚。但内核不一样,内核是所有进程共享的,没有独立的地址空间隔离,一个模块的越界写可能破坏的是另一个完全不相关子系统的数据结构。更麻烦的是,内核崩溃时你往往没有可用的用户态环境来运行调试工具——机器都挂了,你拿什么跑 gdb?
这就引出了内核排查的第一个核心原则:现场保留优先于一切。很多人一看到机器挂了,第一反应是赶紧重启恢复业务,结果重启之后所有内存现场全部丢失,panic 日志如果没配置持久化也一起没了。正确的做法是,在重启之前尽可能把能拿到的信息全部保存下来——串口日志、panic 输出、vmcore 文件、dmesg 缓冲区内容,一个都别放过。
2.2 工具链选型:从 printk 到 kdump 到 ftrace
内核排查的工具链大致可以分为几个层次,不同场景用不同工具,选错了工具就是浪费时间。
最基础的是printk,也就是内核版的 printf。别看不起它,很多问题就是靠合理的 printk 定位的。但 printk 有个致命问题:它会影响时序。你在一个锁竞争的热点路径上加 printk,可能直接改变竞争窗口,导致 bug 消失——这就是所谓的"海森堡 bug",你一观测它就变了。所以 printk 要用,但要有策略地用,不能到处乱撒。
往上一层是ftrace和perf。ftrace 可以在不修改代码的情况下跟踪内核函数调用,对时序影响相对小。perf 则擅长性能剖析和热点定位。这两个工具适合排查性能相关的内核问题,比如调度延迟、中断处理过长、锁持有时间异常。
再往上是kdump + crash。这是排查内核崩溃的杀手锏。kdump 机制会在内核 panic 时启动一个备用的 crash kernel,把主内核的内存镜像 dump 成 vmcore 文件。然后你用 crash 工具加载 vmcore 和对应的 vmlinux(带调试符号的内核镜像),就能像 gdb 调试用户态程序一样分析内核崩溃现场。bt 看堆栈,struct 看数据结构,rd 读内存,search 搜符号,基本上用户态调试能做的事它都能做。
最高层次是KASAN、KCSAN、lockdep这类动态检测工具。KASAN 检测内存越界和 use-after-free,KCSAN 检测数据竞争,lockdep 检测锁依赖关系中的死锁风险。这些工具需要在编译内核时开启对应配置,会带来一定的性能开销,但在测试环境中开启它们,能在 bug 造成实际危害之前就把它揪出来。
| 工具 | 适用场景 | 性能开销 | 是否需要重新编译内核 |
|---|---|---|---|
| printk | 逻辑错误、状态跟踪 | 中到高 | 否 |
| ftrace | 函数调用跟踪、延迟分析 | 低 | 否(需内核支持) |
| perf | 性能剖析、热点定位 | 低到中 | 否 |
| kdump+crash | 崩溃后现场分析 | 无(崩溃后才启动) | 需配置 |
| KASAN | 内存越界、UAF 检测 | 高(2-3倍) | 是 |
| lockdep | 死锁风险检测 | 中 | 是 |
2.3 排查策略:二分法与假设驱动
工具选好了,策略也很关键。内核排查最忌讳的是"漫无目的地看代码"。几万行内核代码,你一行行看,看到明年也看不出问题。有效的策略是假设驱动 + 二分法。
假设驱动是指:根据现象先提出一个或几个可能的假设,然后设计实验去验证或排除。比如机器在跑某个网络压力测试时崩溃,你的假设可能是:网卡驱动的 DMA 操作有越界、协议栈的某个锁有竞争、内存分配失败路径处理有误。然后针对每个假设设计验证方法。
二分法则适用于定位引入 bug 的代码变更。如果你知道某个版本之前是好的,某个版本之后开始崩,那就可以用 git bisect 在内核提交历史中二分查找,快速定位到引入问题的那个 commit。这个方法看起来笨,但极其有效,尤其是面对那些"看起来完全不可能有问题"的代码时。
3. 四类高频内核 Bug 的深度拆解
3.1 内存越界:最隐蔽也最致命
内存越界是内核 bug 里的头号杀手。用户态越界写通常就是当前进程崩掉,影响范围可控。内核态越界写,轻则破坏相邻数据结构导致逻辑错乱,重则直接踩坏页表或调度器数据结构,整机崩溃。
我处理过一个典型案例:某个字符设备驱动在 ioctl 处理函数中,从用户态拷贝数据到内核缓冲区时,没有严格校验用户传入的长度参数。用户传了一个比实际缓冲区大的长度,驱动直接 memcpy,把内核栈上的返回地址给覆盖了。结果就是函数返回时跳到了一个非法地址,panic。
这类问题的排查思路是:首先看 panic 时的堆栈,如果堆栈显示在一个完全不相关的函数里崩溃,或者返回地址明显不对,那大概率是栈被破坏了。然后用 crash 工具检查崩溃时的栈内存,看看有没有被异常数据覆盖的痕迹。如果开启了 KASAN,那更简单,KASAN 会直接告诉你哪次访问越界了、越界了多少字节、访问地址属于哪个对象。
注意:内核中所有从用户态拷贝数据的操作,必须使用 copy_from_user 而不是直接 memcpy。copy_from_user 会做地址合法性检查,而且能正确处理用户态页面被换出的情况。直接 memcpy 用户态指针是内核开发中的大忌。
3.2 空指针解引用:看似简单,排查起来要命
空指针解引用在内核里太常见了。用户态空指针解引用,操作系统给你发个 SIGSEGV,进程崩了完事。内核态空指针解引用,如果没被 panic_on_oops 拦住,可能只是打印一个 oops 然后继续跑,但此时内核状态已经不可信了,后续可能引发更严重的问题。
有个案例让我印象很深:一个内核模块在初始化时,从设备树中读取配置参数,如果设备树里没有对应节点,of_find_node_by_name 返回 NULL,但代码没有检查返回值就直接访问了 node->properties。结果就是空指针解引用。这个问题在开发板上跑没事,因为设备树配置完整;到了客户现场,设备树被裁剪过,节点没了,一加载模块就 oops。
排查空指针问题的关键是看 oops 信息里的"Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000"这一行,以及后面的 PC 值和堆栈。PC 值告诉你崩在哪个函数,堆栈告诉你调用链。然后结合源码看那个函数里哪个指针可能为 NULL。
3.3 锁竞争与死锁:时序的魔鬼
锁的问题是最难排查的,因为它的触发依赖精确的时序。两个线程以不同的顺序获取两把锁,大多数时候没事,偶尔交叉执行就死锁了。这种问题可能跑几天才出现一次,复现难度极大。
lockdep 是排查这类问题的利器。它不需要问题实际发生,只需要在代码中存在不合理的锁获取顺序,lockdep 就会在运行时检测到并打印警告。我强烈建议在所有测试版本的内核中开启 lockdep,它带来的性能开销远小于它帮你避免的调试成本。
如果问题已经发生了,机器卡死但没 panic,那首先要确认是不是死锁。方法是通过 sysrq 触发所有 CPU 的堆栈打印(echo l > /proc/sysrq-trigger),看看是不是有多个 CPU 卡在同一个锁上。如果是,再看这些 CPU 的调用链,找出锁的获取顺序。
3.4 引用计数泄漏:慢性毒药
引用计数泄漏不会立刻导致崩溃,但会慢慢积累,最终导致内存耗尽或者对象无法释放。kobject、device、module 这些内核对象都有引用计数,用 kref 或 atomic_t 管理。泄漏的原因通常是:在某个错误处理路径上忘记调用 put,或者 get 和 put 不配对。
排查引用计数泄漏,最直接的方法是看 /sys/kernel/debug/ 下对应子系统的调试信息,很多子系统会导出对象的引用计数状态。另外,kmemleak 工具可以检测内核内存泄漏,它会定期扫描内存,找出那些没有任何指针指向但也没有被释放的内存块。
4. 从崩溃现场到根因定位的完整实操
4.1 第一步:保留现场,配置 kdump
kdump 的配置分几步。首先要在内核命令行中预留一块内存给 crash kernel,通过 crashkernel=256M 这样的参数指定。然后在用户态安装 kexec-tools,配置 /etc/kdump.conf 指定 vmcore 的保存路径。最后启动 kdump 服务,确认 crash kernel 已经加载。
# 检查 crash kernel 是否已预留 cat /proc/cmdline | grep crashkernel # 启动 kdump 服务 systemctl start kdump systemctl enable kdump # 确认 kdump 状态 systemctl status kdump # 手动触发一次 crash 测试(谨慎操作) echo c > /proc/sysrq-trigger重启后,vmcore 文件会保存在 /var/crash/ 目录下。用 crash 工具加载:
crash /usr/lib/debug/lib/modules/$(uname -r)/vmlinux /var/crash/xxx/vmcore进入 crash 交互界面后,常用的命令有:
- bt:查看崩溃时的堆栈
- bt -a:查看所有 CPU 的堆栈
- log:查看内核日志缓冲区
- ps:查看进程列表
- struct 结构体名 地址:查看指定地址的结构体内容
- rd 地址:读取内存
- dis 函数名:反汇编指定函数
4.2 第二步:解读 panic 信息
panic 信息里包含了很多关键线索,但很多人只看最后几行就懵了。正确的解读顺序是:
先看触发 panic 的原因,比如 "BUG: unable to handle kernel paging request at ffff..." 或者 "kernel BUG at mm/slub.c:1234"。这告诉你问题的类型。
然后看 CPU 寄存器的值,特别是 RIP(指令指针)、RSP(栈指针)、CR2(页错误地址)。RIP 告诉你崩在哪条指令,CR2 告诉你访问了哪个非法地址。
接着看 Call Trace,这是函数调用链。从下往上看,最下面的是最外层的调用者,最上面的是崩溃点。注意看有没有 "?" 标记,带问号的帧表示可能不准确。
最后看 "Code:" 那一行,这是崩溃点附近的机器码,结合反汇编可以确认具体是哪条指令出了问题。
4.3 第三步:用 crash 做深度分析
进入 crash 后,我通常按这个顺序操作:
# 1. 先看崩溃时的堆栈 crash> bt # 2. 看所有 CPU 在干什么,排除死锁可能 crash> bt -a # 3. 看内核日志,找 panic 前的异常信息 crash> log | tail -100 # 4. 如果怀疑内存越界,看崩溃地址附近的内存 crash> rd ffff888012345000 64 # 5. 如果怀疑某个结构体被破坏,打印它 crash> struct my_device ffff888012345000 # 6. 查看模块加载地址,确认符号对应关系 crash> mod有一个技巧很实用:如果你怀疑某个函数有问题,可以用 dis 反汇编它,然后对照源码看汇编层面到底做了什么。有时候编译器优化会让源码和实际执行逻辑产生差异,看汇编能发现一些源码层面看不出来的问题。
4.4 第四步:复现与验证
找到疑似根因后,必须设计实验验证。验证方法包括:
- 如果怀疑是某个 commit 引入的,用 git bisect 确认
- 如果怀疑是某个条件触发的,构造最小复现用例
- 如果怀疑是内存越界,开启 KASAN 重新跑测试
- 如果怀疑是锁问题,开启 lockdep 重新跑测试
只有能稳定复现并验证修复方案有效,才算真正完成了排查。
5. 排查过程中踩过的坑与速查手册
5.1 那些年我们误判过的"根因"
坑一:把现象当原因。看到 OOM 就以为是内存泄漏,结果查了半天发现是某个进程突然申请了大量内存,而那个进程本身是正常的,真正的问题在于内核的内存回收机制没有及时触发。OOM 只是结果,不是原因。
坑二:忽略配置差异。开发环境内核配置和产线环境不一样,开发环境开了 DEBUG 选项,很多问题被检测出来了;产线环境为了性能关掉了这些选项,问题就变成了静默崩溃。排查时一定要确认两边内核配置的差异。
坑三:过度依赖 printk。前面说过,printk 会改变时序。我遇到过一个问题,加了 printk 就复现不了,去掉就复现。后来用 ftrace 替代 printk 才定位到问题。
坑四:忽视硬件因素。有些"内核 bug"其实是硬件问题,比如内存条故障导致随机位翻转,CPU 缓存问题导致数据不一致。排查到一定程度如果毫无头绪,不妨跑一下内存测试和 CPU 压力测试。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查工具 | 快速验证方法 |
|---|---|---|---|
| 整机静默死机 | 死锁、硬件故障 | sysrq + bt -a | 触发 sysrq 看 CPU 堆栈 |
| panic 带非法地址 | 内存越界、UAF | KASAN、crash | 开启 KASAN 重跑 |
| OOM 但内存充足 | 内存碎片、cgroup 限制 | /proc/buddyinfo、cgroup 信息 | 检查碎片和限制配置 |
| 随机 oops | 竞态、未初始化变量 | KCSAN、lockdep | 开启检测工具重跑 |
| 模块加载失败 | 符号依赖、版本不匹配 | dmesg、modinfo | 看 dmesg 具体报错 |
| 性能突然下降 | 锁竞争、中断风暴 | perf、ftrace | perf top 看热点 |
5.3 独家避坑技巧
技巧一:在测试内核中默认开启 KASAN 和 lockdep。性能损失在测试环境可以接受,但能帮你提前发现大量问题。产线内核再关掉这些选项。
技巧二:给 panic 日志加时间戳和 CPU 编号。多核系统上,不同 CPU 的日志交织在一起,没有时间戳根本理不清顺序。通过内核参数 printk.time=1 开启时间戳。
技巧三:用 pstore 保存崩溃日志。pstore 可以把 panic 日志保存到 ACPI ERST 或 RAMoops 区域,重启后还能读取。对于没有配置 kdump 的环境,这是最后的救命稻草。
技巧四:建立内核崩溃案例库。每次排查完一个问题,把 panic 信息、根因、修复方案整理成文档。下次遇到类似现象,先查案例库,能省大量时间。
技巧五:不要忽视 warning。内核的 WARN_ON 和 WARN_ON_ONCE 打印的警告,很多人觉得不影响运行就忽略了。但这些警告往往是更严重问题的前兆,认真对待每一个 warning,能避免很多 panic。
6. 内核排查能力的长期建设
内核排查这件事,工具和技巧是一方面,更重要的是知识积累和思维方式。你需要对内核的各个子系统有基本的了解——内存管理、进程调度、文件系统、网络协议栈、设备驱动模型,不要求每个都精通,但至少要知道它们大致是怎么工作的,出了问题时该往哪个方向想。
我个人的习惯是,每读一个子系统的代码,就画一张数据结构关系图和调用流程图。这些图在排查问题时极其有用,因为内核崩溃往往表现为"一个看似不相关的子系统出了问题",如果你有全局的数据结构视图,就能快速判断出崩溃点附近的数据结构可能被谁破坏了。
另外,多看看内核社区的问题讨论和补丁提交。很多你遇到的问题,别人已经遇到过了,而且可能有现成的修复方案。内核邮件列表、bugzilla、各种技术社区都是宝贵的资源。但要注意,看别人的解决方案时,一定要理解背后的原理,不能照搬,因为环境差异可能导致同样的方案在你这里不生效。
最后说一点关于心态的。内核排查有时候确实很折磨人,一个问题可能查一周都没有进展。这时候不妨换个思路,或者先放一放,让潜意识去处理。我有很多次都是在洗澡或者散步的时候突然想到关键线索的。保持耐心,保持好奇,每一个被你揪出来的内核 bug,都是对系统稳定性的一份贡献。