导言
《[[STM32F103_LL库+寄存器学习笔记09 - DMA串口接收与DMA串口发送,串口接收空闲中断]]》上一章节完成DMA发送与接收。此时,有一个致命的问题可能会导致数据包丢失。原因是USART1接收只开启了接收空闲中断(IDLE),DMA在连续模式下,如果数据一直持续发送(或者数据包的大小比接收缓存区要大),将会出现数据被覆盖,被覆盖的数据等于丢失的数据。
实现更加健壮的串口接收程序是同时利用USART空闲中断来判断数据包的结束,并配合DMA半传输和传输完成中断及时处理数据,从而避免数据溢出或被覆盖。 总的来说就是接收空闲中断 + DMA传输过半中断 + DMA传输完成中断。
方案如下:
- 开启接收空闲中断
- 当数据传输过程中出现空闲(即一段数据传输结束后总线静默),USART空闲中断就会触发。这时,立即关闭DMA,读取DMA剩余计数器(或利用缓冲区总大小减去剩余计数得到实际接收字节数),将这部分数据交给上层处理,然后清除中断标志并重新启动DMA。这样即使数据包不足一个完整缓冲区,也能准确提取数据。
- 开启半传输和全传输中断
- 半传输中断:当DMA接收到缓冲区前半部分的数据时触发。此时可以先处理前半部分的数据,避免数据不断写入后覆盖还未处理的数据。
- 传输完成中断:当DMA完成整个缓冲区的数据接收时触发,同样可处理后半部分数据。这种“双缓冲”技术允许你在数据接收的同时就将已接收部分取出处理,降低丢包风险。
总的来说:
- 对于连续且数据量较大的情况,DMA半传输和传输完成中断能保证数据能及时被轮换处理,不会因缓冲区满而丢失数据。
- 对于数据量较小或者数据传输间有间隔的情况,空闲中断能够准确捕获数据包结束时刻,防止因数据不足而不触发DMA半/全传输中断而导致数据滞留。
另外,中断的处理必须保证简单,尽可能保证快进快出。 所以,数据的处理必须留在主循环来做(配合高效的数据结构ringbuffer)。后续章节会介绍ringbuffer的移植与使用。
以115200波特率计算(假设每字节约10位,即包括起始、数据、停止位),理论上每秒可以传输约11520字节,也就是每毫秒大约11.5个字节。512个字节大概需要44~46毫秒填满。当然,这个值会依据具体的帧配置(比如数据位、停止位、校验位)略有不同。所以,如果数据量很大(一直连续)当触发传输过半中断时,要在大概44ms内把数据搬运出去,否则会被覆盖。
效果如下所示:
项目地址:https://github.com/q164129345/MCU_Develop/tree/main/stm32f103_ll_library10_usart_dma_rx_interrupt
一、代码(LL库)
1.1、main.c
1.2、usart.c
1.3、stm32f1xx_it.c
1.4、编译、下载
如上所示,编译通过。
效果如上所示,大量发送数据包,不丢包!
二、调试传输过半中断与传输完成中断
调试传输过半中断与传输完成中断之前,先被USART1的接收空闲中断关闭掉,从而方便你单独调试这两个中断。如下所示:
2.1、传输过半中断
如上所示,从串口助手发送512个字节到STM32F103,触发了DMA1通道5的传输过半中断。从寄存器表格看到,HTIF5确认被置1了。
如上所示,代码LL_DMA_ClearFlag_HT5(DMA1)
运行完,HTIF5标志位确实被清0了。
如上所示,串口助手发送512个字节给STM32F103,让它触发DMA1通道5的传输过半中断。在中断里将前半段512个字节让DMA1通道4回传给电脑的串口助手。数据传输数量寄存器CNDTR5从0x400(1024)变成0x200(512),写指针指向接收缓存区的偏移512。从整个过程看来,传输过半中断程序调试OK。
2.2、传输完成中断
如上所示,串口助手再一次发送512个字节给STM32F103后,进入传输完成中断。此时,TCIF5被置1。
如上所示,代码LL_DMA_ClearFlag_TC5(DMA1)
运行之后,TCIF5标志被清0。
如上所示,将断点放开后,STM32F103将第二次收到的512个字节回传给电脑的串口助手。此时,寄存器CNDTR5从0x200(512)变回最开始的0x400(1024)。从整个过程看来,传输完成中断正常!
三、寄存器梳理
3.1、启动传输过半中断、传输完成中断
如上所示,寄存器DMA_CCR5的位2与位1置1就可以打开半传输中断与传输完成中断。
// 增加传输完成与传输过半中断
DMA1_Channel5->CCR |= (1UL << 1); // 传输完成中断 (TCIE)
DMA1_Channel5->CCR |= (1UL << 2); // 传输过半中断 (HTIE)
3.2、全局中断DMA1_Channel5_IRQHandler()里判断传输过半标志与传输完成标志
void DMA1_Channel5_IRQHandler(void) {
// 半传输中断
if (DMA1->ISR & (1UL << 18)) {
DMA1->IFCR |= (1UL << 18); // 清除标志
}
// 传输完成中断
if (DMA1->ISR & (1UL << 17)) {
DMA1->IFCR |= (1UL << 17); // 清除标志
}
// 改为if...else if,中断运行的时间更短
}
如上所示,通过寄存器ISR判断是否进入该中断,接着使用寄存器IFCR来清除中断标志位。
四、代码(寄存器)
4.1、main.c
4.2、stm32f1xx_it.c
4.3、编译、下载
4.4、串口助手调试
如上所示,效果跟LL库一样。
五、细节补充
5.1、当接收缓存区大小1024bytes,刚好收到一帧大小512bytes数据时,会怎样???
当接收缓存区大小1024bytes,刚好收到一帧大小512bytes数据时,将会进入传输过半中断,然后再进入接收空闲中断。 然后,传输过半中断与接收空闲中断都有将接收缓存区复制到发送缓存区的功能,会复制两遍数据给发送缓存区吗?代码处理好了,不会! 以下是接收空闲中断里的某部分代码。
// 根据剩余字节判断当前正在哪个半区
// 还有,避免当数据长度刚好512字节与1024字节时,传输过半中断与空闲中断复制两遍数据,与传输完成中断与空闲中断复制两遍数据。
if (remaining > (RX_BUFFER_SIZE/2)) {
// 还在接收前半区:接收数据量 = (1K - remaining),但肯定不足 512 字节
count = RX_BUFFER_SIZE - remaining;
if (count != 0) { // 避免与传输完成中断冲突,多复制一次
memcpy((void*)tx_buffer, (const void*)rx_buffer, count);
}
} else {
// 前半区已写满,当前在后半区:后半区接收数据量 = (RX_BUFFER_SIZE/2 - remaining)
count = (RX_BUFFER_SIZE/2) - remaining;
if (count != 0) { // 避免与传输过半中断冲突,多复制一次
memcpy((void*)tx_buffer, (const void*)(rx_buffer + RX_BUFFER_SIZE/2), count);
}
}
if (count != 0) {
recvd_length = count;
rx_complete = 1;
}
假设RX_BUFFER_SIZE为1024,当串口助手发送512个字节时,DMA剩余计数remaining正好为512。此时remaining不大于512,所以进入else分支,计算得到count = (1024/2) - 512 = 512 - 512 = 0。因此不会再复制数据,也就避免了重复拷贝前半段数据。
当接收缓存区大小1024bytes,刚好收到一帧大小1024bytes数据时,发生顺序大致如下:
- 当接收到前512字节时,DMA触发半传输中断,此时会调用对应中断服务程序,将前半区(偏移0~511)的数据复制到发送缓冲区,并设置接收完成标志。
- 当接收到后512字节时,DMA触发传输完成中断,同样将后半区(偏移512~1023)的数据复制到发送缓冲区(注意,如果使用同一个tx_buffer,则需要根据应用需求处理两次复制的数据是合并还是分别处理)。
- 如果此时串口没有继续接收数据,USART1空闲中断也可能触发。此时,在空闲中断处理代码中,会先禁用DMA,然后通过下面这段代码来计算“新增”的数据长度:
// 禁用 DMA1 通道5,防止数据继续写入
LL_DMA_DisableChannel(DMA1, LL_DMA_CHANNEL_5);
uint16_t remaining = LL_DMA_GetDataLength(DMA1, LL_DMA_CHANNEL_5); // 获取剩余的容量
uint16_t count = 0;
// 根据剩余字节判断当前正在哪个半区
// 还有,避免当数据长度刚好512字节与1024字节时,传输过半中断与空闲中断复制两遍数据,与传输完成中断与空闲中断复制两遍数据。
if (remaining > (RX_BUFFER_SIZE/2)) {
// 还在接收前半区:接收数据量 = (1K - remaining),但肯定不足 512 字节
count = RX_BUFFER_SIZE - remaining;
if (count != 0) { // 避免与传输完成中断冲突,多复制一次
memcpy((void*)tx_buffer, (const void*)rx_buffer, count);
}
} else {
// 前半区已写满,当前在后半区:后半区接收数据量 = (RX_BUFFER_SIZE/2 - remaining)
count = (RX_BUFFER_SIZE/2) - remaining;
if (count != 0) { // 避免与传输过半中断冲突,多复制一次
memcpy((void*)tx_buffer, (const void*)(rx_buffer + RX_BUFFER_SIZE/2), count);
}
}
if (count != 0) {
recvd_length = count;
rx_complete = 1;
}
// 重新设置 DMA 传输长度并使能 DMA
LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_5, RX_BUFFER_SIZE);
LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_5);
对于1024字节的情况,DMA工作在循环模式下,当完整数据传输完成后,DMA的计数器会重新加载为 RX_BUFFER_SIZE(即1024),因此当空闲中断进入处理时:
- remaining 将等于1024,
- 如果进入第一个分支,则计算得到 count = RX_BUFFER_SIZE - remaining = 1024 - 1024 = 0,
- 如果进入第二个分支(一般不会出现,因为remaining不小于512),同样结果为0。
因此,空闲中断处理代码不会再复制数据,也不会重复处理前半区数据。总结来说,当刚好发送1024字节时: - 半传输中断处理了前512字节,
- 传输完成中断处理了后512字节,
- 空闲中断因计算出的新接收字节数为0而不再进行复制。
这就避免了数据重复拷贝的问题!