第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_WARNLOG_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 知识体系总览

下面这张图,是我对日志调试核心逻辑的总结:

日志调试核心知识体系 日志级别设计 日志系统实现 printf调试 日志文件分析 五级日志 TRACE/DEBUG/INFO WARN/ERROR 发布版只留INFO+ 宏定义实现 编译时级别控制 do-while包裹 零运行时开销 利与弊 优点:简单直接 缺点:阻塞/时序影响 中断中禁用 分析技巧 时间戳/grep/awk 关键路径标记 统计+过滤 核心原则:分级输出 + 编译控制 + 事后分析 让日志成为调试的利器,而不是负担

20.6 避坑指南与个人经验

最后,分享几个我踩过的坑:

  • 别在中断里用printf。 我曾经在一个定时器中断里加了调试打印,结果系统每隔几秒就死机一次。查了两天才发现是printf内部用了互斥锁,中断里拿不到锁就死等了。
  • 日志缓冲区要够大。 如果日志输出到环形缓冲区,缓冲区大小至少能存下最近100条日志。太小的话,关键信息还没看到就被覆盖了。
  • 发布版本记得关掉DEBUG。 我建议在makefile里统一控制,而不是手动注释代码。手动操作总会漏掉一两个。
  • 日志字符串别太长。 嵌入式环境里,栈空间和Flash都很宝贵。每条日志控制在80字节以内,够用就好。

日志调试这门手艺,说白了就是「在合适的地方、用合适的级别、输出合适的信息」。级别设计好了,分析起来事半功倍;级别乱设,日志就是一堆噪音。希望今天的内容,能帮你把日志真正用起来,而不是沦为摆设。


公众号:蓝海资料掘金营,微信deep3321