日志系统设计:日志级别、日志格式、异步日志、日志轮转

日志这东西,说白了就是嵌入式系统的「黑匣子」。我做了十几年嵌入式开发,遇到过太多线上问题——程序跑飞了、内存溢出了、外设莫名其妙不工作了。没有日志,你就像瞎子摸象。有了日志,你才能知道系统死前到底发生了什么。

今天我们来聊聊日志系统的四个核心设计点:日志级别、日志格式、异步日志、日志轮转。这四点做好了,你的日志系统才算真正能用、好用、耐看。

一、日志级别:别什么都往日志里塞

我记得刚入行那会儿,有个同事特别喜欢用 printf 到处打信息。结果产品上线后,串口每秒输出几百行日志,CPU 占用率直接飙到 30%。这哪是调试?这是自残。

日志级别的作用,就是让你能按需控制输出量。常见的分级是这样的:

级别 数值 含义 使用场景
FATAL 0 致命错误 系统无法继续运行,必须复位
ERROR 1 错误 功能异常,但系统还能跑
WARN 2 警告 潜在问题,比如内存快满了
INFO 3 信息 关键流程节点,比如初始化完成
DEBUG 4 调试 开发阶段用的详细输出
TRACE 5 跟踪 函数调用、变量变化等细粒度信息

我个人习惯的做法是:在代码里定义一个全局的日志级别变量。发布版本默认设为 INFO,调试版本设为 DEBUG 或 TRACE。这样线上跑的时候不会被打扰,出问题了再远程调高级别。

核心原则:日志级别越低(数值越大),输出越详细。生产环境建议用 INFO 或 WARN 级别,别开 DEBUG。

二、日志格式:统一模板,方便解析

日志格式这件事,我踩过坑。以前有个项目,每个模块的日志格式都不一样——有的带时间戳,有的不带;有的用逗号分隔,有的用空格。结果出问题后,写脚本分析日志时,光解析格式就花了两天。

所以,我建议所有日志统一成这样的格式:

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

举个例子:

[2025-01-15 14:23:45.678] [ERROR] [UART] [uart_driver.c:128] 接收缓冲区溢出,丢弃数据

为什么要这么设计?

  • 时间戳:精确到毫秒,方便定位时序问题
  • 级别:一眼看出严重程度
  • 模块名:快速定位是哪个子系统出的问题
  • 文件名:行号:直接定位代码位置,省去翻代码的时间

在 C 语言里,可以用宏来实现自动填充文件名和行号:

#define LOG_ERROR(mod, fmt, ...) \
    log_output(LOG_LEVEL_ERROR, mod, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

小技巧:时间戳最好用 UTC 时间,避免时区问题。如果系统没有 RTC,可以用系统启动后的相对时间(比如 ticks)。

三、异步日志:别让日志拖慢系统

同步日志的问题在于:每次写日志都要等 I/O 完成。如果日志输出到串口或文件,写一次可能要几毫秒甚至几十毫秒。在高频日志场景下,这会让系统响应变慢。

异步日志的思路很简单:把日志消息丢到一个队列里,然后由专门的日志线程或任务去处理。调用方只管往队列里塞,不用等。

我画了一张图,帮你理解这个流程:

应用线程 调用 LOG_INFO() 日志队列 环形缓冲区 日志线程 写入文件/串口 入队 出队 关键设计要点 • 队列用无锁环形缓冲区,避免加锁影响性能 • 队列满了怎么办?直接丢弃新日志,别阻塞调用方 • 日志线程优先级别太高,别抢了业务线程的时间

异步日志的代码实现也不复杂。核心就是一个环形缓冲区和两个指针:写指针和读指针。

#define LOG_QUEUE_SIZE 256

typedef struct {
    char buffer[LOG_QUEUE_SIZE][128];  // 日志消息缓冲区
    volatile uint32_t head;            // 写指针
    volatile uint32_t tail;            // 读指针
} log_queue_t;

// 入队(由应用线程调用)
int log_enqueue(log_queue_t *q, const char *msg) {
    uint32_t next = (q->head + 1) % LOG_QUEUE_SIZE;
    if (next == q->tail) {
        return -1;  // 队列满了,丢弃
    }
    strncpy(q->buffer[q->head], msg, 127);
    q->head = next;
    return 0;
}

// 出队(由日志线程调用)
int log_dequeue(log_queue_t *q, char *out) {
    if (q->tail == q->head) {
        return -1;  // 队列为空
    }
    strncpy(out, q->buffer[q->tail], 127);
    q->tail = (q->tail + 1) % LOG_QUEUE_SIZE;
    return 0;
}

注意:我曾经在一个项目中,日志队列满了之后阻塞了调用线程,结果导致系统死锁。记住:日志是辅助手段,不能影响主业务。队列满了就丢,别犹豫。

四、日志轮转:别让日志撑爆存储

嵌入式设备的存储空间通常很有限。如果不做日志轮转,日志文件会一直增长,直到把 Flash 或 SD 卡写满。到时候系统可能直接挂掉。

日志轮转的常见策略有两种:

  1. 按大小轮转:单个日志文件达到指定大小(比如 1MB)后,自动切换到下一个文件
  2. 按时间轮转:每天或每小时生成一个新的日志文件

我一般两种结合用。比如:每个日志文件最大 512KB,最多保留 10 个文件。超过 10 个就覆盖最旧的那个。

实现思路是这样的:

#define LOG_FILE_MAX_SIZE  (512 * 1024)  // 512KB
#define LOG_FILE_MAX_COUNT 10

void log_rotate(void) {
    static int current_file = 0;
    static uint32_t file_size = 0;

    if (file_size >= LOG_FILE_MAX_SIZE) {
        current_file = (current_file + 1) % LOG_FILE_MAX_COUNT;
        file_size = 0;
        // 打开新的日志文件
        sprintf(filename, "log_%d.txt", current_file);
        // 如果文件已存在,直接覆盖
    }
    // 写入日志,更新 file_size
}

建议:日志文件名最好带上序号或时间戳,比如 log_001.txt、log_002.txt。这样排查问题时,能按时间顺序查看。

五、避坑指南:我踩过的那些坑

最后分享几个我亲身经历过的教训:

  • 日志里不要用动态内存分配:malloc 可能失败,而且会引入不确定性。用静态缓冲区就好。
  • 日志字符串要短:我见过有人把整个数据包打印出来,一次日志输出几百字节。这在低带宽串口上会卡死系统。
  • 注意日志的原子性:多线程环境下,如果日志输出被中断打断,可能输出乱码。用互斥锁或关中断保护一下。
  • 日志别写敏感信息:密码、密钥这些千万别打出来。我曾经见过一个产品,日志里直接打印了 WiFi 密码……

好了,日志系统的四个核心点就聊到这里。记住:好的日志系统,平时感觉不到它的存在,出问题时它就是你最得力的助手。


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