日志系统设计:日志级别、环形缓冲区与输出策略

日志系统,说白了就是嵌入式系统的“黑匣子”。

我做了十几年嵌入式开发,最怕的就是现场出问题却拿不到有效信息。没有日志,你就像蒙着眼睛修车。所以这一章,咱们把日志系统从头到尾捋一遍。

日志级别定义

日志级别不是拍脑袋定的。我见过不少团队,上来就定义七八个级别,结果没人记得住。我个人习惯,四个级别就够了:

级别 数值 含义 使用场景
ERROR 0 致命错误 系统无法继续运行,必须人工介入
WARN 1 警告 异常但可恢复,比如通信超时重试
INFO 2 关键信息 状态切换、任务启动/停止、参数配置
DEBUG 3 调试信息 变量值、函数入口/出口、循环计数
我的习惯:发布版本只保留 ERROR 和 WARN。DEBUG 日志全关掉,否则性能会崩。你想想看,一个串口每秒打印几百条 DEBUG,CPU 全耗在输出上了。

级别定义用枚举就行,简单直接:

typedef enum {
    LOG_LEVEL_ERROR = 0,
    LOG_LEVEL_WARN  = 1,
    LOG_LEVEL_INFO  = 2,
    LOG_LEVEL_DEBUG = 3
} log_level_t;

#define LOG_PRINT(level, fmt, ...) \
    do { \
        if ((level) <= g_log_threshold) { \
            log_output(level, fmt, ##__VA_ARGS__); \
        } \
    } while(0)

注意那个 g_log_threshold,运行时可以动态调整。我在项目中遇到过,现场调试时把阈值从 ERROR 降到 DEBUG,瞬间抓到那个间歇性崩溃的 bug。

环形缓冲区实现

为什么要用环形缓冲区?直接打印不行吗?

不行。串口打印是阻塞的,一条日志等几十毫秒,实时性全毁了。环形缓冲区是生产者-消费者模型:中断或任务里只管往缓冲区写,后台慢慢往外吐。

核心结构体长这样:

#define LOG_BUF_SIZE 4096

typedef struct {
    uint8_t  buffer[LOG_BUF_SIZE];
    uint32_t head;
    uint32_t tail;
    uint32_t count;
} ring_buf_t;

写入和读取的逻辑,我直接给代码:

int ring_buf_write(ring_buf_t *rb, const uint8_t *data, uint32_t len) {
    uint32_t i;
    for (i = 0; i < len; i++) {
        if (rb->count >= LOG_BUF_SIZE) {
            return -1;  // 缓冲区满了,丢弃
        }
        rb->buffer[rb->head] = data[i];
        rb->head = (rb->head + 1) % LOG_BUF_SIZE;
        rb->count++;
    }
    return (int)len;
}

int ring_buf_read(ring_buf_t *rb, uint8_t *data, uint32_t len) {
    uint32_t i;
    for (i = 0; i < len; i++) {
        if (rb->count == 0) {
            return (int)i;  // 没数据了
        }
        data[i] = rb->buffer[rb->tail];
        rb->tail = (rb->tail + 1) % LOG_BUF_SIZE;
        rb->count--;
    }
    return (int)len;
}
我曾经踩过的坑:环形缓冲区没有做原子操作保护。中断里写,主循环里读,结果 count 变量被撕裂,数据全乱套了。后来加了关中断保护,或者用无锁队列,才彻底解决。

缓冲区大小怎么定?我一般按 2 的幂次来,比如 1024、2048、4096。这样取模可以用位运算,快很多:

#define LOG_BUF_MASK (LOG_BUF_SIZE - 1)
rb->head = (rb->head + 1) & LOG_BUF_MASK;

日志输出策略

输出方式选哪个?看你的硬件资源和调试需求。我列个对比表:

方式 速度 硬件需求 优点 缺点
UART 慢(115200bps) 一个串口 通用,几乎任何板子都有 速度慢,占用引脚
SWO 快(可达 2Mbps) SWO 引脚 + 调试器 不占额外引脚,速度较快 需要调试器支持,只能输出文本
SEGGER RTT 极快(可达 10MB/s) J-Link 调试器 速度最快,双向通信 需要 J-Link,调试器不能断开

我个人最常用的是 RTT。为什么?快。你想想看,UART 打印一条 100 字节的日志要将近 10ms,RTT 只要几微秒。对于实时性要求高的系统,这差距是致命的。

但 RTT 有个问题:调试器必须一直连着。现场部署时,我一般切回 UART,或者把日志写到 Flash 里,等回来再读。

时间戳打点

没有时间戳的日志,就像没有日期的日记——你只知道发生了什么,不知道什么时候发生的。

时间戳怎么打?我推荐用系统滴答时钟(SysTick)。一般 RTOS 都会提供一个 1ms 的 tick,直接拿来用:

uint32_t get_timestamp_ms(void) {
    return xTaskGetTickCount();  // FreeRTOS 示例
}

如果精度要求更高,可以用硬件定时器。比如 Cortex-M 的 DWT 周期计数器,可以做到 CPU 周期级精度:

uint32_t get_timestamp_cycle(void) {
    return DWT->CYCCNT;
}

日志格式我习惯这样:

[时间戳][级别][模块] 消息内容
[1234567][ERROR][CAN] 总线关闭,尝试恢复...

这样一眼就能看出问题发生的时间点和模块。

关键点:时间戳的精度要和你的问题匹配。查死锁用 ms 级就够了,查中断延迟要用 us 甚至 cycle 级。别杀鸡用牛刀,也别牛刀杀鸡。

整体架构

下面这张图,是我项目里用的日志系统架构:

日志系统整体架构 应用代码 日志 API (LOG_PRINT) 级别过滤 环形缓冲区 UART 输出 SWO 输出 RTT 输出 说明:应用代码通过 LOG_PRINT 写入,经级别过滤后存入环形缓冲区,后台任务按策略输出

这个架构的好处是分层清晰。应用层只管调用 LOG_PRINT,不用管底层怎么输出。想换输出方式?改底层策略就行,上层代码一行不动。