1. 嵌入式Debug,先把"瞎猜"这个习惯戒掉
干嵌入式调试和写普通上位机程序完全是两码事。你在实验室里对着一个屏幕敲代码,程序崩了,编译器、调试器会直接告诉你崩在哪一行、什么变量变成了什么值。但当你手里的活儿变成一块真实的板子时,情况就完全不同了:LED不亮、串口乱码、系统运行十分钟后自己重启、按键偶尔失灵、通信偶发超时……你看到的永远是一个"现象",而真正的根因可能藏在电源纹波里、藏在中断优先级配置里、藏在一段看似无关的初始化顺序里,甚至藏在你根本没想到过的栈溢出里。
很多工程师面对这种问题时的第一反应,是凭经验"感觉"是哪个模块的问题,然后开始改代码。改完测一下,不行就再换一处,运气好半小时收工,运气不好折腾两三天,最后甚至怀疑是芯片体质不行。这不是Debug,这是抽卡。真正靠谱的做法,是把从现象到根因这段路走通,靠的是一套系统化的排查方法。我这些年调试踩过的坑,最后沉淀下来就是四类排查法:现象还原、链路归因、分界定责、资源审视。它没有多高深的理论,核心就一句话——把不确定变成确定,把偶然变成必然。
这套方法适合谁?适合刚从裸机开发转向RTOS甚至嵌入式Linux的工程师,也适合那些被"偶发Bug"折磨到想换行的老手。无论你是做单片机、ARM、DSP还是嵌入式Linux应用层,思路都是通用的。区别只在于工具和手段不同,底层逻辑是一致的。
2. 第一类排查法:现象还原——先把Bug变成"能复现的输入"
2.1 为什么你看到的现象本身是不可信的
在做任何分析之前,必须先纠正一个观念:你在调试时观察到的"现象",很可能已经被你的调试手段污染了。这是我踩过最深的坑之一。
举个例子。早期我做一块板子,发现程序只要在串口打印函数里加上一小段延时,崩溃问题就消失了。我当时觉得奇怪,后来才搞清楚,崩溃的根因是某个外设中断处理函数访问了未初始化的指针,而打印函数里的延时恰好改变了中断和主循环之间的竞争窗口,让Bug暂时不触发。反过来也一样:你打开调试器单步执行,程序不复位了,全速跑就复位,这是因为调试器改变了Flash等待周期、暂停了看门狗,甚至改变了芯片的上电时序。
所以在排查的第一步,不是急着分析,而是先"采集原始现场"。你要把自己当成一个侦察兵,记录一切客观事实,而不是先下结论。现象还原的目的,就是把一个模糊的"好像会出事"变成一组精确的复现条件和输入序列。
2.2 一份Bug现场记录表,比你想的更有用
我建议在项目里建立一个简单的Bug现场记录表,每次遇到疑难问题时先填表。表格可以放在Git仓库里,也可以贴在调试工位上,字段就下面这些,不需要多花哨:
| 记录项 | 填写内容举例 | 为什么重要 |
|---|---|---|
| 触发环境 | 室内常温/高温箱/户外,供电方式 | 温度和供电会直接影响晶振、LDO和芯片行为 |
| 硬件版本 | V1.2板、有无飞线、改动记录 | 改版经常引入隐性差异 |
| 代码版本 | Git commit号、分支 | 没有它,你改了半天都不知道改了什么 |
| 编译优化等级 | -O0 / -O2 / -Os | 优化等级不同,Bug可能只在一个等级出现 |
| 输入序列 | 按键顺序、数据包内容、波特率 | 有些Bug是特定输入序列触发的 |
| 复现率 | 10次中崩3次,大概40% | 复现率是判断修复是否有效的关键指标 |
| 持续时间 | 运行30分钟后崩 / 上电立即崩 | 指向的资源类型完全不同 |
不要小看这张表。很多工程师Debug的时候头一热,拿万用表、示波器一顿乱戳,最后发现连问题出现的时间和条件都没记录清楚。有一次我跟一个同事排查一个问题,他跟我描述"不定时重启",我问了三个问题:重启前有没有操作过某个按键?供电是稳压电源还是电池?代码是哪个commit?他一个都答不上来。这种情况,再高明的排查法也帮不了你。
2.3 日志埋点:别把printf当救命稻草
嵌入式里最常用的调试手段是printf,但用不好它反而会引入新的Bug。我见过太多人把printf直接扔进中断处理函数里,或者用阻塞式串口发送,结果一开打印,整个系统的实时性全变了,原本的问题直接不出现。
正确做法是分层埋点,而且要有设计感。至少分成三层:
- 入口日志:每个任务、每个中断的入口处,记录"我进来了"。
- 状态跳转日志:系统状态机的每一次切换,记录"从哪来到哪去"。
- 错误日志:异常分支、超时、错误返回码,必须记录下来。
关键点在于,日志不能阻塞主流程。你可以做一个环形日志缓冲区,中断和任务只管往缓冲区里写,由后台的低优先级任务统一通过DMA发送到串口。比如像这样:
#define LOG_BUF_SIZE 4096 static char log_buf[LOG_BUF_SIZE]; static volatile uint16_t head, tail; void log_write(const char *fmt, ...) { // 将格式化的内容写入 log_buf[head],更新 head // head 越界时回绕,如果缓冲区快满则丢弃旧日志 } void log_flush_task(void) { while (head != tail) { // 从 log_buf[tail] 取数据,通过 DMA-UART 发出 // 更新 tail } }这种结构的好处是用DMA发送,CPU不会在发送期间被卡死。时间戳也最好加上,用系统滴答定时器换算成毫秒,或者用定时器计数器直接量化到微秒级。很多诡异的问题,靠时间戳一对比,位置马上就从"大概在这"变成"精确到某个调用链"。
2.4 最小化触发场景:把复杂系统拆成单变量实验
一旦拿到了复现条件和日志,下一步是缩小范围。我管这一步叫"单变量实验法":每次只改变一个条件,观察现象是否变化。
比如系统通信偶发失败,你就逐个试:关掉另一个外设的中断、降低通信速率、换一根连接线、把某个初始化函数移到main函数最后面。每次只改一处,记录现象。千万别一次改五处然后测试,通过了也不知道是哪处起了作用,失败了更是无从下手。
这一步的坑在于:最小化系统本身可能改变时序。你把外设关掉一部分,中断响应速度变了,原本的问题可能不出现。遇到这种情况,要在记录表里标注"最小化后复现率下降",这本身就是重要的线索——说明根因和时序敏感有关,而不是和某个外设本身有关。
3. 第二类排查法:链路归因——顺着信号找问题
3.1 电气信号的基本检查,别上来就怀疑代码
当现象还原做到位了,很多问题仍然直接指向硬件。这时候不要埋头看代码,而是把调试对象切换到物理层。嵌入式系统里,有相当一部分"软件Bug"最终查出来是硬件信号问题。
最基本的检查,是拿万用表和示波器对着板子从头过一遍。先看电源:板子上每一路电源的实际电压是不是在芯片容忍范围内,纹波有多大,上电瞬间有没有跌落。再看地:万用表量一下各个地网络之间有没有电位差,特别是大电流负载附近的GND网络。然后看晶振:用示波器探头夹住晶振引脚,看波形起振是否稳定、幅度是否足够、频率是否偏移。这些检测的成本很低,但能排除掉一大批基础性故障。
示波器使用时有个小细节容易被忽略:探头的接地线要尽量短,而且要直接接到被测信号附近的GND网络。我见过同事用示波器看一个高频信号,波形全是毛刺,最后发现是探头地线太长,自己形成了天线。把地线换成接地弹簧,波形立刻干净了。这不算什么高深技术,但直接影响你判断的正确性。
3.2 通信协议链路排查:UART、I2C、SPI、CAN的典型坑
通信链路是嵌入式调试的重灾区。每个协议都有自己最容易出问题的点,我把它们整理成了下面这张表:
| 协议 | 典型现象 | 常见根因 | 排查工具 |
|---|---|---|---|
| UART | 乱码、偶发丢字节 | 波特率误差、地线电位差、时钟源偏差 | 示波器测波特率,逻辑分析仪抓帧 |
| I2C | 总线卡死、读不到数据 | SDA被拉低、上拉电阻太小、地址错误 | 逻辑分析仪看ACK/NACK时序 |
| SPI | 数据错位、偶发全零 | CPOL/CPHA配置错误、MISO/MOSI接反 | 示波器对比主机从机的CLK/MOSI时序 |
| CAN | 总线错误帧、超时 | 终端电阻缺失、位时间配置不当 | CAN分析仪统计错误帧类型 |
以UART为例,乱码是最常见的问题。排查思路很简单:先确认波特率误差。理想情况下,通信双方的波特率误差要小于2%,否则累计采样会出错。计算方式是:实际波特率 = 系统时钟 / 分频系数,误差 =(实际值 - 期望值)/ 期望值。很多工程师图省事,直接从别的项目拷贝一个波特率配置,却没注意系统时钟已经被改过了,算出来误差高达5%,表现就是通信时好时坏。你用示波器抓一下UART TX引脚的实际位宽,一算就发现了。
I2C的问题则多半和时序有关。总线卡死的时候,第一反应别是改代码,先拿逻辑分析仪看总线电平。如果SDA停留在低电平不动,说明某个从设备把总线钳住了。这时候可以用软件方法强制释放:把I2C外设复位,再把SDA引脚配成普通GPIO,手动翻转几个时钟周期,让挂在总线上的从设备恢复状态。我后面会放一个完整的案例。
3.3 电源、复位和时钟链路:这三条线是"隐形杀手"
很多偶发死机,最后查出来的根因都在电源和复位链路上。复位引脚是最容易被忽略的地方:你以为程序崩了,其实是复位引脚被外部干扰拉低,芯片重新启动了。
排查复位问题,方向要明确。用示波器长时间监测复位引脚的波形,观察是否出现毛刺或跌落。同时检查复位芯片的阈值电压是否合适:如果阈值设置得太接近工作电压,电源一有波动,复位就误触发。NB-100、MAX809这类复位芯片的阈值选择是有讲究的,必须和电源的最低工作电压留出足够的裕量。
电源链路的排查可以分两步。第一步是静态检查:用万用表量电压是否在规格范围内,LDO或DC-DC的输入输出电容是否按手册焊了,反馈电阻的阻值是否正确。第二步是动态检查:用示波器看电源轨的上电波形,芯片负载突然增大的瞬间,电压有没有跌落超过允许范围。特别需要注意大电流外设(比如Wi-Fi模块、电机驱动)启动的瞬间,如果前面没有足够的储能电容,电压很容易被拉低,导致MCU复位。这种情况下,程序写得再好也没用。
4. 第三类排查法:分界定责——用二分法切开代码
4.1 二分定位法:一注代码搞定的事,不要乱枪打鸟
如果说现象还原是采集情报、链路归因是排查物理层,那分界定责就是在逻辑层面缩小故障范围。这套方法的核心,就是把"整个程序不知道哪里有问题"变成"这段代码有问题、那段代码没问题"。
我用的最多的是二分法。具体操作方式有很多种,最常见的三种:一是在main函数的主循环里插入不同的标志位,看执行到哪里就停止;二是用宏开关或注释把某一大段功能裁掉,看问题是否消失;三是在关键函数的入口处加入可触发的调试信号,比如翻转一个GPIO,用示波器看这个GPIO在故障发生前最后一次翻转的位置。
以我自己的习惯为例,当出现死机型问题的时候,我首先会看系统里最外层的主循环是否还在干活。怎么判断?在主循环里翻转一个LED或者GPIO,如果这个信号还存在,说明主循环没死,问题大概率在中断或者外设状态上;如果信号消失,说明主循环被阻塞了,然后我再用二分法在这个主循环调用的函数列表里不断缩小范围,直到找到阻塞点。
这里有个技巧:不要每次只注释一个函数,那样太慢。先注释掉一半函数,测试,再注释掉剩下的一半,就是真正的二分。一个100个调用的工程,最多7次就能定位到具体函数,这比一个一个试效率高很多。
4.2 状态机思维:整个系统就是一个大状态机
嵌入式软件的本质是一个状态机。按键有按键的状态机,通信协议有协议状态机,主循环Master也有自己的主状态机。Debug的时候,把程序执行的每一步映射到状态机的状态迁移上,会非常清晰。
我建议在代码里给状态机的每个状态和迁移条件都定义一个独立的标识,然后利用第一节说的日志系统,把每一次状态迁移记录下来。这样,当系统出现问题的时候,你手上就有一份完整的状态迁移历史——最后一次正常迁移到了哪个状态?下一个迁移条件为什么没满足?
举个具体的例子。我之前做一个小型物联网网关,偶发性地和云端断连,但看代码逻辑半天没毛病。后来我在MQTT客户端的状态机上加了日志,发现状态迁移序列是这样的:CONNECTED → DISCONNECTING → CONNECTING → CONNECTED,循环往复。再看时间戳,发现每次断连都发生在一次特定的传感器数据上报之后。这时候马上意识到,是传感器上报的数据触发了某个异常分支,导致MQTT连接被强制关闭。然后又回到二分法,关掉传感器上报的某一部分代码,问题就浮出水面了。
4.3 异常与看门狗:让芯片自己告诉我们它在哪里出问题
对于Cortex-M系列单片机,当你遇到HardFault或者死机类问题,强烈建议先写一个异常处理函数,把现场信息保存下来。芯片自己会告诉你很多信息,只是你平时没好好问它。
异常处理的核心是记录这几个关键信息:异常类型(HardFault、MemManage、BusFault、UsageFault)、发生时的PC值、LR值、以及异常栈帧里的通用寄存器值。有了PC和LR,你可以从map文件或IDE的Disassembly窗口反查,定位到具体是哪个函数、哪一行触发的异常。Cortex-M的异常栈帧默认压入到当前使用的栈里,具体是MSP还是PSP看LR的EXC_RETURN值。常用的手段之一,是在启动文件的异常向量里挂一个Hook函数,把现场信息写到一块专用内存区或Flash的保留区,然后通过调试器或串口把它读出来。
看门狗本身不是调试工具,但它可以成为线索提供者。启用看门狗后系统如果周期性重启,说明喂狗超时了——也就是某个任务运行时间超过了看门狗期限。这时候配合时间戳日志,找到喂狗点前后最长的运行区间,问题就就在那个区间里。Keil和STM32的组合经常有人问,为什么开了看门狗之后老重启?十有八九不是狗的问题,而是程序里某个中断处理函数执行时间超过了窗口时间。
5. 第四类排查法:资源审视——很多"玄学"出在资源和时序上
5.1 内存:栈溢出、堆碎片、数组越界,这三兄弟最会藏
嵌入式系统资源有限,内存相关的Bug最难以察觉,因为它往往不会第一时间崩溃,而是默默地把相邻的数据改掉,等真正暴露出来时,表象已经和根因毫无关系了。
先说栈溢出。每个任务或函数调用都需要栈空间,RTOS里每个任务还都有独立的栈。如果任务里声明了一个大数组,比如char buf[2048],而任务栈只有1024字节,运行到那个函数的时候,数据直接把栈底冲穿。表现就是系统运行一段时间后随机死机,而且死机点和根因位置八竿子打不着。
排查栈溢出的手段,常用的是栈顶"金丝雀"法:在任务栈的顶部和底部填充特定值(比如0xA5),周期性检查这个值是否被改写。MDK的编译器也支持-fstack-protector这类保护选项,在函数入口和出口检查栈边界。另外,要善用map文件,MDK/IAR都可以查看每个函数的栈用量估计,粗略确认栈分配是否合理。
堆碎片的问题主要出现在长期运行的后端设备上。频繁的malloc/free会把堆撕碎,导致大块分配失败。这类问题排查起来比较痛苦,一般先统计malloc的调用频率和大小,评估堆是否有碎片化的可能。实在不行,直接在工程里禁用动态内存,改成静态分配,问题往往就消失了。
数组越界则比较"看脸",有的越界连编译器都发现不了。排查时首先要排除一切已知的越界风险——通信协议缓冲区、传感器数据缓存、字符串操作是最容易越界的地方。写代码时强制使用带长度参数的函数,比如snprintf代替sprintf,strncpy代替strcpy。Debug版本开启编译器的Bounds Checking或使用-fsanitize系列选项,如果有条件上硬件检测手段(比如MPU内存保护单元),那就更好了。
5.2 中断与共享资源:竞态条件和优先级是万恶之源
嵌入式系统里,中断和主循环天然共享大量全局变量、缓冲区、外设寄存器。如果没有做好同步,就会出现经典的竞态条件问题。这类Bug奇妙之处在于,它只在特定时序下才触发,你单步执行永远不会崩,全速跑才崩。
举一个典型的坑:主循环里判断一个标志位为真,然后去读一个缓冲区;而中断服务函数负责设置标志位、往缓冲区写数据。问题在于,主循环可能在中断刚设置了标志位、但缓冲区数据还没写完的时候,就把数据读走了。解决这类问题的方法不复杂:要么在临界区里操作共享数据(关中断、或使用RTOS的互斥锁),要么用无锁环形缓冲区,要么把变量类型定义为天生的原子类型,还要记得加volatile修饰。
优先级配置也是一门学问。中断优先级配置不当,会导致低优先级中断占着CPU不放,高优先级中断被无限延迟。排查这种问题,可以做一个"中断耗时审计":在每个中断的入口和出口翻转一个GPIO,用逻辑分析仪统计每个中断的占用时间。你很快就会看到,某个中断处理函数占用了不可思议的长时间——比如在中断里做了浮点运算、CRC软件计算、或者打印日志。
5.3 外设配置冲突与初始化顺序:改一行配置,隐藏一个Bug
外设冲突是嵌入式特有的问题。同一个引脚可能被GPIO、UART、I2C、定时器复用;同一个DMA通道可能被多个外设争抢;同一个中断号可能被多个外设共用。很多疑难杂症,最后发现是初始化顺序或者配置冲突的问题。
最典型的是引脚复用冲突。例如你用STM32开发,PA9和PA10默认复用为USART1的TX和RX,但如果你在初始化里先把它们配成了GPIO输出,用于驱动LED,后面再初始化USART1就会失败或者行为异常。排查这类问题的方法是:把所有外设初始化代码梳理一遍,对照芯片手册的AFIO或GPIO复用表,逐项确认没有引脚复用冲突。
初始化顺序同样重要。一般的原则是:先配置系统时钟,再配置GPIO和外设时钟使能,再初始化外设寄存器,最后才开中断。违反了顺序,外设可能在没有时钟或者时钟不对的情况下被写入寄存器,行为就变得不可预测。
6. 四个实战案例,完整演示四类排查法的配合
6.1 案例一:系统运行几分钟后随机死机
现象:设备上电后工作正常,大约运行3到5分钟后死机,画面定格、按键无响应、串口不再输出。
排查过程第一步,现象还原。我先给程序加了环形日志和状态迁移记录,发现死机前的最后一条日志总是同一个传感器任务的输出。复现率很高,基本每次都能重现。
第二步,链路归因。用示波器看电源信号,纹波正常;看复位引脚,没有跌落;看晶振波形,起振稳定。物理层排除。
第三步,分界定责。为了确认是否主循环崩溃,我在主循环里翻转一个GPIO,死机后这个GPIO停止翻转,说明主循环死了。用二分法注释掉主循环里的大部分任务,发现问题依旧。再用异常处理函数抓现场,HardFault的PC值指向一个内存操作的库函数内部。
第四步,资源审视。检查map文件,发现出问题的任务栈分配只有512字节,而任务内部有一个512字节的局部数组。栈显然是被冲穿了。我把栈增大到2048,问题消失。这个案例说明,很多崩溃看起来是"随机死机",实际上是我们给系统的资源约束太苛刻了。512字节的栈看起来够用,但实际上函数调用链上还有多级嵌套,每级都会压栈。用map文件估算任务栈时,一定把最大调用路径上的所有局部变量、返回地址和寄存器保存都算上,宁可多给一点,也别让栈溢出成为隐形炸弹。
6.2 案例二:UART偶发乱码,重启就好
现象:板子和上位机通信,波特率115200,接收端偶尔出现乱码,重启后短暂正常,过一会又乱。看起来像软件Bug,但排查到最后发现和代码关系不大。
现象还原阶段,我记录了乱码发生的时间和电源状态,发现乱码总是出现在系统里某个电机启动后的一两秒内。链路归因时,用示波器抓TX引脚,发现电机启动时,3.3V电源轨上叠加了一个明显的高频纹波,UART的TX信号在这些时刻波形明显畸变。问题定位到电源端。
解决方案:在电机电源输入端增加滤波电容和磁珠,同时把MCU的供电和电机驱动供电在PCB布局上彻底分开。改完后复测,乱码消失。这个案例的启示是:单片机内部逻辑再严谨,电源不干净,一切通信协议都白搭。排查通信问题,永远先怀疑电源和地,再怀疑电气参数,最后才去抠代码。
6.3 案例三:I2C总线卡死,从设备把SDA拉低
现象:一个温湿度传感器通过I2C读取,工作一段时间后读取超时,I2C总线上的SDA线一直处于低电平,MCU无法再和任何I2C从设备通信。
第一步,链路归因。用逻辑分析仪抓I2C总线,看到在某个读取操作中,从设备应答后SDA一直保持低电平。这是I2C常见的"总线死锁",原因可能是从设备进入了异常状态,或者时钟线上的毛刺让从设备的状态机错乱,一直处于传输中间态。
第二步,用软件手段恢复总线。我写了一个I2C总线恢复函数:把SDA和SCL初始化为普通GPIO输出,然后SCL翻转9个时钟周期,期间SDA输出高电平,让总线上的所有从设备完成复位。这个操作等同于给所有设备发送一个STOP条件,让它们回到空闲状态。恢复后总线和传感器通信恢复正常。为了彻底解决,我重新检查了从设备的手册,发现它在上电后有一个很长的不稳定期,之前MCU可能在这个期间发出了起始条件,导致从设备误判。解决办法是调整了上电后的延时和重试机制。
6.4 案例四:启用了看门狗之后,系统周期性重启
现象:某项目代码稳定运行,但一开启独立看门狗,系统就固定每大约1秒重启一次,关闭看门狗则一切正常。很多工程师的第一反应是"看门狗配置错了",但事实并非如此。
排查思路是:看门狗本身只是执行者,它不会无缘无故咬人,一定是软件没有及时喂狗。我打开系统的中断记录日志,重点观察喂狗点前那段代码的执行时间。结果发现,某个通信模块的底层驱动在处理异常帧时,有一个严重耗时的轮询循环,最坏情况下要等待接近2秒,远远超过看门狗1秒的窗口期。
修复方案有两步:第一步,把那个耗时轮询改成带超时上限的短轮询,查表处理异常帧。第二步,把喂狗操作放到更高优先级的任务里,保证核心循环即使繁忙,看门狗也能被及时喂到。改完之后,看门狗开启状态下稳定运行。这个案例的关键结论是:看门狗不是用来Debug的工具,而是用来暴露Debug方向的探针。系统被它咬重启,说明程序某些路径的执行时间远超预期,这才是真正要解决的问题。
7. 常见问题速查表与我的避坑笔记
7.1 现象与排查方向速查表
整理了一份我自己常用的速查表,贴在工位上,遇到问题直接查行不行:
| 现象 | 优先排查方向 | 再排查方向 |
|---|---|---|
| 系统上电即复位 | 电源电压、外部复位引脚、程序跑飞 | 晶振起振、Boot引脚配置 |
| 运行一段时间后死机 | 栈溢出、堆溢出、看门狗超时 | 电源发热导致的电压跌落 |
| 通信偶发乱码 | 电源纹波、地线电位差、波特率误差 | 晶振频率偏差、外部干扰 |
| 中断不响应 | 中断标志未清除、NVIC优先级配置 | 中断服务函数里死循环 |
| 外设寄存器写不进 | 外设时钟未使能、总线锁定 | 引脚复用冲突、DMA冲突 |
| 系统启动慢/卡顿 | 初始化函数里的大数组/长循环 | Flash等待周期、FPU未开启 |
| 掉电后数据丢失 | 备份寄存器/外部Flash写入失败 | 掉电检测电路、电源电容 |
这张表不是万能的,但大多数时候能帮你确定下一步往哪走。
7.2 几个用时间换来的教训
最后分享几个我踩了无数次坑之后沉淀下来的习惯,都是非常实用的注意事项。
第一个习惯是:一次只改一个变量,改完就测,测完就记录。我在Debug时强制自己不搞连环修改,改一个配置、一个函数、一个常量,测一轮,把结果记录在案。这样做看起来慢,实际上是最快的。因为你可以精确知道哪一步操作让现象发生了变化,整条排查链是完整闭环的。反观那些一次改五处的人,试飞成功了自己都不知道怎么成功的,等下次问题再出现时还得重新踩一遍。
第二个习惯是:重视编译器优化等级。很多Bug只在-O2甚至-Os下出现,在-O0下完全正常。这不是玄学,而是优化改变了变量存储位置、去掉了中间赋值、重排了指令顺序。排查这种问题的通用技巧是:先用-O0复现,如果复现不了,再切到-O2,然后用反汇编窗口确认关键变量是否被优化掉了,必要时对某个变量加volatile。调试的时候,不要一开始就开最高优化,先让系统"好说话"一点。
第三个习惯是:调硬件的坑,永远先怀疑探头和接线。我见过有人花三天排查模拟开关多路切换的串扰问题,最后发现是示波器探头补偿没调好,看到的高频串扰其实全是假象。所有用示波器、逻辑分析仪测出来的信号异常,第一反应都应该是"我的测量方式对吗"——探头补偿对不对、地线长不长、采样率够不够、触发电平合理不合理。测量工具本身不可靠,那后续的分析就全是在沙滩上盖楼。
第四个习惯是:代码版本管理是Debug的必需品。哪怕只是调参,也建议单独建一个分支或者打一个Tag。没有Git管理的嵌入式项目,排查问题时的每一步改动都像在走钢丝,因为你不确定这次改动和上次改动之间的关联。我自己的习惯是,每次调试的关键节点都commit一次,信息写清楚"为什么这么改、现象是什么、结论是什么"。调试完成后,再把有效改动整理成一个正式的 commit,把中间踩到的死胡同全部清理干净。
以上四类排查法,在实际项目里从来不是单兵作战,而是循环使用。你可能从现象还原开始,发现指向链路问题;链路查完发现没问题,又跳回代码分界;分界切到某块代码后,再回到资源审视。整个流程就是一圈一圈地缩小范围,直到根因暴露。我个人做调试最大的体会是:不要急着去改代码,先花足够多的时间把现象记录清楚、把思路理清楚。证据链完整了,根因往往是主动跳出来找你,而不是你苦哈哈地翻遍每一个角落。这套方法,你多完整走几遍,就会变成肌肉记忆。