第2章:日志系统设计——级别、格式与环形缓冲区

日志系统,说白了就是嵌入式系统的「黑匣子」。我做了十几年嵌入式开发,见过太多产品出问题后只能干瞪眼的场景——没有日志,你连问题出在哪都不知道。这一章,咱们就把日志系统的基础打牢。

2.1 日志级别:DEBUG / INFO / WARN / ERROR

日志级别不是拍脑袋定的。我习惯把级别看作「严重程度」和「输出开关」的组合。你想想看,如果所有日志都打开,系统跑起来会慢得让你怀疑人生。所以,分级是必须的。

级别 含义 典型场景 我建议的开关策略
DEBUG 调试信息,开发阶段用 变量值、函数入口/出口、循环计数 发布版默认关闭,调试版打开
INFO 正常运行时的重要事件 系统启动、任务切换、外设初始化成功 发布版保留,但控制输出频率
WARN 异常但不影响运行 内存接近上限、重试次数超阈值 始终打开,用于预警
ERROR 严重错误,需要立即处理 硬件故障、看门狗复位、任务挂死 强制输出,不可关闭
我的经验:DEBUG 级别千万别在中断服务函数里用。我曾经在一个定时器中断里打印调试信息,结果系统直接卡死——printf 本身是阻塞的,中断里调用等于自杀。

2.2 日志格式规范:时间戳 + 模块 + 内容

日志格式不统一,分析起来就是灾难。我见过最离谱的日志,只有一行数字,连是谁打的都不知道。所以,格式必须定死。

我个人习惯的格式模板:

[时间戳] [级别] [模块] [文件名:行号] 内容

举个例子:

[2025-03-21 14:32:18.456] [INFO] [UART_DRV] [uart.c:128] 波特率配置完成: 115200

为什么要有时间戳?因为你要知道事件发生的顺序。为什么要有模块?因为你要快速定位是哪个子系统出了问题。为什么要有文件名和行号?因为代码改了之后,你还能找到原始位置。

注意:时间戳的精度要跟系统时钟匹配。如果系统只有毫秒级定时器,就别硬写微秒。我曾经在项目里看到有人用 RTC 的秒级时间戳,结果两个事件间隔 200ms,日志里显示同一秒——根本没法分析。

2.3 环形缓冲区实现

日志输出不能直接写文件,因为嵌入式系统没有文件系统,或者 Flash 寿命有限。环形缓冲区(Ring Buffer)是标准解法。说白了,就是一个固定大小的数组,写满了从头覆盖。

我画了一张图,帮你理解环形缓冲区的核心逻辑:

环形缓冲区结构图 索引0 索引1 索引2 索引3 索引4 索引5 索引6 索引7 写指针 (head) 读指针 (tail) 关键说明: • 写指针(head):指向下一个可写入的位置,写入后自动+1 • 读指针(tail):指向下一个可读取的位置,读取后自动+1 • 当 head == tail 时,缓冲区为空;当 (head+1) % size == tail 时,缓冲区为满

代码实现其实不复杂。我给出一个最简版本,你可以在项目里直接套用:

#define LOG_BUF_SIZE 256

typedef struct {
    char buffer[LOG_BUF_SIZE][128];  // 128字节每行
    uint16_t head;                   // 写指针
    uint16_t tail;                   // 读指针
    uint16_t count;                  // 当前条目数
} ring_log_t;

ring_log_t log_ring;

void log_write(const char *msg) {
    strncpy(log_ring.buffer[log_ring.head], msg, 127);
    log_ring.head = (log_ring.head + 1) % LOG_BUF_SIZE;
    if (log_ring.count < LOG_BUF_SIZE) {
        log_ring.count++;
    } else {
        // 缓冲区满了,tail 也要跟着移动
        log_ring.tail = (log_ring.tail + 1) % LOG_BUF_SIZE;
    }
}

int log_read(char *out, uint16_t size) {
    if (log_ring.count == 0) return -1;
    strncpy(out, log_ring.buffer[log_ring.tail], size - 1);
    log_ring.tail = (log_ring.tail + 1) % LOG_BUF_SIZE;
    log_ring.count--;
    return 0;
}
核心要点:环形缓冲区最怕的就是「写覆盖读」。我建议在写入时判断缓冲区是否已满,如果满了就丢弃最旧的数据(覆盖 tail),而不是阻塞等待。日志系统不能影响主业务逻辑。

2.4 避坑指南

  • 日志字符串太长怎么办?我建议每条日志控制在 128 字节以内。超过的部分截断,或者分多条输出。我曾经见过有人一条日志打了 2KB,直接把环形缓冲区撑爆了。
  • 多任务环境怎么保证线程安全?写入操作必须加锁。我习惯用关中断的方式保护临界区,因为日志写入时间很短,关中断不会影响实时性。
  • 日志输出到串口还是缓冲区?我的做法是:调试阶段同时输出到串口和缓冲区;发布阶段只写缓冲区,串口关闭。这样既不影响性能,又能在出问题时通过串口导出日志。
血的教训:我曾经在一个项目里,日志缓冲区大小设成了 64 条,结果系统跑了一天后,最早的日志已经被覆盖了 100 多次。等出问题想查日志时,关键信息早就没了。所以,缓冲区大小要根据系统运行时间和日志产生频率来算。一般建议至少能保存 5 分钟以上的日志。