☰
STM32F103串口DMA+空闲中断实现不定长数据收发实战
2026/10/28 1:07:46 网站建设 项目流程

1. 项目缘起与整体方案拆解

串口收发数据这件事,看起来简单,真到项目里用起来坑一点都不少。我最早做 STM32F103 的串口通信时,用的是最原始的方式:每收到一个字节就进一次中断,在中断里把数据塞进缓冲区,再靠主循环去判断一帧数据收完没有。波特率低、数据量小的时候勉强能用,一旦波特率上到 115200 甚至更高,或者对方设备一帧发几百个字节,CPU 基本就被串口中断吃满了,主循环里其他任务全被拖慢。后来接触到DMA + 空闲中断这套组合,才算真正把串口收发这件事做利索了。

这套方案要解决的问题很明确:不定长数据的接收。所谓不定长,就是对方每次发过来的字节数不固定,可能这一帧 8 个字节,下一帧 200 个字节,你没法提前知道长度。传统的做法有两种,一种是靠协议里的帧头帧尾去判断,另一种是定时器超时判断帧结束。前者要求协议必须规整,后者要额外占用一个定时器资源,还得反复调超时阈值。而USART 的空闲中断(IDLE)恰好天然适合干这件事——总线在一帧数据发完之后会保持空闲状态,硬件检测到空闲就会触发一次中断,等于硬件帮你判断了“这一帧发完了”。

我这次用的芯片是STM32F103C8T6最小系统板,开发环境是 Keil MDK,固件库用的是SPL(标准外设库),不是 HAL。选 SPL 的原因很实在:F103 这颗芯片太经典了,SPL 的代码量小、执行效率高、寄存器操作直观,对于要抠时序和资源的场景更友好。HAL 虽然移植方便,但层层封装之后,中断里多绕几圈,对 F103 这种 72MHz 主频、20KB RAM 的芯片来说,能省一点是一点。当然这不是说 HAL 不好,只是这个项目里 SPL 更合我的胃口。

整体方案的核心思路是这样:接收方向用 DMA 搬运 + 空闲中断判帧,发送方向用 DMA 搬运 + 发送完成中断收尾。接收时,DMA 配置成循环模式,串口每收到一个字节,DMA 自动把它搬到内存缓冲区,全程不需要 CPU 插手;当一帧数据发完、总线空闲时,空闲中断触发,我们在中断里算出这一帧实际收了多少字节(用缓冲区总长度减去 DMA 剩余传输数量),然后置个标志位通知主循环去处理。发送时,把要发的数据丢给 DMA,配置成单次模式,发完之后触发发送完成中断,在中断里清标志、准备下一包。这样 CPU 的负担被压到极低,主循环可以安心跑业务逻辑。

这套方案适合谁呢?如果你正在做 F103 的串口通信,数据量偏大、帧长不固定,或者你已经被“一个字节一次中断”折磨过,那这套东西基本就是为你准备的。哪怕你是刚上手 STM32 的新手,只要跟着把 DMA 和空闲中断这两块啃下来,后面做 SPI、ADC 的 DMA 传输都会顺很多,因为底层逻辑是相通的。

2. 核心细节解析与实操要点

2.1 为什么是 DMA 加空闲中断,而不是纯中断

先把“为什么”讲透,不然配置的时候容易照抄参数却不知道在干嘛。纯中断接收的问题在于中断频率。假设波特率 115200,一个字节 10 位(1 起始 + 8 数据 + 1 停止),那么每秒最多能传 11520 个字节,也就是每秒最多触发 11520 次接收中断。每次中断进出栈、判断标志、读数据、存缓冲区,保守估计几十条指令,加起来 CPU 每秒要花几十万条指令在串口上。72MHz 的 F103 每秒能执行 7200 万条指令,看起来占比不高,但别忘了你还有别的中断、还有主循环任务,累积起来就很可观了。

