2. 日志系统架构:环形缓冲区设计、日志后端驱动、异步日志写入

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

我见过太多项目,出了Bug只能靠LED闪烁猜问题。那种感觉,就像在黑夜里找钥匙,效率极低。一个设计良好的日志系统,能让你在几分钟内定位到问题根源。

这一章,我们聚焦日志系统的三个核心:环形缓冲区、后端驱动和异步写入。这三者配合好了,你的日志系统既不会拖慢主业务,也不会在关键时刻丢数据。

2.1 环形缓冲区:无锁设计的基石

为什么用环形缓冲区?

你想想看,嵌入式系统里,内存是稀缺资源。如果每次产生日志都去malloc,不仅慢,还容易产生内存碎片。环形缓冲区,说白了就是一块固定大小的内存,头尾相连,循环使用。

核心优势:

  • 无动态内存分配,避免碎片
  • 读写指针分离,支持无锁单生产者单消费者
  • 固定内存开销,可预测

我在项目中遇到过一个问题:系统跑着跑着突然卡死。查了半天,发现是日志缓冲区满了之后,写操作阻塞了中断服务程序。从那以后,我坚持用覆盖式环形缓冲区——新日志覆盖最旧的日志,保证写操作永远是O(1)。

2.1.1 数据结构设计

/* 环形缓冲区控制块 */
typedef struct {
    uint8_t *buffer;        /* 缓冲区基地址 */
    uint32_t size;          /* 缓冲区大小(必须为2的幂) */
    volatile uint32_t head; /* 写指针 */
    volatile uint32_t tail; /* 读指针 */
} ringbuf_t;

/* 初始化 */
void ringbuf_init(ringbuf_t *rb, uint8_t *buf, uint32_t size) {
    rb->buffer = buf;
    rb->size = size;
    rb->head = 0;
    rb->tail = 0;
}

/* 写入一个字节 */
int ringbuf_put(ringbuf_t *rb, uint8_t data) {
    uint32_t next_head = (rb->head + 1) & (rb->size - 1);
    if (next_head == rb->tail) {
        /* 缓冲区满,覆盖最旧数据 */
        rb->tail = (rb->tail + 1) & (rb->size - 1);
    }
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 0;
}

小技巧:缓冲区大小设为2的幂,这样取模运算可以用位与(&)代替,效率高很多。我习惯用256、512、1024这些值。

2.1.2 无锁读写的关键

为什么能做到无锁?

因为只有一个生产者(中断或主循环写日志)和一个消费者(后端驱动读日志)。只要保证读写指针的原子性,就不需要互斥锁。

我曾经踩过一个坑:在Cortex-M3上,如果指针不是32位对齐的,读写操作就不是原子的。嗯,这里要注意,指针变量一定要声明为volatile,并且确保编译器不会优化掉读写顺序。

避坑指南:我曾经在STM32F4上遇到一个诡异问题——日志偶尔会丢失几行。查了两天才发现,是编译器把tail指针的读取优化到了循环外面。加上volatile关键字后,问题解决。

2.2 日志后端驱动:把数据送出去

环形缓冲区只是暂存。真正的难点在于,怎么把日志数据可靠地送出去?

后端驱动,就是负责这件事的。它从环形缓冲区读取日志,然后通过UART、SPI、USB或者SD卡写出去。

2.2.1 后端驱动架构

我习惯把后端驱动设计成插件式的。这样,换一个输出介质,只需要换一个驱动模块,上层代码完全不用改。

/* 后端驱动接口 */
typedef struct {
    int (*init)(void);
    int (*write)(const uint8_t *data, uint32_t len);
    int (*flush)(void);
} log_backend_t;

/* 注册后端 */
int log_backend_register(log_backend_t *backend);

/* 示例:UART后端 */
static int uart_backend_write(const uint8_t *data, uint32_t len) {
    /* 使用DMA发送,不阻塞CPU */
    return HAL_UART_Transmit_DMA(&huart1, (uint8_t *)data, len);
}

log_backend_t uart_backend = {
    .init = uart_backend_init,
    .write = uart_backend_write,
    .flush = uart_backend_flush,
};

设计原则:

  • write函数不能阻塞,必须立即返回
  • 如果使用DMA,要处理好DMA完成中断
  • flush函数用于强制刷出缓冲区数据

2.2.2 多后端支持

实际项目中,我经常同时使用多个后端。比如:

后端类型 适用场景 注意事项
UART/DMA 开发调试阶段 波特率不要太高,115200够用
SD卡/FATFS 长时间数据记录 注意文件系统写入延迟
USB CDC 与PC上位机通信 需要处理USB枚举和重连
SEGGER RTT J-Link调试 速度极快,但依赖调试器

