拓冰建站拓冰建站
首页 / 资讯中心 / 正文

嵌入式调试:从printf到日志系统与非侵入式工具的正确选用

我见过太多用 printf 调 bug 调到崩溃的同事。板子上一块显示屏花屏他在主循环里加了一行printf(test);结果一打印花屏好了一去掉又复现另一个 RTOS 项目跑几天随机死机他用 printf 打印每个任务状态结果把调度时序打得稀碎问题彻底变成玄学。这不是 printf 本身不行而是调 bug 的方法出了问题。搞嵌入式printf 当然能用而且是起步阶段最友好的工具但如果你想处理时序问题、并发问题、硬件信号问题还指望一根串口线加几行打印那大概率会越调越乱。这篇文章想聊清楚一件事printf 调 bug 到底行不行不行的地方用什么补我把这些年实际用过的替代方案、改造成日志系统的代码、以及踩过的坑都整理出来希望对还在用串口打印死磕 bug 的人有参考价值。1. printf 调 bug 的真实体验为什么香又为什么时常失效1.1 香在门槛低重定向一改什么板子都能打必须承认printf 能在嵌入式调试里称霸这么多年靠的就是极低的使用门槛。随便一颗 MCU只要引出一个 UART 引脚接个 USB 转串口模块重定向一下标准输出就能开始打印。这个过程几乎不依赖调试器也不需要额外硬件甚至板子不连仿真器也能看日志。不同工具链下重定向的写法不太一样这是第一个容易踩坑的地方。Keil MDK 里如果勾选了 MicroLIB通常重写fputc就行#include stdio.h int fputc(int ch, FILE *f) { /* 假设串口外设已经初始化好 */ while ((USART1-ISR USART_ISR_TXE) 0); USART1-TDR (uint8_t)ch; return ch; }GCC 工具链下则要重写_write系统调用或者在启用 newlib nano 时实现_write_rint _write(int file, char *ptr, int len) { for (int i 0; i len; i) { while ((USART1-ISR USART_ISR_TXE) 0); USART1-TDR (uint8_t)ptr[i]; } return len; }很多新工程的坑就在这里用 Keil 默认库而不是 MicroLIB 时fputc不会生效用 STM32CubeIDE 生成工程时如果没找到_writeprintf 直接不输出。还有一类常见问题是在 RTOS 里重定向了 printf但任务栈给得太小第一次打印就把栈干爆系统 HardFault。这些问题都指向一个事实printf 虽然用起来简单但底层链路其实没那么简单。不过我必须承认在硬件 bring-up 阶段printf 仍然是我最常用的工具。板子刚焊好先不管什么日志系统、调试协议串口能打出字来说明电源、时钟、UART 配置基本没问题这一步的价值非常大。这也是为什么即便后面有一堆更高级的调试手段我也会在一开始就把 printf 打通。1.2 低门槛背后藏着五个局限如果只看重定向方便很容易忽略 printf 在真实工程里的先天局限。下面这五条基本覆盖了我在项目中遇到的典型问题。第一时序侵入太严重。以 115200bps 波特率为例传输 1 字节需要大约 87us打印一行 50 字节的日志就是 4.35ms。如果你的主循环周期是 1ms一个 printf 直接让系统节奏乱了四拍。更麻烦的是浮点打印printf(%f, x)这类格式化在 C 库内部可能消耗几百微秒甚至毫秒级 CPU 时间取决于是否启用浮点格式支持、MCU 是否带 FPU。我曾经在一个电机控制项目里往 10kHz 控制环中间塞了一行打印电机当场开始啸叫。这种场景下printf 不是调 bug 的工具而是制造 bug 的元凶。第二你只能看到你事先想到要打印的东西。printf 是盲人摸象式调试你猜测某个变量出了问题于是把它打出来。但 bug 往往不在你猜测的位置。真正需要的信息——寄存器现场、调用栈、全局状态、某段代码执行的顺序——printf 全都给不了。很多时候问题已经发生但日志只记录到了出事前毫秒级的某个状态中间发生了什么完全是空白。第三中断和多任务环境下不安全。printf 本身不是可重入的内部有静态缓冲区多个任务或中断同时调用会互相踩踏。我在多任务项目里就见过一个非常隐蔽的 bug高优先级任务里调用 printf打印到一半被中断打断中断里也有 printf两者共用同一个缓冲区结果日志错乱程序也跟着出问题。更麻烦的是如果打印时关闭了中断保护共享资源关键中断的响应延迟会被拉大这在电机控制、无线通信这类实时性要求高的场景是致命的。嵌入式面试题里经常问printf 为什么不可重入本质就是这个。第四速度太慢。串口 115200bps 的理论极限也就 11.5KB/sRTT 可以轻松跑到几百 KB/s 甚至 MB/s 级别。当你想在短时间内抓取大量数据波形、高频传感器采样值、或者 RTOS 任务切换记录时用串口打印会产生两个后果一是大量数据来不及发出去日志被丢弃二是为了等串口发送程序执行被拖慢。很多工程师为了提速把波特率调到 921600 或更高但即便这样和 SWO 或 RTT 相比依然差了数量级。第五对硬件信号类问题完全无能为力。GPIO 上的毛刺、I2C 总线的 ACK 异常、SPI 时序不满足、电源纹波导致的随机复位这些都是物理世界的问题。printf 只能输出软件层面的状态它看不到引脚电平的变化看不到总线上具体的波形。遇到这类问题你打印再多的日志也没用因为问题发生在 printf 根本覆盖不到的地方。1.3 一个真实案例printf 改变了故障现场这里说一个我印象特别深的案例。有一块板子客户反馈运行几个小时后会复位看门狗超时。我们最开始的做法很常规在主循环里每隔一段时间打印一次alive同时打印几个关键变量的值。结果很奇怪只要打印开得够频繁系统反而不复位打印关掉或频率降低复位就复现。这个现象困扰了我们一个下午。后来仔细想了一下才恍然大悟主循环里除了业务逻辑还有一个喂狗语句。printf 的执行时间大约耗掉了主循环周期的 30%相当于变相拉长了循环时间这会直接影响某个定时器中断里对业务处理时间的判断。真正的问题根本不在主循环而是一个低速外设的忙等逻辑占用了过长 CPU 时间导致某条紧急任务超时。printf 把 CPU 占得更多反而让那条紧急任务碰巧等到了外设完成于是故障被掩盖。这个案例给我的教训非常深任何调试工具都会影响被观测系统的行为printf 的影响尤其大。它不只是多花点 CPU 时间这么简单而是可能改变程序执行的相对时序从而让问题消失、偏移、或者凭空出现。专业一点的叫法是观测者效应。后面我调 bug 时会习惯性地问自己一句我现在加的打印会不会正在改变我盯着的那段逻辑2. 先分清楚你在调哪类 bug再决定要不要用 printf2.1 嵌入式 bug 的三种面孔被 printf 坑过几次之后我开始反思调试策略。后来发现一个特别朴素的道理不是所有 bug 都可以用同一种工具调。嵌入式项目的 bug 大体可以分成三类每类需要的观测手段完全不同。第一类是软件逻辑问题。典型表现某个 if 分支没进、数组越界、状态机跳到了错误状态、函数返回值没做判断。这类问题的根源在代码写错了和硬件、时序关系不大。比如按键消抖逻辑写反了双击识别成了单击这种问题用串口打印、断点单步、日志分析都能解决。第二类是资源与时序问题。典型表现任务饿死、优先级反转、看门狗超时、外设访问冲突、缓冲溢出。这类问题不是某一行代码错了而是多个模块在时间维度上产生了冲突。比如两个任务同时操作同一个 SPI 外设没有加锁偶尔出现数据错乱。这类问题用 printf 调会非常痛苦因为打印本身就占据 CPU 时间、改变调度顺序你很难判断看到的时序是程序真实的时序还是被打乱后的假象。第三类是硬件信号问题。典型表现通讯时好时坏、高温下随机复位、EMI 干扰、引脚电平不对、波形上升沿太缓。这类问题的根源在物理层必须依靠示波器、逻辑分析仪等工具观察电信号。printf 只能告诉你软件看到的结果不对但没法告诉你信号到底怎么了。2.2 软件逻辑问题printf 够用但更好的姿势是断言加日志软件逻辑类问题printf 确实能派上用场但我现在更推荐断言 分级日志的组合理由后面会详细讲。这里先明确一点调试软件逻辑问题的核心思路是尽早发现异常并且留下足够现场。比如状态机跳到了一个非法状态与其在外层打一屏日志然后继续跑不如在状态转移的地方加一个断言switch (state) { case DEVICE_IDLE: /* ... */ break; case DEVICE_RUNNING: /* ... */ break; default: assert_param(0); /* 非法状态立即停车并掉入调试器 */ break; }断言的价值在于快速失败。它不像 printf 那样只是把状态打出来给你看而是一旦条件不满足立刻停止程序或触发内核异常让调试器停在现场。你可以直接在调用栈里看到是谁调用了谁、寄存器是什么值、全局变量是什么状态。这个效率比打印几十行日志再人工分析高得多。2.3 资源与时序问题需要的是非侵入式观测时序类问题的难点在于程序的行为强依赖于时间关系任何注入的额外延迟都可能让问题消失或变化。如果你用 UART 串口打印来做时序观测串口发送本身要占 CPU而且发送速度又慢相当于给原本的系统强行加了一个不确定性很强的延时源调起来自然难上加难。这类问题需要的是非侵入式观测手段。所谓非侵入指的是观测过程尽量不干预程序原本的执行节奏和资源占用。典型方案有三个ITM/SWO 输出、J-Link RTT、以及通过示波器/逻辑分析仪观察 GPIO 翻转。它们不占用 UART 外设、不需要阻塞式发送数据、对 CPU 执行流的影响微乎其微。我一直认为时序问题调试的第一原则是先确保你的观测工具没有改变你正在观测的东西。如果做不到这一点后面所有的分析都可能是错误的。2.4 硬件信号问题printf 根本看不见硬件信号类问题是最容易被只懂软件的工程师忽略的。有个朋友跟我说过一个例子I2C 读传感器偶尔卡死他在读函数前后打印了一堆日志分析状态寄存器结果发现每次卡死时状态寄存器的值都是一样的但看不出为什么卡死。后来用逻辑分析仪抓 I2C 波形才发现是传感器在某次上电瞬间把 SCL 拉低了总线一直处于 busy 状态。软件日志里只能看到等待总线空闲超时但看不到总线上真实的电平状态——因为问题发生在逻辑分析仪能看见、而 printf 看不见的物理层。所以我现在排查 bug 的第一步通常会先问这个现象有没有可能是硬件信号造成的如果有一点可能直接上示波器或逻辑分析仪。千万不要拿 printf 去反复探测软件状态那样只会浪费时间还可能把问题搞得更复杂。2.5 破一个执念printf 只是搬运内外部状态的方式不是调试本身说到底printf 的本质是把 MCU 内部的某些状态通过串口搬到外部世界。它解决了我看不到 MCU 内部这个信息不对称问题。但调试远远不止看到信息这一个环节还包括信息采集哪些状态、什么频率、信息保存出错后怎么恢复现场、信息分析怎么从海量日志里定位根因、以及系统干预出错时怎么停止、恢复、或者自动重启。如果你把 printf 当成调试的唯一手段相当于只拥有了信息搬运这一环其他环节全靠脑补。很多老工程师嘴上说自己用 printf 调一切实际上他们的大脑很擅长从串口日志里补全时序、推测调用关系、还原系统现场。但这种能力需要大量经验支撑对新人并不友好。换成结构化的日志系统、断言、以及非侵入式工具等于把一部分脑补变成可复现、可分析的客观数据。3. 比 printf 更能打的四种手段断言、ITM/SWO、RTT、逻辑分析仪3.1 断言加分级日志把错误拦在发生点先讲我最推崇的软件层方案断言assert 分级日志。它不挑 MCU不需要调试器几乎所有嵌入式项目都能用。先说断言。这里说的断言不是 C 标准库里那个简单的assert.h而是配合嵌入式错误处理的机制。我一般会封装一组宏遇到异常时先尝试保存现场再进入错误处理#define ASSERT(expr) \ do { \ if (!(expr)) { \ log_error(ASSERT fail: %s at %s:%d, #expr, __FILE__, __LINE__); \ save_fault_context(); \ while (1); \ } \ } while (0)save_fault_context()做的事很简单把全局变量、当前状态机、最近 N 条日志循环缓冲、几个关键寄存器的值保存到一片独立的 RAM 区域。这样即使系统随后复位下次开机时 Bootloader 也能把这些信息通过串口或 Flash 吐出来。这是工业设备、汽车电子里非常常见的故障记录设计。再说分级日志。很多人打印日志直接就是printf(xxxx%d\n, x)没有任何级别和模块区分。等到系统大了日志一多想从几百行输出里定位一条有效信息眼睛都快看瞎了。我习惯的日志接口长这样#define LOG_LEVEL_DEBUG 0 #define LOG_LEVEL_INFO 1 #define LOG_LEVEL_WARN 2 #define LOG_LEVEL_ERROR 3 #define LOG_DEBUG(fmt, ...) log_output(LOG_LEVEL_DEBUG, MODULE_NAME, __LINE__, fmt, ##__VA_ARGS__) #define LOG_INFO(fmt, ...) log_output(LOG_LEVEL_INFO, MODULE_NAME, __LINE__, fmt, ##__VA_ARGS__) #define LOG_WARN(fmt, ...) log_output(LOG_LEVEL_WARN, MODULE_NAME, __LINE__, fmt, ##__VA_ARGS__) #define LOG_ERROR(fmt, ...) log_output(LOG_LEVEL_ERROR, MODULE_NAME, __LINE__, fmt, ##__VA_ARGS__)每个模块定义自己的MODULE_NAME日志输出统一带上时间戳、级别、行号。看起来只是多写一点点代码但实际排查问题时的效率提升非常明显。3.2 Cortex-M 自带的 ITM/SWO不占串口、开销极低如果你用的是带 SWD 调试口的 Cortex-M 芯片ITM SWO 是值得优先考虑的非侵入式输出方式。很多工程师不知道这个功能或者知道但从来没配置过实在太可惜了。ITM 是 CoreSight 调试组件里的一个单元可以把它理解成一个软件可写的状态通道。程序往 ITM stimulus port 写一个字节这个字节会通过 SWO 引脚输出调试器比如 J-Link、ST-Link、DAPLink只要接线支持能实时接收并在 IDE 里显示。整个过程硬件自动完成CPU 只需要一条 store 指令开销比压入 FIFO 再驱动 UART 低几个数量级。使用时最简单的方式是把 printf 重定向到 ITM#include core_cm4.h /* 以 Cortex-M4 为例 */ int fputc(int ch, FILE *f) { ITM_SendChar(ch); return ch; }ITM_SendChar内部会判断ITM-PORT[0].u32是否可写然后写入。实际项目里我会把回调封成log_putc方便后面切换输出通道。这套方案有几个坑必须提前知道。第一不是所有芯片都引出了 SWO 引脚。比如一些 LQFP 封装的芯片SWO 默认不是 SWD 引脚组的一部分需要看数据手册是否支持或者复用为其他功能。第二SWO 的带宽有限虽然比 UART 快但大量高频日志也可能把它冲爆需要配置合适的 SWO 时钟分频。第三如果 MCU 进入了低功耗停止模式SWO 通常也会停止工作。第四调试器必须支持 SWO Viewer 功能比如 J-Link 的 SWO 配置、OpenOCD 的tpiu配置需要花一点时间研究。3.3 J-Link RTT速度最快的软串口如果项目已经用了 J-Link那 RTT 绝对是提升调试幸福感的一个好东西。RTT 的原理很简单调试器通过 SWD/JTAG 接口直接访问目标 MCU RAM 里一块环形缓冲区。程序把日志写到缓冲区里调试器通过 J-Link 实时把数据读走。它不需要额外引脚、不需要 UART 外设、不占用中断资源、速度比串口快几个数量级。引入 RTT 只需要把SEGGER_RTT.c和SEGGER_RTT.h加入工程然后调用SEGGER_RTT_printf(0, value %d\n, val);或者直接发送字符串SEGGER_RTT_WriteString(0, hello from mcu\n);在 J-Link 的 RTT Viewer 里可以实时看到输出还可以通过 RTT 输入通道从 PC 向 MCU 发命令这个能力调试在线参数时特别有用。实际测下来RTT 速度能做到几百 KB/s 到数 MB/s抓高频传感器数据、RTOS 任务切换记录都比 UART 从容得多。RTT 也有几个要注意的地方。第一环形缓冲区如果满了新日志会丢默认策略你需要根据需求调整缓冲区大小。第二如果调试器没有连接日志只会往 RAM 里写缓冲区迟早会满然后新日志丢失。第三在 RTOS 多任务环境里多个任务同时写 RTT 缓冲区需要做临界区保护SEGGER 官方代码里默认带锁但如果你的 RTOS 有特殊调度策略需要确认锁的实现是否合适。第四RTT 需要调试器持续运行才能把数据搬运出来量产现场的板子一般不具备这个条件所以 RTT 更适合开发调试不适合产线诊断。3.4 逻辑分析仪用 GPIO 翻转做廉价 Trace每次聊调试工具我都想强调逻辑分析仪的重要性哪怕是很便宜的那种。硬件层面看不到的问题它一眼就能看穿。对于逻辑类码调试不需要复杂的协议分析功能最简单的用法就是GPIO 翻转法。在可疑的代码路径里把某个 GPIO 拉高或拉低然后用逻辑分析仪抓这段 GPIO 的波形观察代码是否按预期顺序执行、执行时间是否符合预期、两个事件之间间隔是否稳定。#define TRACE_GPIO_SPI GPIO_PIN_0 #define TRACE_GPIO_READ GPIO_PIN_1 /* 关键路径前后翻转电平 */ HAL_GPIO_WritePin(TRACE_PORT, TRACE_GPIO_SPI, GPIO_PIN_SET); spi_xfer(...); HAL_GPIO_WritePin(TRACE_PORT, TRACE_GPIO_SPI, GPIO_PIN_RESET); HAL_GPIO_TogglePin(TRACE_PORT, TRACE_GPIO_READ); read_sensor(); HAL_GPIO_TogglePin(TRACE_PORT, TRACE_GPIO_READ);然后从逻辑分析仪上数一下脉冲宽度对比实际时序和理论时序差多少问题往往立刻就清楚了。有一次我调一个显示刷新太慢的问题就在刷新开始和结束各翻转一次引脚逻辑分析仪显示刷新函数本身只用了 10ms但两次刷新之间却隔了 200ms——什么问题主循环里有其他耗时操作插进来了。这个结论用 printf 很难直接得出因为每次打印都会改变循环节奏。逻辑分析仪还可以直接解码 I2C、SPI、UART 协议这比靠 printf 打印寄存器状态去猜通讯过程高效得多。很多 MCU 的调试接口还能输出 ETM 之类的指令 trace但那个依赖芯片和调试器型号普通项目用 GPIO 翻转已经足够解决 90% 的时序问题。下面是四种手段的快速对比调试手段侵入性速度所需硬件典型场景printf UART高低11.5KB/s115200串口线bring-up、低频日志断言 分级日志低低只输出异常时无特殊要求逻辑错误、现场保存ITM/SWO极低中高调试器 SWO 引脚高频日志、性能分析J-Link RTT极低高J-Link 调试器高频日志、交互调参逻辑分析仪极低需要预留 GPIO仅观测信号逻辑分析仪时序测量、协议分析4. 还得用 printf 的话别裸奔把它升级成一套日志系统4.1 一个能直接抄的分级日志模块有朋友可能会说项目已经跑起来了不可能为了调 bug 把串口全换成 RTT。我完全理解。在已经稳定的工程里最小成本的做法不是推翻 printf而是把它升级成一整套可用的日志系统。我常用的日志模块分四层接口层、缓冲区层、输出层、过滤层。接口层给业务代码提供LOG_DEBUG、LOG_INFO、LOG_ERROR这些宏缓冲区层解决打印不能阻塞主流程的问题输出层负责把数据真正送到串口或调试器过滤层做编译期和运行期的级别控制。头文件的核心结构大概是这样typedef struct { uint16_t head; uint16_t tail; uint16_t count; uint8_t buffer[LOG_BUFFER_SIZE]; } log_ring_t; int log_put(const uint8_t *data, uint16_t len); void log_task(void); /* 从环形缓冲区取数据并送往 UART */log_put把日志写入环形缓冲不在中断或主流程里等待串口发送完成。UART 部分用 DMA 或中断发送每当发送完成一个字节就继续从环形缓冲区取下一个字节。这样即使短时间内大量日志涌入也不会阻塞业务逻辑只是缓冲区满了会丢日志而已。4.2 重定向和中文乱码的坑从裸 printf 升级到日志系统最常见的三个坑值得单独说一下。第一个坑是重定向函数选错。前面提过 Keil 的微库和 GCC newlib 入口不同此外还要注意如果使用了 RTOS 的fputc钩子部分 C 库函数可能绕过你的重定向。我的经验是不要在业务代码里依赖标准 printf 一定被重定向成功显式调用自定义log_printf更可靠。第二个坑是中文乱码。很多人打印中文日志串口助手里显示全是乱码原因主要有三一是源文件编码和串口助手解码不一致比如源文件是 UTF-8串口助手按 GBK 解码二是fputc一次发一个字节对 UTF-8 中文这种多字节字符理论上没问题但如果你在中断或 DMA 模式下遇到丢字节多字节字符就会被拆坏三是部分开发环境把中文转成了转义序列串口那边收到的是\uXXXX这种东西。最省心的做法是产品日志统一用英文如果必须用中文请固定 UTF-8 编码并且确保传输链路不丢字节。第三个坑是换行符。printf(\n)在 Windows 下串口助手里显示正常但很多嵌入式串口工具需要\r\n才能正确换行。我的日志模块里统一在输出层把\n替换为\r\n省得每个模块都带\r。4.3 用中断发送 环形缓冲区把打印从主流程摘出去前面提到 printf 的时序侵入问题解决思路不是不打印而是把格式化和发送拆开。格式化部分可以放到业务上下文发送部分交给出栈机制。一个简化版的环形缓冲区入队长这样bool log_ring_push(log_ring_t *ring, uint8_t byte) { uint32_t primask __get_PRIMASK(); __disable_irq(); bool ok false; if (ring-count LOG_BUFFER_SIZE) { ring-buffer[ring-head] byte; ring-head (ring-head 1) % LOG_BUFFER_SIZE; ring-count; ok true; } __set_PRIMASK(primask); return ok; }这里用关闭中断来保护临界区适合单核 MCU。如果用了 RTOS也可以换成taskENTER_CRITICAL()。出队函数在 UART 发送完成中断里调用把缓冲区的下一个字节写入 UART 数据寄存器。环形缓冲区满时怎么办我见过的策略分两派覆盖旧日志或者丢弃新日志。对于实时嵌入式系统我通常选丢弃新日志因为旧日志往往更接近故障发生前的现场而且丢日志本身也说明系统已经处于异常过载状态这本身就是一条有价值的告警。有的同事喜欢覆盖旧日志理由是最新状态最重要这个看需求没有绝对对错。格式化这里还有个细节vsnprintf这种函数在资源紧张的 MCU 上很占栈空间任务栈要给够。尤其是 ARM Cortex-M 上默认 1KB 栈的任务随便一个带浮点格式化的日志就可能爆栈。我习惯把日志任务单独隔离开栈给到 1KB~2KB其他任务不碰日志格式化。4.4 编译期裁剪发布版不留 DEBUG 字符串日志系统还有一个好处是可以通过宏在编译期裁剪。裸 printf 的问题在于即使你后来把大部分打印注释掉了字符串常量也会留在代码里白白占 ROM。用一个全局日志级别运行期判断也能过滤输出但代码体积和运行时开销都没有省掉。我喜欢的方式是编译期LOG_LEVEL控制#define LOG_LEVEL_DEBUG 0 #define LOG_LEVEL_INFO 1 #define LOG_LEVEL_WARN 2 #define LOG_LEVEL_ERROR 3 #ifndef LOG_LEVEL_CONFIG #define LOG_LEVEL_CONFIG LOG_LEVEL_DEBUG #endif #define LOG_DEBUG(...) \ do { \ if (LOG_LEVEL_CONFIG LOG_LEVEL_DEBUG) { \ log_output(LOG_LEVEL_DEBUG, __FILE__, __LINE__, __VA_ARGS__); \ } \ } while (0)如果你想彻底省掉参数求值和字符串引用可以用#if预处理完全抹掉#if (LOG_LEVEL_CONFIG LOG_LEVEL_DEBUG) #define LOG_DEBUG(...) log_output(LOG_LEVEL_DEBUG, __FILE__, __LINE__, __VA_ARGS__) #else #define LOG_DEBUG(...) ((void)0) #endif这俩的区别在于前者只是运行时不输出但代码还在后者是编译期直接删掉连参数都不求值空间和时间都省了。发布版本一般把级别设为LOG_LEVEL_WARN或者LOG_LEVEL_ERROR既能保留关键故障信息又能把 DEBUG 日志全部清掉。4.5 实战中容易踩到的附加坑最后补充几个我在实际项目里踩过、概率不算低的坑。一是调试串口和业务串口冲突。很多开发板默认用 UART2 作为调试串口但如果业务里某个外设也要用同一条 UART日志和业务数据就会混在一条线上。我的建议是调试串口独占不要复用业务串口否则排查问题时看到的既有日志又有业务帧很难分清是谁污染了谁。二是串口中断优先级问题。如果 UART 中断优先级比某个关键外设中断低那当关键中断长时间运行时串口日志就可能因为缓冲区满而丢失。低优先级中断的日志丢失容易被误判成软件 bug。我一般把日志 UART 中断优先级设得比较低但在系统资源允许的前提下也不能低到被饿死需要权衡。三是重定向到 DMA 后忘记处理HAL_UART_TxCpltCallback结果发送一半卡住。使用 DMA 发送时一定要确认发送完成回调会被调用否则日志发到一半就停了很多人第一反应是程序死机了实际上只是 DMA 没飞起来。四是日志时间戳的精度。很多人的时间戳用的是HAL_GetTick()分辨率只有 1ms想分析微妙级的时序完全不够。如果需要分析高频事件至少要上 DWT 的 cycle counter或者用硬件定时器做高精度时间戳。这个我在时序排查项目中吃过亏后面专门做了个log_timestamp_us()来替代HAL_GetTick()。5. 我的日常调试组合什么阶段用什么工具5.1 不同开发阶段不同打法工具选型不是越高级越好而是要在合适的阶段用合适的组合。我现在的新项目基本按以下节奏来。硬件 bring-up 阶段板子刚回来电源、时钟、下载器都要验证我会优先打通 UART printf配合示波器看电源波形和晶振起振。这个阶段 printf 主要是验证系统活着不需要复杂结构。功能开发阶段模块一个一个往外调跑通一个就加对应的日志和断言。此时会把日志系统完整搭好分级、时间戳、模块名都齐了。每新增一个模块就给它加一个MODULE_NAME宏。这样到后期集成时日志天然就是结构化的不用再返工。时序与性能优化阶段这个阶段我基本很少用 UART 打印更多的是 J-Link RTT 加逻辑分析仪。RTT 用来抓高频采样数据和任务调度的快照逻辑分析仪用来测量关键路径的 GPIO 翻转时序。只有到了这一步你才能真正看到程序跑起来之后到底先干谁、后干谁。稳定性测试阶段板子在实验室或者客户现场长时间运行不可能一直有人守在那里看串口。此时最重要的工具是断言 故障现场保存把错误信息写到 RAM 特定区域然后系统复位等待下一次读取。我还习惯在关键模块里加状态机 dump把当前状态、最近状态跳转历史、关键输入参数全部打包到一块固定结构体出问题后可以一键导出。5.2 选型速查表给一张我常用的选型表大家可以按场景直接对照场景我的首选理由板子刚焊好确认能不能跑UART printf最简单、硬件依赖最少逻辑分支写错了状态机跳飞断言 分级日志快速失败现场保存任务调度混乱/死锁J-Link RTT RTOS trace非侵入观察真实调度传感器数据采集频率高J-Link RTT速度足够流量大也不怕I2C/SPI 通讯异常逻辑分析仪直接看协议波形不是猜中断响应时间超了GPIO 翻转 示波器精确测量中断入口到出口时间产品量产现场偶发故障断言 故障现场保存无人值守时能留下现场上位机联动调试RTT 双向通道从 PC 直接向 MCU 发指令5.3 几条实在建议最后说几条不踩坑能省时间的经验都是真金白银换来的。第一调试工具链要在项目启动时搭好不要等 bug 来了再补。很多项目前期图省事串口就用个最简陋的 printf不搞日志分级。等到系统联调时日志乱成一锅粥再回头补日志系统成本比一开始就做高好几倍。调试设施和业务代码一样需要规划。第二日志要设计得让上位机可解析。级别、时间戳、模块名、关键字尽量统一格式方便写脚本过滤。我习惯的格式是[时间戳][级别][模块][行号] 日志内容这样 PC 端用grep或者 Python 脚本处理都很方便。没有结构化的日志最后只能人眼在几千行输出里找效率太低了。第三不要忽视调试器本身的功能。很多朋友用 J-Link 只是下载程序最多加几个断点但断点、条件断点、Watch 窗口、寄存器窗口、调用栈这些能力在定位疑难问题时价值极大。比如断点打到某个条件成立时才暂停比在循环里写if再打印强得多。尤其是当问题和某个变量值相关时条件断点能精准命中现场。第四永远记住工具越好越要警惕它改变现场。RTT 和 ITM 虽然比 printf 侵入性小很多但它们也会占用一点 CPU 和 RAM也会影响低功耗模式。当你调试一个极难复现的 bug 时先把所有调试输出关掉看看问题是否还在。如果关了反而不复现说明调试工具本身正在影响系统行为这个时候要换一种侵入性更小的观测方式。我现在拿到一块新板子第一件事不是写一堆 printf而是把调试接口预留好SWO 引脚、两三个空闲 GPIO 作为 trace 引脚、一个专用的调试串口、一块足够大的日志缓冲区。这些调试基础设施看似不起眼但在真正面对疑难 bug 时它们的价值比任何花哨的代码技巧都大。希望你看完这篇能从一个 printf 用到底变成看菜下饭、按场景选工具把调试这件破事变成一件有掌控感的事。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门