龙芯 3B6000M xHCI "HC died" 排查:ZLP 规避与未解的第二触发路径

一台基于龙芯 3B6000M 的 CPE,5G 上网使用中会突然整机断网——而且不只断 5G,挂在同一个 USB 控制器下的有线网卡、蓝牙、摄像头全部瞬间消失。查 dmesg,每次都是同样的四连:

cdc_mbim wwp0s25u3: NETDEV WATCHDOG: transmit queue 0 timed out
xhci_hcd 0000:00:19.0: xHCI host not responding to stop endpoint command
xhci_hcd 0000:00:19.0: xHCI host controller not responding, assume dead
xhci_hcd 0000:00:19.0: HC died; cleaning up

这篇文章记录从 2026-09-09 首次留档到 09-29 的完整排查:如何定位故障链、如何用协议边界构造秒级复现器、如何用一个几行的内核补丁把故障频率压下去两个量级,以及 26.5 小时后故障在白名单生效状态下复发时,我们学到了什么。

1. 现场与基线

排查第一步是把环境钉死,避免后面每个假设都在变动的地基上验证:

项目值
平台Loongson 3B6000M/4-EVB,片上 xHCI(非独立桥片)
xHCI 控制器0000:00:19.0,PCI ID 0014:7a34 rev 01,传统 PIC IRQ
固件Loongson-FwSdk-V5.0.0431-stable202602
系统Loongnix 25,内核 6.6.52-loong64(6.6.52-1.11-lnd.15)
5G 模组MEIG SRM821,VID:PID 2dee:4d53,SuperSpeed
驱动/接口cdc_mbim,/dev/cdc-wdm0 + wwp0s25u3
数据端点EP 0x81 IN / EP 0x01 OUT,bulk,wMaxPacketSize=1024,bMaxBurst=0

两个先决条件直接决定了排查的形状:

  • xhci_hcd/xhci_pci 均为内建(=y),不能靠替换 .ko 测补丁,每一个内核侧假设的验证都是一次完整构建 + 装机;
  • 该 PCI function 没有 reset_method,不支持 FLR。unbind/bind 能让设备重新枚举,但它不是硬复位。

恢复手段:对 0000:00:19.0 做 xHCI unbind/bind,约 4 秒后 SRM821 重新枚举,蜂窝连接、同控制器下其他 USB 设备一并恢复。但 09-16 一次自动恢复留下了重要观察——重绑定后 USB 设备和 ModemManager 对象都回来了,journal 里却跟着出现:

WARN Event TRB for slot 2 ep 1 with no TDs queued?
ERROR Transfer event TRB DMA ptr not part of current TD
cdc_mbim NETDEV WATCHDOG: transmit queue 0 timed out

设备"存在"和传输环"健康"是两回事。任何只检查对象存在、不验证数据面的恢复逻辑,都会把这种半死状态误判为成功。这个认识后来直接写进了 watchdog 的改进项。

2. 定位故障链:第一现场不在 watchdog

用 debugfs 把 slot 映射到设备:出问题的 slot 对应 SRM821,interface 1 的数据端点是 bulk EP 0x01(OUT)。所以日志里反复出现的 slot N ep 1,指的是 MBIM 上行 bulk OUT 路径。据此还原完整故障链:

bulk OUT Transfer Event 异常(ring 存在,但 td_list 为空)
  → TX URB 无法完成
  → usbnet_tx_timeout()
  → unlink_urbs() → xhci_urb_dequeue()
  → Stop Endpoint 命令超时
  → xhci_halt() → xhci_hc_died()

时序上有个关键细节:WARN Event TRB ... no TDs 比 TX watchdog 早 9~11 秒。TX watchdog 只是下游连锁的第一声报警,第一现场是 Transfer Event 与软件 TD 状态的失步——看门狗响起时,案子已经发生十秒了。

这一步同时排除了两个候选:

  • runtime PM:故障瞬间控制器 PCI 运行时电源态为 active/on,runtime suspended 时间为 0,无证据;
  • 上游 66cb618bf0bb("prevent potential failure in handle_tx_event() for Transfer events without TRB"):它处理的是 ep_ring == NULL,本案 ep_ring 存在、td_list 为空,不是同一路径;且该补丁历史上在稳定分支的回移曾造成严重回归,直接排除重新应用。

