做嵌入式开发和硬件调试的朋友,一定都经历过这种场面:功能明明是对的,代码翻来覆去查不出毛病,偏偏在你快要放弃的时候,它又自己好了。这种“偶发 bug”远比必现 bug 更折磨人,因为它意味着你面对的很可能不是逻辑错误,而是环境、时序、硬件体质、甚至批次差异混合出来的疑难杂症。
我最近一口气处理了三个典型的偶发故障,分别涉及串口通信假故障、蓝牙连接不稳定、固件烧录失败。三种问题,三个维度,但排查思路最后收敛到了同一条方法论上:先把“嫌疑对象”隔离出来,再决定是换机验证、取证复现,还是做对照实验。这篇文章就把这三个案例完整拆开,讲讲我是怎么一步步从“怀疑人生”到“定位根因”的,里面有不少常规文档不会写的实操细节,希望能给正在被偶发 bug 折磨的人一点参考。
1. 偶发 bug 的通用排查思路:先定性,再定位
1.1 偶发问题为什么难查:三类根源先分清
碰到的偶发 bug 多了,我习惯先给问题做个粗分类,因为它们对应的排查手段完全不同。
第一类是“资源竞争型”。比如串口数据偶尔丢字节、蓝牙偶尔断开,多半是 DMA 通道冲突、中断优先级被打乱、缓冲区被踩,或者是多任务环境下共享资源没有加锁。这类问题的特点是:频率低、无规律、换一台电脑或者换一个环境可能就没了。
第二类是“硬件体质型”。比如某个批次的芯片电平阈值偏了、某颗晶振起振慢、某条排线屏蔽差,这种问题往往在特定温湿度、特定供电条件下才暴露,而且是“换板子就好,过几天又犯”。
第三类是“工具链型”。比如烧录失败、驱动时好时坏、编译环境和下载器兼容性问题,这类问题的特点是:代码本身没问题,但工具链某个环节不稳定,导致“同样的操作,结果不一样”。
知道了这三类根源,接下来就不是闷头改代码了,而是先搭建一个能稳定复现问题的环境。注意,这里说的是“能稳定复现问题的环境”,不是“能稳定复现问题”——这俩有本质区别。前者是为了让故障更频繁地出现,方便观察和取证;后者是被动等待故障,效率极低。
1.2 排查工具的准备:日志、录屏、对照物三件套
不管是哪类问题,我建议手边常备三样东西。
日志记录工具是底线。串口数据用串口调试助手或者自己写个 Python 脚本,把所有接收到的原始字节带时间戳存到文件里,不要只盯着屏幕看。蓝牙相关的日志,Android 端可以用开发者选项里的蓝牙 HCI 日志抓取,或者用 hcidump 工具;如果是调试 BLE 设备,nRF Connect 这类工具能直接看到连接参数和断开原因。
录屏取证工具是容易被忽视但非常关键的一环。很多硬件问题跟 UI 操作时序强相关,特别是蓝牙配对、App 连接这类场景。我一般会在复现问题的时候,用手机自带的录屏功能把整个过程录下来,包括信号强度指示、操作步骤、断开那一刻的画面。为什么一定要录屏?因为偶发问题往往来得快去得快,你盯着屏幕看十次都不一定能捕捉到,但只要录了像,就可以一帧一帧回放,找出断开前最后发生了什么。
对照样本是第三个法宝。手头最好保留至少两块不同批次或不同来源的板子,这就是“新旧批次对照”的基础。很多时候偶发 bug 是某一批物料的问题,你拿两块批次不同的板子做对照测试,能省掉大量怀疑代码的时间。
2. 案例一:串口假故障,为什么换台电脑就好了
2.1 现象描述:设备在 A 电脑上“坏了”,在 B 电脑上“好了”
第一个案例是典型的串口假故障。一块基于 STM32F4 的设备主板,通过 USB 转串口芯片(CH340)跟电脑通信。现场反馈:设备在工控机上完全打不开串口,表现为“打开串口失败”或者“发数据没反应”,但在开发用的笔记本上一切正常。
我第一次接到这个反馈,下意识怀疑是代码问题,于是拿着串口调试助手反复比对配置:波特率 115200、8 数据位、1 停止位、无校验,完全一致。又怀疑是不是板子坏了,换了一块全新的板子接上工控机,故障依旧。这时候问题就很有趣了:代码没问题,板子没问题,那问题只能出在“这台电脑”和“这块板子”之间的某个环节上。
2.2 逐步排查:从端口号到驱动版本再到芯片体质
先看设备管理器。打开设备管理器,发现 CH340 的驱动装上了,但端口号被分配到了 COM9 之后的高编号。很多老旧的上位机软件只扫描 COM1-COM8,这会导致“打开串口失败”。解决方法是手动改端口号,在设备管理器里右键设备,选中“端口设置”里的“高级”,把 COM 端口号改到 COM1-COM4 这种低编号区间。
改完端口号问题依旧,这就说明不只是端口号的问题了。我换了个思路,用只读方式打开串口试试,结果能打开,这说明串口本身是通的。那“发数据没反应”是怎么回事?
这时候我怀疑到了驱动层级。CH340 的驱动版本很乱,Windows 10 和 Windows 11 自带的驱动有时会跟设备不兼容,表现为“能识别,但通信不正常”。我把工控机上的 CH340 驱动卸载掉,重新装了官方最新版,结果问题解决了。但我没有就此打住,因为这种“卸载重装”式的修复,很可能只是碰巧撞上了。
真正让我意识到这是“假故障”的,是一个关键实验:把工控机上的 USB 口从 USB 3.0 换到 USB 2.0,发现故障概率明显降低。再深挖下去,发现设备板上的 CH340 芯片是一批早期版本,对 USB 3.0 接口的兼容性不好,偶尔会出现枚举失败或者通信异常。工控机前置 USB 口全是 3.0,所以问题频繁;开发笔记本的 USB 口是 2.0,所以一直正常。
2.3 经验的沉淀:串口问题排查顺序表
这个案例结束之后,我总结了一份串口偶发故障的排查顺序,按照“软件到硬件、系统到芯片”的优先级排列:
| 排查步骤 | 操作内容 | 验证方法 | 命中概率 |
|---|---|---|---|
| 1 | 检查设备管理器端口编号 | 改到 COM1-COM8 再测试 | 中 |
| 2 | 检查上位机软件是否只扫描低端口号 | 查看软件配置或文档 | 中 |
| 3 | 卸载重装最新 USB 转串口驱动 | 官方驱动安装后复测 | 高 |
| 4 | 换 USB 口(3.0 换 2.0) | 故障是否消失或明显减少 | 中 |
| 5 | 用示波器抓 USB 口数据线和 DTR/RTS 信号 | 观察枚举时序和设备复位信号 | 中 |
| 6 | 换不同批次 CH340 芯片或换 FTDI 芯片模块 | 对照测试 | 高 |
这里要说一个常被忽略的细节:很多 USB 转串口模块的 DTR 和 RTS 信号是直接接到 MCU 的复位引脚和 BOOT0 引脚的。如果你的板子用了这种设计,上位机软件在打开串口的时候会自动拉低 DTR,导致 MCU 被复位,表现就是“打开串口后设备没反应,但设备管理器里一切正常”。这不是假故障,这是硬件设计上的“真坑”。
排查这类问题时,不要只盯数据收发,留意一下控制线的状态。在串口调试助手里,打开“DTR”和“RTS”的勾选项,来回切换几次,看设备是不是跟着复位了。如果确实存在这个问题,要么改硬件设计,让控制线不经过复位电路;要么在上位机软件里不勾选 DTR/RTS 控制。
3. 案例二:蓝牙断开的“悬案”,靠录屏取证翻案
3.1 现象描述:蓝牙耳机/设备连接时好时坏,完全找不到规律
第二个案例是个典型的“玄学”问题:一个基于 HC05 蓝牙模块(也有用杰理蓝牙方案的版本)的设备,跟手机 App 连接后,经常出现“用着用着就断开,但过一会儿又能自动连上”的现象。用户反馈了很多次,但开发这边怎么测都测不出来,因为断开的时间点完全随机,有时候几分钟,有时候一两个小时。
这个问题让人抓狂的点在于:设备端代码看了无数遍,逻辑没有任何问题;手机 App 端的回调也都正常处理了。但就是断,而且断开后不总是自动重连。
我决定换个思路,先做录屏取证。这个方案其实很简单:在手机上调出开发者选项里的“不锁定屏幕”功能,打开系统自带的屏幕录制,让手机保持录屏状态放在那里,同时连接设备进行长时间稳定性测试。录屏是为了拿到断开瞬间的第一手证据,包括当时的信号强度、设备的连接状态、App 界面上的告警信息。
录了大概四个小时,终于抓到一次断开。回放录屏的时候,我注意到一个细节:断开之前,手机上方的蓝牙图标短暂消失了一下,然后又出现,紧接着 App 就提示“设备已断开”。这个细节看起来不起眼,但非常重要,因为它说明断开的根因不在 App 层,而在系统蓝牙协议栈这一层。
3.2 深挖系统层:从抓包日志到连接参数
既然锁定到了蓝牙协议栈,接下来要做的就是抓取蓝牙芯片的日志。Android 手机可以开启“蓝牙 HCI 日志”功能,抓下来是一个 btsnoop 文件,用 Wireshark 打开就能看到蓝牙控制器和主机之间的所有交互。
打开抓包文件,我看到了一个可疑现象:设备在断开之前,有几次 L2CAP 层的“连接更新请求”,也就是双方在协商连接参数。具体点说,Android 系统发起了从 30ms 到 45ms 的连接间隔更新请求,而设备端拒绝了这一次请求,但紧接着系统又发了第二次,设备端同意了。
问题就出在“拒绝”这第一次请求上。HC05(基于 CSR 芯片)这类蓝牙模块的默认连接参数比较激进,而 Android 系统对连接参数有功耗要求,两者协商不拢时,系统会认为设备不符合规范,几次失败后就主动断开连接。这就是为什么断开看起来“毫无规律”——它跟手机当前的负载、功耗策略、以及设备的应答时序都有关系。
解决方向也明确了:修改设备端的连接参数,让 GAP/连接参数更符合 Android 的期望值。HC05 模块通过 AT 指令可以配置连接参数(具体指令格式因固件版本而异,一般是 AT+PARAM 这类指令),把连接间隔从默认的 20ms 改成 30-50ms 区间,同时把 slave latency 调低,能大幅降低被系统断开的概率。
3.3 实操启示:录屏取证的价值和注意点
在这个案例里,录屏取证起到了“翻案”的作用。没有录屏,我就没有证据判断是系统层断开还是 App 层断开;没有证据,就只能靠猜,而靠猜的排查方式在偶发 bug 面前几乎必败。
录屏取证有几个实用技巧:
录屏前先把手机的通知栏里所有无关通知清理干净,避免通知提示遮挡关键信息。开启“显示触摸操作”功能,这样回放时能看到手指的操作路径,对排查 UI 操作触发的问题非常有用。用支架固定手机,不要手持,保持画面稳定。如果涉及设备端的指示灯状态,可以在画面里同时放置一个时钟,用来对齐不同日志的时间线。
还有一个技巧是“屏幕录制 + 串口日志同步录”。手机开录屏的同时,把设备端的串口调试信息也用电脑录下来,然后以“设备和电脑之间的时钟同步”为桥梁,把两条时间线对齐。这样复盘的时候,就能看到“App 界面上发生了什么”和“设备内部发生了什么”的因果关系。对齐时间线的方式很简单:在录屏开始时,同时给设备和电脑发送一个带串口打印的复位信号,录屏里能看到这一刻,串口日志里也能看到这一刻。
4. 案例三:“新旧批次对照”烧录排查,一把辛酸泪
4.1 现象描述:同样代码,旧板子能烧录,新板子烧不进去
第三个案例特别具有代表性:一批新型号的板子刚焊好回厂,准备批量烧录固件,结果 Keil 5 里点下载,进度条走到一半提示“Cannot Access Target”,或者干脆就是“擦除失败”。而拿了手头旧批次的板子试,同样的接线方式、同样的工程配置,一把烧进去。这就尴尬了:代码肯定是没问题的,因为旧的板子能烧;板子大概率也没问题,因为换了一块新的也一样失败。
我首先怀疑的是烧录器的问题。常见的 ST-Link 或者 J-Link,在接线较长或者供电不稳的情况下,容易出现烧录失败。但这次换了好几个烧录器、换了 USB 线、换了个电脑,问题依旧。这就基本排除了工具本身的问题。
接下来排查目标锁定在了“新旧批次差异”上。
4.2 对照实验设计:逐项排除批次差异
“新旧批次对照”不是简单地把两块板子放在一起对比,而是要设计一个对照实验,逐项排查。
先排查供电差异。用万用表量了两块板子的 3.3V 电压,旧板子是 3.31V,新板子是 3.28V,都在正常范围内,但烧录不进去的板子在上电瞬间会跌到 3.02V 左右。这个电压跌落就是重大嫌疑对象。烧录器在擦写 FLASH 时需要比较高的电流脉冲,如果板子上的电源纹波过大或者稳压器带载能力不足,就会在擦写瞬间拉低 MCU 的电压,导致烧录中途失败。
验证方法很简单,用一个外接的 3.3V 稳压电源,直接给新板子的 MCU 供电(断开原本的 LDO 输出),然后再烧录,一把过。这就确认了:不是 MCU 的问题,是板子的供电设计或某颗电容的问题。
又做了一组对照:把新板子上的一颗 22uF 的陶瓷电容换成了低 ESR 的型号,再上电测试,电压跌落明显缓解,烧录也稳定了。最终定位到是某批次物料里的一颗电容 ESR 偏高,在脉冲电流下性能不足导致的“烧录假故障”。
4.3 更多烧录失败场景的排查串讲:从 Keil 到 ESP32
“新旧批次对照”这个思路,还可以扩展应用到其他烧录场景。
比如用 Keil 5 烧录时提示 “No Target Connected” 和 “Cannot Access Target”,两者的排查方向不同。“No Target Connected”多半是接线问题、目标板没上电、或者是烧录器驱动没装好;而“Cannot Access Target”则往往意味着烧录器已经发现了芯片,但和目标芯片的握手失败。这个握手失败的原因可能是芯片的 SWD 引脚被复用占用,也可能是芯片进入了低功耗模式,还有一种可能是芯片本身已经被锁死,需要先用串口工具擦除或者用烧录器的“unlock”功能解锁。
如果你用的是 ESP32 系列,烧录失败的排查思路又有不同。ESP32 的烧录模式要求板子在复位后保持 IO0(GPIO0)为低电平,才能进入下载模式。很多时候烧录失败是因为 GPIO0 被外部电路拉高了,或者复位时序不对。用 esptool.py 这类官方工具时,它会先自动控制 DTR/RTS 来切换复位和 IO0 状态,但如果你的板子没有自动下载电路,就会一直卡在“Waiting for download”阶段。解决办法是手动按住 BOOT 键,点击复位,然后松开 BOOT 键,让芯片进入下载模式。
还有 GD32 和 CH32 系列,它们和 STM32 的烧录方式不同。GD32 可以用串口 ISP 或者 SWD,但部分型号的串口 ISP 下载需要特定的 BOOT 引脚配置;CH32 系列支持 WCH-Link 下载,也可以用串口下载,但要注意新批次的 CH32X035 这类芯片,烧录工具的版本可能要更新到比较新的版本才能支持。遇到“能识别到芯片但烧录一直失败”的情况,优先去官网下载最新版本的烧录工具,而不是怀疑板子坏了。
为了让你少走弯路,我把几种常见的烧录失败现象和排查方向整理成了速查表:
| 现象 | 首要排查点 | 次要排查点 | 特殊场景 |
|---|---|---|---|
| Keil 提示 Cannot Access Target | 复位电路、SWD 引脚被占用 | 供电是否稳定 | 芯片锁死需先解锁 |
| Keil 提示 No Target Connected | 接线、目标板供电 | 驱动安装 | ST-Link 固件升级 |
| ESP32 Waiting for download | GPIO0 电平、复位时序 | 自动下载电路接线 | 手动按 BOOT 进入下载模式 |
| GD32 串口 ISP 失败 | BOOT 引脚配置 | 串口芯片电平是否匹配 | 波特率误差过大 |
| CH32 烧录中途失败 | 烧录工具版本 | 供电瞬态跌落 | 换批次芯片对照测试 |
| 烧录校验失败 | 时钟晶振是否起振 | FLASH 写保护 | 芯片体质问题 |
4.4 烧录问题背后的“批次”思维
这个案例的核心收获不是“电容 ESR 偏高导致烧录失败”这个结论本身,而是“新旧批次对照”这个方法。
具体操作是这样的:拿到一块无法烧录的新板子,先从旧批次中挑一块功能完好的板子作为对照组,在完全相同的环境下烧录。如果旧板子能烧,就基本确定问题出在新板子的硬件差异或物料批次上。然后用对比法逐步缩小范围:把新板子的电源部分、晶振部分、SWD 部分的元器件参数和旧板子逐一比对;把新板子和旧板子的关键电压电平量一遍;必要时用飞线把旧板子上某个可疑元件替换到新板子上做交叉验证。
这种对照法同样适用于其他场景。MCU 主频不对,拿新旧两块板子分别输出 PWM,用示波器量实际频率,看是否因为新批次的晶振负载电容不匹配导致频率偏差。通信距离变短了,拿新旧板子的射频匹配电路做对比,用网络分析仪看 S11 参数,确认是否因阻抗失配导致发射功率回退。功耗异常偏高,对比新旧板子的静态电流,逐模块断开供电,找出差异点。
批次性问题最坑的地方在于:它不会让你完全不能用,而是“偶尔不能用”,或者“某些功能弱了一点”。这种灰度的故障最难定位,因为单看一台设备,你很难判断是设计问题还是个体问题。只有把新旧两块板子放在一起,让差异浮出水面,才能把模糊的“感觉不对”变成明确的“这里不一样”。
5. 偶发 bug 排查的底层逻辑与一套可以抄作业的流程
5.1 三条核心方法论:隔离、取证、对照
三个案例讲完,回头总结,你会发现它们背后的方法论是相通的。
第一条是隔离变量。串口假故障案例里,代码、板子、电脑三个变量,通过换机、更换驱动、替换 USB 口逐步隔离,最终定位到 USB 3.0 接口兼容性上。隔离变量的本质不是“挨个试”,而是每次只改变一个变量,并记录结果。只有严格控制变量的实验,才能告诉你因果关系的方向。
第二条是取证优先。蓝牙断开的案例里,如果不是录屏拍到了蓝牙图标先消失再出现这个细节,我大概率会陷入“改代码-无效-再改代码”的死循环。偶发问题的特点决定了它不会在你盯着看的时候出现,所以必须借助工具把现场记录下来。录屏、串口日志、蓝牙抓包、逻辑分析仪的数据,都是在案发现场找证据。
第三条是对照实验。烧录失败的案例里,“新旧批次对照”就是把两个高度相似但行为不同的对象放在一起,通过差异点反推问题根源。对照实验对偶发问题尤其有效,因为偶发问题往往不具备“稳定的单一状态”,你无法在同一个对象上做“改了以后会不会好”的验证,因为你不能确定它是真的好了还是偶然好了。但两个对象之间的确定性差异是最好的线索。
5.2 一套可以实操的排查流程模板
结合三个案例,我梳理了一套通用排查流程,可以直接复制到你的项目里使用。
第一步,明确故障定义。写清楚“什么场景下,做了什么操作,出现了什么现象”,把复现步骤和现象描述从“偶尔不行”细化为“每次执行某个特定操作,有百分之多少的概率失败”。这一步决定了后面所有排查的效率。
第二步,建立嫌疑清单。列出所有可能导致这个问题的因素,从代码逻辑到硬件设计、从驱动兼容性到信号完整性、从环境因素到批次物料,能想到的都写下来。不要急着排除,先列全。
第三步,设计隔离实验。从嫌疑清单里挑出最容易验证的一项,设计一个最小实验来验证它。记住,一次只验证一个假设。如果实验没有命中,就从清单里划掉,接着验证下一个。
第四步,取证和留痕。任何一次故障发生,都要留下证据。录屏、截图、日志文件、串口打印,全都保存好。如果条件允许,把当时的核心参数(固件版本、编译时间、板子序列号)也一并记录。这样万一后面需要复盘,资料是齐全的。
第五步,对照与交叉验证。当嫌疑集中到具体的硬件或物料时,用新旧批次、不同来源的板子做对照。有条件的话,把可疑元件互换测试,用结果直接说话。
第六步,修复和回归。修复后不要只验证一次,要做一个压力测试。比如原来一小时出现一次,修复后至少要连续测试四个小时不出现,才能算基本通过。如果能设计一个加速复现的方法,比如提高波特率到极限值来压测串口稳定性,或者频繁切换蓝牙连接状态来压测连接逻辑,会更快得到结论。
5.3 偶发 bug 排查的认知门槛
最后说个比较务虚、但我觉得最核心的事。
接到偶发 bug 的报告,最忌讳的第一反应就是“不可能”或“我代码里没这个问题”。这种防御性心态会直接影响你的排查思路,让你把所有异常都归因到外部环境、用户误操作,结果绕了一大圈,最后还是回到自己身上。
真正有用的心态是“暂定凶手是所有人”。把代码、硬件、工具链、环境、用户操作全部列为嫌疑人,然后逐个审问。审问的唯一依据是证据,不是直觉。这种心态不是说你要去怀疑每一行代码,而是要避免在没有任何证据的情况下给问题定性。
实际调试过程中,我发现偶发 bug 还有一个天然特性:它会导致排查者丧失“信噪比”判断能力。一次测试通过了,你觉得修好了;下一次测试失败了,你又陷入焦虑。这种情绪波动非常消耗精力。所以我在操作中给自己定了个规矩:拿到结论后,不急着发消息说“修好了”,先做一轮至少两小时的压力测试,确认稳定了再跟团队同步。这既是对自己负责,也是对团队负责。
我在实际调试中还有一个习惯:每定位一个偶发 bug,就顺手写一条排查笔记。不用写得很长,就记下现象、排查过程、最终根因、验证方式这四列。时间长了,这就是一套专属的“偶发 bug 排查案例库”。下次再遇到相似的问题,直接按图索骥,能省下大量的重复劳动。这个方法强烈建议你也试试。