基于 STM32 的 DMA 传输完成中断与半传输中断在环形缓冲区中的无锁设计

1. 引言:为什么需要无锁环形缓冲区?

在嵌入式实时系统中,数据采集(如 ADC、UART、SPI)通常依赖 DMA 将外设数据传输到内存。传统做法是使用一个简单的线性缓冲区,当 DMA 传输完成后触发中断,CPU 在中断中处理数据。但这种方式存在两个问题:

  • 中断频率高:每次传输完成都触发中断,CPU 频繁介入,影响实时性。
  • 数据覆盖风险:若 CPU 处理不及时,新数据可能覆盖未处理的数据。

环形缓冲区(Ring Buffer)通过循环读写指针解决了数据覆盖问题,但多线程(或中断与主循环)访问时仍需同步机制,如关中断或互斥锁。然而,锁会引入阻塞和优先级反转,在实时系统中可能致命。

无锁设计利用硬件特性(DMA 双缓冲)和原子操作,避免显式锁,实现高效、安全的并发访问。STM32 的 DMA 控制器支持传输完成中断(TC)和半传输中断(HT),这为无锁环形缓冲区提供了天然基础。

2. 原理:DMA 半传输与完成中断如何实现无锁?

2.1 环形缓冲区与 DMA 的结合

设想一个环形缓冲区,大小为 BUF_SIZE(通常为 2 的幂,便于取模)。我们将其划分为两个等大的半区:[0, BUF_SIZE/2)[BUF_SIZE/2, BUF_SIZE)。DMA 配置为循环模式(Circular Mode),从外设(如 ADC)连续写入缓冲区。

  • 当 DMA 写满前半区时,触发半传输中断(HT)
  • 当 DMA 写满整个缓冲区(即后半区也满)时,触发传输完成中断(TC)

此时,CPU 可以在中断中处理已满的半区数据,而 DMA 继续写入另一个半区。由于两个半区在时间上交替使用,且中断处理速度远快于 DMA 填充速度,因此不会发生数据覆盖。

2.2 无锁的关键:读写指针的原子性

在无锁设计中,我们维护两个索引:

  • read_index:CPU 当前读取的位置(由主循环或中断更新)。
  • write_index:DMA 当前写入的位置(由硬件更新,但我们可以通过中断标志推断)。

由于 DMA 是硬件写入,我们无法直接读取其内部指针。但通过中断标志,我们可以知道 DMA 已写满哪个半区。因此,我们将 read_index 设计为仅由 CPU 更新,而 write_index 隐含在中断状态中。这样,CPU 在中断中处理数据时,只需更新 read_index,而主循环读取时,只需检查 read_index 与中断状态,无需锁。

关键点:确保对 read_index 的读写是原子的(在单核 Cortex-M 上,32 位对齐的读写是原子的),并且使用 volatile 修饰,防止编译器优化。

3. 配置步骤:以 STM32F4 系列为例

3.1 硬件资源

  • 外设:ADC1(或 UART、SPI),启用 DMA2 Stream 0(示例)。
  • 缓冲区:uint16_t buffer[BUFFER_SIZE],其中 BUFFER_SIZE = 512,半区大小 HALF_SIZE = 256

3.2 初始化 DMA

#define BUFFER_SIZE 512
#define HALF_SIZE   (BUFFER_SIZE / 2)

volatile uint16_t buffer[BUFFER_SIZE];
volatile uint8_t  half_ready = 0;  // 0: 无半区就绪, 1: 前半区就绪, 2: 后半区就绪

void DMA_Init(void) {
    // 使能 DMA2 时钟
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE);

    DMA_InitTypeDef DMA_InitStructure;
    DMA_InitStructure.DMA_Channel = DMA_Channel_0;  // ADC1 映射到 DMA2 Stream0 Channel0
    DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;
    DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)buffer;
    DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory;
    DMA_InitStructure.DMA_BufferSize = BUFFER_SIZE;
    DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
    DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
    DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
    DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
    DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;  // 循环模式
    DMA_InitStructure.DMA_Priority = DMA_Priority_High;
    DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable;
    DMA_InitStructure.DMA_FIFOThreshold = DMA_FIFOThreshold_HalfFull;
    DMA_InitStructure.DMA_MemoryBurst = DMA_MemoryBurst_Single;
    DMA_InitStructure.DMA_PeripheralBurst = DMA_PeripheralBurst_Single;

    DMA_Init(DMA2_Stream0, &DMA_InitStructure);

    // 使能半传输和传输完成中断
    DMA_ITConfig(DMA2_Stream0, DMA_IT_HT | DMA_IT_TC, ENABLE);

    // 使能 DMA 流
    DMA_Cmd(DMA2_Stream0, ENABLE);
}

3.3 中断服务程序(ISR)