3. 第一轮假设:stable 补丁缺失 → 回移 A/B

6.6.52 落后于后续 stable 的 xHCI 修复。与本故障路径最相关的一组共 9 个:

commit主题
53cd1bb1f8ebMitigate failed set dequeue pointer commands(6.6.58)
809dd30ee901Fix Link TRB DMA in command ring stopped completion event(6.6.60)
ae0ef0d756d2Fix TD invalidation under pending Set TR Dequeue(6.6.64)
61329b25dc1dRetry Stop Endpoint on buggy controllers
f1ece345ad2cLimit Stop Endpoint retries(6.6.70)
6cd8e621a689Make Stop Endpoint error handling generic(6.6.70)
8b2e38f2a9b7Avoid redundant Stop Endpoint commands(6.6.70)
6299fb6190feSet DESI bits in ERDP correctly(6.6.90)
f377434945f5Use more than one Event Ring segment(6.6.90)

前 7 个正对 Stop Endpoint 超时这条终点路径,后 2 个涉及事件环容量与事件指针——当时只是"顺带拉齐基线",后来证明它们才是最有意思的(见第 7 节)。

补丁回移的构建纪律花了不少时间,但事后看每一项都必要:

  • 不动原工作树,隔离 worktree + 独立分支,基线与被测机在装的包精确对齐;
  • 标准 bindeb-pkg 出 image + headers,保证机上 4 个 DKMS 模块能跟着重建;
  • 与 stock 的 Module.symvers 逐行比对:21662 个导出符号中只有 7 个 xHCI 符号 CRC 变化,逐一核对在装 DKMS 模块均不引用它们;
  • 旧内核保留在 GRUB 高级菜单,随时回退。

结果:09-17 装机,当天同签名故障再次复现。这 9 个补丁单独上机,没有解决问题。但这次"失败"的 A/B 并不白做——它把"内核 6.6.52 落后于 stable"这个最省事的解释关掉了,逼着排查转向设备与协议侧。

4. 第二轮假设:ZLP——为什么恰好是 bulk OUT

重新看参数:

dwNtbOutMaxSize = 16384
tx_max          = 16384
wNtbOutMaxDatagrams = 16
bulk OUT wMaxPacketSize = 1024

三个事实拼在一起:

  1. USB 规范要求用 short packet 终止传输;传输长度恰为 wMaxPacketSize 整数倍时,必须补发一个零长度包(ZLP)示意结束。
  2. MBIM/NCM 规范本不需要 ZLP——NTB 头部自带长度,接收方数字节即可。但内核 cdc_mbim 出于历史兼容(早期模块固件依赖 ZLP),对非白名单设备默认开 FLAG_SEND_ZLP。
  3. 16384 % 1024 == 0。usbnet 据此设置 URB_ZERO_PACKET,xHCI 把一个 16384 字节的 bulk OUT URB 建成两个 TD:一个数据 TD,一个 ZLP TD。

第一现场恰好落在这个端点——ZLP 路径是目前最佳候选。注意量词:到这里它还只是"待验证的触发条件",不是根因。

复现器:瞄准协议边界,而不是堆流量

网络栈给了现成的扳机。NCM 聚合在大吞吐上行时会追求打满 tx_max,把碎片包拼凑并 pad 到 16384——于是 ZLP TD 稳定出现。udp_blast.py 把这件事做得精确:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, 25, b'wwp0s25u3\0')  # SO_BINDTODEVICE
# 1136B 负载 + 28B IP/UDP 头 = 1164B
# 14 个包聚合 = 16296B,再加 88B NCM 头 = 16384B,精准打满 tx_max → 强制 ZLP
payload = b'x' * 1136
while True:
    s.sendto(payload, ('114.114.114.114', 53))

stock 内核上这个脚本秒级必杀:不出 3 秒 HC died,如果 SSH 恰好挂在同控制器的网卡上,会话当场死掉,只能靠串口或旁路网络看现场。

反过来也解释了为什么日常 ping 打不死:NCM 头(NTH16/NDP16)加上为对齐插入的填充具有随机性,小包极难命中 1024 的整数倍;而大流量下载/上传会主动聚合打满——这正是"日常大流量容易死"的机制解释。

