一、为什么日志采集需要环形缓冲区?
做嵌入式开发的朋友,一定遇到过这种场景:系统突然崩溃了,你想查一下崩溃前的日志,结果发现——日志丢了。或者更糟,日志把系统拖死了。
为什么会这样?
传统的日志方案,无非两种:
- 直接写文件:每次产生日志都调用文件系统API。文件系统操作涉及磁盘I/O,慢得离谱。在高频日志场景下,CPU大部分时间都在等I/O完成。
- 一次性分配大缓冲区:比如开一个1MB的数组,日志往里写。写满了怎么办?要么覆盖旧数据,要么停止写入。前者丢数据,后者阻塞系统。
我在一个车载项目中就吃过这个亏。当时用了一个静态数组做日志缓冲,结果某个模块疯狂打日志,直接把缓冲区撑爆了,系统进入异常处理——连异常原因都没来得及打印出来。嗯,从那以后,我再也不敢用这种"裸缓冲区"了。
环形缓冲区(Ring Buffer)就是来解决这个问题的。它本质上是一个固定大小的缓冲区,但通过两个指针——头指针和尾指针——实现了循环利用。数据写满了?没关系,覆盖最旧的数据。读数据?从最新位置开始读。整个过程不需要动态内存分配,不需要移动数据,时间复杂度O(1)。
核心结论:环形缓冲区让日志采集做到了"零阻塞、零分配、零拷贝"。在DMA加持下,CPU几乎不用管日志搬运的事。
二、环形缓冲区的核心原理
2.1 头指针和尾指针
环形缓冲区本质上就是一个数组,加上两个指针:
- 头指针(Head):指向下一个要写入的位置。写数据时,数据放在Head位置,然后Head向前移动。
- 尾指针(Tail):指向下一个要读取的位置。读数据时,从Tail位置取数据,然后Tail向前移动。
当Head追上Tail时,缓冲区满了。当Tail追上Head时,缓冲区空了。
你想想看,这不就是一个"生产者-消费者"模型吗?日志采集线程是生产者,DMA传输或日志输出线程是消费者。环形缓冲区就是它们之间的通道。
我的习惯:我一般把Head和Tail都定义为无符号整型,而不是指针。这样在做取模运算时更安全,不容易踩内存。
2.2 环形缓冲区的三种状态
| 状态 | 条件 | 说明 |
|---|---|---|
| 空 | Head == Tail | 没有数据可读 |
| 满 | (Head + 1) % Size == Tail | 不能再写入(预留一个空位区分空/满) |
| 正常 | 其他情况 | 可以读写 |
这里有个细节:为什么满状态要预留一个空位?因为如果不预留,空和满都是Head==Tail,你没法区分。当然,你也可以用计数器来区分,但那样就多了一个共享变量,在多核环境下需要加锁。我个人倾向于预留一个空位,简单可靠。
2.3 核心操作代码
直接看代码吧。这是我项目中实际用过的环形缓冲区实现,去掉了无关细节:
#define RING_BUF_SIZE 1024 // 必须是2的幂
typedef struct {
uint32_t head;
uint32_t tail;
uint8_t buffer[RING_BUF_SIZE];
} ring_buf_t;
// 写入一个字节
int ring_buf_put(ring_buf_t *rb, uint8_t data)
{
uint32_t next_head = (rb->head + 1) & (RING_BUF_SIZE - 1);
if (next_head == rb->tail) {
return -1; // 缓冲区满了
}
rb->buffer[rb->head] = data;
rb->head = next_head;
return 0;
}
// 读取一个字节
int ring_buf_get(ring_buf_t *rb, uint8_t *data)
{
if (rb->head == rb->tail) {
return -1; // 缓冲区空了
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) & (RING_BUF_SIZE - 1);
return 0;
}
注意看,我用的是 & (RING_BUF_SIZE - 1) 而不是 % RING_BUF_SIZE。为什么?因为取模运算在ARM Cortex-M上要几十个周期,而位与运算只要1个周期。前提是缓冲区大小必须是2的幂。这个优化在日志高频写入时效果很明显。
我曾经踩过的坑:有一次我把RING_BUF_SIZE定义成了1023,结果位与运算直接越界了。排查了半天才发现是大小不是2的幂。所以,一定要确保缓冲区大小是2的幂,并且在代码里加个静态断言。
三、为什么环形缓冲区特别适合DMA日志采集?
说白了,DMA和环形缓冲区是天作之合。
DMA传输的特点是:它只管搬运数据,不关心数据含义。你给它一个源地址、一个目的地址、一个长度,它就闷头搬。搬完了触发中断告诉你。
环形缓冲区的特点是:数据是连续存放的(虽然是逻辑上的环,但物理内存是线性的)。DMA可以直接从环形缓冲区里搬数据,不需要额外的拷贝。
具体来说,DMA日志采集的流程是这样的:
- 日志产生时,CPU把数据写入环形缓冲区(Head移动)。
- DMA控制器从环形缓冲区的Tail位置开始,搬运一段数据到输出设备(比如UART、SPI Flash、网络缓冲区)。
- DMA传输完成后,更新Tail指针。
- 整个过程CPU只在写入日志和DMA完成中断时介入,其他时间该干嘛干嘛。
我在一个数据采集项目中,用这个方案把日志吞吐量从200KB/s提升到了2MB/s,CPU占用率从35%降到了5%以下。效果立竿见影。
四、环形缓冲区的设计要点
4.1 大小选择
环形缓冲区的大小不是随便定的。太小了容易丢日志,太大了浪费内存。
我的经验公式:
- 缓冲区大小 >= 最大日志产生速率 × 最大允许延迟
- 比如:日志峰值速率是100KB/s,DMA每10ms搬运一次,那么缓冲区至少需要 100KB/s × 0.01s = 1KB
- 实际项目中,我会再乘2作为安全余量
4.2 多生产者场景
如果多个任务(或中断)同时写日志,环形缓冲区需要加锁。但加锁会影响性能。
我的做法是:每个优先级一个环形缓冲区。高优先级中断用独立的缓冲区,不需要锁。低优先级任务共享一个缓冲区,用关中断来保护。
4.3 与DMA的配合
DMA传输时,Tail指针的更新要小心。如果DMA正在搬运数据,你更新了Tail,DMA可能读到错误的数据。
解决方案:
- DMA传输开始前,锁定Tail到当前值
- DMA传输完成后,在中断中更新Tail
- 写入端(Head)不受影响,可以继续写
一个小技巧:如果DMA支持链表模式(Scatter-Gather),可以把环形缓冲区拆成两段,DMA自动处理跨越边界的情况。这样连取模运算都省了。
五、环形缓冲区的可视化
下面这张图展示了环形缓冲区的核心逻辑。我画得比较简洁,但关键点都在了:
图中绿色格子表示已经写入但还没被读取的数据,蓝色格子是空闲空间。Head指向下一个写入位置,Tail指向下一个读取位置。当Head追上Tail时,缓冲区满;当Tail追上Head时,缓冲区空。
六、总结
环形缓冲区不是什么高深的技术,但用好了能解决大问题。尤其是在DMA日志采集场景下,它让CPU从繁重的I/O操作中解放出来,专注于业务逻辑。
记住几个关键点:
- 大小必须是2的幂,用位与代替取模
- 预留一个空位区分空和满
- 多生产者场景要小心锁的问题
- 与DMA配合时,注意Tail指针的更新时机
下一章,我会讲DMA控制器的配置和初始化。到时候你会看到,环形缓冲区是怎么和DMA的寄存器打配合的。
公众号:蓝海资料掘金营,微信 deep3321