第20章 日志调试:日志级别设计、日志文件分析、printf调试的利与弊
说到调试,很多人第一反应就是打断点、单步执行。但说实话,在嵌入式开发里,很多时候你根本没法用调试器——比如产品已经部署到现场了,或者你的代码跑在RTOS的某个中断上下文里。这时候,日志就是你唯一的眼睛。
我做了十几年嵌入式,日志调试一直是我的看家本领。今天就跟大家聊聊,怎么把日志用好,而不是用成一团乱麻。
20.1 日志级别设计——别让日志变成噪音
很多新手写日志,上来就是printf("xxx")。项目小的时候还行,等代码量一上来,终端刷屏刷得你根本找不到重点。为什么会这样?因为没有级别。
我个人习惯把日志分成五个级别,从低到高:
| 级别 | 宏定义 | 含义 | 典型场景 |
|---|---|---|---|
| TRACE | LOG_TRACE | 最详细的跟踪信息 | 函数入口/出口、循环次数 |
| DEBUG | LOG_DEBUG | 调试阶段的关键变量 | 传感器原始值、状态机跳转 |
| INFO | LOG_INFO | 正常运行的里程碑 | 系统启动、任务创建成功 |
| WARN | LOG_WARN | 异常但可恢复 | 重试次数超限、缓存接近满 |
| ERROR | LOG_ERROR | 严重错误 | 内存分配失败、硬件故障 |
你想想看,如果所有日志都混在一起,现场工程师给你发来100MB的日志文件,你从哪看起?有了级别,直接grep "ERROR" 就定位到问题了。
核心原则:发布版本只保留INFO及以上级别。TRACE和DEBUG只在开发阶段开启。
我见过最惨的一个项目,同事把DEBUG级别的日志全开着就发布了。结果产品跑了一个月,Flash被日志写满了,系统直接挂掉。嗯,这就是血的教训。
20.2 日志系统的实现——一个轻量级方案
别用那些花里胡哨的日志库。在嵌入式里,越简单越可靠。我一般这样设计:
// log.h
#ifndef __LOG_H__
#define __LOG_H__
#include <stdio.h>
#include <stdint.h>
#define LOG_LEVEL_NONE 0
#define LOG_LEVEL_ERROR 1
#define LOG_LEVEL_WARN 2
#define LOG_LEVEL_INFO 3
#define LOG_LEVEL_DEBUG 4
#define LOG_LEVEL_TRACE 5
// 编译时设定当前日志级别
#ifndef CURRENT_LOG_LEVEL
#define CURRENT_LOG_LEVEL LOG_LEVEL_DEBUG
#endif
#define LOG_ERROR(fmt, ...) \
do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_ERROR) \
printf("[ERROR] %s:%d: " fmt "\r\n", __FILE__, __LINE__, ##__VA_ARGS__); } while(0)
#define LOG_WARN(fmt, ...) \
do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_WARN) \
printf("[WARN] %s:%d: " fmt "\r\n", __FILE__, __LINE__, ##__VA_ARGS__); } while(0)
#define LOG_INFO(fmt, ...) \
do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_INFO) \
printf("[INFO] %s:%d: " fmt "\r\n", __FILE__, __LINE__, ##__VA_ARGS__); } while(0)
#define LOG_DEBUG(fmt, ...) \
do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) \
printf("[DEBUG] %s:%d: " fmt "\r\n", __FILE__, __LINE__, ##__VA_ARGS__); } while(0)
#define LOG_TRACE(fmt, ...) \
do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_TRACE) \
printf("[TRACE] %s:%d: " fmt "\r\n", __FILE__, __LINE__, ##__VA_ARGS__); } while(0)
#endif // __LOG_H__
使用时,在编译选项里加 -DCURRENT_LOG_LEVEL=LOG_LEVEL_INFO,就能控制输出级别。发布版本改成 LOG_LEVEL_WARN 或 LOG_LEVEL_ERROR,TRACE和DEBUG的代码在编译阶段就被优化掉了,零开销。
小技巧:用 do { ... } while(0) 包裹宏,可以避免 if-else 匹配问题。这个坑我踩过,后来就再也不敢偷懒了。
20.3 printf调试的利与弊
printf调试,说白了就是「打桩」。在怀疑的地方加一句打印,看输出对不对。这个方法简单粗暴,但确实有效。
优点很明显:
- 上手快,一行代码搞定
- 不依赖调试器,串口线一接就能看
- 适合中断、DMA等难以打断点的场景
缺点也不少:
- printf本身是阻塞的,会影响时序。我在项目中遇到过,加了一堆printf后,原本稳定的电机控制开始抖动了——因为打印占用了CPU时间,导致控制周期被拉长。
- 输出太多,反而看不清。你想想看,一秒打印几百行,人眼根本反应不过来。
- 发布版本里如果忘了删,会泄露调试信息,甚至拖慢系统。
注意:在中断服务函数里慎用printf!很多printf实现会调用malloc或等待锁,在中断里用可能导致死锁或系统崩溃。我曾经因为这个bug排查了整整两天。
20.4 日志文件分析——从海量数据里找线索
日志写出来了,怎么分析?我分享几个实战技巧。
技巧一:加时间戳
每条日志最好带上系统滴答计数或绝对时间。没有时间戳的日志,就像没有坐标的地图——你知道发生了什么事,但不知道先后顺序。
#define LOG_INFO(fmt, ...) \
do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_INFO) \
printf("[%lu][INFO] " fmt "\r\n", get_tick_ms(), ##__VA_ARGS__); } while(0)
技巧二:用grep和awk做初步过滤
拿到日志文件后,我一般先做这几步:
grep "ERROR" log.txt—— 先看错误grep -E "ERROR|WARN" log.txt | head -50—— 看前50条异常awk '{print $1}' log.txt | sort | uniq -c—— 统计各级别日志数量
技巧三:关键路径打标记
在状态机跳转、任务切换等关键位置,加一个唯一的「标记字符串」。比如:
LOG_INFO("[MARK] STATE_MACHINE: IDLE -> RUNNING");
这样在日志里搜索 MARK,就能快速勾勒出系统的运行轨迹。
20.5 知识体系总览
下面这张图,是我对日志调试核心逻辑的总结:
20.6 避坑指南与个人经验
最后,分享几个我踩过的坑:
- 别在中断里用printf。 我曾经在一个定时器中断里加了调试打印,结果系统每隔几秒就死机一次。查了两天才发现是printf内部用了互斥锁,中断里拿不到锁就死等了。
- 日志缓冲区要够大。 如果日志输出到环形缓冲区,缓冲区大小至少能存下最近100条日志。太小的话,关键信息还没看到就被覆盖了。
- 发布版本记得关掉DEBUG。 我建议在makefile里统一控制,而不是手动注释代码。手动操作总会漏掉一两个。
- 日志字符串别太长。 嵌入式环境里,栈空间和Flash都很宝贵。每条日志控制在80字节以内,够用就好。
日志调试这门手艺,说白了就是「在合适的地方、用合适的级别、输出合适的信息」。级别设计好了,分析起来事半功倍;级别乱设,日志就是一堆噪音。希望今天的内容,能帮你把日志真正用起来,而不是沦为摆设。
公众号:蓝海资料掘金营,微信deep3321