DMA 的思路是把“搬运”这件事从 CPU 手里拿走。串口收到数据后,硬件直接向 DMA 控制器发请求,DMA 控制器把数据从 USART 数据寄存器搬到内存,全程不经过 CPU 内核。CPU 只在“一帧收完”这个时间点被叫醒一次,处理完继续干别的。中断次数从“每字节一次”降到“每帧一次”,这个数量级的差别,在高速通信场景下是决定性的。

那为什么还要空闲中断?因为 DMA 本身不知道“一帧”的边界在哪。如果只用 DMA,你得等缓冲区满了才知道收完,可不定长数据根本填不满缓冲区。空闲中断补上了这块拼图:总线空闲 = 一帧结束,硬件直接告诉你边界。两者配合,一个负责搬,一个负责判,分工明确。

2.2 DMA 循环模式与缓冲区设计的关键取舍

接收 DMA 我配置成循环模式(Circular),这一点很关键。循环模式下,DMA 搬完一圈会自动回到缓冲区开头继续搬,不会停。为什么不用普通模式?因为普通模式搬满一次就停了,你得在中断里重新配置 DMA 才能继续接收,中间这段时间如果对方又发数据就会丢。循环模式省去了重装 DMA 的麻烦,接收可以一直挂着。

缓冲区大小我一般取 256 字节,这个值不是随便定的。太小了,比如 64 字节,如果对方一帧发 100 字节,DMA 会绕回去覆盖前面的数据,导致帧内容错乱;太大了,比如 1024 字节,虽然不容易溢出,但每次算长度、拷贝数据都更费时间,而且 F103C8T6 只有 20KB RAM,缓冲区不能太奢侈。256 字节对绝大多数应用场景够用了,一帧数据超过 256 字节的情况其实很少见,真遇到了再往上加。

这里有个容易踩的坑:循环模式下,DMA 剩余传输数量(NDTR)是动态变化的。假设缓冲区 256 字节,当前 NDTR 是 200,说明已经搬了 56 个字节;如果 NDTR 是 10,说明已经搬了 246 个字节。所以一帧的实际长度 = 缓冲区总长度 - 当前 NDTR。但要注意,如果一帧数据刚好把缓冲区填满并绕回,NDTR 会从 0 跳回 256,这时候算出来的长度就不对了。所以实际项目里,要么保证单帧长度不超过缓冲区,要么在协议里加长度字段做校验。

2.3 空闲中断的触发时机与标志清除

空闲中断的触发条件是:在接收到一个字节后,总线保持空闲状态超过一个字节的传输时间。注意这个“一个字节传输时间”是硬件自动判断的,不需要你配置。比如波特率 115200,一个字节 10 位,传输时间约 86.8 微秒,那么总线空闲超过这个时间,IDLE 标志就会置位。

清除 IDLE 标志有个固定的套路:先读 SR 寄存器,再读 DR 寄存器。这是 F103 参考手册里明确写的,顺序不能反。很多人只读 SR 不读 DR,结果标志清不掉,中断反复触发,程序直接卡死。我一般写成这样:

