在 STM32G474 自研 USB-CAN 设备上实现 PCAN-USB classic 兼容层后,遇到过一个很有迷惑性的故障:PCANBasic 的 CAN_Write() 连续返回成功,但 CAN 总线上只能看到几百帧,随后彻底停止发送。CAN 错误计数始终为 0,扩大缓冲区、关闭占用软件、降级驱动都没有解决。
本文记录完整的取证、驱动逆向、协议还原、固件修复和真机验证过程。结论是:问题不在 CAN 控制器和 USB 缓冲,而是固件没有响应 PCAN classic 驱动的 0x1E DEVICE_DATA challenge,驱动将设备标记为 CloneCheck=Error (2),随后在随机门限处主动停止提交 USB 收发请求。
说明:本文涉及的 PEAK VID/PID 与私有协议仅用于实验室兼容性验证,不应作为量产设备身份。商业产品应使用合法分配的 USB 身份和自有协议。
一、测试环境
- MCU:STM32G474CBT6
- 固件:DluCanFD,PCAN-USB classic 协议适配层
- PCAN 驱动:4.6.4.16846;同时对比 5.1.2
- 接收端:ZCAN EBLINK
- CAN 波特率:500 kbit/s
- 发送方式:PCANBasic,约 1 ms 一帧
- 固件设备版本:
bcdDevice=0x5602
二、故障现象
第一次物理重新插拔后,PCANBasic 连续调用 CAN_Write() 3000 次,所有调用都返回成功,但 ZCAN 实际只收到 800 帧,最后一帧出现在约 1.016 秒。双方 CAN 错误计数均为 0。
不重新插拔,立即再次发送 1000 帧,ZCAN 收到 0 帧。继续调用 CAN_Write(),直到第 32767 帧才返回 QXMTFULL (0x80)。
| 场景 | PCANBasic 写入 | ZCAN 实收 | 总线错误 |
|---|---|---|---|
| 重新插拔后的首次测试 | 3000 次成功 | 800 | 0 |
| 不插拔立即复测 | 1000 次成功 | 0 | 0 |
| 持续写入 | 第 32767 帧出现 QXMTFULL | 无新增 | 0 |
这组数据有两个关键特征:
- 停止点位于几百帧且每次可能不同,不像固定大小的 MCU 环形缓冲溢出。
CAN_Write()成功只代表报文进入驱动队列,不代表驱动已经向 USB 端点提交 URB。
三、排查过的错误方向
1. ZCANPRO 独占设备
关闭 ZCANPRO 并重新初始化 ZCAN 通道后,现象不变。独占确实会影响测试,但不是这次几百帧停止的根因。
2. CAN 总线、终端电阻和错误状态
两端的 TX/RX error counter、bus-off 状态均正常,已收到的 800 帧内容也完全正确,因此可以排除物理层持续错误和 CAN 控制器 bus-off。
3. USB/CAN 缓冲区不足
如果 MCU 发送缓冲或 USB 端点拥塞,通常会在固件侧看到队列满、端点写失败或稳定的容量边界。实际停止点具有随机性,而且主机队列最终积累到 32767 帧才报告 QXMTFULL,说明报文根本没有继续进入 USB 提交路径。
4. 降级 PCAN 驱动
将驱动降到 4.6.4.16846 后仍然复现。后续静态对比确认 4.6.4 和 5.1.2 都包含相同类型的发送门控,因此降级不能解决协议响应缺失。
四、真正根因:CloneCheck 进入发送门控
对 classic 驱动的发送路径反汇编后发现,TX 函数在提交 EP2 URB 前会调用一个检查函数。该函数读取设备对象中的 CloneCheck 状态;当状态为 Error (2) 时,会生成约 100..999 次的随机计数阈值。达到阈值后,驱动直接跳过 USB 提交。
具体函数偏移会随驱动版本变化。在本次分析的二进制中,TX 路径位于约 0x3049C,检查函数位于约 0x32300,状态字段位于设备对象 +0xA30。这些偏移只是定位证据,不应作为稳定接口使用。
这可以同时解释:
- 为什么每次只能发送几百帧,而不是固定数量;
- 为什么 CAN error counter 始终为 0;
- 为什么第一次停止后,不重新枚举就连第一帧也不再下发;
- 为什么 PCANBasic 仍能短时间返回成功,最终才因主机队列堆积报告 QXMTFULL。
继续追踪设备初始化流程发现,驱动会通过 EP1 命令通道执行 0x1E DEVICE_DATA challenge。原固件没有实现该命令:SET 被忽略,GET 返回全零,驱动因此把 CloneCheck 状态置为 2。
五、0x1E DEVICE_DATA 协议还原
PCAN classic 命令记录固定为 16 字节:
byte 0 function
byte 1 number:01=GET,02=SET
byte 2..15 args[14]
驱动首先发送两个 SET 记录,合并出 17 字节 challenge:
1E 02 00 <11 bytes> 11 00
1E 02 01 <6 bytes + 5 bytes zero padding> 11 00
合并后:
byte 0 = 01
byte 1..16 = 16 字节随机 challenge
随后通过两个 GET 读取响应:
byte 0 = 1E
byte 1 = 01
byte 2 = selector
byte 3..13 = 分片数据
byte 14..15 = 总长度 17,小端
合并后的响应格式为:
byte 0 = key index,当前为 00
byte 1..16 = AES-128(challenge[1..16], fixed key)
AES 语义为 ECB 单块加密,无 padding、无 IV、无字节倒序。固定 key 字节不在本文公开;下面的已知向量用于验证轮变换、状态排列和端序:
challenge = 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
cipher = 27 3B 87 FA 10 BB 8E 27 F7 F3 16 76 86 4F 4A E7
六、固件修复
修复保持在 Middleware/usbcan/pcan 协议适配层,没有修改通用 USB core,也没有扩大 CAN/USB 缓冲区。
1. challenge 分片状态机
- 保存 17 字节 challenge 和两个分片的有效位。
- 收到 selector 0 时先清除旧状态,再校验总长度为 17、marker 为 1,并复制前 11 字节。
- selector 1 只有在 selector 0 有效后才接受,复制剩余 6 字节。
- 初始化和 USB 协议 reset 都清除 challenge 状态,禁止复用旧响应。
- challenge 不完整、selector 非法或长度错误时,GET 保持返回长度 0。
2. AES-128 软件实现
使用 256 字节 S-box、16 字节状态和 16 字节就地轮密钥扩展:
- 初始 AddRoundKey;
- 9 轮 SubBytes、ShiftRows、MixColumns、AddRoundKey;
- 末轮省略 MixColumns;
- 无动态内存,不依赖 STM32 AES 外设;
- 单次加密主要栈占用约 32 字节。
这里没有额外拆分 C 文件。DEVICE_DATA 是 PCAN 协议私有行为,继续由原有适配器承担,避免给 USB core 增加协议耦合。
3. 测试脚本纠错
原真机脚本只判断“收到数量大于 0”,因此曾把 800/3000 错误标记为成功。修复后通过标准改为:
发送成功数 == 请求发送数
实际接收数 == 实际发送数
两个方向都满足时 overall_ok 才为 true
七、验证结果
静态与构建验证
- 协议线格式测试:24/24 通过。
- 测试脚本直接解析 C 源码中的 key 与 S-box,并用已知向量校验 AES 输出。
- 两段 GET 响应逐字节验证。
- Keil Arm Compiler 6.24:0 Error、0 Warning。
- 构建尺寸:Code 58788 B,RO-data 3688 B。
真机验证
烧录后首次严格测试为 2999/3000。丢失的一帧位于接收 worker 启动窗口,但最后一帧仍持续到约 3.781 秒,已经明确越过原驱动门限。随后在同一次 USB 枚举下、不重新插拔,立即执行 5000 帧双向测试:
| 方向 | 发送 | 接收 | 结果 |
|---|---|---|---|
| PCAN → ZCAN | 5000 | 5000 | 通过 |
| ZCAN → PCAN | 5000 | 5000 | 通过 |
测试结束后,PCAN 状态为 0,ZCAN 双向 error counter 均为 0,overall_ok=true。更关键的是测试之间没有重新插拔:旧故障状态下第二次测试应为 0 帧,而修复后可以继续完整发送 5000 帧,说明 CloneCheck 门控已经解除。
八、烧录与升级注意事项
本工程采用 Bootloader 与 App 分区:
0x08000000 .. 0x08007FFF Bootloader
0x08008000 .. 0x0801EFFF App
0x0801F000 .. 0x0801F7FF BootInfo
0x0801F800 .. 0x0801FFFF Config
AppUpgrade.bin:供设备 USB Bootloader 升级,只包含 App。- 使用烧录器直接写
AppUpgrade.bin时,目标地址必须是0x08008000,并需要考虑 BootInfo/CRC 更新。 DluCanFD_full.bin:完整出厂镜像,烧到0x08000000,包含 Bootloader、App 和已回填的 BootInfo。- Keil 下载 AXF/HEX 时,文件自身携带各 Load Region 地址。
更新 PCAN 兼容固件后建议物理重新插拔一次,让 Windows 销毁旧设备对象并重新执行 challenge。仅复位 CAN 通道不足以清除驱动已经缓存的 CloneCheck 错误状态。
九、经验总结
- API 返回成功不等于数据已经到达硬件。 主机驱动可能只完成入队,USB URB 是否提交必须单独取证。
- 随机的几百次门限更像策略状态,而不是容量边界。 缓冲问题往往具有稳定容量和可观测的满队列路径。
- 驱动降级不是协议缺失的修复。 多个版本保留相同 challenge 时,固件必须正确实现协议。
- 测试通过标准必须是数量守恒。 对转发和压力测试,只判断“收到过帧”会掩盖严重截断。
- 协议私有逻辑应留在协议适配层。 这次修复无需修改 USB core,也不需要扩大缓冲区。
最终根因链路可以概括为:
固件未实现 0x1E DEVICE_DATA
→ challenge GET 返回零
→ 驱动设置 CloneCheck=Error (2)
→ 随机 100..999 次后停止提交 USB URB
→ CAN_Write 继续入主机队列
→ 最终 QXMTFULL
这次问题真正困难的地方,不是 AES 实现本身,而是区分“应用 API 成功”“驱动入队成功”“USB 提交成功”和“CAN 总线实际发送成功”这四个不同层级。只有把证据逐层对齐,才能避免一直在缓冲区和物理层上兜圈。