void DMA2_Stream0_IRQHandler(void) {
    if (DMA_GetITStatus(DMA2_Stream0, DMA_IT_HT)) {
        DMA_ClearITPendingBit(DMA2_Stream0, DMA_IT_HT);
        half_ready = 1;  // 前半区已满
    }
    if (DMA_GetITStatus(DMA2_Stream0, DMA_IT_TC)) {
        DMA_ClearITPendingBit(DMA2_Stream0, DMA_IT_TC);
        half_ready = 2;  // 后半区已满
    }
}

3.4 主循环处理数据

void ProcessData(uint16_t *data, uint16_t size) {
    // 处理数据,例如计算平均值或发送到上位机
}

int main(void) {
    // 系统初始化...
    DMA_Init();
    ADC_Init(); // 配置 ADC 并启动 DMA 传输

    while (1) {
        if (half_ready == 1) {
            ProcessData((uint16_t*)buffer, HALF_SIZE);  // 处理前半区
            half_ready = 0;
        } else if (half_ready == 2) {
            ProcessData((uint16_t*)buffer + HALF_SIZE, HALF_SIZE);  // 处理后半区
            half_ready = 0;
        }
        // 其他任务...
    }
}

4. 完整代码示例(基于 HAL 库)

以下使用 STM32 HAL 库,更通用。

// 缓冲区定义
#define BUFFER_SIZE 512
#define HALF_SIZE   (BUFFER_SIZE / 2)

uint16_t buffer[BUFFER_SIZE];
volatile uint8_t half_ready = 0;

// DMA 初始化(HAL)
void MX_DMA_Init(void) {
    __HAL_RCC_DMA2_CLK_ENABLE();

    hdma_adc.Instance = DMA2_Stream0;
    hdma_adc.Init.Channel = DMA_CHANNEL_0;
    hdma_adc.Init.Direction = DMA_PERIPH_TO_MEMORY;
    hdma_adc.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_adc.Init.MemInc = DMA_MINC_ENABLE;
    hdma_adc.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
    hdma_adc.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
    hdma_adc.Init.Mode = DMA_CIRCULAR;
    hdma_adc.Init.Priority = DMA_PRIORITY_HIGH;
    hdma_adc.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
    HAL_DMA_Init(&hdma_adc);

    // 关联 DMA 到 ADC
    __HAL_LINKDMA(&hadc, DMA_Handle, hdma_adc);

    // 使能中断
    HAL_NVIC_SetPriority(DMA2_Stream0_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(DMA2_Stream0_IRQn);
}

// 中断回调(HAL 会自动调用)
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) {
    half_ready = 1;
}

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {
    half_ready = 2;
}

// 主循环
int main() {
    HAL_Init();
    // ... 时钟、GPIO、ADC 初始化 ...
    MX_DMA_Init();
    HAL_ADC_Start_DMA(&hadc, (uint32_t*)buffer, BUFFER_SIZE);

    while (1) {
        if (half_ready == 1) {
            ProcessData(buffer, HALF_SIZE);
            half_ready = 0;
        } else if (half_ready == 2) {
            ProcessData(buffer + HALF_SIZE, HALF_SIZE);
            half_ready = 0;
        }
    }
}

5. 注意事项与优化建议

  • 缓冲区大小:必须为 2 的幂,以便使用位运算代替取模(index & (SIZE-1)),提高效率。
  • 内存对齐:确保缓冲区地址按 4 字节对齐,避免 DMA 访问错误。
  • 中断优先级:DMA 中断应设置为较高优先级,确保及时处理,但不要高于系统节拍,以免影响其他实时任务。
  • 数据一致性:在中断中修改 half_ready 时,主循环读取时需使用 volatile,并考虑内存屏障(在 Cortex-M 上通常不需要,但若使用编译器优化,可加 __DMB())。
  • 处理时间:确保 ProcessData 的执行时间远小于 DMA 填充一个半区的时间,否则可能丢失数据。若处理耗时,可考虑将数据拷贝到另一缓冲区,或使用双缓冲加标志。
  • 扩展性:若需处理更多数据,可增加半区数量(如四分之一中断),但需注意 DMA 中断类型有限(HT 和 TC),可结合定时器或外部中断。

6. 总结

通过利用 STM32 DMA 的半传输和完成中断,我们实现了一个高效的无锁环形缓冲区。该设计避免了锁的阻塞,降低了中断频率,且代码简洁。它特别适用于高速数据采集(如音频、传感器)和通信协议解析(如 UART 接收不定长数据)。开发者可根据实际需求调整缓冲区大小和中断处理逻辑,轻松移植到其他 STM32 系列。

掌握这一技巧,将使你的嵌入式系统在数据吞吐量和实时性上迈上一个新台阶。