1. 项目缘起与整体设计思路
STM32F103 这颗芯片,搞嵌入式的基本都摸过。价格便宜、资料多、外设齐全,拿来做串口通信更是家常便饭。但很多人用串口,还停留在“中断里一个字节一个字节收”的阶段,波特率一高就丢数据,CPU 被频繁打断,主循环卡得跟幻灯片似的。这个项目要解决的,就是不定长数据的串口高效收发问题——用 DMA 搬运数据,用空闲中断判断一帧结束,CPU 几乎不参与搬运过程。
先说清楚这个方案能干什么。你有一个上位机或者另一个单片机,通过串口往 STM32F103 发数据,每帧长度不固定,可能是 8 个字节的指令,也可能是 200 个字节的数据包。传统做法要么定长接收,要么靠超时判断帧尾,前者不灵活,后者浪费 CPU 时间。DMA + 空闲中断的组合,能让串口在收到一帧完整数据后自动通知你,期间 CPU 该干嘛干嘛,完全不操心。
适合谁来参考?如果你已经能点亮 LED、会配置 GPIO 和基本中断,但对 DMA 和串口高级用法还不太熟,这篇内容就是给你准备的。我会从 SPL 标准外设库的角度出发,把配置流程、参数计算、代码实现和踩坑经验全部摊开讲。标准库虽然老,但 STM32F103 上用的人多,资料全,拿来理解底层机制比 HAL 更直观。
为什么选 SPL 而不是 HAL?HAL 抽象层厚,代码量大,一个串口 DMA 初始化能牵扯出一堆回调函数,初学者容易迷失在框架里。SPL 直接操作寄存器级别的外设结构体,每一行代码你都知道它在干什么。对于 F103 这种资源有限的芯片,SPL 生成的代码更精简,运行效率也更高。当然,如果你已经习惯了 CubeMX 那套流程,原理是相通的,只是 API 名字不同。
整个方案的核心思路分三块:DMA 负责数据搬运,把 USART 数据寄存器的内容自动搬到内存缓冲区,或者把内存缓冲区的数据自动搬到 USART 发送寄存器;空闲中断负责帧边界检测,当串口总线在一个字节传输时间内没有新数据时,硬件自动触发中断,告诉你“这一帧结束了”;环形缓冲区负责数据缓存,解决 DMA 接收和主循环处理速度不匹配的问题。三者配合,才能做到高波特率下不丢数据、不卡主循环。
注意:DMA 和空闲中断的配合有一个关键前提——DMA 必须工作在循环模式或者及时重新使能,否则一帧收完后 DMA 就停了,下一帧数据来了也搬不进去。
2. 核心细节解析与实操要点
2.1 DMA 通道选择与数据流配置
STM32F103 的 DMA1 有 7 个通道,每个通道对应固定的外设请求。USART1 的接收是 DMA1_Channel5,发送是 DMA1_Channel4;USART2 接收是 DMA1_Channel6,发送是 DMA1_Channel7;USART3 接收是 DMA1_Channel3,发送是 DMA1_Channel2。这个对应关系是硬件固定的,选错了通道,DMA 请求根本不会触发。
配置 DMA 的时候,有几个参数必须搞清楚。方向:接收是外设到内存,发送是内存到外设。数据宽度:USART 数据寄存器是 8 位的,所以外设和内存的数据宽度都设成 8 位,别设成 16 位,否则会出错。地址增量:外设地址固定,内存地址递增。传输模式:接收用循环模式,发送用正常模式。优先级:如果多个 DMA 通道同时工作,根据数据重要性分配,串口接收一般设中等优先级。
这里重点说循环模式。接收 DMA 设成循环模式后,当传输计数器减到 0,硬件自动重装载初始值,DMA 继续工作,不需要软件干预。这样串口来多少数据,DMA 就搬多少,永远不会停。但循环模式有个问题:你无法直接知道当前搬了多少个字节。所以需要配合空闲中断,在帧结束时读取 DMA 的剩余传输数量,反推出这一帧收了多少字节。
2.2 空闲中断的触发机制与清除方法
空闲中断(IDLE)是 USART 的一个硬件特性。当串口接收完一个字节后,如果在下一个字节的传输时间内没有收到新数据,硬件就认为总线空闲了,置位 IDLE 标志。如果 IDLEIE 使能了,就会触发中断。这个“一个字节传输时间”跟波特率有关,比如 115200 波特率下,一个字节 10 位(1 起始 + 8 数据 + 1 停止),传输时间约 86.8 微秒。也就是说,两个字节之间间隔超过 86.8 微秒,就会触发空闲中断。
清除空闲中断的标志,标准流程是“先读 SR 寄存器,再读 DR 寄存器”。这个顺序不能反,也不能只读一个。SR 是状态寄存器,DR 是数据寄存器。读 SR 清除 IDLE 标志,读 DR 清除 RXNE 标志。如果你只读 SR 不读 DR,IDLE 标志可能清不掉,中断会反复触发。这个坑我踩过,当时调了半天以为 DMA 配置有问题,后来查参考手册才发现是这个清除顺序的讲究。
提示:在空闲中断服务函数里,除了处理数据,一定要记得清除 IDLE 标志,否则中断会一直挂在那里,程序跑飞。
2.3 环形缓冲区的设计与实现
DMA 接收数据是“后台”进行的,主循环处理数据是“前台”进行的。如果主循环处理慢了,DMA 还在往缓冲区里写,就会覆盖还没处理的数据。环形缓冲区就是解决这个速度不匹配问题的。它的本质是一块固定大小的内存,用读指针和写指针标记当前读写位置,写指针追上读指针表示缓冲区满,读指针追上写指针表示缓冲区空。
在 DMA + 空闲中断的方案里,环形缓冲区通常有两级。第一级是 DMA 直接写入的缓冲区,大小固定,比如 256 字节。第二级是应用层的环形缓冲区,空闲中断触发后,把 DMA 缓冲区里的数据拷贝到环形缓冲区,然后主循环从环形缓冲区里取数据处理。为什么要两级?因为 DMA 缓冲区在 DMA 工作期间不能被随意修改,而环形缓冲区可以灵活读写。
环形缓冲区的容量选择有讲究。太小了容易溢出,太大了浪费内存。一般根据你的数据帧最大长度和主循环的处理周期来估算。假设最大帧长 200 字节,主循环最慢 10 毫秒处理一次,串口波特率 115200,10 毫秒能收约 115 个字节。那环形缓冲区至少 200 + 115 = 315 字节,取个整数 512 字节比较稳妥。
3. 实操过程与核心环节实现
3.1 硬件连接与最小系统检查
先确认硬件没问题。STM32F103 最小系统板,USART1 的 TX 是 PA9,RX 是 PA10。用 USB 转串口模块连接的时候,注意 TX 接 RX,RX 接 TX,交叉连接。供电方面,F103 是 3.3V 供电,如果 USB 转串口模块输出的是 5V 电平,需要在 RX 线上加电平转换电路,或者用支持 3.3V 的模块。我见过有人直接把 5V 的 TX 接到 F103 的 RX,短时间能用,时间长了芯片发热,最后串口收发都不正常了。
最小系统板上一般有 8MHz 晶振和 32.768kHz 晶振。系统时钟配置成 72MHz,APB2 给 USART1 提供时钟,APB1 给 USART2/3 提供时钟。DMA1 挂在 AHB 总线上,时钟使能别忘了。这些时钟使能漏掉任何一个,外设都不会工作,而且不会报错,就是没反应,查起来很费时间。
3.2 串口初始化与 DMA 配置代码
先看串口初始化的关键代码。波特率 115200,8 位数据,1 位停止,无校验,无硬件流控。这些参数跟你的通信协议要匹配,上位机设成一样,否则收到的全是乱码。
void USART1_Init(u32 bound) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // PA9 TX 复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; 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); USART_InitStructure.USART_BaudRate = bound; 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); 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_Cmd(USART1, ENABLE); }DMA 接收配置的代码:
#define USART1_RX_BUF_SIZE 256 u8 USART1_RX_BUF[USART1_RX_BUF_SIZE]; void USART1_DMA_Rx_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (u32)USART1_RX_BUF; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = USART1_RX_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_Medium; 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_PeripheralBaseAddr填的是 USART1 数据寄存器的地址,DMA_MemoryBaseAddr填的是接收缓冲区的首地址。DMA_DIR_PeripheralSRC表示从外设搬到内存。DMA_Mode_Circular是循环模式,DMA 搬完一轮自动重来。USART_DMACmd是使能串口的 DMA 接收请求,这一句不能漏,漏了 DMA 配置再好也没用。
3.3 空闲中断服务函数与数据处理
空闲中断服务函数是整个方案的核心。它要做三件事:判断是不是空闲中断、计算收到多少字节、把数据取走并重新准备 DMA。
#define RING_BUF_SIZE 512 u8 Ring_Buf[RING_BUF_SIZE]; volatile u16 Ring_Write = 0; volatile u16 Ring_Read = 0; void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { // 清除空闲中断标志:先读SR,再读DR USART1->SR; USART1->DR; // 计算本次接收到的数据长度 u16 remaining = DMA_GetCurrDataCounter(DMA1_Channel5); u16 received = USART1_RX_BUF_SIZE - remaining; // 把数据从DMA缓冲区搬到环形缓冲区 for(u16 i = 0; i < received; i++) { Ring_Buf[Ring_Write] = USART1_RX_BUF[i]; Ring_Write = (Ring_Write + 1) % RING_BUF_SIZE; } // 重新设置DMA传输数量,准备接收下一帧 DMA_SetCurrDataCounter(DMA1_Channel5, USART1_RX_BUF_SIZE); } }这里有个细节:DMA_GetCurrDataCounter返回的是剩余传输数量,用总大小减去剩余数量,就是已经收到的字节数。但要注意,如果 DMA 是循环模式,当传输计数器减到 0 后会自动重装载,这时候剩余数量又变成总大小了。所以必须在空闲中断里及时读取,否则数据会错乱。
还有一个容易忽略的点:DMA_SetCurrDataCounter在循环模式下其实不需要手动调用,因为硬件会自动重装载。但如果你用的是正常模式,就必须手动重设,否则下一帧数据来了 DMA 不工作。我建议接收用循环模式,省去手动重设的麻烦,但读取剩余数量要快,别等 DMA 转了一圈才读。
3.4 主循环数据解析与 printf 重定向
主循环里从环形缓冲区取数据,判断帧头帧尾,解析指令。取数据的逻辑是:读指针不等于写指针,说明有数据,取一个字节,读指针后移。
void Process_Data(void) { while(Ring_Read != Ring_Write) { u8 byte = Ring_Buf[Ring_Read]; Ring_Read = (Ring_Read + 1) % RING_BUF_SIZE; // 在这里做协议解析 } }printf 重定向到串口,调试的时候特别方便。标准库的 printf 默认输出到控制台,在单片机里需要重写fputc函数。
int fputc(int ch, FILE *f) { while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); USART_SendData(USART1, (u8)ch); return ch; }如果发送数据量大,建议也用 DMA 发送。DMA 发送的配置跟接收类似,方向反过来,模式用正常模式,每次发送前设置传输数量,发送完成中断里做后续处理。发送 DMA 的通道是 DMA1_Channel4。
4. 常见问题与排查技巧实录
4.1 数据丢包与乱码问题排查
串口通信出问题,先分清楚是硬件问题还是软件问题。乱码通常是波特率不匹配、时钟配置错误、电平不兼容。检查三个地方:上位机波特率和代码里是否一致;系统时钟是否真的配到了 72MHz,可以用 MCO 引脚输出时钟用示波器看;TX/RX 电平是否匹配,3.3V 和 5V 混接容易出问题。
丢包的原因更多。如果波特率不高也丢包,先看 DMA 缓冲区是不是太小,一帧数据超过缓冲区大小,DMA 会覆盖前面的数据。再看空闲中断里读取剩余数量的时机,如果主循环里也在读,可能读到中间状态。还有中断优先级的问题,如果串口中断被其他高优先级中断打断太久,空闲中断响应不及时,DMA 可能已经转了一圈。
注意:DMA 接收缓冲区大小一定要大于单帧最大长度,否则一帧数据没接收完,DMA 指针就绕回去了,数据被覆盖,解析必然出错。
4.2 DMA 传输计数器的读取时机
DMA_GetCurrDataCounter这个函数返回的值,在 DMA 工作过程中是实时变化的。如果你在空闲中断里读,读到的是帧结束那一刻的剩余数量,计算出来的接收长度是准确的。但如果你在主循环里读,DMA 可能还在搬数据,读到的值没有意义。
还有一个隐蔽的问题:DMA 传输完成中断和空闲中断同时触发。如果一帧数据刚好把 DMA 缓冲区填满,DMA 传输完成中断会触发,同时空闲中断也可能触发。这时候两个中断都在读剩余数量,可能重复处理数据。解决办法是在 DMA 传输完成中断里也做处理,或者把 DMA 缓冲区设大一点,避免刚好填满的情况。
4.3 环形缓冲区的溢出保护
环形缓冲区如果没有溢出保护,写指针追上读指针后继续写,就会覆盖未处理的数据。加一个溢出标志,当写指针即将追上读指针时,丢弃新数据或者覆盖旧数据,根据你的业务需求决定。我一般选择丢弃新数据并置位溢出标志,主循环检测到溢出标志后做相应处理,比如清空缓冲区重新同步。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 收到乱码 | 波特率不匹配 | 核对上位机与代码波特率 | 统一设置为 115200 |
| 数据丢包 | DMA 缓冲区太小 | 计算单帧最大长度 | 缓冲区设为最大帧长 2 倍 |
| 空闲中断不触发 | IDLE 标志未清除 | 检查 SR/DR 读取顺序 | 先读 SR 再读 DR |
| DMA 不搬运 | 通道选错或时钟未使能 | 查参考手册 DMA 请求映射表 | 使能 DMA1 时钟,选对通道 |
| 帧长度计算错误 | 剩余数量读取时机不对 | 在空闲中断内读取 | 避免主循环读取 |
4.4 实操心得与避坑清单
第一个坑:DMA 时钟使能。RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE)这一句经常被漏掉,因为 USART 的时钟在 APB2 上,DMA 的时钟在 AHB 上,两个总线容易混淆。漏掉 DMA 时钟,DMA 配置全部无效,但编译不报错,运行时就是没反应。
第二个坑:空闲中断标志清除。前面说过,先读 SR 再读 DR,顺序不能反。我试过只读 SR,中断反复触发,程序卡在中断里出不来。也试过用USART_ClearITPendingBit清除,这个函数对 IDLE 标志无效,必须用读寄存器的方式。
第三个坑:DMA 发送和接收共用中断。DMA1_Channel4 和 DMA1_Channel5 的中断是分开的,但如果你同时用了多个 DMA 通道,中断服务函数里要判断是哪个通道触发的中断。标准库的DMA_GetITStatus可以判断,别搞混了。
第四个坑:printf 重定向的阻塞问题。fputc里用while等待发送完成,如果串口发送被阻塞,整个程序都会卡住。调试阶段可以用,正式产品建议用 DMA 发送或者环形缓冲区发送,避免阻塞主循环。
第五个坑:波特率误差。STM32F103 的 USART 波特率是通过 APB 时钟分频得到的,72MHz 下 115200 的误差很小,但如果你改了系统时钟,比如超频到 128MHz,波特率误差可能变大,导致通信不稳定。用示波器测一下实际波特率,误差超过 3% 就要调整分频系数。
4.5 性能测试与优化建议
实测下来,115200 波特率下,DMA + 空闲中断的方案,CPU 占用率不到 1%。对比纯中断接收,每收到一个字节进一次中断,115200 波特率下每秒约 11500 次中断,CPU 大部分时间都在进出中断,主循环几乎跑不动。DMA 方案下,只有帧结束时才进一次中断,假设每帧 100 字节,每秒才 115 次中断,差距非常明显。
如果要进一步提高性能,可以考虑几个方向。提高波特率:F103 的 USART 最高支持 4.5Mbps,但实际受限于线缆和电平转换芯片,一般 921600 比较稳定。增大 DMA 缓冲区:减少空闲中断的触发频率,但会增加内存占用。双缓冲 DMA:F103 的 DMA 不支持双缓冲,但可以用两个通道交替工作,实现乒乓缓冲。硬件流控:数据量大且接收端处理慢时,用 RTS/CTS 流控避免丢数据。
这个方案后续还可以扩展。比如加上 CRC 校验,在空闲中断里计算帧校验和,主循环只处理校验通过的数据。或者加上超时重传机制,发送方在一定时间内没收到应答就重发。再或者把环形缓冲区改成消息队列,每个消息是一个完整帧,主循环直接取帧处理,不用自己拼帧。
我个人在实际操作中的体会是,DMA + 空闲中断这套组合,难点不在配置,而在调试。配置代码网上到处都是,但每个项目的数据特征不一样,缓冲区大小、中断优先级、环形缓冲区容量这些参数需要根据实际情况调整。我一般会先用小缓冲区跑通流程,然后用逻辑分析仪抓波形,确认数据收发正常,再逐步加大缓冲区、提高波特率,观察稳定性。这个过程急不得,参数调好了,这套方案跑起来非常稳,几个月不用重启。