2. 日志系统设计:日志级别定义、日志格式化、日志输出接口、环形缓冲区实现

日志系统,说白了就是嵌入式系统的「黑匣子」。我在做HAL层开发的头两年,其实没太重视这东西。直到有一次,一个偶发的I2C通信异常让我在实验室熬了三个通宵——没有日志,你根本不知道系统崩溃前发生了什么。从那以后,我设计任何模块,第一件事就是先把日志系统搭好。

今天我们就来聊聊,一个靠谱的HAL层日志系统该怎么设计。我会从四个核心维度展开:日志级别、格式化、输出接口,还有环形缓冲区。

2.1 日志级别定义

日志级别不是拍脑袋定的。我个人习惯参考Linux内核和Android HAL的规范,再结合嵌入式场景做裁剪。常见的级别定义如下:

级别 数值 含义 典型场景
FATAL 0 致命错误 系统无法继续运行,必须复位
ERROR 1 错误 功能异常,但系统可继续
WARN 2 警告 非预期状态,不影响当前功能
INFO 3 信息 关键流程节点、状态切换
DEBUG 4 调试 开发阶段详细输出
TRACE 5 跟踪 函数调用、变量值变化

核心原则:发布版本只保留FATAL、ERROR、WARN三级。DEBUG和TRACE只在开发版启用。你想想看,如果量产设备每秒输出几百条调试日志,不光浪费Flash寿命,还会拖慢系统响应。

我在项目中遇到过一件事:有个同事把日志级别全开,结果设备跑了一个月,Flash写坏了。嗯,这就是没做好级别控制的代价。

2.2 日志格式化

日志格式要「机器可读,人眼可看」。我建议的格式模板是:

[时间戳][级别][模块名:行号] 消息内容

举个例子:

[2025-01-15 14:30:22.456][ERROR][I2C:128] 总线超时,重试第3次

为什么要包含行号?因为嵌入式环境里,你经常没法用GDB。有了行号,定位问题快得多。我曾经靠这个格式,十分钟就找到了一个DMA中断冲突的bug。

格式化函数实现如下:

void log_format(uint8_t level, const char *module, int line, const char *fmt, ...) {
    char buffer[LOG_BUFFER_SIZE];
    int len = 0;
    
    // 1. 时间戳
    uint32_t ms = get_system_ms();
    len += snprintf(buffer + len, sizeof(buffer) - len, 
                    "[%u.%03u]", ms / 1000, ms % 1000);
    
    // 2. 级别
    const char *level_str[] = {"FATAL", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"};
    len += snprintf(buffer + len, sizeof(buffer) - len, 
                    "[%s]", level_str[level]);
    
    // 3. 模块和行号
    len += snprintf(buffer + len, sizeof(buffer) - len, 
                    "[%s:%d]", module, line);
    
    // 4. 用户消息
    va_list args;
    va_start(args, fmt);
    len += vsnprintf(buffer + len, sizeof(buffer) - len, fmt, args);
    va_end(args);
    
    // 5. 输出
    log_output(buffer, len);
}

小技巧:时间戳用系统滴答计数(ms)而不是RTC时间。原因很简单——RTC可能还没初始化,日志系统就得先跑起来。我一般用SysTick或定时器计数,精度够用,还省事。

2.3 日志输出接口

输出接口要抽象化。你不能把日志写死在UART上,万一客户要求用SPI屏显示呢?

我习惯定义一个输出接口结构体:

typedef struct {
    void (*init)(void);
    void (*write)(const char *data, uint16_t len);
    void (*flush)(void);
} log_output_t;

然后注册不同的后端:

static log_output_t uart_output = {
    .init = uart_init,
    .write = uart_write,
    .flush = uart_flush,
};

static log_output_t flash_output = {
    .init = flash_log_init,
    .write = flash_log_write,
    .flush = flash_log_flush,
};

// 运行时切换
void log_set_output(log_output_t *output) {
    g_current_output = output;
    g_current_output->init();
}

这样做的好处是啥?你想想看,调试阶段用UART输出到PC,量产阶段切到Flash存储,甚至可以通过蓝牙远程拉取日志。接口不变,底层随便换。

注意:输出函数里不能有阻塞等待。尤其是Flash写入,动辄几十毫秒。我建议用DMA + 双缓冲,或者干脆把日志先扔进环形缓冲区,由后台任务慢慢刷。

2.4 环形缓冲区实现

环形缓冲区是日志系统的「心脏」。为什么不用链表?因为嵌入式环境里,动态内存分配是禁忌——你不知道什么时候会分配失败,也不知道碎片化有多严重。

环形缓冲区,说白了就是一个固定大小的数组,加上读写指针。核心结构如下:

#define RING_BUF_SIZE 4096

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint32_t write_idx;
    volatile uint32_t read_idx;
} ring_buffer_t;

