当前位置:首页 > 嵌入式 > 嵌入式分享
[导读]环形缓冲区(Ring Buffer)是嵌入式系统中中断与主循环数据传递的经典结构——UART接收、ADC采样、SPI数据流都离不开它。然而,很多工程师在实现时踩过同一个坑:中断往缓冲区写数据的同时,主循环正在读,导致读写指针错乱,数据丢失或重复。本文总结三种经过工业验证的同步方案,从简单到高效,适用不同场景。


环形缓冲区(Ring Buffer)是嵌入式系统中中断与主循环数据传递的经典结构——UART接收、ADC采样、SPI数据流都离不开它。然而,很多工程师在实现时踩过同一个坑:中断往缓冲区写数据的同时,主循环正在读,导致读写指针错乱,数据丢失或重复。本文总结三种经过工业验证的同步方案,从简单到高效,适用不同场景。

一、关中断法(临界区保护)

最朴素的方法:在读或写操作前后关中断/开中断,保证操作的原子性。

#define BUF_SIZE 64

uint8_t ring_buf[BUF_SIZE];

volatile uint16_t head = 0, tail = 0;


// 中断中写入(生产者)

void ISR_put(uint8_t data) {

   uint16_t next = (head + 1) % BUF_SIZE;

   if (next != tail) {  // 不满

       ring_buf[head] = data;

       head = next;

   }

}


// 主循环读取(消费者)

uint8_t main_get(uint8_t *data) {

   uint8_t ret = 0;

   __disable_irq();

   if (head != tail) {

       *data = ring_buf[tail];

       tail = (tail + 1) % BUF_SIZE;

       ret = 1;

   }

   __enable_irq();

   return ret;

}

优点:实现简单,逻辑直观。缺点:关中断时间过长会影响系统实时性,尤其在高频中断(如1Msps ADC)中不可接受。

二、无锁原子操作(单生产者单消费者)

如果只有一个中断写入、一个主循环读取(单生产者单消费者),可以利用原子变量避免关中断。关键在于读写指针各自独立,且操作可被原子化。

// 使用C11原子操作或编译器内置函数

#include <stdatomic.h>

atomic_uint16_t head_atomic = 0;

atomic_uint16_t tail_atomic = 0;


void ISR_put(uint8_t data) {

   uint16_t h = atomic_load(&head_atomic);

   uint16_t t = atomic_load(&tail_atomic);

   uint16_t next = (h + 1) % BUF_SIZE;

   if (next != t) {

       ring_buf[h] = data;

       atomic_store(&head_atomic, next);  // 最后更新head

   }

}


uint8_t main_get(uint8_t *data) {

   uint16_t h = atomic_load(&head_atomic);

   uint16_t t = atomic_load(&tail_atomic);

   if (h != t) {

       *data = ring_buf[t];

       atomic_store(&tail_atomic, (t + 1) % BUF_SIZE);

       return 1;

   }

   return 0;

}

关键:写者先写数据再更新head,读者先读数据再更新tail,保证数据可见性。由于Cortex-M的32位读写本身就是原子的(对齐访问),甚至可以不用原子库,直接用volatile修饰指针变量。但为了可移植性,建议使用atomic_uint16_t。

优点:零中断封锁,性能最优。缺点:仅适用于单生产者单消费者;多中断源同时写仍需保护。

三、双缓冲(乒乓缓冲)

当数据量较大(如音频帧、图像行)时,逐个元素的环形缓冲区效率低,且中断处理时间过长。双缓冲方案让中断填充一个缓冲区,主循环处理另一个,写满后交换。

#define FRAME_SIZE 256

uint8_t buf0[FRAME_SIZE];

uint8_t buf1[FRAME_SIZE];

volatile uint8_t writing_to = 0;  // 0: buf0, 1: buf1

volatile uint8_t ready = 0;       // 主循环可读的标志

uint16_t idx = 0;


void ISR_put_sample(uint8_t data) {

   if (writing_to == 0) {

       buf0[idx++] = data;

   } else {

       buf1[idx++] = data;

   }

   if (idx >= FRAME_SIZE) {

       idx = 0;

       writing_to ^= 1;   // 切换缓冲区

       ready = 1;         // 通知主循环

   }

}


void main_process(void) {

   if (ready) {

       uint8_t *data = (writing_to == 0) ? buf1 : buf0; // 上一帧数据

       process_frame(data, FRAME_SIZE);

       ready = 0;

   }

}

优点:中断内只做简单的数组写入,无指针竞争;适合批量数据处理。缺点:需要双倍内存;切换瞬间可能有短暂的不一致(但可通过ready标志规避)。

四、三个翻车高发点

缓冲区大小是2的幂却不使用位与运算:很多人用% BUF_SIZE取模,但除法指令在MCU上很慢。如果BUF_SIZE是2的幂,用& (BUF_SIZE-1)代替,速度提升数倍。

忘记volatile:读写指针若不声明为volatile,编译器优化可能导致主循环永远看不到中断更新的值。

多中断源共用同一缓冲区:两个不同优先级的中断同时写,即使关中断也无法保证原子性(因为高优先级中断可以打断低优先级中断)。此时需要每个中断独立缓冲区,或使用硬件FIFO。



本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
关闭