规避 A/B:一个字节的差分

echo 16383 > /sys/class/net/wwp0s25u3/cdc_ncm/tx_max

tx_max 变 16383 后不再整除 1024,ZLP 从此不出现。同负载下传输平顺、数据面正常;恢复 16384 则故障如约而至。一进一出,触发条件闭环——ZLP 是实证的高频触发条件。中间机制(模组固件如何被 ZLP 影响)是推断,未做 usbmon 直接观测;实证的是主机侧 ZLP TD 路径与故障的强耦合。

5. 固化规避:cdc_mbim 白名单补丁

tx_max 是运行时参数,重新枚举或重启即失效。固化方案是一个只动设备匹配表的小补丁:在 cdc_mbim.c 的 mbim_devs[] 中为 2dee:4d53 增加精确的 USB_DEVICE_AND_INTERFACE_INFO 条目,driver_info 指向不带 FLAG_SEND_ZLP 的结构——与内核里 Ericsson MBIM 设备的"ZLP 合规白名单"同一思路。

生效后的行为变化:驱动不再对该模组设 ZLP;usbnet 走 pad 路径,把满 16384 的 NTB 尾部补 1 字节成 16385,不再是 1024 的倍数,ZLP 从源头消失。

补丁进入 09-20 构建的自建内核(6.6.52-1.12+lnd.17)。注意该内核不含第 3 节的 9 个补丁——这一点用两条二进制证据钉死,后面要用:

  • modinfo cdc_mbim 的 alias 表存在精确条目 usb:v2DEEp4D53ddcdscdpic02isc0Eip00in*,只有匹配表里有对应条目才会生成它;
  • /proc/kallsyms 中 xhci_process_cancelled_tds 缺席——该符号由 9 补丁链中的 ae0ef0d756d2 引入,基线本无,缺席即证明该链未合入。

顺带一个取证教训:这台机器上 /boot/System.map 只有 83 字节,是打包 profile 的残缺产物,从它 grep 得到的"无符号"结论是假的。符号存在性以 kallsyms 为准。

6. 26.5 小时后,故障回来了

boot -1:09-28 09:03 → 09-29 11:36,白名单生效状态,日常使用流量(非灌包,事后专门确认过),一次完整 HC died:

11:36:13 WARN Event TRB for slot 3 ep 1 with no TDs queued?
11:36:22 NETDEV WATCHDOG: transmit queue 0 timed out 6076 ms
11:36:27 xHCI host not responding to stop endpoint command
11:36:27 xHCI host controller not responding, assume dead
11:36:27 HC died; cleaning up    (usb3/usb4 总线设备全灭)

签名与补丁前逐条一致:同一端点(bulk OUT)、同一首案(WARN no TDs 早于 watchdog 约 9 秒)、同一终点。而当天早些时候,同一台机器用 test.py(udp_blast 的计量版)灌了 529MB / 5 分钟,没有触发。

看似矛盾,其实合理:test.py 的负载形态是 1136B×14 精确打满 16384 NTB——它瞄准的恰是白名单已经阻断的 ZLP 路径。规避生效之后,原来的复现器就失明了。日常使用反而复现,说明存在第二条触发路径,正常上行流量就能踩中,不需要极限聚合。这对严重性评估也是坏消息:问题从"压测场景"回到了"真实日常场景"。

到这里,证据支持的结论(注意量词与核验状态):

  • 「ZLP 是唯一触发条件」——被证伪;
  • 「白名单大幅降低频率」——成立,但是量级判断而非严格故障率:补丁前灌包秒级~分钟级必杀、日常使用反复故障(间隔无精确记录);补丁后 529MB 灌包未触发、日常 26.5 小时一次(单次观察)。严格的每 GB / 每小时故障率需要暴露量数据支撑,被动采样器已部署在积累;
  • 「白名单后 TX 零 ZLP」——高置信推断,usbmon 实测未做;
  • 9 补丁(无白名单)复现,白名单(无 9 补丁)复现——组合从未测过,第二路径是否恰好落在 9 补丁覆盖范围内,现有数据无法区分。

7. 假设排序与判别实验

