资讯动态

嵌入式Bug排查实战:从偶发复位到内存越界的定位方法

发布时间:2026/10/23 5:32:38 来源:尧图企业网站定制
1. 先想清楚再动手嵌入式Bug排查的两条底层原则排查Bug这事尤其是嵌入式软件开发里的Bug很多人第一反应是赶紧打日志、赶紧上调试器、赶紧改代码试试。我年轻时也这样后来吃了不少亏才明白动手越早往往浪费的时间越多。真正要做的第一件事是把问题想清楚。嵌入式开发最让人头疼的不是Bug本身有多难而是它的表现方式和PC端完全不一样。一个变量写飞了可能当场崩溃也可能三个小时后才在某次中断里爆炸一个栈溢出可能在你的开发板上永远不出现跑到产线上却随机复位。所以拿到一个Bug先别急着查代码先回答三个问题第一这个Bug是必现、大概率出现、还是偶尔出现这决定了你用哪种手段。必现的Bug调试器单步追就行偶发的Bug你要先想办法把它变成必现的否则后面所有手段都是瞎猜。第二这个Bug的表现属于哪一类我一般把它分成三种系统复位看门狗咬、硬件异常、任务卡死死循环、死锁、逻辑错乱输出不对但系统还在跑。三种类型的排查思路完全不同。复位类问题优先查内存越界、栈溢出、硬件干扰卡死类优先查互斥锁、等待条件、中断丢失逻辑错乱优先查状态机、数据结构、变量被意外修改。第三这个Bug最近改动过什么不要跟我说我最近没改这块事实上绝大多数Bug都是改出来的。我见过一个项目功能一直正常某次为了加一个日志功能动了一个全局变量的初始化位置结果整个系统的行为都变了当时谁也没往这个方向上想因为日志功能怎么可能会影响业务逻辑。这三问想清楚之后才进入真正的排查环节。而且在整个排查过程中有一条铁律必须遵守一次只改一个变量。很多人排查Bug的习惯是先怀疑A改一下同时怀疑B也改一下C好像也有问题顺手加个打印。改完一轮问题消失了但你说不清楚是哪个改动让它消失的。这种歪打正着式修复在嵌入式项目里特别危险因为它复现条件不稳定你根本不知道修的是病因还是病状。我现在的习惯是排查时准备一个文档把每一次实验的假设、改动、结果都记录下来。哪怕这个Bug当天就解决了这份记录也值得留因为同一类问题大概率还会在另一个项目里出现。排查Bug不是靠灵光一现是靠排除法而排除法的前提就是一次一个变量。2. 静态手段不运行代码也能砍掉一半Bug很多人一想到排查Bug第一反应是跑起来看但嵌入式软件开发里有一类手段完全不需要运行目标代码成本最低、见效最快那就是静态排查。说句实在话我遇到的Bug里至少三分之一是靠静态手段直接锁定的根本不需要上调试器。2.1 编译告警全开把编译器当第一道防线这条我放在最前面说因为它最容易被忽视。很多项目的编译告警是只开默认级别的甚至有人用IAR、Keil时压根不关心告警窗口里刷了多少黄字。我强烈建议嵌入式项目从头就把编译器告警开到最严格档位并且把关键告警当成错误处理。以GCC为例我常用的编译选项组合是这样-Wall -Wextra -Wshadow -Wpointer-arith -Wcast-qual -Wwrite-strings -Wstrict-prototypes -Wmissing-prototypes -Werrorimplicit-int -Werrorreturn-type -Werroruninitialized前面几个是打开更多告警类别后面几个是把最常见的三种问题直接升级为编译错误。这么做会让编译变烦但非常值得。未初始化变量、函数返回类型错误、隐式声明这类问题早就在编译阶段爆出来你根本不需要费劲去查。实际项目里我在别的模块里遇到过一回来一个全局数组被定义为uint8_t类型但另一个文件里用int类型的外部声明去访问它。这类跨文件的类型不一致在没有开严格告警时完全不出来运行时的表现却是数组后面的变量被莫名其妙改写。开了-Werror之后编译器当场就报错了。这就是典型的编译器比人眼可靠。2.2 二分法剪枝把怀疑范围快速减半还有一个我经常用的静态排查技巧说出来有点像笨办法但实际效率极高就是二分法剪枝。当系统出现一个诡异行为你完全没有头绪不知道该从哪里查起时这个方法能帮你快速把范围缩小。具体做法是把整个软件按模块边界分成前后两半在前半段的末尾加一个临时打印或者对后半段进行条件编译屏蔽#if 0 ... #endif。如果问题消失了说明Bug在后半段如果问题还在说明Bug在前半段。然后对出问题的那一半再切一刀再验证一次。如此循环通常四到五次定位之后就能把嫌疑范围缩小到一两个文件这时候再配合调试器或者日志去精确定位效率会高得多。有人会说这样反复编译、烧录、测试不也浪费时间吗但对那些没有明显规律、靠读代码又读不出来的问题这反而是最省时间的路径。因为人的大脑对几百个文件、几万行代码是有认知极限的你不可能一口一口全啃完。我之前做过一个项目某个外设的中断号不对导致中断服务程序里读取外设状态永远失败。单靠代码走查那条初始化流程我反复看了五遍都没发现问题因为配置寄存器的宏定义分布在三个头文件里非常分散。后来用二分法剪枝从主流程的老后面开始往前切切到外设初始化模块时问题必现然后我盯着那几十行配置代码逐条查宏定义十分钟就发现了问题。2.3 代码审查的盲区与借人借眼最后一个静态手段是代码审查但我要说的不是找同事走查这么简单。我个人的经验是你自己刚写完的代码你带着我写的是对的这个前提去读是读不出问题来的。这几乎是心理学问题不是你能力不足。所以我的习惯是遇到特别诡异的问题时找一个对这块代码不太熟悉的同事向他完整地讲一遍这个Bug的现象、我怀疑的方向、相关的代码逻辑。注意不是让他帮我查而是给他讲一遍。很多人在讲的过程中会突然发现自己之前一直跳过的某个逻辑漏洞。这就是所谓的橡皮鸭调试法只不过橡皮鸭换成了真人。此外代码审查的时候有几个固定重点我每次都查循环边界还是、数组下标会不会越界、状态机转移条件有没有漏掉状态、变量作用域有没有同名变量遮蔽、中断与主循环共享的变量有没有加保护。这几类位置是嵌入式Bug的高发区你单独看业务逻辑的时候不容易发现但有意识地按这个清单过一遍往往能直接命中。3. 动态手段日志、断言与状态追踪三件套静态手段解决的是代码层面有明显漏洞的问题但嵌入式软件里还有一大类Bug只有运行起来才会暴露某些时序条件不满足、中断与主循环竞争、外部信号毛刺导致状态机跳飞。这类问题只能靠动态手段也就是让代码在运行时自己交代状态。3.1 串口日志的设计不是随便printf就完事嵌入式调试里最常见的动态手段是串口打印但很多人的日志打得很随意导致信息量大但信息量低。我见过太多项目日志刷屏刷得飞起到真正出问题时根本找不到关键信息或者打出来的东西看不出先后关系。我的建议是从项目一开始就建立一个简单的日志模块至少要包含三样东西级别、模块标签、时间戳。级别就是常用的DEBUG/INFO/WARN/ERROR模块标签用来区分是哪个子系统打的日志比如SYS、NET、SENSOR时间戳可以是系统Tick值换算出来的毫秒数。为什么强调时间戳因为很多Bug是时序问题没有时间戳的日志只能告诉你发生了A和B有时间戳才能告诉你A和B间隔了多久、谁先发生。我之前排查过一个两个传感器数据融合打架的问题现象是输出值每隔一段时间跳变一次。加了时间戳之后才发现根本不是算法问题而是两个传感器的中断服务程序里都做了长时间的软件滤波计算导致第二个传感器的数据采样时刻被延长数据时效性差了近10毫秒。写日志还有一个坑在中断服务函数里直接调printf。这条我反复踩很多串口打印库是阻塞式的会关中断放在中断里一旦打印内容太长轻则丢数据重则直接破坏实时性。正确做法是中断里只往环形缓冲区丢日志由低优先级任务或者后台循环统一刷到串口。3.2 断言把沉默的错误变成响亮的崩溃嵌入式系统里最怕的一种Bug是悄悄错。某个指针变成了非法值但你没有立即使用它而是在50行之后才解引用这时候表现出来的是一个完全看不懂的崩溃现场。我所用的一个有力工具就是断言assert它能把悄悄错变成当场死让你在错误发生的第一现场停下来。C库自带的assert当然可以用但嵌入式环境里我建议自己写一个带更多上下文的断言宏#define ASSERT(expr) \ do { \ if (!(expr)) { \ assert_fail(#expr, __FILE__, __LINE__); \ } \ } while(0)assert_fail里做的事也很简单关中断、把当前错误码和出错位置存到一个固定地址、打印一行ASSERT FAIL: 表达式, 文件, 行号、然后进死循环。为什么是进死循环而不是直接复位因为一复位现场就没了你只能看到系统重启看不到任何线索。死在现场然后用调试器挂上去看当前栈顶、寄存器、全局变量这才是断言的价值。使用断言时我的原则是在这些地方加函数入口参数合法性检查、链表/队列操作前后检查头尾指针、状态机迁移前检查当前状态、中断与任务共享变量被修改前后检查范围。加了断言之后代码确实会变慢一点点但这种代价换来的是问题定位时间从几小时变成几分钟绝对值。3.3 状态追踪环形缓冲区记录历史最后这一种动态手段专治偶发、复现不了、不知道什么时候错的顽固Bug。这类Bug的特征是你发现出问题时已经是几秒钟之后了日志早就被冲掉现场早就变了。这时候需要的是历史记录不是实时打印。我的做法是在系统里常驻一个全局的环形缓冲区Ring Buffer专门记录关键事件的轨迹。每条记录就一两个字节的事件ID加上当时的时间戳。正常运行时它几乎不消耗CPU一旦系统进入异常状态比如断言触发或者外部看门狗复位之前的最后一口气就把这个环形缓冲区里的内容通过串口或者掉电保存区域导出来。这个思路说白了就是飞机的黑匣子。它能回答一个关键问题系统崩溃之前最后那几个事件是什么有了这个信息再配合代码走查通常很快能找到触发点。我在一个项目里遇到过一个极其诡异的现象系统运行完全正常但只要特定的一条通讯指令到达设备必定复位。靠实时日志什么都打不出来因为崩溃发生的时刻几乎和指令解析在同一瞬间。后来用环形缓冲区记录最近50个事件复现之后导出来清清楚楚地看到指令接收完成事件、紧接着缓冲区指针事件、然后复位。顺着事件指向的缓冲区指针代码一查果然是接收长度没有做上限校验超长报文直接把缓冲区写穿踩坏了栈。这个Bug如果不用历史记录法靠实时日志几乎不可能定位。4. 工具链排查调试器、示波器与逻辑分析仪的正确用法静态手段和日志手段能把范围缩小到某个模块、某几行代码但到了最后一步往往需要工具去抓取芯片内部的真实状态。嵌入式调试的工具很多我用得最多的是三样硬件调试器、示波器、逻辑分析仪。很多人工具是买了的但用法不对我重点说说那些容易踩坑的地方。4.1 调试器硬件断点和数据断点才是核心技能嵌入式开发里人人都会用调试器但绝大多数人只会用两种功能全速运行、F5/F10单步。说实话这两个功能在排查复杂Bug时的作用很有限。单步只能对付必现的代码流程错误对于运行时数据被改坏中断干扰这类问题毫无办法。真正有用的是硬件断点和数据断点硬件监视点。数据断点的意思是你告诉调试器当这个变量被写入并且值等于某个条件时立刻停下来。这对于排查某个变量神秘被修改的问题几乎是神器。我在一个项目里遇到过一个用户配置项程序里没有任何代码去写它但它运行一段时间后会自动变成0xFF。我盯着代码找了两轮都没找到写入的地方。后来在调试器里给这个变量设了一个数据断点条件是值变化就停结果程序一跑起来第一次停下来时断点指向的位置让我目瞪口呆不是C代码而是一个外设寄存器地址原来配置项的位置逼近了一个硬件寄存器地址映射区某个DMA操作乱飞把数据写到了那片地址上。如果没有数据断点这种问题靠人眼几乎无法定位。还有一个技巧硬件断点的数量通常很有限一般4到6个用的时候要有优先级。我的习惯是遇到死循环问题时先设一个函数返回地址断点让它跑一轮看它卡在哪一层遇到溢出问题时优先用数据断点盯数组最后一个元素后面的那个变量。4.2 HardFault定位从复位原因反查栈回溯Cortex-M系列芯片环境下很多复位类Bug最终都会落到HardFault上。看门狗复位只是结果真正的病因往往是一次非法的内存访问、一次除零、一次未对齐访问这些都会跳进HardFault异常处理函数。很多人看到系统复位了第一反应是查看门狗、查电源、查晶振但正确的第一反应应该是把HardFault的现场抓出来看。具体做法是在HardFault异常处理函数里打断点或者临时改成死循环然后打开调试器的调用栈窗口Call Stack去看看是从哪条代码跳进来的。很多时候栈回溯会直接给你指出出错函数的名称顺着这个函数的指针操作去找就能定位根因。但如果栈已经被破坏了回溯不出来怎么办这时候要看几个关键寄存器链接寄存器LR的值、栈指针SP的值、以及压栈到栈里的几个返回地址。Cortex-M在进异常前会自动压栈R0-R3、R12、LR、PC、PSR所以栈顶往上一点存的就是出事的PC也就是发生异常时正在执行的指令地址。在调试器里把这个地址对应到代码行号就能知道程序是在哪一句崩的。我用这个方法定位过一个问题最后发现是某个函数里对只读的Flash地址做了一次写操作触发了总线错误。另外很多调试器包括一些开源方案有自动解析HardFault的插件可以自动把栈回溯打印出来。就算没有插件按上面说的手搓一个HardFault钩子函数把栈里的内容导出来也是完全可以的。这比盲目复位要高效得多。4.3 示波器和逻辑分析仪解决软件看着没问题的时序Bug有一部分Bug代码逻辑你看一百遍都是对的但它就是随机出问题。这类问题往往不在软件逻辑本身而在物理时序。比如某个信号的上沿太慢导致外部芯片采样不稳定又比如某两个信号之间的建立时间不满足数据手册要求。这时候示波器和逻辑分析仪就是最终裁判。我遇到过最典型的一个一块板的按键检测程序理论上没任何问题但实际使用中大约百分之五的概率按键无效。后来用示波器一量发现按键按下时产生的抖动根本不是预想的10毫秒而是接近30毫秒而软件里的消抖逻辑只做20毫秒等于扫到一半就判定为没有按下就跳过了。这种问题你用调试器、日志、代码走查全都无效只有示波器放在那里真相一目了然。逻辑分析仪则更适合用来排查通信协议和时序关系问题。比如SPI、I2C、UART数据偶尔错误可以用逻辑分析仪抓波形直接数时钟周期、看数据位对比协议手册。我见过一个UART偶发收到错字节的问题用逻辑分析仪抓出来才发现波特率的实际误差比理论值高一个数量级原因是时钟源选错了分频系数。光靠软件仿真根本看不出来。5. 内存与栈类Bug嵌入式软件开发里最隐蔽的一类问题在嵌入式C语言开发中我如果说有一类Bug占到了所有难缠Bug的一半以上那就是内存问题。内存越界、栈溢出、野指针、重复释放、缓冲区溢出它们的共同特征是破坏发生时没有立即体现而是在遥远的某个地方爆炸。所以排查时必须有专门的手段把它们从抓不到变成抓得到。5.1 栈溢出检测给你的任务栈装上水位线Cortex-M系列处理器的栈是向下生长的栈溢出就是把数据写到了栈底之外的地方通常紧挨着就是其他变量的存储区或者堆区结果就是数据互相覆盖行为随机。排查栈溢出我长期用的办法有三种。第一种启动时在任务栈里填充魔术数。操作系统初始化每个任务栈时把整个栈空间用0xAAAAAAAA或0xDEADBEEF填充运行一段时间后检查栈中还有多少魔术数没被覆盖。这个值就是当前栈使用水位线。定期把各任务的水位线上报出来你就可以知道哪些任务栈余量已经很危险。我曾经在某个项目里发现一个任务的栈水位线高得吓人正常使用的栈空间只有总大小的一半不到但用率却高达九成后来查出来是这个任务里临时定义了一个巨大的局部结构体数组瞬间把栈顶压得很深。改成静态数组后水位线立刻降了下来。第二种充分利用编译器的栈分析工具。GCC有一个-fstack-usage选项可以在编译后生成每个函数预估的栈使用量文件结合调用关系就能算出最大栈深度。Cortex-M内核的开发还可以用一些IDE自带的栈分析功能不需要运行就能知道理论极限。第三种如果芯片带内存保护单元MPU可以把任务栈底部的区域配置为不可访问。一旦栈溢出踩到保护区会立刻触发硬件异常中断在当前函数里栈回溯直接就过去了。这个手段最硬核但它能帮你把随机错误直接变成必现HardFault。5.2 内存越界与野指针从堆管理到释放后置空堆内存相关的Bug比栈溢出更麻烦因为堆分配器本身会维护元数据一旦越界写坏了这些元数据表现往往是malloc偶尔返回同样的指针、free时崩溃、内存泄漏。这类问题排查我习惯从两端入手。一端是让自己更容易发现越界。做法是自定义一个malloc的包装函数在多分配的实际大小后面多加8个字节在这8个字节里填入魔术数比如0xDEADBEEF并在free时检查这8个字节有没有被改动。如果发现被改动了就说明这块内存在使用过程中被写越界了。这个技术在嵌入式环境里很好实现不需要依赖外部库。虽然会多消耗一点点内存但它能把内存被写坏变成释放时报错省下无数排查时间。另一端是堵住野指针源头。嵌入式项目里最常见的野指针就是某处free(ptr)之后没有把ptr置为NULL而另一处代码还在用这个指针。我的项目规范里有一条硬性规定释放指针后立即手动置空并配合静态检查工具自动扫描。可能有人觉得这是小习惯但我在真实项目里看到过太多因为忘了置空导致的疑难Bug有些甚至花了一两个月才定位。5.3 看门狗复位的排查先确定复位源再动手很多嵌入式系统的复位Bug最终表现在看门狗超时复位上但看门狗只是一个执行机制根本原因可能是任务卡死、死循环、异常一直得不到处理。排查这类问题我给的第一个建议是先确认到底是谁复位的芯片。大部分主流单片机在复位后都有复位状态寄存器的备份可以区分是上电复位、掉电复位、看门狗复位、软件复位还是硬件异常复位。如果没有硬件复位标志也可以用一个掉电不丢失的存储区域比如部分MCU的备份寄存器或者外部EEPROM每次启动时把上一次的复位原因记录下来。我遇到过一个问题现象是运行不定时复位排查方向一直对着软件死循环喂狗超时走结果加上复位原因记录之后发现大部分复位根本就不是看门狗干的而是另一路电源芯片的掉电检测误触发。如果没有这个复位原因记录后续可能要白排查两周。如果确认了就是看门狗复位那就要回答为什么没有及时喂狗。常见原因有三类一是某个循环确实跑飞了执行不到喂狗点二是某段代码长时间关中断把喂狗任务卡住了三是中断优先级反转高优先级中断持续抢占低优先级喂狗任务饿死。每一类都有对应的排查手段但先说清楚复位源是前提。6. 两个实战案例从偶发复位到根因定位的完整过程前面的方法讲得再多不如完整走一遍真实排查过程更能说明问题。我挑两个印象最深的案例把当时的排查链路、用到的工具、中间的弯路全部写出来给大家一个参考。6.1 案例一运行几分钟后随机重启最后连复位原因都找不到当时的情况是一台设备的功能测试一切正常但连续运行大约五到十分钟后就会随机重启重启后又能正常跑一阵子。拿到这个Bug我第一件事就是查复位原因寄存器结果查出来的复位源是软件复位——意思是有代码主动执行了复位指令。这就很奇怪了项目里根本没有主动复位的业务逻辑。于是我把搜索重点放在谁能执行复位指令上。全代码检索之后发现只有异常处理里有一段遇到不可恢复错误就软复位的逻辑也就是说大概率是系统触发了某个硬件错误然后在错误处理里主动复位了。这个发现很关键它把看门狗和电源抖动两个方向先排除了把矛头指向了硬件异常。接下来就是等它复现并想办法抓现场。我在HardFault异常处理函数里改成死循环而不是复位同时在调试器里设好断点系统跑着跑着就停在了HardFault入口。把栈回溯调出来一看出错点在某个曾经被我自己排除过的网络协议解析函数里。再往下看发现它调用了一个查表函数传进去的下标变量没有任何范围检查。到这一步根因基本浮出水面某个协议字段的数值异常导致查表下标越界读到了非法地址触发硬件异常进而软复位。但为什么是运行几分钟后随机因为异常报文不是每次都有而是随机到达的。修复方案也很简单查表前加范围校验非法值直接丢弃并打印告警日志。这个Bug的排查时间总共大约一天半其中一半时间花在错误地把问题当成看门狗问题上。如果一开始就用调试器抓HardFault现场半天足够。6.2 案例二显示偶发花屏但逻辑分析仪一抓现形第二个案例是显示接口偶尔花屏概率很低大概开机几十次出现一次而且花屏之后系统不崩溃就是画面错乱几秒钟才恢复。这种Bug的特点就是软件逻辑看起来完全没问题因为显示数据本身是从数组里搬出来的数据源检查过很多遍都是对的。一开始我也按数据错误去查查DMA配置、查缓冲区地址、查缓存一致性全部正常。后来换了一个思路万一是显示接口的时钟信号偶尔抖动了一下导致数据的建立保持时间不满足呢于是把逻辑分析仪挂到显示接口的时钟线和数据线上连续跑了一个多小时终于抓到一次异常波形时钟频率在某几个周期里明显偏慢同时数据线电平切换的时间点几乎贴着时钟边沿属于典型的时序裕量不足。这时才开始检查时钟树的配置果然发现显示外设的时钟分频系数被改小了一档导致实际输出频率接近上限再加上PCB走线的寄生电容偶尔就出现了建立时间违规。修复就是把分频系数调回去留足裕量此后花屏问题再没出现过。这个案例给我的教训是当软件看起来全对时别犹豫上物理层的测量工具时序边界才是嵌入式世界里最容易出隐蔽问题的地方。6.3 排查方法的选择顺序我的经验总结经过了这些年的折腾我给自己总结了一套排查Bug时的选择顺序不一定适合所有人但至少能少走弯路先区分Bug类型复位、卡死、还是逻辑错乱这决定了后续的一切手段。优先做静态排查编译告警、代码走查、二分法剪枝尽量不动硬件不跑现场。动态手段按成本递增来日志和断言最便宜先上数据断点和硬件断点其次示波器和逻辑分析仪属于重武器用在软件全对但物理不对的场景。内存类问题优先怀疑数组越界、栈溢出、野指针。在C语言嵌入式项目里这类Bug实在太多几乎每个疑难杂症最后都是内存问题。每次改动都记录改了什么、为什么改、结果如何这是排查中最便宜也最容易被忽视的投资。我个人在排查Bug上最大的一点体会是不要对代码有它应该不会错的执念。每次我说出这句话往往就是我要花三天时间才能找到一个下标的边界错误的时候。反过来老老实实按方法走一条一条排除再顽固的Bug也有现形的一天。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