记一次诡异的USB设备动画卡死问题排查:元凶竟是JPG文件
问题背景
最近在开发一个基于USB通信的按钮设备动画播放功能时,遇到了一个非常诡异的问题:程序运行后,USB按钮上的动画在30分钟内极高概率会卡在某帧不再播放,但LED灯效和按键检测功能却一切正常。
更诡异的是,完全相同的代码和USB设备,在另一台Win7笔记本电脑上可以稳定运行过夜。这个问题耗费了大量时间排查,中间经历了多次看似合理的错误假设,最终解决方案却出人意料——用Windows画图软件重新保存所有JPG资源文件。
本文将完整记录这个问题的排查过程、对照实验设计和最终的根因分析。
一、系统架构
先介绍一下基本架构。USB按钮通过libusb库进行通信,使用BULK传输模式。核心代码简化如下:
public class UsbButtonSpin {
private static final byte IN_ENDPOINT = (byte) 0x81; // 读取端点
private static final byte OUT_ENDPOINT = 0x02; // 写入端点
private static final int TIMEOUT = 0; // 无限超时(隐患!)
/**
* 播放idle动画(循环播放)
*/
public void playIdleAnimation() throws Exception {
File animation = new File(diceDir + "idle");
File[] files = animation.listFiles();
for (File file : files) {
// 1. 轮询按键状态
int pressTime = handleButtonStatus();
// 2. 发送帧头信息
sendStartMessage(packageSize, fileSize, effectType, pressTime);
// 3. 逐块发送帧数据
FileInputStream fis = new FileInputStream(file);
byte[] data = new byte[512];
while (fis.read(data) != -1) {
write(ByteBuffer.wrap(data));
}
fis.close();
// 4. 控制帧率(约30fps)
TimeUnit.MILLISECONDS.sleep(30 - elapsed);
}
}
/**
* USB读操作 - 查询按键状态
*/
private void read(byte[] data) {
// 发送查询请求
LibUsb.bulkTransfer(handle, OUT_ENDPOINT, requestBuffer, transferred, TIMEOUT);
// 接收设备响应 ← 这里超时时间为0,无限等待
LibUsb.bulkTransfer(handle, IN_ENDPOINT, responseBuffer, transferred, TIMEOUT);
}
}
// 主循环线程
while (running) {
try {
usbButton.playIdleAnimation();
} catch (Exception e) {
// 日志被注释掉了 ← 为排查增加难度
}
}
关键信息: 代码中使用的是LibUsb.bulkTransfer(),即BULK传输模式。这种模式下,数据传输有CRC校验和重试机制,保证数据完整性,但不保证实时性。动画数据量大且要求完整不丢失,所以选择BULK模式是合理的。
二、问题现象
| 现象 | 状态 |
|---|---|
| 动画播放 | ❌ 30分钟内极高概率卡死 |
| LED灯效切换 | ✅ 正常响应 |
| 按键检测 | ✅ 正常工作 |
| 查看线程状态 | ✅ 线程仍在运行 |
| 其他USB功能 | ✅ 均可用 |
关键线索: 动画卡住后,其他USB功能(LED控制、按键检测)依然正常,说明USB通信链路未中断,设备固件也未崩溃。问题并非"设备死了",而是"动画数据通道堵了"。
三、排查全过程的对照实验
实验一:排除构建方式的影响
假设: 是否是使用Ant的build.xml打包成JAR时,资源处理方式与IDEA直接运行不同导致的问题?
实验设计:
| 组别 | 运行方式 |
|---|---|
| 实验组A | IDEA直接运行 |
| 实验组B | Ant打包JAR运行 |
实验过程:
- 使用完全相同的代码和资源文件
- 分别在IDEA中直接运行和打包成JAR后运行
- 多次测试,观察动画是否停止
实验结果:
两种运行方式均出现动画停止问题。
结论: 排除构建方式的差异。问题根源在代码逻辑或资源文件本身。
实验二:深入代码分析——添加全链路日志追踪
假设: 程序在运行过程中某处抛出了异常,被空的catch块吞噬,导致循环退出。
实验设计:
在所有关键方法入口、出口和USB操作处添加日志,形成全链路追踪:
private void read(byte[] data) {
log.debug(">>> read() called");
ByteBuffer buffer = ByteBuffer.allocateDirect(BUTTON_REQUEST_DATA.length);
buffer.put(BUTTON_REQUEST_DATA);
IntBuffer transferred = IntBuffer.allocate(1);
log.debug("read(): sending OUT request...");
int result = LibUsb.bulkTransfer(handle, OUT_ENDPOINT, buffer, transferred, TIMEOUT);
log.debug("read(): OUT request completed, result={}", result);
log.debug("read(): waiting for IN response...");
result = LibUsb.bulkTransfer(handle, IN_ENDPOINT, buffer, transferred, TIMEOUT);
log.debug("read(): IN response received, result={}", result);
// ...
}
// playIdleAnimation() 循环中也添加日志
for (int i = 0; i < files.length; i++) {
log.debug("=== Frame {}/{} started ===", i+1, files.length);
// ...
}
同时在catch块启用日志:
} catch (Exception e) {
log.error("USB thread exception: ", e);
}
实验过程:
- 启用全链路日志
- 运行程序,持续监控日志输出
- 等待动画停止后检查日志文件
实验结果:
- 日志中没有出现任何异常信息
catch块没有被触发- 日志显示程序正常循环播放,没有错误
- 但动画确实卡在某一帧不再更新
关键发现: 程序没有崩溃,线程没有退出,循环仍在执行。
实验三:多机器对比测试
假设: 是否存在操作系统差异导致的问题?
实验设计:
选择不同操作系统的机器进行对比测试:
| 机器 | 操作系统 |
|---|---|
| 机器A(问题机) | Win10 |
| 机器B(问题机) | Win10 |
| 机器C(参考机) | Win7 笔记本 |
实验过程:
- 使用完全相同的JAR包和USB设备
- 每台机器运行多次,观察动画是否停止
实验结果:
- 多台Win10机器均出现动画停止问题
- Win7笔记本电脑挂机一直都没有问题
关键发现: Win7老笔记本始终稳定,从不出问题。这提示我们问题可能与系统环境差异有关,但具体是什么差异,此时仍不清楚。重要的是:问题不是代码逻辑的必然缺陷(否则所有机器都应该出问题),而是在特定环境下才会触发的边界条件。
实验四:锁定资源文件——决定性实验
假设: 原始示例idle图片文件没有问题,新制作的idle图片文件存在差异。
实验设计:
| 实验组 | 资源来源 | 文件数量 |
|---|---|---|
| 对照组 | 原始示例idle图片 | 49张(调整数量一致) |
| 实验组 | 新制作的idle图片 | 49张 |
实验过程:
- 代码完全不变
- 仅替换idle文件夹中的图片文件
- 调整原始资源为同样49张图片进行播放
实验结果:
- 原始的示例idle文件没有问题
- 新的资源idle图片会出现该问题
- 即使数量调整为一致的49张,新idle依然有问题
关键发现:
- 问题根源锁定在资源文件本身
- 不是数量问题,不是命名问题,而是文件内容本身的差异
- 原始图片和新图片虽然都是JPG格式,但内部结构存在差异
实验五:验证修复方案
假设: 新图片文件可能包含额外的元数据或非标准编码参数,用画图重新保存可以去除这些差异。
实验设计:
| 实验组 | 处理方式 |
|---|---|
| 修复组 | 用Windows画图打开 → 另存为JPG → 覆盖原文件 |
| 对照组 | 原始新图片(不处理) |
实验过程:
- 将新制作的idle图片,逐张用画图重新保存
- 替换到idle文件夹
- 在之前出问题的机器上多次长时间运行测试
实验结果:
- 用画图重新保存后,长时间运行,动画播放不会停止
- 对照组仍然必定复现问题
结论: 用画图重新保存资源文件后,问题彻底解决。
四、根因分析
4.1 问题本质
结合所有实验结果,问题的完整因果链如下:
新JPG文件(可能由Photoshop等工具导出)包含额外元数据或复杂编码结构
↓
文件体积更大、结构更复杂
↓
FileInputStream.read() 耗时出现波动
↓
某帧处理时间被拉长
↓
紧接着的USB IN请求遇到设备响应时序异常
↓
bulkTransfer(IN_ENDPOINT) 因 TIMEOUT=0 永久阻塞
↓
动画卡在当前帧,线程假死
4.2 新JPG文件的问题推测
原始示例图片和新图片虽然都是.jpg后缀,但内部结构可能存在差异:
| 可能差异 | 原始图片(推测) | 新图片(推测) |
|---|---|---|
| 编码方式 | 基线(Baseline) | 可能是渐进式或其他 |
| 元数据(EXIF) | 无 | 可能包含拍摄信息等 |
| 颜色配置(ICC) | 无 | 可能嵌入色彩配置文件 |
| 文件体积 | 较小 | 较大 |
这些差异会导致:
- 文件读取时间不同: 更大更复杂的文件,读取耗时更长且波动更大
- USB通信时序偏移: 每一帧的处理时间波动,积累到一定程度后,恰好让后续的IN请求落在设备无法及时响应的窗口
4.3 Windows画图的作用
Windows画图使用最基础的JPG编码器,重新保存后会:
- 生成基线JPG(Baseline JPEG)
- 去除所有EXIF元数据
- 去除ICC颜色配置文件
- 使用简单的编码参数
- 显著减小文件体积
- 产生稳定且可预期的文件读取速度
4.4 为什么Win7笔记本正常?
虽然我们没有深入对比两台机器的具体硬件配置,但合理的推测方向包括:
- USB控制器差异: 不同代的USB控制器对时序偏差的容忍度不同
- 文件系统行为差异: 不同Windows版本的文件缓存策略可能不同
- 系统负载差异: 后台进程数量和资源竞争情况不同
无论具体原因是什么,关键结论是:通过标准化JPG文件消除了触发条件,使代码在不同的系统环境下都能稳定运行。
五、完整解决方案
解决方案(已验证有效)
用Windows画图重新保存所有JPG资源文件:
- 右键JPG文件 → 打开方式 → 画图
- 文件 → 另存为 → JPEG图片
- 覆盖原文件
- 对所有动画帧图片重复此操作
原理: 标准化JPG文件结构,消除文件读取时序的不确定性。
六、总结
对照实验清单
| 实验 | 假设 | 验证方式 | 结论 |
|---|---|---|---|
| 实验一 | Ant打包方式导致 | IDEA vs JAR对比 | ❌ 排除 |
| 实验二 | 代码异常退出 | 全链路日志追踪 | ❌ 无异常,锁定阻塞点 |
| 实验三 | 操作系统差异 | Win7/Win10多机对比 | ❌ 非直接原因,有关联 |
| 实验四 | 资源文件差异 | 原始图片 vs 新图片对比 | ✅ 确认根因 |
| 实验五 | 画图修复验证 | 重新保存前后对比 | ✅ 验证修复 |
经验教训: 这次排查经历再次证明,在嵌入式/硬件相关开发中,软件层面的每一个细节都可能与硬件通信产生微妙的关联。一个图片文件的结构差异,最终竟能导致USB通信阻塞——这种看似"玄学"的bug背后,其实都有清晰的因果链。而严格的对照实验,是穿越迷雾、抵达真相的最可靠路径。
如有类似问题,欢迎交流讨论。

434

被折叠的 条评论
为什么被折叠?



