ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

内核漫游两周:从vmcore到sock_put,一次引用计数失衡的排查实录

内核漫游两周:从vmcore到sock_put,一次引用计数失衡的排查实录 1. 问题初现一台偶尔脑裂的服务器接到这个活儿是在一个周五的下午。客户那台跑业务系统的 x86_64 服务器运行着定制过的 Linux 5.10 内核连续几天都在凌晨三四点随机 panic。日志里没有明显的触发特征唯一的变化是那段时间正好有大批量的备份流量在跑。他们自己用dmesg抓了一段栈指着最后的调用说你看看这是不是内核核心坏了。1.1 第一现场vmcore 里的不可能地址第一个 vmcore 是现场抓下来的。用crash工具打开bt命令拉出栈最后几帧停留在kfree_skb的某个路径上出错方式是general protection fault访问的地址是0xdead0000000000a8这样的毒化值。熟悉内核的同学一看就明白这是 slab 对象被释放后链表指针被改写poison机制检测到了野指针内核立刻刹车。这里先给新手解释一下内核态不像用户态解引用一个空指针不会优雅崩溃而是整个系统直接oops或者 panic。更麻烦的是崩在kfree_skb不代表凶手就是网络核心。内核对象被写坏以后往往是等到下一次被别人使用才爆炸。这就像一栋楼的承重墙被人偷偷挖了一个洞你发现问题不是看见洞本身而是某天半夜天花板掉下来了你顺着天花板砸下来的位置去找才看到那个洞。当时日志里还有一段很关键的上下文是崩溃前几百行的BUG: kernel NULL pointer dereference后面跟着一串RIP寄存器和Call Trace。我在白板上把这串东西抄下来发现所有线索都指向一个词sock_put。那时我还不敢下结论但心里已经清楚这趟浑水要蹚一阵子了。1.2 为什么直接冲进源码是错的团队里当时有个年轻同事第一反应是翻tcp_v4_rcv觉得是内核网络栈的问题甚至想直接给最近的提交打补丁。我按住他说了句后来被反复引用的话内核里没有 try-catch出问题的地方十有八九不是出事的地方。我让他先回答三个问题第一这个 panic 是必现还是概率性的第二崩掉的对象生命周期有多长第三哪些路径能碰到这个对象。这三个问题前两个靠复现实验回答最后一个靠数数回答。后面两周我们基本就是干这两件事。现在回头看这个项目与其说是在修 bug不如说是在做一场内核漫游。你要在代码的丛林里找到那个洞靠的不是灵感而是把每一条路径、每一笔账都数清楚。标题里说的数了两周字面意义上真没夸张。2. 内核漫游的装备清单定位内核问题说白了就是在现象和代码之间来回穿梭。装备不用多但每样都得知道什么时候用、什么时候放弃。我见过太多人拿着一张 oops 就开始读源码读了两周也没找到洞因为方向错了。2.1 把现象钉死复现的三种手段第一周的头两天我们根本没碰源码先解决复现问题。先用常规手段iperf3开 32 个并发流压网络发现概率太低跑一个通宵也就一两次。然后换方案写了个简单的乱序、重复、丢包注入脚本配合tc netem模拟网络抖动把触发概率提上去一大截。最后一步在一台带 KASAN 和 KCSAN 的 debug 内核测试机上跑同样的流量果然比生产环境报得更早、更准。很多老手一上来就上 ftrace 或者 bpftrace我建议反过来先在干净的 debug 内核上复现用 KASAN 这种报警器把越界访问的范围锁定。内核的问题几乎都是概率性的复现不了后面所有分析都是空中楼阁。复现手段只要搭好后面所有验证都有据可依。2.2 四件趁手工具的取舍最后我们实际用到的工具其实就四个列个表给大家参考工具用途使用时机dmesg/journalctl抓第一现场报错、oops、panic 前后日志第一时间看崩溃前几百行的上下文crash vmcore离线分析崩溃现场看寄存器、看 slab 状态、反汇编拿到 vmcore 之后最关键的取证手段ftrace/kprobes跟踪关键函数的调用频率、出入栈已经锁定函数范围需要画路径时bpftrace / eBPF在热点函数上做统计不打断流程需要定量对比比如统计 hold/put 差值这些工具不是按优先级排列的而是按从粗到细用的。先用 dmesg 看方向再用 crash 看现场然后用 ftrace 画路径最后用 bpftrace 做定量统计。每一步都能把嫌疑范围缩小一个量级比闷头读代码效率高得多。2.3 画调用链而不是靠猜复现稳定后我们用 ftrace 把涉及 skb 生命周期和 socket 引用的关键函数全部挂上sock_hold、sock_put、kfree_skb、skb_clone、skb_orphan。然后重放流量把每一次调用的调用栈和计数都 dump 下来。echo p:sock_hold sock_hold /sys/kernel/debug/tracing/kprobe_events echo p:sock_put sock_put /sys/kernel/debug/tracing/kprobe_events echo 1 /sys/kernel/debug/tracing/events/kprobes/enable cat /sys/kernel/debug/tracing/trace_pipe /tmp/refcnt_trace.log这时候漫游的感觉就出来了。内核网络路径跟城里早晚高峰的地铁一样同一批包会经过不同的检票口、换乘站有的走快线有的走慢线。我们记录的每个函数就像在地铁站里数人流的计数器。第一天画出来的调用链有十几层看着眼花但把关键节点的计数一汇总怀疑范围迅速从整个网络栈缩到socket 引用这块。3. 两周排查实录现在进入正题。这两周的具体细节很多我挑最有价值的写。整个过程可以分成两个阶段第一周压缩变量第二周清点账目。3.1 第一周压缩变量缩小包围圈第一周做的事情本质上是把问题从随机 panic压缩成特定条件下的引用计数异常。第三天傍晚有个关键进展。我们发现 panic 总是发生在eventfd唤醒路径附近。我们这套定制系统里用户态程序通过eventfd等待内核侧的数据就绪信号内核侧在处理 TCP 数据包时会把socket指针临时交给一个 workqueue 去触发唤醒。问题就出在这个临时交给上。eventfd的唤醒机制本身很成熟wake_up之前会检查等待队列问题在于 workqueue 里拿到的那个socket指针是谁的、生命周期怎么算。我们把sock_hold/sock_put的调用日志按 socket 地址分组拉出来发现一个规律panic 的机器上总有那么一两个 socket 的 put 次数比 hold 次数多。这就是经典的引用计数下溢。当时我们还排除了几个干扰项先查了内存硬件用mcelog扫了一遍没有 ECC 错误又怀疑是某个网卡驱动在中断上下文里释放了 skb单独卸载驱动后问题还在还排查过spinlock睡眠死锁的可能因为日志里出现过BUG: scheduling while atomic但那个只是症状不是根因。这些排除工作花了两天但非常必要不然后面没法安心数账。3.2 第二周逐对清点引用计数有了这个怀疑方向第二周基本就在做一件事清点。对就是标题里说的数了两周。我们把改动过的 TCP 快速路径代码打印出来贴了一整面墙。每个sock_hold是一笔借每个sock_put是一笔贷goto out、return是可能提前结账的出口。两天时间我们逐行核对每一对借和贷确认了 90% 的路径都是平账的剩下的 10% 集中在三个提前 return 的分支上。这里给个审计表的示例方便大家理解我们在干什么路径hold 位置put 位置提前返回分支是否平账正常收包路径入口 hold正常处理后 put无平ACK 快路径入口 hold处理后 putif (ack_now) return平提前 put延迟唤醒路径入口 holdworkqueue 中 putif (sk_close) goto out不平少一次 hold最后一行就是突破口。那个分支在判断到 socket 已经关闭时选择把 skb 丢进 workqueue 做延迟释放但没在丢进去之前补一次sock_hold。换句话说这笔货在移交仓库的时候没有做交接签字仓库那边却照常做了一次还账账目自然就负了。为了确认这个判断我们补了一条 bpftrace 的统计命令直接看每个 socket 上 put 减 hold 的累计值bpftrace -e kprobe:sock_hold { [arg0] count(); } kprobe:sock_put { [arg0] - count(); } /tmp/refcnt_diff.txt跑完一看异常 socket 的差值稳定在为负跟前面人工对账的结果一致。到这时候结论基本钉死了。3.3 突破提前 return 分支里的那个洞问题代码长什么样我简化一下保留关键结构static int fast_rcv(struct sock *sk, struct sk_buff *skb) { // 入口已经持有 sk 的引用由调用方保证 if (unlikely(sk-sk_state TCP_CLOSE)) { // 进入这里之前本来应该再 sock_hold(sk) 一次 // 因为下面的 tcp_tw_deadline_fire 会让 workqueue // 在稍后执行 sock_put。 tcp_tw_deadline_fire(sk, skb); goto out; } // 正常路径... sock_put(sk); out: return 0; }你仔细看正常路径把调用方给的那次引用还了提前返回分支把 skb 交给 workqueue 后也走同一出口还了。但 workqueue 执行的时候还会再 put 一次。注意这不是简单的双 put而是少了一次持有的转让本应该在移交前sock_hold一次把引用从调用方持有变成workqueue 持有代码里漏了这一步。在锁和引用计数这套体系里这种缺口我习惯叫它洞。它不是一条明显的越界写也不是一个可见的死锁而是账目上的一道裂缝。平时单线程收包完全正常一旦 socket 在关闭瞬间还有包飞过来这道裂缝就会把引用计数拉成负的内核把已释放的内存当作正常的 socket 继续用最终在某次 spinlock 加锁或 eventfd 唤醒的时候一脚踩进毒化区。4. 洞的解剖、修复与复盘4.1 失衡的引用如何变成核心的洞很多人不理解一个引用计数瑕疵怎么就能让整个内核在看似不相干的spinlock上报错内核分配 socket 用的是 slab 缓存对象释放后会写入特定 poison 值并且把对象放进 freelist。这时候如果还有另一个上下文正握着这个指针它在下一次访问时就会读到已经改成 freelist 指针的内容。我们的场景里workqueue 稍后调sock_put这时候引用计数已经减到负值触发refcount_t的保护机制于是内核主动 panic。新版内核的refcount_t会在归零时触发饱和保护这其实是好事它让问题暴露得更早。但也带来一个副作用panic 的位置离真正的犯罪现场更远了。所以排查时你要意识到看到的栈是被保护机制提前引爆的结果实际损坏发生得更早、更隐蔽。这也是为什么我们花了整整两周做对账而不是盯着 panic 栈猜。这种现象在 Linux 内核虚拟化、Android GKI 内核一类大量合入定制补丁的环境里特别常见。厂商会在标准内核上叠加很多 out-of-tree 驱动和改动每叠一层引用交接的约定就多一分被破坏的风险。内核缓冲、skb 生命周期、socket 引用这些最基本的东西往往就是洞的藏身处。4.2 补丁与验证修复本身很小三行if (unlikely(sk-sk_state TCP_CLOSE)) { sock_hold(sk); tcp_tw_deadline_fire(sk, skb); goto out; }改完之后先在 debug 内核上用之前的乱序包注入脚本连续跑了三天KASAN 零报警又回到生产环境观察了两周panic 没有再现。更直接的证据来自 bpftrace 的统计连续抓 10 万个收包事件sock_put的总次数与sock_hold严格一致账终于平了。这个验证流程建议抄下来改完代码第一轮跑 KASAN第二轮跑压力复现第三轮用 bpftrace 做定量对比。三关都过才敢说补丁有效。少一关都不行因为内核的问题经常是修好了一个洞又露出另一个洞。4.3 复盘能不能更快找到它事后复盘如果一开始就用 bpftrace 统计所有sock_hold和sock_put的差值可能第二天就能发现问题。但现实是我们先用两周验证了所有不是这个的路径其中有一半的时间花在排除 kmemleak、排除设备驱动 bug、排除硬件 ECC 上。慢但扎实。我的体会是内核问题排查定量统计比代码阅读更快但代码阅读能帮你建立信任。最后的结论必须同时得到两者的支撑缺一个都不算数。那种看一眼就觉得是这里的直觉在复杂内核面前基本靠不住。5. 常见误判与内核排查的军规5.1 我踩过的三个坑第一个坑把 vmcore 的栈当成根因。崩在kfree_skb不等于kfree_skb是凶手它往往只是受害者。真正的凶手可能在几百毫秒前就把内存写坏了你看到的栈是案发现场不是犯罪过程。第二个坑在没复现的情况下反复读源码。读代码读到吐不如先花两天把触发条件钉死。我们最终复现用的是tc netem加乱序注入如果一开始就做这个第二周都不一定需要。第三个坑忽略refcount_t的保护行为。旧内核用atomic_t做引用计数下溢到负数还能继续跑很久新内核的refcount_t会在归零后强制 panic这是好事但它也让犯罪现场离犯罪时间更远了。排查时要主动把这个因素算进去别被 panic 栈带偏。5.2 六条可以直接抄的经验复现优先没有稳定复现手段所有分析都是猜测先搭复现环境再谈别的。用 debug 内核KASAN、KCSAN、UBSAN 比人眼快得多测试机一定要开。先画调用链ftrace 或 bpftrace 把关键函数计数拉出来再决定读哪段源码。怀疑一切但一次只锁定一个变量临时内核、额外模块、驱动逐个隔离。引用计数要像对账一样逐对清点每次hold都要回答它对应哪个put漏一个就是洞。修完验证三遍debug 内核压测、生产灰度、bpftrace 定量对比少一步都别上线。这套方法不只是给 x86 服务器用的。嵌入式内核源码、Android 内核驱动 ko只要是跑 Linux 内核的地方引用和缓冲的管理逻辑都是一样的。你在 GKI 内核上做一次 out-of-tree 驱动适配如果不动引用关系出这种洞只是时间问题。用 fuzz 工具去怼内核也是一样的道理fuzz 能帮你把洞找出来但最后的修复还是得靠人一笔一笔数清楚。6. 写在最后漫游之后6.1 我改掉的三个工作习惯第一次碰到这类问题的人总想靠灵感找到洞但内核漫游教会我的是灵感是给运气好的人的普通人靠的是清单和账本。现在我再接到内核崩溃的反馈第一件事永远是问能复现吗而不是你改了啥。第二个习惯是看补丁先看引用计数。任何改动只要动了socket、skb、file这些有生命周期管理的对象我都会先检查有没有破坏平账关系。这个习惯帮我在后面好几个项目里提前排掉了雷。第三个习惯是留现场。生产机的 vmcore 默认要开崩溃后不要急着重启先抓证据。内核调试跟刑侦一样现场没了案子基本就断了。那次之后我们所有机器的内核参数都加了crashkernelautokdump 服务常驻。6.2 内核这趟浑水值得蹚如果你也在学 linux 内核或者正被一个偶发内核问题折磨我的建议是不要把内核想得太玄。它就是一套严格的账本和约定越界、失衡、缺页、死锁都是账不平的表现。先学会用工具把账目拉出来再学会对着源码一笔一笔地数你会发现所谓核心有个洞往往只是有人在某个不起眼的分支里少签了一个字。两周时间找到三行代码看起来不划算。但这两周换来的是整个团队对内核引用模型的系统认知以后再遇到类似问题心里就有了地图。内核漫游这趟路走得慢但每一步都算数。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进