4、日志缓冲区设计:环形缓冲区原理、读写指针管理、缓冲区溢出处理策略

好,咱们接着聊日志系统的核心——缓冲区设计。

说实话,智能手表这玩意儿,RAM 就那么几百 KB,你不可能给日志开个几百 KB 的缓冲区。我见过不少新手,上来就 malloc 一个大数组,结果系统跑着跑着就 OOM 了。嗯,这里就要请出我们的老朋友——环形缓冲区

4.1 环形缓冲区原理

环形缓冲区,说白了就是一个固定大小的数组,加上两个指针。一个写指针,一个读指针。数据写满了,就绕回来覆盖最旧的数据。你想想看,这不就是“循环利用”吗?

我个人习惯用结构体来封装它,这样代码看着清爽,也方便移植。

typedef struct {
    uint8_t *buffer;    // 数据区
    uint32_t size;      // 缓冲区大小(必须是2的幂)
    uint32_t head;      // 写指针
    uint32_t tail;      // 读指针
} ring_buffer_t;

这里有个小细节——size 我建议设置成 2 的幂。为什么?因为可以用位运算代替取模,速度能快不少。我在 STM32F4 上测过,位运算比取模快了将近 10 个时钟周期。对于高频日志写入,这点优化很值。

小技巧: 如果 size 是 2 的幂,那么 index % size 可以写成 index & (size - 1)。比如 size=256,那 head & 0xFF 就是取模结果。

4.2 读写指针管理

读写指针的管理,是环形缓冲区的核心。我见过最典型的 bug,就是指针溢出后没有正确回绕,导致数据错乱。

先看写操作:

uint32_t ring_buffer_write(ring_buffer_t *rb, const uint8_t *data, uint32_t len) {
    uint32_t i;
    for (i = 0; i < len; i++) {
        rb->buffer[rb->head] = data[i];
        rb->head = (rb->head + 1) & (rb->size - 1);
        // 如果写指针追上了读指针,说明缓冲区满了
        if (rb->head == rb->tail) {
            // 这里可以丢弃最旧的数据,或者停止写入
            // 我习惯的做法是:移动读指针,丢弃最旧的数据
            rb->tail = (rb->tail + 1) & (rb->size - 1);
        }
    }
    return i;
}

读操作类似:

uint32_t ring_buffer_read(ring_buffer_t *rb, uint8_t *data, uint32_t len) {
    uint32_t i;
    for (i = 0; i < len; i++) {
        if (rb->head == rb->tail) {
            break;  // 缓冲区空了
        }
        data[i] = rb->buffer[rb->tail];
        rb->tail = (rb->tail + 1) & (rb->size - 1);
    }
    return i;
}

这里有个关键点:空和满的判断。当 head == tail 时,到底是空还是满?我早期做项目时就被这个问题坑过。后来我采用了一个简单的方法——牺牲一个存储单元。也就是说,缓冲区最多存 size - 1 个字节。这样 head == tail 就只表示空,满的条件是 (head + 1) % size == tail

核心原则: 永远不要让 head 追上 tail 之后还继续写。要么丢弃旧数据,要么阻塞等待。智能手表这种实时系统,我建议直接丢弃旧数据。

4.3 缓冲区溢出处理策略

溢出,说白了就是写得太快,读得太慢。我在项目中遇到过好几次,日志打印太频繁,结果把系统搞崩了。嗯,这里分享三种策略:

策略 做法 适用场景
丢弃最旧数据 写指针追上读指针时,读指针前移 日志系统、实时数据采集
阻塞等待 缓冲区满时,写操作阻塞直到有空间 串口打印、低优先级任务
覆盖最新数据 写指针直接覆盖,读指针不动 波形记录、最后N条日志

我个人最常用的是丢弃最旧数据。为什么?因为智能手表上,我们更关心最近的日志。比如系统崩溃前的那几秒,往往是最关键的。你想想看,如果缓冲区里存的是半小时前的日志,那对调试有啥用?

避坑指南: 我曾经在一个项目中用了“覆盖最新数据”策略,结果读线程读到一半,数据被写线程覆盖了,读出来全是乱码。后来我加了一个“正在读”的标志位,写之前先检查一下。切记,多任务环境下一定要考虑同步问题。

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

缓冲区大小 = 最大日志速率 × 最坏情况下的响应时间

比如,日志最大写入速率是 10KB/s,最坏情况下读线程 100ms 才响应一次,那缓冲区至少需要 1KB。再留个 50% 余量,1.5KB 就差不多了。智能手表上,我一般设 2KB 到 4KB,够用。

4.4 实战中的小细节

最后说几个我在实际部署中踩过的坑:

  • 中断中写日志: 中断服务程序里千万别做复杂的写操作。我习惯在中断里只放一个标志位,然后在主循环里批量写入。
  • DMA 配合: 如果 MCU 支持 DMA,可以把日志缓冲区直接映射到 DMA 的源地址,这样读操作不占 CPU。我在 nRF52840 上就这么干过,效果很好。
  • 调试时加个计数器: 我习惯在结构体里加一个 overflow_count,每次发生溢出就加 1。这样调试时能直观看到缓冲区够不够用。

好了,环形缓冲区这块就聊到这儿。说白了,它就是个“固定大小的循环队列”,用好了,日志系统稳如老狗。下一节咱们聊聊日志格式化,怎么把二进制数据变成人能看懂的字符串。