一、原理讲解:为什么会有竞态?
在STM32中,使用DMA接收串口数据并配合空闲中断(IDLE)是处理不定长数据的经典方案。但很多开发者忽略了DMA的半满中断(HT)和全满中断(TC)与空闲中断之间的竞态问题。
1.1 DMA传输机制
- DMA配置为循环模式(Circular)时,接收缓冲区被分为两半:前半区和后半区。
- 当DMA写指针到达缓冲区一半时,触发半满中断(HT);到达末尾时,触发全满中断(TC);然后自动回绕。
- 串口空闲中断在总线空闲(一个字节时间无数据)时触发,此时DMA可能正处于任意位置。
1.2 竞态场景
假设缓冲区大小为N,DMA当前写指针为pos。当空闲中断发生时,CPU需要读取从上次读取位置到pos之间的数据。但如果此时DMA恰好跨越了半满或全满边界,就会产生以下问题:
- 数据覆盖:CPU尚未处理完前半区数据,DMA已开始写后半区,若处理速度慢,可能回绕覆盖未读数据。
- 指针不一致:在读取DMA剩余传输计数(NDTR)时,DMA可能正在更新该寄存器,导致读到的值不准确。
- 中断嵌套:空闲中断与DMA中断同时挂起,若优先级配置不当,可能打断关键操作。
二、缓冲区设计:双缓冲+读写指针
为解决竞态,推荐使用“双缓冲+读写指针”方案:
- 定义两个缓冲区:
bufA和bufB,每个大小相同(如256字节)。 - DMA循环填充
bufA,当半满中断触发时,表示bufA前半区已满,此时将DMA目标切换到bufB(通过修改DMA内存地址),同时通知应用处理bufA前半区数据。 - 当全满中断触发时,表示
bufA后半区已满,再次切换回bufA(或使用第三个缓冲区),并处理bufA后半区。 - 空闲中断发生时,根据当前DMA目标缓冲区和NDTR计算有效数据长度,并拷贝到应用缓冲区。
关键点:在切换缓冲区时,必须短暂关闭DMA或使用临界区保护,防止DMA在切换过程中写入错误地址。
三、CubeMX配置步骤
- 使能USART1,模式为Asynchronous,波特率115200。
- 在DMA Settings中添加USART1_RX的DMA请求,模式为Circular,数据宽度Byte,优先级High。
- 使能USART1全局中断(NVIC),并设置合适的优先级(建议高于DMA中断)。
- 在NVIC中使能DMA通道中断(如DMA1_Channel5),用于半满/全满中断。
- 生成代码,HAL库会自动初始化DMA和串口。
四、完整代码示例
4.1 定义缓冲区与状态
#define BUF_SIZE 256
uint8_t dma_buf[2][BUF_SIZE]; // 双缓冲
volatile uint8_t active_buf = 0; // 当前DMA目标缓冲区索引
volatile uint16_t last_pos = 0; // 上次读取位置(在active_buf中)
volatile uint8_t data_ready[2] = {0}; // 标记缓冲区是否有待处理数据
uint8_t app_buf[BUF_SIZE * 2]; // 应用层拼接缓冲区
uint16_t app_len = 0;
4.2 启动DMA接收
void uart_dma_start(void) {
HAL_UART_Receive_DMA(&huart1, dma_buf[0], BUF_SIZE);
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}
4.3 空闲中断处理(在USART1_IRQHandler中调用)
void uart_idle_handler(void) {
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
// 获取当前DMA剩余计数
uint16_t ndtr = __HAL_DMA_GET_COUNTER(huart1.hdmarx);
uint16_t pos = BUF_SIZE - ndtr; // 当前写指针位置
uint8_t buf_idx = active_buf;
uint16_t start = last_pos;
uint16_t len = 0;
// 计算有效数据长度(考虑回绕)
if (pos >= start) {
len = pos - start;
} else {
len = BUF_SIZE - start + pos;
}
if (len > 0) {
// 拷贝数据到应用缓冲区(注意临界区保护)
__disable_irq();
memcpy(&app_buf[app_len], &dma_buf[buf_idx][start], len);
app_len += len;
last_pos = pos;
__enable_irq();
// 通知应用任务处理app_buf
data_ready[buf_idx] = 1;
}
}
}
4.4 DMA半满/全满中断处理
void DMA1_Channel5_IRQHandler(void) {
if (__HAL_DMA_GET_FLAG(huart1.hdmarx, DMA_FLAG_HT5)) {
__HAL_DMA_CLEAR_FLAG(huart1.hdmarx, DMA_FLAG_HT5);
// 半满:当前缓冲区前半区已满,切换缓冲区
dma_switch_buffer();
}
if (__HAL_DMA_GET_FLAG(huart1.hdmarx, DMA_FLAG_TC5)) {
__HAL_DMA_CLEAR_FLAG(huart1.hdmarx, DMA_FLAG_TC5);
// 全满:当前缓冲区后半区已满,切换缓冲区
dma_switch_buffer();
}
}
void dma_switch_buffer(void) {
__disable_irq();
uint8_t old_buf = active_buf;
active_buf = 1 - active_buf; // 切换
// 重新配置DMA目标地址
huart1.hdmarx->Instance->CMAR = (uint32_t)dma_buf[active_buf];
// 重置NDTR(可选,但循环模式下自动重载)
__enable_irq();
// 标记旧缓冲区数据待处理
data_ready[old_buf] = 1;
}
4.5 应用层处理
void process_data(void) {
for (int i = 0; i < 2; i++) {
if (data_ready[i]) {
// 处理dma_buf[i]中的数据(长度需根据last_pos计算)
// 这里简化:假设每次切换时整个缓冲区有效
// 实际需结合last_pos和当前pos
data_ready[i] = 0;
}
}
if (app_len > 0) {
// 解析app_buf
app_len = 0;
}
}
五、注意事项
-
临界区保护:在修改DMA目标地址和读取NDTR时,务必使用
__disable_irq()/__enable_irq()或配置中断优先级,防止DMA中断打断空闲中断。 - 中断优先级:建议USART1空闲中断优先级高于DMA中断,确保空闲事件及时响应。
- 缓冲区大小:根据最大帧长和系统处理能力选择,通常256~1024字节。
- 数据一致性:在拷贝数据前,先读取NDTR并计算长度,再拷贝,避免DMA在拷贝过程中写入新数据。
- 错误处理:使能DMA传输错误中断,并在错误时重启DMA。
-
内存对齐:DMA缓冲区建议使用
__attribute__((aligned(4)))对齐,提高访问效率。
通过上述双缓冲设计和临界区保护,可有效解决半满中断与空闲中断的竞态问题,实现稳定可靠的不定长串口收包。