引言

在嵌入式实时系统(RTOS)中,多任务协作常涉及数据流传递,例如传感器采集、网络协议栈或日志系统。经典实现是环形缓冲区(Ring Buffer),配合互斥锁(Mutex)或关中断保护。然而,当存在多个生产者任务时,锁竞争会显著增加上下文切换开销,甚至引发优先级反转。本文提出一种优化方案:利用 RTOS 的计数信号量(Counting Semaphore)作为“空位”和“数据”的计数器,结合无锁环形缓冲区的单写单读特性,实现多生产者单消费者(MPSC)的无锁写入,仅需在必要时进行临界区保护,从而降低延迟并提高确定性。

原理分析

传统环形缓冲区的问题

  • 环形缓冲区通常由读索引(read_idx)和写索引(write_idx)维护。
  • 多生产者并发写入时,必须互斥更新 write_idx,否则数据覆盖或索引错乱。
  • 使用 Mutex 或关中断会阻塞其他生产者,且关中断影响实时性;Mutex 可能引起优先级反转(低优先级任务持有锁,高优先级等待)。

信号量如何辅助无锁化

  • 计数信号量维护一个计数器,支持 give(释放)和 take(获取)操作,通常由 RTOS 内核实现,基于临界区或原子指令,开销远小于通用锁。
  • 我们使用两个信号量:
    • empty_sem:初始化为缓冲区大小,表示空闲槽位数。
    • data_sem:初始化为 0,表示可读数据块数。
  • 生产者写入前先 take(empty_sem),若缓冲区满则阻塞(或超时),写入后 give(data_sem)
  • 消费者读取前先 take(data_sem),读取后 give(empty_sem)
  • 关键:由于每个生产者只操作自己的写入位置(通过原子递增 write_idx),且消费者只操作 read_idx,只要保证 write_idx 和 read_idx 的更新是原子的,就能避免锁。

无锁写入的关键点

  • 多生产者需要原子递增 write_idx。在 ARM Cortex-M 上,可使用 LDREX/STREX 指令或 RTOS 提供的临界区 API(如 taskENTER_CRITICAL)实现短临界区,但临界区极短(仅几条指令),远优于持有整个缓冲区锁。
  • 消费者是单线程,read_idx 更新无需原子操作。
  • 通过信号量计数,保证不会覆盖未读数据,也不会读取空数据,从而消除数据竞争。

实现步骤

1. 定义环形缓冲区结构

#define BUF_SIZE 16  // 必须是2的幂,便于取模

typedef struct {
    uint8_t data[BUF_SIZE];
    volatile uint16_t write_idx;  // 生产者共享
    volatile uint16_t read_idx;   // 消费者独有
} RingBuffer;

2. 初始化信号量和缓冲区

RingBuffer rb;
SemaphoreHandle_t empty_sem, data_sem;

void init_mpsc(void) {
    rb.write_idx = 0;
    rb.read_idx = 0;
    empty_sem = xSemaphoreCreateCounting(BUF_SIZE, BUF_SIZE);
    data_sem = xSemaphoreCreateCounting(BUF_SIZE, 0);
}

3. 生产者写入函数(无锁)

int producer_write(uint8_t byte) {
    // 获取空位,若满则阻塞(或返回错误)
    if (xSemaphoreTake(empty_sem, portMAX_DELAY) != pdTRUE) {
        return -1;
    }

    // 原子获取写入位置并更新(使用临界区保护)
    uint16_t pos;
    taskENTER_CRITICAL();
    pos = rb.write_idx;
    rb.write_idx = (rb.write_idx + 1) & (BUF_SIZE - 1);
    taskEXIT_CRITICAL();

    // 写入数据(此时该位置已被“预留”)
    rb.data[pos] = byte;

    // 通知消费者有数据
    xSemaphoreGive(data_sem);
    return 0;
}

4. 消费者读取函数(单线程)

int consumer_read(uint8_t *byte) {
    if (xSemaphoreTake(data_sem, portMAX_DELAY) != pdTRUE) {
        return -1;
    }

    uint16_t pos = rb.read_idx;
    rb.read_idx = (rb.read_idx + 1) & (BUF_SIZE - 1);

    *byte = rb.data[pos];

    xSemaphoreGive(empty_sem);
    return 0;
}

5. 任务示例

void producer_task(void *arg) {
    uint8_t val = 0;
    while (1) {
        producer_write(val++);
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

void consumer_task(void *arg) {
    uint8_t val;
    while (1) {
        if (consumer_read(&val) == 0) {
            // 处理数据
        }
    }
}

注意事项

  • 缓冲区大小必须为2的幂:使用位与运算代替取模,提高效率。
  • 原子递增 write_idx:在 Cortex-M 上,taskENTER_CRITICAL 会关中断,但仅保护几条指令,影响极小。也可使用 __atomic 内置函数(如 __atomic_fetch_add)实现无临界区。
  • 信号量操作是阻塞的:若生产者无法等待,可设置超时(如 pdMS_TO_TICKS(100))并处理超时错误。
  • 内存屏障:在写入数据后、give(data_sem) 前,通常需要 __DSB() 或依赖 RTOS 内部屏障,确保数据可见性。在单核 MCU 上,由于信号量操作本身有内存屏障,通常安全。
  • 多消费者场景不适用:本方案仅支持单消费者,若需多消费者需额外机制。
  • 优先级反转:虽然避免了互斥锁,但信号量操作仍可能阻塞高优先级任务,需合理设置任务优先级和超时。

总结

通过结合计数信号量和原子索引更新,我们实现了多生产者单消费者的无锁环形缓冲区,显著减少了锁竞争和上下文切换开销。该方案在 FreeRTOS 等主流 RTOS 上易于实现,适用于高吞吐、低延迟的嵌入式应用。开发者可根据实际需求调整缓冲区大小和信号量超时,以平衡实时性与吞吐量。