读写逻辑:

// 写入
int ring_buf_write(ring_buffer_t *rb, const uint8_t *data, uint32_t len) {
    for (uint32_t i = 0; i < len; i++) {
        uint32_t next = (rb->write_idx + 1) % RING_BUF_SIZE;
        // 如果缓冲区满了,覆盖旧数据
        if (next == rb->read_idx) {
            rb->read_idx = (rb->read_idx + 1) % RING_BUF_SIZE;
        }
        rb->buffer[rb->write_idx] = data[i];
        rb->write_idx = next;
    }
    return len;
}

// 读取
int ring_buf_read(ring_buffer_t *rb, uint8_t *data, uint32_t max_len) {
    uint32_t count = 0;
    while (rb->read_idx != rb->write_idx && count < max_len) {
        data[count++] = rb->buffer[rb->read_idx];
        rb->read_idx = (rb->read_idx + 1) % RING_BUF_SIZE;
    }
    return count;
}

关键点:读写指针都用volatile修饰。为什么?因为中断里可能会写日志,主循环里读日志。没有volatile,编译器优化后可能永远读不到新数据。我曾经在这个坑里摔过一次,排查了整整一天。

这里我画了一张环形缓冲区的工作流程图,帮你直观理解:

环形缓冲区工作流程 0 1 2 3 4 5 6 7 8 写指针 读指针 写入流程 ① 检查写指针下一个位置是否等于读指针 ② 如果相等 → 缓冲区满,读指针前移(覆盖旧数据) ③ 写入数据,写指针前移 写指针追上读指针 = 缓冲区满,自动覆盖最旧数据

你看这个图,写指针一直在往前跑,读指针在后面追。当写指针快追上读指针时,说明缓冲区快满了。这时候有两种策略:

  • 丢弃新日志:保证旧日志不丢失,但可能漏掉关键信息
  • 覆盖旧日志:保留最新信息,但最开始的日志会丢失

我个人推荐覆盖策略。为什么?因为出问题时,你更关心「崩溃前发生了什么」,而不是「系统启动时干了什么」。

2.5 避坑指南

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

  • 中断里别用复杂格式化:snprintf这种函数可能很慢,还可能有重入问题。我一般在中断里只做简单拷贝,格式化交给后台任务。
  • 注意临界区保护:如果日志系统被多个任务或中断同时调用,读写指针必须加锁。最简单的做法是关中断,但关中断时间不能太长。
  • 缓冲区大小要合理:太小容易丢日志,太大浪费RAM。我一般按「系统最大日志产生速率 × 期望保留时间」来估算。比如每秒最多100条日志,每条平均50字节,想保留10秒,那就是50KB。

我的习惯:开发阶段用4KB环形缓冲区 + UART输出。量产阶段用16KB环形缓冲区 + Flash存储。Flash里再分两个区,轮流写,防止单区损坏导致所有日志丢失。

好了,日志系统的核心设计就这些。下一节我们会聊错误码的规范定义和传递机制,到时候再细说。

可可精品资料库