干嵌入式的人八成都有过这种经历程序跑着跑着就死了看代码感觉哪哪儿都对一上电就不对或者Bug一个星期才出现一次出现一次就让你调三天。更气人的是很多时候你连个屏幕都没有只有一块目标板和一个不太好用的调试器。这种时候能不能快速定位问题靠的不是运气而是有没有一套成体系的排查思路。我做了十来年嵌入式从8位单片机一路折腾到Cortex-A级别的高端平台踩过的坑不敢说比谁都多但也确实趟出了几条管用的路子。这篇东西不聊理论就说实操把我在实际项目里用过的排查Bug的方法、工具组合、以及一些常规文档里不会写的经验一次说清楚。无论是刚入行的新手还是被某个疑难杂症折磨得头疼的老手这篇文章都值得花几分钟扫一遍也许能给你提供一条新思路。1. 日志输出法最朴素的排查手段也有门道日志是所有嵌入式调试手段里面门槛最低、但也最容易翻车的一种方式。说它门槛低是因为一个串口、一根杜邦线、一个串口助手就能干活说它容易翻车是因为你用不好它日志本身就会变成新的Bug来源。1.1 输出渠道怎么选串口、环形缓冲、非易失存储很多人一上来就喜欢在代码里到处加printf然后连上串口看输出。说实话在开发调试阶段这没什么问题串口也确实是最快的反馈通道。但你要考虑两个事一是你的串口波特率能不能扛住你的打印频率二是中断里能不能直接调printf。我见过不止一个项目为了图省事在定时器中断里直接打印结果中断触发频率一高打印还没发完下一次中断又来了。轻则日志乱码重则直接卡死。正确做法是中断里只把数据扔进环形缓冲区主循环或者低优先级任务里再统一输出。// 简单的环形缓冲区示例 #define LOG_BUF_SIZE 2048 static char log_buf[LOG_BUF_SIZE]; static volatile uint16_t head 0; static volatile uint16_t tail 0; void log_put(char c) { uint16_t next (head 1) % LOG_BUF_SIZE; if (next ! tail) { // 满则丢弃或按需处理 log_buf[head] c; head next; } } void log_task(void) { while (tail ! head) { uart_send_byte(log_buf[tail]); tail (tail 1) % LOG_BUF_SIZE; } }有些场景连串口都没有比如纯电池供电的便携设备或者产品已经封壳了调试口根本没引出来。这时候就要考虑把日志写到非易失存储里比如外挂的SPI Flash或者SD卡。我做过一个某可穿戴项目产品交给测试部门跑整机老化出了问题拿回来一看Flash里就留着最后几分钟的运行记录靠那点信息直接锁定了是某个传感器驱动在低功耗唤醒后没有重新初始化。这种手段在量产阶段特别有用值得提前在固件里预留。1.2 日志的分级、格式与触发开关日志不是印得越多越好。日志印得太多有用的信息会被淹死印得太少出了问题什么都看不到。我比较习惯的做法是分四级ERROR、WARN、INFO、DEBUG然后用一个编译期宏或者全局开关控制编译等级。#define LOG_LEVEL LOG_LEVEL_INFO #if LOG_LEVEL LOG_LEVEL_DEBUG #define LOG_DEBUG(...) do{ log_printf(D, __VA_ARGS__); }while(0) #else #define LOG_DEBUG(...) do{}while(0) #endif调高等级的时候所有调试信息都出来发布版本直接把DEBUG级日志裁掉只保留ERROR和WARN这样既不影响性能出问题了也还有线索可查。日志格式同样别轻视。我在项目里会统一要求格式里带时间戳、模块名、函数名比如[12345ms][I][DRV_I2C] write reg 0x3A val 0x01 OK时间戳对排查时序类问题特别关键你看到两行日志相差了100ms但代码逻辑上两个操作之间应该只有1ms这就说明中间发生了你没预料到的事顺着这个点往下查往往能揪出大问题。1.3 我踩过的日志坑伪中断、休眠日志丢失、printf重定向日志排错看着简单但有几个坑我都是实实在在踩过的。第一个坑是在中断服务函数里做复杂日志格式化。printf这种函数走的是可变参数内部逻辑一堆还有可能申请堆内存放在中断里轻则破坏实时性重则死锁。我手里的原则是中断里最多丢几个字节进环形缓冲格式化的事交给上下文环境去办。第二个坑是休眠唤醒瞬间日志丢失。有些低功耗设备平时睡得好好的你一加日志它每次唤醒都多耗几百毫秒打印电流曲线面目全非甚至影响外部看门狗。这种情况我建议在休眠前后把日志输出彻底关断只在唤醒后第一行打一条记号别在敏感时序里掺沙子。第三个坑是重定向printf之后无限递归把自己搞死。有些IDE或者SDK会让你重写_write或者fputc往串口送数据如果你在里面不小心又调用了printf就直接栈溢出了。重定向函数里只做最干净的底层驱动调用别的什么都别干。2. 调试器断点法把程序冻结在案发现场如果说日志是事后翻证据那调试器就是现场抓现行。2.1 硬件调试器选型与接线常识嵌入式调试器千奇百怪但主流基本就几个CMSIS-DAP便宜好用很多开发板自带、J-Link功能强速度快、ST-LinkST芯片标配、以及各芯片厂商自己出的调试器。选型主要看目标芯片架构比如Cortex-M内核的芯片基本都能用CMSIS-DAP/J-Link有些RISC-V芯片就只能用专用的调试器先查清楚再买别买回来发现不支持。接线这块SWD模式只需要四根线SWCLK、SWDIO、GND如果目标板供电不稳最好再拉一根VCC过来做参考电平。我见过非常多的初学者因为只接了SWDIO和SWCLK没接GND导致调试器一连接就掉线。GND是参考地不接它信号电平就是悬空的连接自然不稳定。2.2 断点、Watchpoint、条件断点的真实用法断点人人会用但会用和用得巧是两回事。普通断点适合定位程序是不是走到这里了。比如怀疑某个if分支没执行就直接在分支里下断点跑起来看命中不命中。但这个只能看到没到看不了变量变化。硬件Watchpoint这个非常强它能在某个变量被改写时停下来不用你手动去猜哪一行代码改了它。GDB里用watch命令比如watch g_state一旦g_state的值发生变化CPU立刻暂停。排查那种某个全局变量莫名其妙被改了的问题这个比断点好用十倍。条件断点比如你只想在循环执行到100次的时候停普通断点会停100次烦死条件断点直接写成break main.c:128 if count 100省时间。注意这个如果条件判断太复杂调试器会明显拖慢运行速度在时序敏感的地方慎用。2.3 调试器的局限时序敏感场景为何失灵说句得罪人的话凡是跟严格时序相关的Bug调试器基本帮不上忙反而会帮倒忙。原因很简单断点一命中CPU就暂停了外设还在跑。你暂停的时候定时器还在计数DMA还在搬运中断标志还在置位。你看到的那一刻已经不是Bug现场的原始状态了。我之前处理过一个I2C通信偶发超时的Bug加断点跑半天也不触发去掉断点裸奔几分钟就复现。后来想明白了断点让I2C时序整体变慢恰好避开了那个边界条件。所以调试器适合排查逻辑型Bug比如状态机跳错、指针指飞、数组越界但像时序竞争、外部信号毛刺这类问题别在调试器上死磕换下一章的方法。3. 信号级测量法看不见的数据要先看得见嵌入式系统一半的Bug藏在代码里另一半藏在硬件信号里。代码写得再对引脚时序不对、电平不稳、波形太差照样功能异常。这种时候示波器和逻辑分析仪才是你的主角。3.1 示波器查毛刺、时序违例、上下拉问题示波器最常用的几个场景查毛刺。比如一个按键中断偶尔触发两次代码里加了防抖还是偶发这时你可以用示波器勾住按键引脚把触发电平设成中间阈值捕捉正常波形里夹带的毛刺。如果看到引脚上有几十纳秒的窄脉冲就要考虑是不是IO口上下拉配置不对、板子走线串扰或者ESD防护器件问题。查上升沿/下降沿时间。I2C、UART这类协议对边沿速率有要求如果上拉电阻选得太大上升沿会变缓距离远了就识别不了。示波器一测上升沿如果是几十微秒级别肯定有问题正常应该在纳秒级。查时序违例。比如你的SPI设备要求片选拉低后至少等100ns再送时钟如果代码写得不讲究片选刚拉低就发数据有些敏感设备就会收错。示波器可以直接量到片选到时钟第一个边沿的时间差是不是符合手册要求一目了然。3.2 逻辑分析仪解协议问题我如何定位一次I2C死锁示波器看模拟信号在行但要解协议内容逻辑分析仪更香。它可以直接把I2C总线上的原始波形解码成地址、寄存器、数据值不必对着波形一个个数时钟数高低电平。我有一次调某传感器驱动I2C读寄存器偶尔返回全0xFF代码里反复检查时序感觉没问题。后来把逻辑分析仪挂到SDA和SCL上抓了一次完整通信发现设备在NACK之后总线被拉低不放主控端等了半天也没有等到停止条件。再仔细看驱动代码原来是在某条错误分支里漏发了STOP信号导致总线被锁死。这种问题靠眼睛读波形得读一个小时逻辑分析仪几秒钟就给你解码出来了。3.3 触发设置抓住偶发异常的关键偶发问题的排查触发设置是核心。以逻辑分析仪为例你要抓一个偶发出现的时钟缺失可以设成下降沿触发加超时判定。再比如你想抓本来应该发生的通信没发生可以用通讯起始条件做触发。触发条件设置不好你等一天也抓不到那一次异常设置对了几分钟就能抓到。我个人的习惯是抓偶发问题采样率至少是信号频率的4倍以上采样深度尽量开满最好能把触发前的一段数据也存下来pre-trigger这样你能看到异常发生前总线上发生了什么这对于判断因果链至关重要。4. 二分定位法与代码走读没有工具时的老办法也管用调试器和示波器确实好用但有些时候你手头就是什么工具都没有或者Bug的复现条件非常苛刻插个调试器就复现不了。这时候回到基本功反而更高效。4.1 丢手雷式二分法快速锁定问题模块二分法是排查未知问题最经典的策略。核心思路先把能砍的功能全部砍掉看问题还在不在如果还在就砍掉一半代码如果不在就加回一半。反复缩小范围直到锁定到某个模块甚至某一行。举个例子某个系统通电后指示灯不亮。你先用串口打印确认主循环跑没跑跑说明电源和时钟大概率没问题再看系统初始化走到哪一步如果初始化函数打完第一行后指示灯任务就不执行了说明卡在初始化里再把初始化代码里外设初始化的几个函数逐个注释总能找到是哪一行让系统跑飞。这个套路听着笨但在代码不熟、文档缺失的遗留项目里比什么高级工具都管用。4.2 代码走读怎么走才有效代码走读听起来像老古董但在排查逻辑复杂的状态机、消息交互类Bug时效果出奇的好。关键是怎么走。我自己走读时的习惯是拿一支笔一张纸把每次函数调用的入参、出参、静态变量、全局变量变化都记录下来。不走不知道一走吓一跳——很多Bug其实就是某个全局变量在一个函数里被改了另一个函数还在拿旧值当判断依据。比如一个状态变量你在定时器中断里被清零了但主循环里还拿它的旧状态做分支判断程序行为就会变得非常诡异。走读的时候重点盯几个位置所有静态变量和全局变量的赋值点、中断与主循环共享的数据、回调函数里的指针参数。往往问题就藏在跨模块的数据交互上。4.3 Git历史比对旧版本没Bug新版本有Bug怎么办还有一种非常常见的情况昨天还跑得好好的今天改了几行代码功能就坏了。这种情况别急着在代码里翻先去看Git提交历史把昨天到今天改动过的文件和代码全部过一遍。我处理过一个问题某加密模块突然反复复位查了半天没头绪后来翻Git记录发现同事今天上午顺便改了一个延时函数的参数把原本的10ms改成1ms下游模块因为时序没跟上状态机直接崩了。如果没有版本比对这种问题靠自己从头看代码可能要看半天。所以我的建议是每次改动尽量做到一次提交一个逻辑不要把无关的改动混在一起不然出了问题版本比对都是灾难。5. 编译器告警、静态检查与内存检测把隐患拦在运行之前与其在Bug出现后绞尽脑汁地排查不如在编译阶段就把隐患消灭掉。这一章聊聊编译器告警、静态分析和内存类的运行时检测。5.1 告警全开与常用静态检查工具我在自己写的工程里会把编译器告警等级调到最高并且把警告当成错误来处理。在GCC里面核心参数大概是这样的gcc -Wall -Wextra -Werror -Wshadow -Wpointer-arith \ -Wstrict-prototypes -Wmissing-prototypes-Werror这条尤其重要。平时你觉得这个警告无所谓不影响运行可很多Bug的苗头就是从一个警告开始的。比如未初始化的变量、隐式类型转换、函数声明不匹配真到出问题的时候你再回头看这些警告往往会拍大腿。除了编译器告警静态检查工具也建议跑一跑。开源领域比较常用的是cppcheck用法很简单cppcheck --enableall --inconclusive --stdc99 src/它能查出空指针解引用、数组越界、资源泄漏、逻辑错误等一大堆问题。嵌入式代码也可以配上-I参数把芯片厂商的通用库头文件加进去减少误报。这类工具有它的误报率但扫描出来的每一条都值得认真看一眼——至少有一次它帮我提前发现了一个只在特定优化等级下才出现的数组越界写省了我半夜调板子的功夫。5.2 内存问题栈溢出、越界、野指针的排查思路嵌入式里最经典、最恶心的问题非内存问题莫属。它们往往不像逻辑错误那样每次稳定复现而是时不时抽风一次而且表现千奇百怪——有可能变量值突然被改有可能是函数返回地址被破坏导致跑飞也有可能是栈溢出导致系统不断复位。排查内存问题我有几个固定的思路看栈大小嵌入式设备栈空间往往只有几KB。如果你的函数里定义了大的局部数组或者递归层级太深栈很容易就爆了。最快的排查方式在启动文件里把栈区初始化为一个固定模式比如0xAA然后在系统跑一段时间后查看栈区末尾还有多少字节没有被改写。如果栈顶区域已经被污染说明栈不够用加大栈或者优化调用深度。看数组越界某些芯片或者SDK支持内存保护单元MPU你可以给关键数组所在的区域设置MPU访问权限一越界就触发HardFault直接告诉你哪一行出了问题。没有MPU的芯片可以在数组前后填充金丝雀值然后在程序主循环里检查金丝雀值有没有被动过。看野指针野指针的问题排查起来更隐蔽。我常用的手段是在指针使用前后打印指针值配合日志对比看指针是什么时候从正常地址变成了0xDEADBEEF或者某个明显非法的值。如果条件允许在释放内存或者删除对象前把对应指针显式置为NULL比什么都不做强得多。5.3 动态插桩与断言的最佳实践断言assert在嵌入式里很容易被忽略但它其实是一个非常强大的辅助排查工具。我建议在固件里大量使用断言来检查程序不变量。比如某个模块要求调用前必须关中断你就可以在函数入口加一个断言检查中断状态比如某个全局变量要求永远在0到100之间那每次写入后都检查一下再继续。#define ASSERT(cond) do{ \ if (!(cond)) { \ log_printf(ASSERT FAIL: %s:%d %s\r\n, __FILE__, __LINE__, #cond); \ soft_reset(); \ } \ }while(0)断言的好处在于它能将深藏的逻辑错误在早期就暴露出来而不是等到系统跑到某个不可预知的位置才崩溃。但注意事项也很明确发布版里一定要把断言关掉或者降级为日志记录不然客户现场一触发断言整个系统复位损失远大于Bug本身。6. 疑难杂症排查中断、DMA、并发、硬件跑飞下面聊聊几种嵌入式特有的、难度比较高的Bug类型。这些类型在普通软件开发中几乎遇不到但在嵌入式里属于老兵绕不开的坎。6.1 中断丢失、误触发的排查中断问题的表现千奇百怪一会儿是中断没反应一会儿是中断连续触发好几次。排查思路我总结为三步第一步确认中断确实被触发。用最简单的办法在中断处理函数的第一行置一个GPIO高电平示波器看这个引脚有没有脉冲。如果没有说明中断信号本身没到CPU问题在外设配置或硬件连接上如果有说明中断确实进来了问题在中断处理逻辑或中断标志清除上。第二步检查中断标志的清除顺序。不少外设中断标志在读取后还需要软件主动清零。如果你忘记清除中断服务函数退出后同一条中断会再次进入表现为中断响应了多次。这个在项目里我遇到过不止一次尤其是新接手别人代码的时候一定要看中断服务函数末尾有没有清标志。第三步检查中断优先级与临界区。如果中断A的处理时间很长而中断B在此期间来了很多次B的挂起位可能被保留也可能因为寄存器太浅而丢失。这种情况下要么合理地分配优先级要么把A里的事务处理移到主循环中做中断里只做最紧急的收尾。6.2 DMA与CPU的缓冲一致性一个非常隐蔽的坑现在的Cortex-M芯片内置Cache已经很常见了。CPU通过Cache读写内存而DMA是绕过Cache直接访问物理内存的。这就导致一个问题CPU写了一份数据到内存还没Cache回写DMA就开始搬运搬的可能是旧数据——搬完了CPU还在Cache里读读到的也可能是旧数据。这种问题最典型的排查手段是在DMA搬运前后加内存屏障和Cache操作。比如ARM架构下__DSB()和SCB_CleanDCache_by_Addr()之类的操作可以保证Cache数据同步到物理内存。我调过的一个某图像采集项目摄像头DMA搬过来的图像偶尔出现条纹后来查明是CPU的Cache还没把缓冲区里的配置数据回写到内存DMA已经开始按旧配置工作了。加上Cache清理操作之后再也没出现过条纹。调试这类问题还有一个技巧既然DMA直接读物理内存那你可以临时把代码里涉及到的缓冲区变量改成volatile让编译器每次都物理访问内存这样能在开发阶段快速验证是不是Cache一致性问题。6.3 HardFault与程序跑飞如何从现场的尸体上找线索程序跑飞是嵌入式开发最让人崩溃的一个Bug。一上电就死在HardFault_Handler里或者偶尔跑着跑着跳进一个空循环叫天不应叫地不灵。实际上HardFault的现场是能尸检的。以Cortex-M为例进入HardFault后堆栈里保存着被中断时刻的寄存器状态。可以在调试器的寄存器窗口里找到堆栈指针然后把堆栈内容dump出来看里面的返回地址指向哪里、调用栈长什么样。绝大多数调试器都有自动解析调用栈的功能直接看函数调用链就能定位是哪个函数在什么位置出错。如果没有调试器那就用一招土办法在HardFault_Handler里写一段代码把几个关键寄存器的值存到专门的全局变量里然后系统复位重启后通过串口把这些信息打出来。再配合启动文件里的栈帧布局手工解析出当时程序大概在做什么。这个办法虽然麻烦但在现场出问题时是唯一能拿到的尸体证据。7. 排查方法速查表与组合拳怎么打最后把核心的排查方法整理成一张速查表。遇到具体问题的时候对照着选方案能少走不少弯路。现象首选方法备选方案适用场景程序逻辑错乱代码走读 断点调试Git历史比对状态机跳错、条件分支错误偶发复位HardFault现场解析看门狗日志内存越界、栈溢出变量莫名被改硬件Watchpoint金丝雀值填充野指针、数组越界外设通信失败逻辑分析仪抓波形示波器查时序I2C、SPI、UART协议问题中断行为异常GPIO脉冲 示波器检查标志位清除中断丢失、重复触发低功耗唤醒异常日志分级 触发日志示波器测电流曲线休眠唤醒时序问题性能卡顿记录时间戳日志示波器测任务周期死循环、高优先级任务占用数据被DMA搬错Cache同步检查临时改为volatile验证DMA与CPU缓存一致性问题偶发偶现、极难复现二分法裁剪代码静态检查 告警全开复杂交互、环境依赖问题有了速查表更重要的是知道怎么把多种方法组合起来用。我的经验是排查一个疑难杂症不要只指望一种工具而是要由外到内、层层收紧。先用仪器确认硬件信号正常再用日志缩小到大致模块然后用调试器或者走读锁定具体代码最后用静态检查或者断言排查潜在的深层次问题。三个层面配合着来通常比你在一个层面上死磕高效得多。比如说一个定时器导致的周期性串口乱码问题。先拿示波器看串口引脚波形如果帧波形本身是好的说明波特率没问题再看日志打印的时间戳发现每次乱码都出现在某个中断被触发的前后怀疑是中断抢占导致打印被打了断再把中断处理函数里的打印挪到环形缓冲里问题消失。整个排查过程可能就半小时但如果一上来就去读代码可能半天也找不到那行在中断里打印的代码。最后说点个人体会每次带新人做嵌入式项目我都强调一句话排查Bug最重要的不是工具而是思路。你手里的示波器再贵、调试器再高级如果没有一个清晰的排查路径一样会在海量的信息里迷失方向。这些年我自己最大的收获是养成了一种怀疑一切的习惯——代码怀疑、配置怀疑、硬件怀疑、工具怀疑。很多时候你信誓旦旦认为是A的问题折腾了半天最后发现罪魁祸首其实是B。所以遇到快把自己逼疯的疑难杂症不妨慢下来把已知信息全部列出来画张因果图再决定从哪条线查起。这个思路值得多说一句排查Bug时不要急着动手改代码。先花几分钟把问题描述清楚把现象写下来把怀疑点排序。往往在写描述的过程中就能发现自己之前忽略的某个细节Bug的答案也就在那个细节里。希望这份排查指南对你有用。嵌入式这条路坑多但每踩过一个坑功力就涨一分。