1. 一次内核崩溃引发的排查复盘
内核崩溃这件事,很多人第一反应是"重启就好了"。我最初也是这么想的。直到某次线上环境连续三天在凌晨两点左右触发内核 panic,重启后跑不到六小时又挂,才意识到问题的严重性。那台机器承载着核心数据同步任务,每次崩溃都意味着至少半小时的数据延迟,业务方已经开始在群里@所有人了。
这篇文章要聊的,就是Linux内核bug排查这件事。具体来说,我会把这次排查的完整过程拆开揉碎,从日志分析、现场保护、复现路径构造,到最终定位到一个内存管理子系统的竞态条件,每一步都讲清楚为什么这么做、踩过哪些坑、有哪些可以复用的技巧。适合谁看?如果你正在被内核崩溃、系统hang死、OOM误报、驱动异常这类问题折磨,或者你是个后端开发/运维/SRE,迟早要跟内核打交道,那这篇内容应该能帮你省下不少深夜加班的时间。
内核排查跟应用层排查最大的区别在于:你没有printf可用。应用层崩了,加个日志、打个堆栈,问题基本就清楚了。内核崩了,整个系统直接躺平,你只能靠崩溃前留下的那点线索去反推。这就好比刑侦破案,现场已经被破坏了,你得从弹壳、指纹、监控录像的碎片里还原真相。所以内核排查的核心能力,其实是现场保护和线索提取。
我这次遇到的问题,表面上看是内存分配失败导致的panic,但真正的原因藏得很深。整个排查过程大概持续了一周,中间走了不少弯路,也积累了一些我觉得值得分享的经验。下面我会按照实际排查的顺序来展开,但会把每个环节的原理和操作细节都补全,方便你直接抄作业。
2. 内核崩溃现场的第一反应与信息采集
2.1 崩溃日志到底该怎么读
内核崩溃时,控制台会打印一大段信息,很多人看到就懵了。其实这段信息是有固定结构的,我把它拆成几个关键部分:
- Oops/panic头部:告诉你崩溃类型,是空指针解引用、页错误、还是主动触发的BUG()。
- CPU寄存器快照:RIP(指令指针)指向崩溃时执行的指令地址,这是最关键的线索。
- 调用栈(Call Trace):函数调用链,从下往上看,能还原出代码执行路径。
- 内核版本与模块列表:确认环境,排除版本不匹配导致的误判。
我那次崩溃的日志头部是这样的(脱敏后):
BUG: unable to handle kernel NULL pointer dereference at 0000000000000018 RIP: 0010:mem_cgroup_charge+0x3a/0x1a0 Call Trace: <IRQ> page_add_new_anon_rmap+0x2f/0x80 do_anonymous_page+0x1c5/0x6d0 __handle_mm_fault+0x5e3/0x8a0 handle_mm_fault+0x9c/0x160 do_page_fault+0x1e2/0x4a0看到mem_cgroup_charge里偏移0x3a处访问了NULL+0x18,第一反应是某个cgroup相关的指针为空。但这里有个陷阱:RIP指向的地址不一定是问题根源。编译器优化、内联、指令重排都可能让RIP偏移看起来很奇怪。所以不能只看这一行,必须结合调用栈和寄存器一起分析。
提示:如果崩溃日志被截断了,优先找
RIP、Call Trace、CR2(页错误地址)这三个字段。CR2能告诉你到底访问了哪个非法地址,比RIP更直接。
2.2 现场保护:别急着重启
我见过太多人一看到panic就条件反射按重启键。这是排查内核bug最大的忌讳。重启意味着所有现场信息丢失,你只能等下次崩溃,而下次崩溃可能几天后才出现。
正确的做法是:
- 配置kdump:让内核在崩溃时自动把内存镜像dump到磁盘。这是事后分析的基础。
- 保留串口日志:如果机器有串口,接上串口线,所有内核输出都会实时打印到另一台机器上。没有串口的话,配置
netconsole把日志通过网络发出去。 - 记录崩溃时间点:精确到秒,方便跟其他监控数据对齐。
kdump的配置其实不复杂,但有几个坑我踩过:
# 检查内核是否支持kdump grep CONFIG_CRASH_DUMP /boot/config-$(uname -r) # 安装kexec-tools yum install kexec-tools # 或 apt install kexec-tools # 配置crashkernel预留内存 # 在grub里加上 crashkernel=256M # 注意:预留内存不能太小,否则dump会失败注意:
crashkernel的大小要根据物理内存来定。内存越大,dump文件越大,预留空间也要相应增加。我一般按物理内存的1/16来预留,最小256M。如果dump失败,先检查这个参数。
2.3 用crash工具做初步分析
拿到vmcore文件后,用crash工具打开:
crash /usr/lib/debug/lib/modules/$(uname -r)/vmlinux /var/crash/vmcore进去之后,我习惯先跑几个固定命令:
| 命令 | 作用 | 我的使用场景 |
|---|---|---|
bt | 查看崩溃时的调用栈 | 确认崩溃线程和上下文 |
log | 查看内核日志缓冲区 | 还原崩溃前的最后输出 |
ps | 查看进程状态 | 确认是否有异常进程 |
vm | 查看内存使用 | 判断是否OOM相关 |
kmem -s | 查看slab分配情况 | 排查内存泄漏 |
那次bt的输出显示崩溃发生在软中断上下文(<IRQ>标记),这很关键。软中断上下文里不能睡眠,如果代码路径里有任何可能睡眠的操作,就会出问题。顺着这个线索,我重点看了mem_cgroup_charge的调用链,发现它是在匿名页缺页异常处理中被调用的。
3. 从调用栈反推问题根源
3.1 理解缺页异常的处理流程
要搞清楚为什么mem_cgroup_charge会崩,得先明白匿名页缺页异常的处理流程。我用一个生活化的类比来解释:
想象你去图书馆借书(分配内存)。正常情况下,你先查目录(页表),发现书不在架上(页表项为空),然后去书库找(分配物理页),找到后登记借阅记录(建立映射),最后把书给你(返回用户空间)。
这个过程在内核里对应:
- CPU访问虚拟地址,发现页表项无效,触发缺页异常。
- 内核的
do_page_fault接管,判断异常类型。 - 对于匿名页(没有文件背景的内存),调用
do_anonymous_page。 - 分配物理页框,然后调用
page_add_new_anon_rmap建立反向映射。 - 在建立映射前,需要向内存控制组(memcg)记账,这就是
mem_cgroup_charge。
问题就出在第5步。mem_cgroup_charge需要访问当前进程所属的memcg结构,但如果这个结构在某些并发场景下被释放了,就会访问到空指针。
3.2 竞态条件的发现过程
单看一次崩溃,很难判断是竞态。我的做法是统计多次崩溃的共性:
- 崩溃时间:都在凌晨2点左右,正好是定时任务批量启动的时间。
- 崩溃CPU:不固定,说明不是某个CPU的硬件问题。
- 崩溃进程:都是同一个数据同步进程的子线程。
- 调用栈:完全一致。
这些共性指向一个结论:问题跟特定进程的行为相关,且跟并发有关。
接下来我在测试环境构造复现。思路是:用多线程频繁创建和销毁cgroup,同时触发大量匿名页分配。大概跑了三小时,复现了。复现后立刻用crash分析,发现崩溃时memcg的引用计数为0,但代码路径还在使用它。
这里涉及一个内核内存管理的经典问题:引用计数与生命周期管理。memcg结构在进程退出时会被释放,但如果此时还有缺页异常在处理中,且该异常路径持有的是旧的memcg指针,就会出问题。修复方案通常是在关键路径上加引用计数保护,或者用RCU机制延迟释放。
3.3 用ftrace追踪函数调用
为了确认竞态窗口,我用了ftrace。ftrace是内核自带的追踪工具,不需要额外安装,功能却非常强大。
# 挂载tracefs mount -t tracefs none /sys/kernel/tracing # 设置追踪函数 echo mem_cgroup_charge > /sys/kernel/tracing/set_ftrace_filter echo function > /sys/kernel/tracing/current_tracer echo 1 > /sys/kernel/tracing/tracing_on # 跑测试用例 # ... # 查看结果 cat /sys/kernel/tracing/trace通过ftrace的输出,我看到了mem_cgroup_charge和mem_cgroup_put的调用时序。在某些时间窗口内,put先于charge执行,导致charge访问了已释放的结构。这就是竞态的实锤。
实操心得:ftrace的
function_graph模式比function模式更直观,能看到函数进出和耗时。但开销也更大,生产环境慎用。测试环境随便跑。
4. 内核调试的常用武器库
4.1 printk与动态调试
虽然我说内核里没有printf,但printk是有的。只是它有几个限制:日志级别、缓冲区大小、不能睡眠。用printk调试内核模块时,我一般用pr_debug配合动态调试:
// 在代码里加 pr_debug("memcg pointer: %p, refcnt: %d\n", memcg, atomic_read(&memcg->refcnt)); // 运行时开启 echo 'file memcontrol.c +p' > /sys/kernel/debug/dynamic_debug/control动态调试的好处是默认不输出,需要时再开,对性能影响小。生产环境排查偶发问题时特别有用。
4.2 kprobe与eBPF
kprobe可以在几乎任何内核函数上插桩,eBPF则是更现代的方案。我这次排查后期用了eBPF来统计memcg的生命周期:
// 伪代码示意 kprobe/mem_cgroup_charge { @start[tid] = nsecs; } kretprobe/mem_cgroup_charge { @time = hist(nsecs - @start[tid]); delete(@start[tid]); }这样能看到mem_cgroup_charge的耗时分布。如果某些调用耗时异常长,说明可能发生了锁竞争或内存回收,这些都是竞态的温床。
注意:eBPF需要内核版本支持(一般4.9+),且需要
CONFIG_BPF相关配置。老内核只能用kprobe或systemtap。
4.3 锁与并发问题的排查思路
内核并发问题排查有个基本套路:
- 确认是否真的并发:看崩溃是否只在多CPU、多线程场景出现。
- 找共享数据:崩溃点访问的数据结构,哪些字段是共享的?
- 查保护机制:共享数据有没有锁、引用计数、RCU保护?
- 验证保护是否失效:用ftrace或kprobe观察锁的获取释放时序。
我这次的问题,共享数据是memcg结构,保护机制是引用计数。失效原因是引用计数的获取和释放不在同一个临界区,中间有窗口。修复方案是在do_anonymous_page路径上提前获取引用,确保整个缺页处理期间memcg不会被释放。
5. 常见内核问题的速查与避坑
5.1 内核问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 空指针解引用 | 结构体未初始化、竞态释放 | 看RIP偏移、检查引用计数 |
| 页错误 | 非法地址访问、权限问题 | 看CR2、检查页表 |
| 软锁死 | 死循环、锁未释放 | 看NMI watchdog、检查锁 |
| OOM | 内存泄漏、cgroup限制 | 看meminfo、slab统计 |
| 内核hang | 死锁、硬件问题 | 看所有CPU栈、检查锁顺序 |
| 文件系统只读 | 磁盘错误、内核bug | 看dmesg、检查磁盘健康 |
5.2 我踩过的坑
坑一:只看RIP不看调用栈。RIP偏移可能因为内联而指向无关函数,调用栈才是执行路径的真相。
坑二:忽略时间相关性。我一开始没注意崩溃都在凌晨2点,浪费了两天时间。后来把崩溃时间和cron任务对齐,立刻有了方向。
坑三:在生产环境直接开ftrace。ftrace的function模式全量追踪时开销很大,我在生产环境开了一次,直接把机器负载拉满,差点引发二次故障。后来改成只在测试环境用,或者用set_ftrace_filter限制范围。
坑四:忘记保存原始vmcore。分析过程中可能会用crash修改内存视图,如果没保存原始文件,后面想重新分析就麻烦了。我现在的习惯是vmcore文件只读,分析时复制一份。
5.3 内核排查的心态建设
内核bug排查是个耐心活。我这次一周才定位,中间有几次觉得"肯定是硬件问题"想放弃。但每次想放弃时,我就问自己:如果是硬件问题,为什么每次崩溃的调用栈都一样?这个反问帮我坚持了下来。
另外,不要一个人死磕。内核社区、邮件列表、IRC频道都是资源。我最后是在一个内核开发者的邮件列表里搜到了类似问题的讨论,才确认了修复方向。搜索关键词要精准,比如"mem_cgroup_charge NULL pointer race condition"比"kernel crash"有效得多。
6. 从修复到验证的完整闭环
6.1 补丁的编写与测试
定位到问题后,修复本身反而简单了。核心改动是在do_anonymous_page里提前获取memcg引用:
// 修改前 mem_cgroup_charge(page, vma->vm_mm, GFP_KERNEL); // 修改后 memcg = get_mem_cgroup_from_mm(vma->vm_mm); if (!memcg) return -ENOMEM; mem_cgroup_charge(page, memcg, GFP_KERNEL); mem_cgroup_put(memcg);但改完不能直接上生产,必须经过:
- 单元测试:内核有
tools/testing/selftests,跑相关用例。 - 压力测试:用
stress-ng或自己写的多线程测试跑至少24小时。 - 回归测试:确认没有引入新的崩溃或性能退化。
我那次跑了72小时压力测试,期间用perf监控了内存分配延迟,确认没有明显退化才上线。
6.2 验证修复效果
上线后怎么确认修好了?我的做法是:
- 监控崩溃率:之前每天1-2次,修复后一周零崩溃。
- 监控memcg生命周期:用eBPF统计memcg创建销毁次数,确认没有异常。
- 保留回滚方案:内核补丁回滚比应用麻烦,所以提前准备了旧内核镜像,万一有问题能快速切换。
提示:内核补丁上线后,至少观察一个完整的业务周期(比如一周),确认没有在特定业务场景下触发新问题。
6.3 沉淀与分享
问题解决后,我把整个排查过程整理成了内部文档,包括:
- 崩溃日志的完整分析路径
- kdump配置模板
- ftrace和eBPF的使用示例
- 竞态问题的通用排查框架
这份文档后来帮团队里其他人解决了两个类似问题。内核排查的经验就是这样,一次踩坑,长期受益。
7. 一些额外的实战建议
如果你刚开始接触内核排查,我建议从这几个方向入手:
第一,熟悉crash工具。把bt、log、ps、vm、kmem这几个命令练熟,大部分崩溃分析都够用了。
第二,学会读内核源码。不用全读,但崩溃涉及的函数一定要读。我这次把mm/memory.c和mm/memcontrol.c的相关函数读了三遍,才理清调用关系。
第三,建立自己的排查清单。每次遇到新问题,把排查步骤记下来,慢慢就形成体系了。我的清单现在有二十多条,覆盖了从日志采集到补丁验证的全流程。
第四,关注内核版本差异。同一个函数在不同内核版本里可能行为完全不同。我这次的问题在5.10上有,在5.15上已经被重构掉了。所以排查前先确认内核版本,再看对应版本的源码。
第五,善用社区资源。内核邮件列表、bugzilla、各种技术博客都是宝库。但搜索时要注意,很多问题已经被修复了,你要找的是跟你内核版本匹配的讨论。
最后说个我自己的体会:内核排查最难的其实不是技术,而是在信息不足的情况下做判断。你永远拿不到完整的信息,永远要在不确定性中前进。这时候经验就很重要了,而经验只能靠一次次实战积累。所以别怕遇到内核问题,每解决一个,你的能力边界就往外扩了一圈。