基于 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 系列。
掌握这一技巧,将使你的嵌入式系统在数据吞吐量和实时性上迈上一个新台阶。