一次内核 Panic 的根因追踪
从 BUG 陷阱指令到修复提交
2026-05-30 · 案例分享
设备上线后突然崩溃,控制台打印出 Kernel BUG。对嵌入式开发者来说,这种场景应该不陌生。
本文记录一次真实的内核 Panic 排查过程——从 panic 日志到根因定位再到修复提交的全流程。
在这个过程中我们会看到 netmap 的运行模式如何影响 skb 的生命周期、Linux 内核桥接的转发逻辑、
以及一个看似普通的 skb 是如何在三次穿越协议栈后引爆的。希望能给同样在网络驱动开发中遇到类似问题的朋友一些参考。
一、现象:设备上线即崩溃
设备是 Realtek 平台的网关,运行 Linux 5.10 内核,使用 ca_ne 网卡驱动并加载了 netmap 模块做网络监控。网口刚 up 起来,控制台直接打印:
Kernel BUG at pskb_expand_head+0x2d0/0x360
Internal error: Oops - BUG: 00000000f2000800 [#1] SMP
pc : pskb_expand_head+0x2d0/0x360
lr : ca_ni_virtual_instance_open+0x424/0x690 [ca_ne]
完整的 panic 现场:
pc : pskb_expand_head+0x2d0/0x360
lr : ca_ni_virtual_instance_open+0x424/0x690 [ca_ne]
x0: 0x00000680 ← nhead = 1664 bytes
x1: 0x00000002 ← ntail 参数
x19: 0xffffff8013087180 ← skb 指针
CPU: 0 PID: 4470 Comm: client
Tainted: G O ← O = 外挂模块
Hardware: Realtek Taurus ENG Board
Code: 97e9f614 17ffff8c a90363f7 f90023f9 (d4210000)
d4210000 是 ARM64 的 BRK #0x800 指令,内核用这个宏来主动触发 BUG()。不是硬件异常,是内核自己认为状态不对,主动炸的。nhead = 0x680(1664 字节)意味着 ca_ne 驱动需要为封装头部预留很大的空间——这个数字后面会解释为什么重要。
二、前置知识:理解三个关键模块
在深入分析之前,先理清三个关键背景。
1. netmap 的四种运行模式
netmap 是一个高性能的网络 I/O 框架,允许用户态程序直接读写网卡环形缓冲区。它有四种运行模式,理解它们对定位问题至关重要:
| 模式 | 特点 | 本案例关联度 |
|---|
| Generic | 驱动无 native netmap 支持时走内核路径,通过 dev_queue_xmit 收发 | ✅ ca_ne 走此模式 |
| Monitor | 旁路抓包模式,TX/RX 双向截获,TX 包通过 skb_get 重新注入内核 | ✅ 根因所在 |
| Transparent | 网卡可同时被 netmap 和内核使用,bridge 等正常转发 | ✅ bridge 正常转发 |
| Exclusive | 网卡被 netmap 独占,内核无法使用 | ❌ 本场景未涉及 |
本案例中 Generic + Monitor + Transparent 三层叠加——ca_ne 无 native 支持走 Generic 路径,Monitor 模式负责截获和回注,Transparent 模式让 bridge 能够正常转发。缺任意一层都不会触发这个 BUG。
2. Linux 内核 skb 的引用计数模型
skb(struct sk_buff)是内核网络子系统中最重要的数据结构。它的引用管理有两套独立的计数:
skb->users:整个 skb 结构体的引用计数。skb_get() 加一,kfree_skb() / consume_skb() 减一。为零时释放。
dataref:数据区(skb->head 指向的线性缓冲区)的共享计数。skb_clone() 时加一,因为 clone 和原 skb 共享同一片数据区。
一个关键区别:
| 操作 | users | dataref | skb_shared() |
|---|
| skb_clone() | 不变 | +1 | 不变 |
| skb_get() | +1 | 不变 | 变为 true |
这就是问题的关键——skb_get() 会让 skb_shared() 返回 true,而 pskb_expand_head 恰恰不接受 shared 状态的 skb。
3. 内核 bridge 的洪泛转发逻辑
当 bridge 收到一个目的 MAC 未知的帧时,会执行洪泛(flood)——向所有桥接端口发送一份。br_flood 的实现有一个值得注意的细节:
第一个出口直接传递 原始 skb,不做 clone。
从第二个出口开始,才调用 skb_clone() 为每个端口复制一份。
这是出于性能优化的考虑——如果 flood 的目标端口只有一个,就不需要 clone。但在 netmap monitor 场景下,这个优化恰好成了 BUG 的触发条件之一。
三、调用链还原:三次穿越协议栈
从 panic 堆栈可以还原出这个 skb 的完整旅行路线。它经历了三次 dev_queue_xmit,穿越三个不同的网络设备:
起点:netmap monitor 截获 TX 包 → skb_get() 持有一个额外引用
第1次 ↴ nm_os_generic_xmit_frame 注入 → vlan0(VLAN 子接口)
→ 经过 vlan_dev_hard_start_xmit 剥去 VLAN 标签
第2次 ↴ VLAN 剥头后的 skb → br0(网桥)
→ bridge 查 MAC 表未命中,执行洪泛
→ ca_ne 是第一个桥接端口,收到原始 skb(不 clone)
第3次 ↴ br_flood 传递原始 skb → ca_ne(物理端口)
→ ca_ne 需要 1664 字节 headroom 封装协议头
→ 调用 pskb_expand_head(skb, 1664, ...) → BUG_ON(skb_shared)
为什么是三次?这不是异常,而是 netmap + VLAN + bridge 三层虚拟化下的必然路径——每穿越一个虚拟设备就要一次 dev_queue_xmit。正常情况下 qdisc 层会在入队前清理 skb 的共享状态,但 netmap monitor 的注入路径绕过了这一环节。
四、根因分析:三个条件缺一不可
触发这个 BUG 需要以下三个条件同时满足:
| # | 条件 | 原因 |
|---|
| 1 | netmap monitor 模式已启用 | nm_os_generic_xmit_frame 调用 skb_get(),users 从 1 升为 2,skb_shared 变为 true |
| 2 | bridge 洪泛时 ca_ne 是第一个端口 | br_flood 对第一个出口传递原始 skb,不 clone。如果是第二个端口则 clone 后 users=1,不会触发 |
| 3 | ca_ne 需要扩展 headroom | ca_ne 调用 pskb_expand_head(nhead=1664),函数内部 BUG_ON(skb_shared(skb)) 被触发 |
这也是为什么这个 BUG 在常规场景下从未被发现——日常使用中要么 netmap 没加载、要么 bridge 没有配置、要么 ca_ne 恰好不是第一个洪泛端口。三个小概率事件同时发生,才撞上了这个隐藏的 bug。
关于 headroom = 1664:ca_ne 驱动需要为多级隧道封装预留头部空间——
外层 VLAN 标签(4 字节)、可能的 QinQ 叠加(再加 4 字节)、PPPoE 头部(6 字节)、
甚至未来可能叠加的隧道协议——驱动开发者选择了 1664 字节这个足够安全的数字。
这个数字本身没问题,问题出在 pskb_expand_head 不检查 skb 是否 shared。
五、为什么平时不炸?
这个问题引发了一个更深的疑问:既然 pskb_expand_head 有 BUG_ON(skb_shared),那内核里其他调用它的地方为什么不会崩溃?
答案是:qdisc 层在入队前就已经做了清理。常规路径下,skb 在进入 qdisc 队列时,
__dev_queue_xmit 内部会处理 skb 的共享状态,确保送到 ndo_start_xmit 的 skb 不是 shared 的。
但 netmap monitor 的 TX 注入路径是这样的:
skb_get(skb) → users=2 → 直接 dev_queue_xmit(skb) → ... → ca_ne
↑ 没有经过标准的 qdisc 入队前共享态清理
事实上,Linux 内核的 ip6_finish_output2() 函数中就有完全相同的防御逻辑,连注释都写明了:
/* pskb_expand_head() might crash, if skb is shared */
if (skb_shared(skb)) {
nskb = skb_clone(skb, GFP_ATOMIC);
if (likely(nskb)) {
consume_skb(skb);
skb = nskb;
}
}
ca_ne 之前没有做同样的防御——不只是 ca_ne,很多 OOT(树外)驱动都没有。因为它们假设内核送到 ndo_start_xmit 的 skb 一定是非共享的,而这个假设在 netmap monitor 注入路径下不成立。
六、修复方案
改动策略
| 模块 | 改动方式 | 理由 |
|---|
| ca_ne 驱动 | ✅ 内部加 ca_ni_skb_unshare | 最小改动,自己防御 |
| netmap | ❌ 不改 | 第三方模块,影响面不可控 |
| Linux bridge | ❌ 不改 | 内核核心组件,副作用不可控 |
核心修复代码
在 ca_ne 驱动的 xmit 路径中,pskb_expand_head 调用前做一次防御性 clone:
/* 如果 skb 被多个引用共享,先 clone 再释放原引用 */
if (unlikely(skb_shared(skb))) {
nskb = skb_clone(skb, GFP_ATOMIC);
if (unlikely(!nskb))
return NETDEV_TX_OK; /* 丢包保护,不 panic */
consume_skb(skb); /* 释放 ca_ne 持有的引用 */
skb = nskb; /* 用新的非共享 skb */
}
修复后的 skb 生命周期:
原始 skb(users=2, shared)
→ skb_clone() 得到 nskb(users=1, !shared)
→ consume_skb(原始) 释放一份引用
→ nskb 传给 pskb_expand_head → BUG_ON(skb_shared) ✅ 通过
→ dev_hard_start_xmit 收尾时 consume_skb(nskb) → users=0 → 释放
七、经验总结
1. skb 的生命周期管理比你以为的复杂
skb_get 和 skb_clone 对引用计数的影响完全不同——一个加 users,一个加 dataref。skb_shared() 只看 users。这个区别如果理解不透,定位这种 BUG 时会走很多弯路。
2. netmap 不止是一个"抓包工具"
netmap 的四种模式(Generic/Monitor/Transparent/Exclusive)决定了它和内核协议栈的交互方式。monitor 模式的 TX 回注路径绕过了内核标准的 qdisc 共享态清理,这就是问题的起点。
3. bridge 洪泛的性能优化也有代价
br_flood 对第一个端口不 clone 是一个合理的性能优化,但它假设驱动不会修改 skb 的共享状态。当这个假设被打破,优化就成了隐患。
4. OOT 驱动要做好防御性编程
内核标准路径(如 ip6_finish_output2)已经有 skb_shared 的防御逻辑,但 OOT 驱动不能依赖这个保障。在 pskb_expand_head 或 skb_cow_head 调用前主动检查 skb 的共享状态,是必要的防御性编程习惯。
一句话总结:在调用 pskb_expand_head 之前,永远确保 skb 不是 shared 的。内核标准路径已经在做了,netmap 回注路径和 OOT 驱动要自己补上这道防线。
本文由 小易的AI工坊 发布 · 案例来自真实项目经验