做嵌入式开发这些年我最深的感受是写代码的时间可能只占三成剩下的时间几乎都在跟Bug搏斗。而且嵌入式里的Bug和纯软件还不一样它不给你漂亮的报错堆栈也没有断点一停就能看清全局的待遇更多时候是“现象出现一下又消失”“换个板子就正常”“加了打印就好了”让人非常抓狂。所以排查嵌入式Bug本质上是一场“科学实验”提出假设、设计验证、缩小范围、最终定位根因。这篇文章我就把自己用过的几种方法整理出来从最朴素的日志法到调试器、示波器、二分法再到一次真实的顽固Bug复盘希望能给你一些可以立刻上手的思路。1. 为什么嵌入式Bug总是“一会好一会坏”先弄清楚敌人长什么样在讲具体方法之前我觉得有必要先把嵌入式Bug为什么难排查这回事说清楚。否则你拿着示波器也不知道该测哪里开着调试器也不知道该看哪个变量。1.1 中断、DMA与主循环并发问题的“三重门”纯软件里的并发你还能靠锁、靠线程模型去控制但嵌入式里的并发是物理级别的。一个外设中断可能在主循环执行到任何一条指令时触发DMA可能在你看不到的后台悄悄改写一块内存实时操作系统的任务切换更是随时都可能发生。我遇到过最典型的一个问题某个标志位在中断里被置位主循环里查询并清除。逻辑上看不出问题但运行一段时间后功能就偶发失效。最后排查发现主循环里先读了标志位还没执行清除操作中断又进来了再次对标志位置位等回到主循环时清除操作把新标志也一并清掉了相当于丢失了一次中断事件。这种Bug的难点在于它依赖精确的时序窗口概率低、复现难。你要是没意识到“中断主循环天然就是并发”多半会在逻辑错误里绕圈子。1.2 硬件耦合Bug不一定是代码问题嵌入式程序的运行结果强依赖硬件行为。同样是读写一个外部传感器上电时序差了几毫秒读回来的寄存器值可能就是错的PCB布线导致的信号串扰可能让你的SPI通信在某个温度区间频繁出错。这类问题最迷惑人的地方是代码放在ARM仿真器里看完全正常变量值也都对但实际运行就是不对。因为仿真器本身也参与了时序它会改变程序的执行速度甚至掩盖竞争问题。我习惯把问题先分成两类一类是“纯逻辑型”另一类是“硬件相关型”。前者靠代码审查和调试器解决后者往往需要示波器、逻辑分析仪介入。1.3 资源有限没有标准库兜底出错方式更原始纯软件出Bug多数是空指针、数组越界程序会立即崩溃或者抛出异常。但嵌入式里MCU的RAM可能只有几KB到几十KB你写错一个数组下标越界的数据可能恰好落在某个全局变量上程序不崩、不报错只是那个变量的值变得很诡异。更麻烦的是栈溢出。嵌入式里栈空间是固定分配的递归调用不小心深了一层或者某个函数里声明了一个稍微大点的局部数组就可能把栈顶踩到堆区或全局变量区。这时候程序的行为会变得毫无逻辑甚至会产生一种“负负得正”的效果你试图修复一个Bug反而触发了另一个隐藏问题。理解这三类根源你在选用排查手段时心里就有数了中断并发类的问题要理清时序硬件相关类的问题要借助仪器资源类的问题要想办法量化空间使用。2. 日志输出与串口打印朴素但最靠得住的手段很多人觉得日志法太土不如调试器高级。但在真实项目里日志法往往是效率最高的第一手段尤其是在现场设备、或者无法连接调试器的场景下它甚至是唯一手段。2.1 怎么设计一套“够用”的日志系统我见过不少人做日志就是随手printf一串字符唉打出来的东西除了自己没人看得懂也没有时间戳信息根本对不上。我的建议是哪怕时间紧迫也至少做三件事第一给日志分级。用ERROR、WARN、INFO、DEBUG这四级就够了。平时跑INFO级别查问题的时候打开DEBUG。所有日志输出都包一层宏比如LOG_DEBUG(fmt, ...)这样将来想统一加时间戳、想重定向输出通道只需改这一个宏。第二带上模块标识。一个项目里肯定有通信、显示、存储、控制等多个模块日志前缀里用[UART]、[FLASH]、[MOTOR]这种标签grep的时候能省一半时间。第三尽可能带时间戳或者计数值。嵌入式里不一定要精确到毫秒的RTC时间你可以用一个32位的毫秒计数器每1ms中断累加一次日志里打印这个值就能知道两条日志之间的时间间隔这对分析时序问题极其重要。我推荐直接在UART上输出波特率用115200或更高接一个USB转串口工具就能看。如果UART口不够用也可以用半双工的方式或者复用某个调试专用引脚做单线输出。2.2 埋点不能瞎埋要有“叙事线”日志打印最关键的是埋点位置。好的日志像一条叙事线你在状态机切换的地方打一条、在关键数据解析完打一条、在错误分支打一条配合时间戳基本就能还原出程序执行的完整路径。我经常用的一个技巧是“配对打印”。比如进中断的时候打印[INT] enter出中断的时候打印[INT] exit中间如果卡死了你就能在日志里看到只有enter没有exit。这个方法排查死锁、卡死在某个循环里的问题特别好用。另一个技巧是“循环计数打印”不要在循环里每条都打印那样日志量太大会严重拖慢程序而是用计数器累加每1000次打印一次当前计数你就能知道这个循环到底在跑、还是卡住了。我踩过一个坑调试一个通信丢包问题我在中断服务函数里直接调用了日志输出函数。结果不仅丢包更严重了程序还频繁死机。原因是串口发送是一个阻塞过程在中断里做重活会把整个系统拖死甚至引发中断嵌套风暴。所以在中断里打日志最好只设置一个标志位把实际的打印放到主循环里处理。2.3 日志法的天然局限时间与空间的代价日志法最怕两件事一是日志太多导致程序运行速度改变某些偶发Bug反而被“修好”了这种情况有个经典说法叫“加了打印就好了但不知道为什么”——其实是因为打印函数消耗的几十微秒改变了时序窗口二是缓冲区溢出如果用一个环形缓冲区攒日志缓冲区大小不合适要么丢日志要么越界覆盖其他内存。我的实践是生产版本里保留ERROR和WARN级别的输出把INFO和DEBUG全部关掉调试版本里全部打开。这样既能保证线上问题有迹可循又不会因为日志改变程序行为。另外日志字符串尽量用flash常量存储别在栈上构造大字符串否则会挤压本就不富裕的RAM。3. 断点调试与在线仿真用好调试器而不是靠运气日志法是“事后看记录”调试器则是“实时看现场”。嵌入式调试器配合IDE可以做到打断点、单步执行、查看变量、查看外设寄存器非常好用。但我观察到一个现象很多人只是把调试器当“跑起来看看变量值”的工具断点乱打单步乱按效率很低。3.1 硬件断点、软件断点与数据观察点各自用来干什么断点分硬件断点和软件断点。硬件断点是CPU内部调试单元实现的数量有限一般MCU提供4到8个但是可以在任何存储器地址上打断包括flash里的代码不改变程序内容。软件断点则是把指令替换成一条调试指令数量可以很多但只能设在可写的存储器上对运行在flash里的代码IDE通常也会借助硬件调试单元以某种方式实现。数据观察点是我特别推荐的一个功能很多人忽视它。你可以设置某个变量地址当这个变量被写入时CPU就会停下来。这在排查“全局变量莫名被改”这类问题时是神器。我曾经遇到一个变量总是偶发变成异常值怀疑是某个指针越界写到了它但找不到是谁干的。用数据观察点监视这个变量的地址断下来后看调用栈立刻定位到是一个数组越界写入。如果要靠肉眼审查代码那可能得花好几天。3.2 条件断点与调试效率嵌入式里很多Bug不是每执行一次就触发而是满足特定条件才会出现。比如循环到第1528次时才出错。这时候手动按F5继续1257次显然不现实可以设置条件断点条件写成i 1528命中后自动停下。不过要注意条件断点会明显拖慢执行速度因为每次经过断点调试器都要停下来评估条件。如果断点在一个高频执行路径上程序可能会慢到“看起来像死机”。我的习惯是先用普通断点确认路径确实会被执行再改成条件断点条件尽量用简单表达式不要用函数调用。3.3 实时操作系统里的调试别被任务切换搞晕如果一个项目跑了实时操作系统调试起来就更有挑战性了。你停在断点处看到的当前栈可能只是一个空闲任务根本看不到你关心的那个任务的数据。很多调试器有实时操作系统感知功能能在任务列表里切换查看每个任务的栈和变量甚至可以设置某个任务内的断点只有该任务执行到时才停。但我也要提醒一句即使调试器很强大也不要指望它在所有场景下都靠谱。实时操作系统场景下某些断点会改变系统调度行为导致原本出现的Bug消失。这时候我会毫不犹豫切换到日志法或者用下面要讲到的硬件仪器。3.4 调试器失效的常见场景与对策调试器不是万能的。有几个场景它会失效低功耗模式下CPU睡眠时调试单元也跟着断电你没法打断点看门狗超时复位可能在你刚停下时就把系统复位了调试器断点根本站不住某些量产板没有引出调试接口或者产品外壳封装后无法连接调试器。针对这几个场景我一般这么处理低功耗调试把看门狗暂时关闭或者设置调试期间冻结看门狗很多MCU调试单元都提供这个选项看门狗复位问题先弄清楚是不是代码长时间停在断点导致看门狗饿了如果是可以先用日志法量产板没调试接口那只能在开发阶段留好测试点或者在代码里预埋调试逻辑。4. 示波器与逻辑分析仪把Bug“看”出来如果说调试器是软件医生的听诊器那示波器和逻辑分析仪就是嵌入式开发者的眼睛。很多Bug在代码层面根本看不出端倪但只要把探头往某个引脚上一搭波形异常一目了然。4.1 什么时候该放下代码去拿仪器我的判断标准很简单当你怀疑问题跟时间有关的时候就该上仪器了。举几个典型例子I2C通信偶发失败你想确认SCL和SDA上的时序是否符合规范逻辑分析仪解码一看就能看到“应答位丢了”“时序毛刺”UART偶发乱码示波器抓一下波形发现某个字节的起始位变窄了很可能是波特率偏差或者外部干扰PWM输出偶尔跳变看波形就能看到不该出现的窄脉冲。还有一类特别隐蔽的问题叫“信号完整性问题”。比如某个GPIO读到的电平不稳定代码里明明拉高了实际却读到低电平。用示波器一测发现引脚上有很大的振铃高电平其实只维持了很短的纳秒级时间MCU采样时正好采到了跌落的部分。这种情况代码再怎么看也看不出来。4.2 逻辑分析仪和示波器的分工别用错工具这两个仪器很多人分不清。我的理解是示波器主要看模拟波形看电压高低、上升下降沿、噪声和毛刺适合分析电源质量、信号完整性、时序细节逻辑分析仪主要看数字时序采样通道多可以做协议解码适合抓SPI、I2C、UART这类数字通信波形。举个例子你怀疑一个外设的中断请求引脚有时序问题想确认MCU是不是错过了某个低脉冲。用示波器看能看清脉冲宽度和电压但如果这个脉冲只偶尔出现一次你得开很大的时基去等它用逻辑分析仪可以长时间连续采样触发条件设为“下降沿”抓到后再缩小到异常附近分析。我在排查某个传感器读取问题时就是用逻辑分析仪抓的SPI总线发现其中一个片选信号在某次传输完成后没有拉高导致SPI从机以为传输还没结束后续数据就全错了。这个片选信号是由MCU的GPIO控制的代码审查看不出来问题但波形一眼就暴露了。4.3 实用建议不一定买贵的很多新手觉得示波器要买几万块的其实入门阶段一台几百兆带宽的国产示波器就足够用了。逻辑分析仪市面上也有很多便宜的USB方案配上配套上位机软件协议解码功能都挺全。但有一点我劝你别省探头。好的探头和劣质探头测出来的波形差异非常大尤其是高频信号。劣质探头的寄生电容甚至会改变被测电路的行为。还有一个使用技巧测量时探头地线尽量用短弹簧地不要用那根长长的鳄鱼夹地线否则会引入很大噪声。我见过有人用长地线测电源纹波测出来的全是假信号误导了好几天。5. 二分定位与静态代码审查不动硬件也能缩范围有些Bug你不方便用仪器也没法一直挂着调试器比如问题在客户现场或者复现概率极低。这时候就得靠“思维方法”来缩小范围。二分法和代码审查是两大法宝。5.1 二分定位法像查字典一样找Bug二分法的思路很简单一个功能链路由很多环节组成你不确定哪个环节出问题就从中间掐断。举个例子某个设备升级固件后无法连网。调用链大致是应用层调用接口、协议栈组包、驱动发送、外设响应。我先在应用层接口处加一个日志发现调用正常再在驱动发送前加一个日志发现根本没走到这里。那问题就锁定在协议栈组包这一层。然后我再在这一层内部细化排查。这个方法看起来简单但实操中有个关键技巧不要在一条路径上重新跑完整流程验证每个点那样太慢而是找出最可能出错、信息量最大的节点先测。我曾经排查过一个随机死机问题现象是运行几小时到几天不等。这种随机性问题没法用二分法直接跑因为一次验证周期太长。我的做法是先根据死机时保存的现场信息比如看门狗复位原因寄存器、栈指针值做初步判断把怀疑范围缩小到某一两个中断处理函数再针对这两个函数做插桩统计。5.2 编译器告警与静态分析让工具当第一道防线排查Bug最理想的时机是Bug还没诞生的时候。GCC的-Wall -Wextra能发现很多隐患比如变量未初始化、有符号数比较、函数声明不匹配。我见过有人因为数组下标是uint8_t类型循环到255后又回到0导致越界访问这类问题编译器不一定能报但加上合适的警告选项多少能提醒一下。除编译器告警之外静态分析工具也值得引入。它们能发现空指针判空缺失、资源泄漏、表达式优先级问题等编译器不容易发现的隐患。虽然静态分析会有误报需要人工确认但在代码量稍大的项目里它能提前筛掉一批低级错误。有一回我跑静态分析它报了一个“数组越界可能”的告警我看了一眼觉得那个分支不可能走到忽略它了。后来线上出了Bug排查到最终还是那个位置只是触发条件更隐蔽。从那以后我对静态分析的每条告警都会认真对待哪怕确认是误报也要弄清楚为什么误报。5.3 代码审查重点看这几类问题让同事帮你review代码永远比自己反复看效率高。但我发现真正有效的代码审查不是从头到尾通读而是带着问题去查。我审查别人代码时重点看三类问题第一类是中断和并发相关的查共享变量有没有加保护中断里有没有做耗时操作第二类是资源相关的查内存分配有没有释放、文件句柄有没有关闭、DMA缓冲区有没有重叠第三类是边界条件查循环结束条件、数组大小、通信解析时的帧长度校验。有一次我帮某开发者排查USB枚举不稳定的问题我review代码时发现他用了同一个全局缓冲区既做接收DMA目标又做发送源。正常情况下收发不同时能跑但一旦某个瞬时时序到来收发的DMA同时访问这个缓冲区数据就被互相覆盖了。这种问题靠调试器很难看因为不是每次都必现。6. 一次顽固Bug的完整排查复盘中断标志位的“幽灵”讲了这么多方法我挑一个曾经让我折腾了很久的真实案例完整复盘一遍。这个问题不算特别高端但非常典型几乎把前面说的方法都用上了。6.1 现象与初步判断当时我在调试某个采用实时操作系统的物联网网关设备。现象是设备运行几小时后网络连接会突然断开之后无法自动重连必须手动复位才能恢复。而且这个现象不是每次都有有时候一天都不出现有时候早上一次下午一次。我最初的判断倾向于协议栈崩溃或者任务栈溢出。因为现象是“运行一段时间后功能失效”很像是资源耗尽或栈被踩。6.2 逐层排查过程我先用日志法在关键模块加上带时间戳的日志尤其是网络重连流程和任务状态切换点。跑了一天日志显示某个任务的栈使用率在异常升高于是我把栈空间调大了一点但问题依旧。接着我用调试器挂上发现当故障出现时是一个网络事件任务卡在了一个信号量等待上始终没有等到网络服务任务的释放。这看起来像死锁。我审查了两个任务之间的信号量交互逻辑上是正确的没有发现明显的先释放后等待或互相等待。我又打开逻辑分析仪抓了一下模组的电源和复位引脚发现故障前没有任何异常波动电源也平稳。那基本排除硬件故障。此时我重新梳理日志发现一个细节每次故障出现前都会有一条外部中断日志而且这条日志出现的时刻正好和信号量没有被释放的时间点吻合。这个外部中断来自一个传感器接口按设计应该和网络任务没有任何关系为什么它的中断会导致网络任务卡住我逐步追踪发现外部中断服务函数里有一个全局标志位该标志位同时被主循环里的某个状态机读取使用。故障出现时外部中断触发太频繁导致标志位在极短时间内被反复置位和清除而网络任务恰好也要读取这个标志位作为某个判断条件。由于没有临界区保护标志位的更新和读取存在竞态导致网络任务读到错误值后跳出了正常流程再也没有被唤醒。6.3 根因与教训根因找到了一个跨模块共享的全局标志位既被中断修改又被多个任务读取没有做原子性保护。更关键的是这个标志位的语义边界极其模糊——它被复用了既表示“外部事件发生”又表示“状态机允许跳转”两个语义叠加在一起只要时序稍微不对就会产生逻辑悖论。这件事给我留下的教训特别深。第一全局变量是嵌入式Bug的头号温床能不共享就不共享必须共享就加保护哪怕是简单的关中断操作第二排查问题时不要过早笃定一个方向我一度认为是协议栈或者栈溢出浪费了大量时间第三日志法配合逻辑分析仪是我在这次排查里效率最高的组合——日志帮我锁定了时间关联逻辑分析仪帮我排除硬件干扰调试器则帮我看到了最终的等待状态。那个外部中断后来改成只在中断里置位专用标志业务判断放到主循环里单独处理并加上临界区保护。修复后再也没有复现。7. 排查工具与手段的选择思路先快后准先软后硬最后分享一点我个人总结的选择思路也算是我自己平时排查Bug的行动指南。很多人拿到一个Bug就喜欢开调试器一顿断点或者直接拆开外壳准备测波形其实都不对。我倾向于先用最便宜的手段去缩小范围只在必要的时候动用高成本手段。优先用日志和代码审查因为它们不需要额外硬件能快速定位到大概范围如果日志显示问题跟时间强相关再上逻辑分析仪和示波器如果问题只出现在某种运行模式下且方便连接调试器就用断点和数据观察点如果现场不能接调试器就把日志系统做好配合远程上传日志也能排查大部分问题。另外我特别鼓励大家给每个难缠的Bug写一份简短复盘文档。把现象、排查过程、用到的工具、最终根因写下来。这不是为了给谁看而是因为在嵌入式领域Bug的踩法往往高度相似下次遇到类似问题时这份文档能帮你直接跳到正确方向省下几天时间。我在实际排查中经常是回头翻自己以前写的复盘笔记然后发现“这一幕我见过”。排查Bug说到底是一门手艺活方法的骨架大家都懂真正的差异在于经验、耐心和找对切入点的直觉。希望这篇文章里提到的几种方法能帮你在下次面对顽固Bug时多几条思路少走几个弯路。
阅读完成 · 觉得有帮助?