补丁前后触发条件完全不同(ZLP 满包 vs 日常流量)、终点签名完全相同——这个组合指向公共基础设施。当前假设按优先级:

  1. 事件环事件丢失(单段事件环 + ERDP DESI 缺陷)→ Stop Endpoint 完成事件丢失 → 驱动等到超时,误判 HC died。依据:lnd.17 实测 ERSTSZ=1(单段事件环,与 kallsyms 判定互证);WARN no-TD 与事件迟到/错位的形态吻合。若成立,部分场景的 "HC died" 其实是误杀,控制器可能根本没死,正确修复是事件环扩展补丁——而不是换硬件。
  2. 3B6000M xHCI 硬件/微码缺陷(Stop Endpoint 状态机或 DMA 一致性毛刺)。依据:命令超时在 stock / 9 补丁 / lnd.17 三种内核上一致出现。同族平台有 DMA 一致性前科——前科提高先验,但不是本案证据。
  3. 模组固件对非 ZLP 传输形态假死(降级为第三候选):WARN no-TD 是主机侧失步证据,不指向模组,仅作诱因保留。

判别实验分被动、主动两路。被动:HC died watcher 自动抓取控制器 MMIO(经 sysfs resource0 mmap 读;/dev/mem 受 STRICT_DEVMEM 限制不可用),下次故障瞬间留下寄存器状态:

现场读数指向
USBSTS.CNR=1 且 USBCMD.RUN=1控制器真死(假设 2)
寄存器正常(RUN=1/CNR=0/HCH=0)+ ERDP 滞后或 EHB=1事件丢失(假设 1)
HCH=1需结合 CRCR.CA 区分软/硬 halt

健康基线已在故障前采好:USBCMD=0x00000005(RUN=1 INTE=1),USBSTS=0x00000000,ERSTSZ=0x1,ERDP 无 pending 事件。主动:单独回移两个事件环补丁(6299fb6190fe + f377434945f5)构建判别内核做日常观察——若复现消失,假设 1 获强支持。

还有一条现实约束塑造了所有后续设计:被测机走 5G 资费,死循环灌包约 150GB/天不可接受。后续验证一律零流量/限流量:

  • 被动暴露量采样:systemd timer 每 10 分钟把 wwp0s25u3 计数写入 journal,日常使用自然积累分母;
  • 零流量触发器:libusb 绕过网络栈,直接对 EP 0x01 构造「1024 整数倍 ±ZLP / SG 多段」传输,形态完全可控、零资费(执行需临时断数据面,时机另定);
  • HC died 自动取证器:如上,盯 journal 关键字命中即抓寄存器 + dmesg + 网卡计数。

8. 结论与复盘

已确立:

  1. 完整故障链与端点映射:bulk OUT Transfer Event 与软件 TD 失步 → TX watchdog → Stop Endpoint 超时 → HC died → 同控制器全部设备消失;
  2. ZLP 是实证的高频触发条件(秒级复现器 + tx_max=16383 规避闭环);
  3. 白名单补丁大幅降低故障频率,但未消除;
  4. 9 个 stable xHCI 补丁单独上机无效;
  5. 存在第二触发路径,日常上行流量即可触发。

未确立(下一步):第二路径根因(三个假设待 MMIO 取证与判别内核裁决);组合修复(9 补丁 + 白名单,唯一未测组合)是否足够;严格故障率(等被动暴露量数据);白名单后零 ZLP 的 usbmon 直接观测。

几条可迁移的经验:

  • 时序定位第一现场:WARN 比 watchdog 早 9~11 秒,把注意力从"网络超时"拉回"传输环失步",后面所有假设都建立在这个偏移上;
  • 复现器要瞄准协议边界,不是堆流量:精确打满 NTB 的 1136B×14,比盲目跑 iperf 有效一个数量级;
  • 规避生效后复现器会失明:test.py 对第二路径失效是结构性必然,不能当成"修好了"的证据——需要被动暴露量统计和不同维度的取证器接棒;
  • 二进制证据优先于文档叙述:modinfo alias / kallsyms 钉死"机器上装的是什么",残缺的 System.map 差点给出相反答案;
  • 修复组合要显式测试:两套修复各自单独无效,不回答组合是否有效——这个空位只能靠实验填。

参考