我个人习惯,开发阶段用UART+RTT双后端。UART用于常规日志,RTT用于高频调试信息。这样,即使UART被其他任务占用,RTT还能正常工作。

2.3 异步日志写入:不阻塞主业务

这是整个日志系统的灵魂。

你想想看,如果每次写日志都要等UART发送完,那主业务还跑不跑了?尤其是在中断里写日志,绝对不能阻塞。

2.3.1 异步写入流程

异步写入的核心思想:生产者只负责把日志丢进环形缓冲区,消费者(后端驱动)在空闲时把数据刷出去。

  1. 日志产生:中断或主循环调用log_printf,格式化后写入环形缓冲区
  2. 触发传输:设置一个标志位,或者触发一个软件中断
  3. 后端处理:在空闲任务或低优先级中断中,读取环形缓冲区,通过DMA发送
  4. 完成回调:DMA传输完成后,释放缓冲区空间

经验之谈:我建议在空闲任务(IDLE task)中处理后端写入。这样,系统忙的时候日志会积压在环形缓冲区,系统闲的时候再批量刷出去。既不影响实时性,又能充分利用带宽。

2.3.2 实现示例

/* 日志写入API(生产者) */
void log_printf(const char *fmt, ...) {
    va_list args;
    va_start(args, fmt);
    
    /* 格式化到临时缓冲区 */
    char temp_buf[LOG_LINE_MAX];
    int len = vsnprintf(temp_buf, LOG_LINE_MAX, fmt, args);
    va_end(args);
    
    if (len <= 0) return;
    
    /* 写入环形缓冲区 */
    for (int i = 0; i < len; i++) {
        ringbuf_put(&log_rb, temp_buf[i]);
    }
    
    /* 触发后端处理 */
    log_backend_trigger();
}

/* 后端处理函数(消费者,在空闲任务中调用) */
void log_backend_process(void) {
    uint8_t buf[64];
    uint32_t len = 0;
    
    /* 从环形缓冲区读取一批数据 */
    while (len < sizeof(buf)) {
        if (ringbuf_get(&log_rb, &buf[len]) != 0) {
            break;
        }
        len++;
    }
    
    if (len > 0) {
        /* 通过DMA发送 */
        backend_write(buf, len);
    }
}

2.3.3 性能对比

写入方式 阻塞时间 CPU占用 适用场景
同步阻塞 高(ms级) 高 仅用于调试,不用于产品
中断发送 低(us级) 中 低频日志,中断频繁
DMA异步 极低(ns级) 低 高频日志,产品级推荐
DMA+双缓冲 零阻塞 极低 极致性能,RTOS环境

我记得有一次,一个电机控制项目,控制周期只有50us。如果用同步日志,一个printf就能让控制周期超时。换成DMA异步写入后,日志写入时间从50us降到了不到1us,问题迎刃而解。

2.4 整体架构图

下面这张图,展示了整个日志系统的数据流。你可以看到,从日志产生到最终输出,中间经过了环形缓冲区和后端驱动两层解耦。

日志系统整体架构 日志产生 中断 / 主循环 / 任务 格式化 vsnprintf / 自定义格式 环形缓冲区 无锁写入,覆盖旧数据 日志后端驱动(消费者) 空闲任务 / 低优先级中断 中读取环形缓冲区 通过DMA批量发送,不阻塞CPU 触发传输 UART / DMA 串口调试 SD卡 / FATFS 数据记录 USB CDC 上位机通信 SEGGER RTT J-Link调试 关键设计要点: 1. 生产者(日志产生)与消费者(后端驱动)通过环形缓冲区解耦 2. 后端驱动使用DMA异步写入,保证主业务零阻塞

2.5 避坑与最佳实践

最后,分享几个我踩过的坑和总结的经验:

避坑1:我曾经在环形缓冲区里存了时间戳,结果发现时间戳是在写入时获取的,而不是在日志产生时获取的。对于高频日志,这两者可能相差几十微秒,导致时序分析完全错误。记住:时间戳一定要在日志产生时获取。

避坑2:DMA传输完成中断里,不要直接操作环形缓冲区的读指针。我曾经这么干过,结果导致读写指针不同步,日志乱序。正确的做法是:在DMA完成中断里设置一个标志,然后在空闲任务中更新读指针。

最佳实践:

  • 环形缓冲区大小至少能容纳100条日志,避免高频写入时丢失
  • 日志等级(ERROR/WARN/INFO/DEBUG)要支持运行时动态调整
  • 关键日志(如系统崩溃)要使用同步写入,确保不丢失
  • 定期检查环形缓冲区的溢出次数,作为系统健康度的指标