一、为什么需要DMA+空闲中断?
传统串口接收采用单字节中断,每收到一个字节就进一次中断,在高波特率下CPU开销巨大,且容易丢数据。DMA+空闲中断方案利用DMA自动搬运数据,仅在总线空闲时触发一次中断,极大降低CPU负载,特别适合不定长帧的接收场景。
- DMA:自动将串口数据寄存器(DR)的数据搬运到内存缓冲区,无需CPU干预。
- 空闲中断:当串口总线检测到空闲(即一个字节时间无新数据)时触发,标志一帧数据接收完成。
二、半包与粘包的成因分析
2.1 半包现象
半包指一帧数据被拆分成多次接收。常见原因:
- 发送方分多次发送(如先发帧头,再发数据)。
- 总线干扰导致字节间出现短暂空闲。
- 高优先级中断打断DMA传输,导致空闲中断提前触发。
2.2 粘包现象
粘包指多帧数据被合并成一次接收。常见原因:
- 发送方连续发送多帧,帧间无足够空闲时间。
- 空闲中断响应延迟,DMA已接收后续帧数据。
三、边界处理核心策略
3.1 双缓冲与长度记录
使用两个缓冲区交替接收,每次空闲中断时记录当前DMA剩余计数,计算已接收长度,并切换缓冲区。避免处理数据时被新数据覆盖。
3.2 帧头帧尾校验
在应用层协议中定义明确的帧头和帧尾(如0xAA 0x55 ... 0x0D 0x0A),接收后根据帧头帧尾提取完整帧,丢弃不完整或无效数据。
3.3 超时补帧机制
对于半包,可结合定时器实现超时补帧:若空闲中断后一段时间内未收到后续数据,则强制结束当前帧。
四、完整代码实现
4.1 硬件与初始化配置
以STM32F4系列为例,使用USART1(PA9/PA10),DMA2 Stream2 Channel4。
#define RX_BUF_SIZE 256
uint8_t rx_buf[2][RX_BUF_SIZE]; // 双缓冲
volatile uint16_t rx_len[2] = {0};
volatile uint8_t buf_idx = 0; // 当前缓冲区索引
volatile uint8_t frame_ready = 0;
void USART1_Init(void) {
// 使能时钟
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN;
// GPIO配置 PA9 TX, PA10 RX 复用
GPIOA->MODER &= ~(GPIO_MODER_MODER9 | GPIO_MODER_MODER10);
GPIOA->MODER |= (2 << GPIO_MODER_MODER9_Pos) | (2 << GPIO_MODER_MODER10_Pos);
GPIOA->AFR[1] |= (7 << GPIO_AFRH_AFSEL9_Pos) | (7 << GPIO_AFRH_AFSEL10_Pos);
// USART配置 115200 8N1
USART1->BRR = 84000000 / 115200; // 假设APB2时钟84MHz
USART1->CR1 |= USART_CR1_TE | USART_CR1_RE | USART_CR1_IDLEIE;
USART1->CR3 |= USART_CR3_DMAR;
USART1->CR1 |= USART_CR1_UE;
// DMA配置
DMA2_Stream2->CR &= ~DMA_SxCR_EN;
while (DMA2_Stream2->CR & DMA_SxCR_EN);
DMA2_Stream2->PAR = (uint32_t)&USART1->DR;
DMA2_Stream2->M0AR = (uint32_t)rx_buf[0];
DMA2_Stream2->NDTR = RX_BUF_SIZE;
DMA2_Stream2->CR = (4 << DMA_SxCR_CHSEL_Pos) | DMA_SxCR_MINC | DMA_SxCR_CIRC;
DMA2_Stream2->CR |= DMA_SxCR_EN;
// NVIC配置
NVIC_EnableIRQ(USART1_IRQn);
NVIC_SetPriority(USART1_IRQn, 1);
}
4.2 空闲中断处理
void USART1_IRQHandler(void) {
if (USART1->SR & USART_SR_IDLE) {
// 清除空闲标志(先读SR再读DR)
volatile uint32_t tmp = USART1->SR;
tmp = USART1->DR;
(void)tmp;
// 计算已接收长度
uint16_t remain = DMA2_Stream2->NDTR;
rx_len[buf_idx] = RX_BUF_SIZE - remain;
// 切换缓冲区
buf_idx ^= 1;
DMA2_Stream2->CR &= ~DMA_SxCR_EN;
while (DMA2_Stream2->CR & DMA_SxCR_EN);
DMA2_Stream2->M0AR = (uint32_t)rx_buf[buf_idx];
DMA2_Stream2->NDTR = RX_BUF_SIZE;
DMA2_Stream2->CR |= DMA_SxCR_EN;
frame_ready = 1;
}
}
4.3 应用层帧解析
#define FRAME_HEAD1 0xAA
#define FRAME_HEAD2 0x55
#define FRAME_TAIL1 0x0D
#define FRAME_TAIL2 0x0A
void ProcessFrame(uint8_t *buf, uint16_t len) {
uint16_t i = 0;
while (i < len) {
// 查找帧头
if (buf[i] == FRAME_HEAD1 && i + 1 < len && buf[i+1] == FRAME_HEAD2) {
uint16_t start = i;
uint16_t j = i + 2;
// 查找帧尾
while (j + 1 < len) {
if (buf[j] == FRAME_TAIL1 && buf[j+1] == FRAME_TAIL2) {
uint16_t frame_len = j + 2 - start;
// 处理完整帧
HandleCompleteFrame(&buf[start], frame_len);
i = j + 2;
break;
}
j++;
}
if (j + 1 >= len) {
// 未找到帧尾,半包,暂存
SavePartialFrame(&buf[start], len - start);
break;
}
} else {
i++;
}
}
}
4.4 主循环处理
int main(void) {
USART1_Init();
while (1) {
if (frame_ready) {
frame_ready = 0;
uint8_t idx = buf_idx ^ 1; // 刚接收完的缓冲区
ProcessFrame(rx_buf[idx], rx_len[idx]);
}
}
}
五、注意事项与调试技巧
- 空闲中断清除顺序:必须先读SR再读DR,否则空闲标志无法清除,导致中断反复触发。
- DMA循环模式:使用循环模式时,NDTR自动重装,但切换缓冲区需先关闭DMA再重新配置,避免数据错位。
- 缓冲区大小:根据最大帧长和波特率合理设置,建议为最大帧长的2倍以上,防止DMA溢出。
- 半包暂存:应用层需维护一个拼接缓冲区,将半包数据与后续数据合并后再解析。
- 粘包处理:解析时严格按帧头帧尾提取,丢弃帧间无效字节,避免多帧粘连导致解析错误。
- 中断优先级:空闲中断优先级不宜过高,避免打断关键时序;但需高于DMA传输完成中断(若使用)。
-
内存屏障:在切换缓冲区时,确保DMA已停止再修改M0AR和NDTR,必要时加入
__DSB()。
通过上述双缓冲+帧头帧尾校验+半包暂存的组合策略,可稳定处理绝大多数半包与粘包场景,已在多个工业项目中验证可靠。
暂无评论,来抢沙发~