引言

在嵌入式RTOS(如FreeRTOS、RT-Thread)中,任务间通信是核心需求。环形缓冲区因其固定内存和O(1)操作,常用于数据流场景(如传感器采集、日志输出)。但多生产者并发写入时,传统做法是使用互斥锁保护临界区,这会导致:

  • 优先级反转:低优先级任务持锁阻塞高优先级任务。
  • 上下文切换开销:每次读写都触发调度。

本文提出一种基于计数信号量的无锁化方案,利用信号量的原子性管理缓冲区状态,避免显式锁,适用于多生产者单消费者(MPSC)模型。

原理剖析

环形缓冲区基础

环形缓冲区由读索引(read_idx)和写索引(write_idx)构成,当写索引追上读索引时表示满,读索引追上写索引时表示空。传统实现中,多生产者需互斥保护写索引,消费者需保护读索引。

信号量的角色

信号量是RTOS提供的同步原语,其take/give操作是原子性的。我们可以利用两个信号量

  • 空位信号量(empty):初始值为缓冲区大小,表示可写空间。
  • 数据信号量(data):初始值为0,表示可读数据。

生产者写入前take(empty),成功后直接写入缓冲区(无需锁),然后give(data)。消费者take(data)后读取,再give(empty)。这样,信号量本身协调了读写节奏,但多生产者同时写入时,写索引仍存在竞争

无锁化关键:原子写索引更新

为了消除写索引竞争,我们利用单消费者特性:消费者只读不写索引,因此写索引仅被多个生产者共享。我们可以使用原子操作(如Cortex-M的LDREX/STREX)或RTOS提供的原子接口(如FreeRTOS的taskENTER_CRITICAL,但那是关中断,非无锁)。更优雅的做法是:每个生产者独立写入不同槽位,通过信号量分配槽位。

具体设计:

  • 维护一个写槽位计数器(write_slot),初始为0。
  • 生产者take(empty)成功后,使用原子操作获取当前write_slot值并递增(如__atomic_fetch_add),该值即为本次写入的槽位索引。
  • 写入完成后,give(data)

由于empty信号量保证最多N个生产者同时进入,且原子操作确保每个生产者获得唯一槽位,因此无需锁保护写索引。

配置步骤

1. 定义缓冲区结构

#define BUFFER_SIZE 16

typedef struct {
    uint8_t data[BUFFER_SIZE];
    volatile int write_slot;  // 原子递增
    SemaphoreHandle_t empty;
    SemaphoreHandle_t data_sem;
} MPSC_RingBuffer;

2. 初始化

void mpsc_init(MPSC_RingBuffer *rb) {
    rb->write_slot = 0;
    rb->empty = xSemaphoreCreateCounting(BUFFER_SIZE, BUFFER_SIZE);
    rb->data_sem = xSemaphoreCreateCounting(0, BUFFER_SIZE);
}

3. 生产者写入

void mpsc_produce(MPSC_RingBuffer *rb, uint8_t value) {
    if (xSemaphoreTake(rb->empty, portMAX_DELAY) == pdTRUE) {
        // 原子获取槽位
        int slot = __atomic_fetch_add(&rb->write_slot, 1, __ATOMIC_SEQ_CST);
        slot %= BUFFER_SIZE;  // 环形
        rb->data[slot] = value;
        xSemaphoreGive(rb->data_sem);
    }
}

4. 消费者读取

uint8_t mpsc_consume(MPSC_RingBuffer *rb) {
    uint8_t value;
    if (xSemaphoreTake(rb->data_sem, portMAX_DELAY) == pdTRUE) {
        // 消费者唯一,直接读取当前读索引(需维护读索引)
        static int read_slot = 0;  // 注意:消费者单任务,可静态
        value = rb->data[read_slot];
        read_slot = (read_slot + 1) % BUFFER_SIZE;
        xSemaphoreGive(rb->empty);
    }
    return value;
}

完整代码示例(FreeRTOS)

#include "FreeRTOS.h"
#include "semphr.h"
#include <stdint.h>

#define BUFFER_SIZE 16

typedef struct {
    uint8_t data[BUFFER_SIZE];
    volatile int write_slot;
    SemaphoreHandle_t empty;
    SemaphoreHandle_t data_sem;
} MPSC_RingBuffer;

MPSC_RingBuffer rb;

void mpsc_init(MPSC_RingBuffer *rb) {
    rb->write_slot = 0;
    rb->empty = xSemaphoreCreateCounting(BUFFER_SIZE, BUFFER_SIZE);
    rb->data_sem = xSemaphoreCreateCounting(0, BUFFER_SIZE);
}

void mpsc_produce(MPSC_RingBuffer *rb, uint8_t value) {
    if (xSemaphoreTake(rb->empty, portMAX_DELAY) == pdTRUE) {
        int slot = __atomic_fetch_add(&rb->write_slot, 1, __ATOMIC_SEQ_CST);
        slot %= BUFFER_SIZE;
        rb->data[slot] = value;
        xSemaphoreGive(rb->data_sem);
    }
}

uint8_t mpsc_consume(MPSC_RingBuffer *rb) {
    uint8_t value = 0;
    static int read_slot = 0;
    if (xSemaphoreTake(rb->data_sem, portMAX_DELAY) == pdTRUE) {
        value = rb->data[read_slot];
        read_slot = (read_slot + 1) % BUFFER_SIZE;
        xSemaphoreGive(rb->empty);
    }
    return value;
}

// 任务示例
void producer1_task(void *arg) {
    uint8_t i = 0;
    while (1) {
        mpsc_produce(&rb, i++);
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

void producer2_task(void *arg) {
    uint8_t i = 100;
    while (1) {
        mpsc_produce(&rb, i++);
        vTaskDelay(pdMS_TO_TICKS(15));
    }
}

void consumer_task(void *arg) {
    uint8_t val;
    while (1) {
        val = mpsc_consume(&rb);
        // 处理数据
        vTaskDelay(pdMS_TO_TICKS(20));
    }
}

注意事项

  • 原子操作支持__atomic_fetch_add需要编译器支持(GCC ARM),若使用其他编译器,可用RTOS提供的临界区(但会短暂关中断,影响实时性)。
  • 内存屏障__ATOMIC_SEQ_CST确保写入顺序,防止编译器重排。
  • 缓冲区大小:应为2的幂,便于取模优化(slot & (BUFFER_SIZE-1))。
  • 消费者单任务:本方案依赖单消费者,若多消费者需额外同步。
  • 信号量超时:生产者在缓冲区满时会阻塞,可能导致生产者任务挂起,需根据场景设置超时。
  • 静态读索引:消费者任务中static变量确保唯一性,若消费者被删除重建,需重置。

总结

通过信号量分配槽位并结合原子操作,我们实现了多生产者单消费者的无锁环形缓冲区,避免了互斥锁的开销,提升了系统实时性。该方法在数据采集、日志系统等场景中非常实用。开发者可根据具体RTOS调整信号量API,但核心思想通用。希望本文能助你在嵌入式并发编程中更上一层楼。