void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { // 先读 SR,再读 DR,清除 IDLE 标志 volatile uint32_t temp; temp = USART1->SR; temp = USART1->DR; (void)temp; // 计算本帧长度 uint16_t recv_len = USART_RECV_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 置标志,通知主循环 USART1_RxFlag = 1; USART1_RxLen = recv_len; } }

注意:temp变量一定要加volatile,否则编译器优化可能把这两次读操作优化掉,导致标志清不掉。这个坑我踩过,调试了半天才发现是编译器干的。

2.4 发送方向的 DMA 配置要点

发送方向相对简单,但也有讲究。DMA 配置成普通模式(Normal),因为发送是一次性的,发完就停,不需要循环。每次要发数据时,先关掉 DMA 通道,重新设置源地址和数据长度,再打开通道。这里的关键是:在启动 DMA 发送之前,要确保上一次发送已经完成,否则会出现数据覆盖。

我一般用一个发送忙标志来管理:

uint8_t USART1_TxBusy = 0; void USART1_SendData(uint8_t *buf, uint16_t len) { while (USART1_TxBusy); // 等待上一次发送完成 USART1_TxBusy = 1; DMA_Cmd(DMA1_Channel4, DISABLE); while (DMA_GetCmdStatus(DMA1_Channel4) != DISABLE); DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA1_Channel4->CMAR = (uint32_t)buf; DMA_Cmd(DMA1_Channel4, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); }

发送完成中断里把USART1_TxBusy清掉,同时关闭串口的 DMA 发送请求。这里有个细节:DMA 传输完成中断和串口发送完成中断是两回事。DMA 传输完成只代表数据从内存搬到了串口数据寄存器,不代表最后一个字节已经从 TX 引脚发出去。如果要确保数据真正发完,应该用串口的TC(Transmission Complete)中断,而不是 DMA 的传输完成中断。不过大多数场景下,DMA 传输完成就够了,因为数据寄存器里的字节很快就会被硬件发出去。

3. 实操过程与核心环节实现

3.1 硬件连接与最小系统确认

先确认硬件。STM32F103C8T6 最小系统板,供电 3.3V,串口用的是 USART1,对应引脚PA9(TX)和 PA10(RX)。如果你用的是 USB 转串口模块,注意电平匹配:模块如果是 5V 电平,TX 接到 PA10 之前最好加个电平转换,或者确认模块支持 3.3V 电平输出。我见过有人直接把 5V 的 TX 怼到 PA10 上,短时间能用,时间长了容易损伤引脚。

接线就三根:模块 TX 接 PA10,模块 RX 接 PA9,GND 对接。如果你要调试,建议把 PA9 也接到模块的 RX 上,这样能看到单片机发出来的数据。供电方面,最小系统板可以用模块的 3.3V 输出供电,也可以用独立的 3.3V 电源,但一定要共地。

3.2 时钟与 GPIO 初始化

F103 的 USART1 挂在APB2 总线上,所以要先使能 APB2 上的 USART1 时钟和 GPIOA 时钟。DMA1 挂在AHB 总线上,用RCC_AHBPeriphClockCmd使能。这一步顺序无所谓,但都要在配置外设之前做。

void USART1_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // PA9 TX 复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA10 RX 浮空输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); }

TX 配成复用推挽,RX 配成浮空输入,这是标准做法。RX 也可以配成上拉输入,防止悬空时收到乱码,但一般模块的 TX 有驱动能力,浮空输入就够了。

3.3 USART 参数配置与中断使能

波特率我设的是 115200,8 位数据,1 位停止,无校验。这个配置是绝大多数场景的默认值。如果你要和特定设备通信,先确认对方的参数,尤其是校验位,很多工业设备用的是偶校验。

void USART1_Config(void) { USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); // 使能空闲中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); // 使能串口 USART_Cmd(USART1, ENABLE); // 配置 NVIC NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }

提示:空闲中断只需要使能USART_IT_IDLE,不需要使能USART_IT_RXNE。因为数据是 DMA 搬的,RXNE 中断用不上,使能了反而增加中断负担。

3.4 DMA 接收通道配置

USART1 的接收对应DMA1_Channel5,发送对应DMA1_Channel4。这个对应关系是芯片固定的,不能随便选,具体看参考手册的 DMA 请求映射表。

#define USART_RECV_BUF_SIZE 256 uint8_t USART_RxBuffer[USART_RECV_BUF_SIZE]; void USART1_DMA_RxConfig(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)USART_RxBuffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = USART_RECV_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); DMA_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); }

几个参数解释一下:DMA_DIR_PeripheralSRC表示数据从外设流向内存;DMA_PeripheralInc_Disable表示外设地址不递增,因为串口数据寄存器就一个地址;DMA_MemoryInc_Enable表示内存地址递增,这样数据才能依次存进缓冲区;DMA_Mode_Circular就是前面说的循环模式。

3.5 主循环里的数据处理

中断里只置标志、算长度,真正的数据处理放到主循环。这样做的好处是中断执行时间极短,不会阻塞其他中断。

int main(void) { USART1_GPIO_Init(); USART1_Config(); USART1_DMA_RxConfig(); USART1_DMA_TxConfig(); while (1) { if (USART1_RxFlag) { USART1_RxFlag = 0; // 处理接收到的数据 ProcessFrame(USART_RxBuffer, USART1_RxLen); // 处理完后,重置 DMA 计数器和标志 // 注意:循环模式下不需要重置计数器,但需要清标志 } } }

这里有个细节要说明:循环模式下,DMA 会一直搬,缓冲区里的数据会被新数据覆盖。所以主循环处理数据的速度要跟得上接收速度,否则旧数据还没处理完就被覆盖了。如果处理逻辑比较耗时,建议在中断里把数据拷贝到另一个缓冲区,主循环处理拷贝后的数据。拷贝 256 字节对 F103 来说很快,几微秒的事。

3.6 printf 重定向到串口

调试的时候,把printf重定向到串口非常方便。SPL 下重定向需要实现fputc函数:

int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }

然后在 Keil 里勾选Use MicroLIB,否则printf会依赖标准库,代码体积会大很多。这个重定向用的是阻塞发送,适合调试打印,不适合高频数据发送。正式的数据发送还是走 DMA。

4. 常见问题与排查技巧实录

4.1 空闲中断反复触发或进不去

这是最常见的问题,基本都出在标志清除上。前面说过,清 IDLE 标志必须先读 SR 再读 DR,顺序反了或者只读一个都不行。我遇到过一次,代码里写的是先读 DR 再读 SR,结果中断一直触发,程序卡在中断里出不来。后来查参考手册才发现顺序有要求。

还有一种情况是中断进不去。检查三个地方:USART_ITConfig(USART1, USART_IT_IDLE, ENABLE)有没有调用;NVIC 里USART1_IRQn有没有使能;中断服务函数名是不是写成了USART1_IRQHandler,拼写错误的话链接器不会报错,但中断就是进不去。

4.2 DMA 接收数据错位或丢失

数据错位通常是缓冲区溢出导致的。如果一帧数据长度超过缓冲区大小,DMA 会绕回开头覆盖数据,这时候算出来的长度是错的。解决办法有两个:一是加大缓冲区,二是协议里加长度字段,收到后校验。我一般两个都做,缓冲区给 256,协议里再加个长度字节。

数据丢失可能是 DMA 没使能,或者USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE)忘了调用。这两个都要检查。还有一种情况是 DMA 通道被其他外设占用了,F103 的 DMA1 有 7 个通道,每个通道对应固定的外设请求,不能随意分配,配置前先看手册确认通道没冲突。

4.3 发送数据不完整或乱码

发送乱码先查波特率。两边波特率不一致,收到的就是乱码。如果波特率对,检查 DMA 发送配置里的数据长度是不是对的,DMA_SetCurrDataCounter设的长度和实际要发的字节数要一致。

发送不完整通常是 DMA 还没发完就被下一次发送打断了。这就是前面说的USART1_TxBusy标志的作用,发送前必须等上一次完成。另外,如果发送缓冲区是局部变量,函数返回后内存被回收,DMA 还在搬这块内存,就会发出乱码。发送缓冲区一定要用全局变量或静态变量。

4.4 常见问题速查表

现象可能原因排查方向
空闲中断反复触发IDLE 标志清除顺序错误确认先读 SR 再读 DR
空闲中断进不去中断未使能或函数名错误检查 ITConfig、NVIC、函数名
接收数据错位缓冲区溢出加大缓冲区或协议加长度校验
接收数据丢失DMA 未使能或通道冲突检查 DMA_Cmd 和通道映射
发送乱码波特率不一致核对两边波特率配置
发送不完整上次发送未完成加发送忙标志等待
printf 无输出未勾选 MicroLIBKeil 选项里勾选 Use MicroLIB

4.5 几个实测有效的避坑经验

第一个经验:中断里尽量少干活。我见过有人在空闲中断里直接解析协议、拼包、做业务判断,结果中断执行时间过长,影响了其他中断的响应。正确做法是中断里只算长度、置标志,最多把数据拷贝到另一个缓冲区,剩下的全交给主循环。

第二个经验:DMA 发送的源地址要用全局变量。这个坑很隐蔽,局部数组在栈上,函数一返回栈就被复用了,DMA 搬的是已经被覆盖的数据。我当初调这个 bug 调了一下午,最后用示波器看波形才发现发出来的数据是乱的。

第三个经验:调试时先用低波特率。9600 波特率下,即使配置有小问题,也更容易观察和定位。等逻辑跑通了再往上提到 115200 甚至更高。高波特率对时钟精度要求更高,如果晶振不准,高波特率下误码率会明显上升。

第四个经验:用 DMA 测速验证配置是否正确。可以写个简单的测试,让 DMA 连续搬运一大块内存,用定时器计时,算一下实际传输速率。如果速率明显低于理论值,说明配置有问题,比如数据宽度设错了,或者通道优先级太低被其他 DMA 请求抢占了。

5. 性能优化与扩展思路

5.1 双缓冲接收提升吞吐

单缓冲区在高速连续接收时会有覆盖风险。如果数据帧间隔很短,主循环还没处理完上一帧,下一帧就来了,DMA 会直接覆盖。解决办法是双缓冲(Double Buffer):准备两个缓冲区,DMA 填一个的时候,主循环处理另一个。F103 的 DMA 本身支持双缓冲模式,配置稍微复杂一点,但能显著提升吞吐能力。

实现思路是:DMA 配置成双缓冲模式,两个内存地址交替使用。每次空闲中断触发时,判断当前 DMA 正在用哪个缓冲区,主循环处理另一个。这样即使处理速度稍慢,也不会丢数据。代价是 RAM 占用翻倍,256 字节变 512 字节,对 F103C8T6 来说还能接受。

5.2 结合定时器做超时保护

空闲中断虽然好用,但有个前提:对方必须真的把总线置为空闲。如果对方发完一帧后立刻又发下一帧,中间没有空闲间隔,空闲中断就不会触发,数据会一直堆在缓冲区里。这种情况在连续数据流场景下很常见。

解决办法是加一个定时器超时保护。配置一个定时器,比如 1ms 周期,在空闲中断里重置定时器计数。如果超过一定时间没有空闲中断,但缓冲区里已经有数据了,就强制触发一次处理。这样即使对方不给空闲间隔,也能保证数据被及时取走。定时器可以用 TIM3 或 TIM4,配置成简单的向上计数模式就行。

5.3 从 USART1 扩展到其他串口

这套方案不局限于 USART1。F103 有 3 个 USART 和 2 个 UART,每个都可以配 DMA。扩展的时候注意两点:一是 DMA 通道映射不同,USART2 的接收是 DMA1_Channel6,发送是 DMA1_Channel7,USART3 又不一样,具体查手册;二是中断服务函数名不同,USART2 是USART2_IRQHandler,别写错了。

如果多个串口同时用 DMA,还要注意 DMA 通道优先级。F103 的 DMA1 有 7 个通道,优先级可以配置,高优先级的通道会优先获得总线。如果某个串口数据量大,把它的 DMA 通道优先级设高一点。

5.4 代码结构建议

最后说一下代码组织。我习惯把串口相关的代码分成三个文件:usart1.h放宏定义和函数声明,usart1.c放初始化和中断服务函数,main.c里只调用接口。这样结构清晰,移植到其他项目也方便。

接口设计上,对外只暴露三个函数:USART1_Init()初始化,USART1_SendData()发送,USART1_GetRxFlag()和USART1_GetRxData()获取接收数据。内部细节全部封装起来,主循环不需要关心 DMA 和中断怎么配的。这种分层设计在项目变大之后优势很明显,改底层不影响上层业务。

我个人在实际操作中的体会是,DMA 加空闲中断这套东西,第一次配的时候会觉得寄存器多、步骤繁琐,但配通一次之后,后面再做类似的串口、SPI、ADC 的 DMA 传输,基本就是套模板。真正花时间的不是配置本身,而是排查那些标志位、顺序、缓冲区之类的细节问题。把上面这些坑都踩一遍,后面就顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询