日志系统搭建:日志等级定义、日志格式规范、环形缓冲区实现、日志输出接口封装

日志系统,说白了就是嵌入式系统的「黑匣子」。我做了这么多年嵌入式测试,遇到过太多现场复现不了的bug。没有日志,你就像蒙着眼睛找针。这一章,咱们就把这个黑匣子搭起来。

日志等级定义

日志等级不是拍脑袋定的。我见过不少团队,日志等级搞得比操作系统优先级还复杂,结果现场一跑,全是DEBUG信息,关键错误反而被淹没了。

我个人习惯,嵌入式系统用5级就够了:

等级 宏定义 数值 使用场景
ERROR LOG_LEVEL_ERROR 1 系统无法继续运行的致命错误
WARN LOG_LEVEL_WARN 2 不影响运行但需要关注的异常
INFO LOG_LEVEL_INFO 3 系统关键状态变化、初始化完成等
DEBUG LOG_LEVEL_DEBUG 4 开发调试阶段的详细流程信息
TRACE LOG_LEVEL_TRACE 5 函数进出、变量值等最细粒度信息
我的经验:发布版本默认只开ERROR和WARN。INFO在需要现场分析时动态打开。DEBUG和TRACE只在开发阶段用。这样既保证性能,又保留现场诊断能力。

日志格式规范

格式不统一,日志分析工具就没法用。我踩过这个坑——有一次现场出了问题,三个工程师写的日志格式都不一样,解析脚本写了三天。

我建议的格式模板:

[时间戳][等级][模块:函数:行号] 日志内容

具体到代码实现:

#define LOG_FORMAT "[%s][%s][%s:%s:%d] %s\r\n"
// 示例输出:
// [2024-01-15 14:30:25.123][ERROR][CAN:CanSend:128] 总线离线,重试第3次

这里有几个要点:

  • 时间戳:必须带毫秒。我遇到过两次日志时间一模一样的情况,后来才发现是没加毫秒,根本没法还原时序。
  • 模块名:用缩写,比如CAN、UART、FATFS。太长会浪费环形缓冲区空间。
  • 行号:用__LINE__宏。别嫌麻烦,出问题的时候,行号能帮你省半天时间。

环形缓冲区实现

为什么用环形缓冲区?嵌入式系统写日志不能阻塞主流程。你想想看,如果每次写日志都去刷Flash,系统响应就完蛋了。

环形缓冲区的核心结构:

typedef struct {
    uint8_t *buffer;      // 缓冲区基地址
    uint32_t size;        // 缓冲区大小
    volatile uint32_t head;  // 写指针
    volatile uint32_t tail;  // 读指针
} RingBuffer_t;

写操作的核心逻辑:

int32_t RingBuf_Write(RingBuffer_t *rb, const uint8_t *data, uint32_t len) {
    uint32_t next_head;
    
    for (uint32_t i = 0; i < len; i++) {
        next_head = (rb->head + 1) % rb->size;
        
        // 缓冲区满,覆盖最旧数据
        if (next_head == rb->tail) {
            rb->tail = (rb->tail + 1) % rb->size;
        }
        
        rb->buffer[rb->head] = data[i];
        rb->head = next_head;
    }
    
    return len;
}
注意:head和tail必须用volatile修饰。我曾经在一个多任务系统里忘了加volatile,结果编译器优化后,写指针一直不更新,日志全丢了。查了两天才找到原因。

缓冲区大小怎么定?我一般按这个公式算:

  • 系统峰值日志产生速率 × 最大容忍延迟时间 × 1.5(安全系数)
  • 比如:每秒最多产生2KB日志,后台任务每100ms刷一次,那就是 2KB × 0.1s × 1.5 = 300字节。实际取整到512字节或1KB。

下面这张图展示了环形缓冲区的工作流程:

环形缓冲区工作流程 环形缓冲区(大小=8) head(写位置) tail(读位置) 工作流程: 1. 日志写入 → 数据存入head位置,head后移 2. 后台任务读取 → 从tail位置取出数据,tail后移 3. 缓冲区满 → 覆盖最旧数据(tail被迫后移) 4. 缓冲区空 → head == tail,读取任务等待 数据流向

日志输出接口封装

接口封装的核心思想:上层调用者不需要知道日志最终写到哪。是串口、文件、还是网络?那是底层的事。

我常用的接口设计:

// 日志初始化
int Log_Init(LogOutputFunc output, uint32_t buf_size);

// 日志写入(对外接口)
void Log_Write(uint8_t level, const char *module, 
               const char *func, int line, 
               const char *fmt, ...);

// 日志刷出(后台任务调用)
int Log_Flush(void);

// 日志等级动态设置
void Log_SetLevel(uint8_t level);

具体实现时,我习惯用可变参数宏封装:

#define LOG_ERROR(mod, fmt, ...) \
    Log_Write(LOG_LEVEL_ERROR, mod, __FUNCTION__, __LINE__, fmt, ##__VA_ARGS__)

#define LOG_INFO(mod, fmt, ...) \
    if(g_log_level >= LOG_LEVEL_INFO) \
        Log_Write(LOG_LEVEL_INFO, mod, __FUNCTION__, __LINE__, fmt, ##__VA_ARGS__)

#define LOG_DEBUG(mod, fmt, ...) \
    if(g_log_level >= LOG_LEVEL_DEBUG) \
        Log_Write(LOG_LEVEL_DEBUG, mod, __FUNCTION__, __LINE__, fmt, ##__VA_ARGS__)
关键设计点:
  • DEBUG和TRACE等级加条件判断,避免不必要的函数调用开销
  • Log_Write内部只做格式化+写入环形缓冲区,不做IO操作
  • Log_Flush由低优先级后台任务调用,负责实际输出

输出函数注册机制也很重要。我做过一个项目,调试时用串口输出,量产时改成SPI Flash存储。接口不变,只换注册函数:

// 串口输出
int Uart_Output(const uint8_t *data, uint32_t len) {
    return HAL_UART_Transmit(&huart1, data, len, 100);
}

// Flash存储输出
int Flash_Output(const uint8_t *data, uint32_t len) {
    return Flash_Write_Log(data, len);
}

// 初始化时选择
Log_Init(Uart_Output, 1024);   // 调试版
// Log_Init(Flash_Output, 2048); // 量产版
我曾经踩过的坑:在中断服务函数里直接调用Log_Write。中断里用printf或者复杂格式化,轻则系统卡死,重则栈溢出。正确的做法是:中断里只设标志位,由任务上下文去调用日志接口。