1. STM32串口DMA发送与接收实现详解
在嵌入式开发中,串口通信是最常用的外设之一。传统的串口发送方式会占用CPU资源,而使用DMA(直接内存访问)可以大幅提升系统效率。本文将详细介绍如何在STM32上实现带缓存的串口DMA发送功能,并附带DMA接收的实现方法。
1.1 为什么需要DMA串口通信
在资源受限的嵌入式系统中,CPU时间是非常宝贵的。当使用普通串口发送时,CPU需要等待每个字节发送完成才能处理下一个字节,这会造成大量CPU时间的浪费。DMA技术允许外设直接与内存交换数据,无需CPU干预,可以显著提高系统性能。
以一个115200bps的串口为例,发送一个字节大约需要87μs。如果发送100字节数据,使用传统方式将占用CPU约8.7ms,而使用DMA几乎不占用CPU时间。
2. 硬件设计与CubeMX配置
2.1 CubeMX基本配置
使用STM32CubeMX工具可以快速生成DMA串口的初始化代码。关键配置步骤如下:
- 在Pinout & Configuration界面启用USART1
- 配置合适的波特率、数据位、停止位和校验位
- 在DMA Settings选项卡中添加USART1_TX的DMA通道
- 设置DMA为Memory to Peripheral模式
- 配置DMA优先级为中等
- 启用DMA中断
注意:不同系列的STM32芯片DMA配置可能略有不同,需参考对应芯片的参考手册。
2.2 DMA发送缓存设计
为了实现高效的串口通信,我们设计了一个先进先出(FIFO)的发送缓存结构:
c复制#define Stack_Data_num 50 // 缓存区可存储的最大消息数
#define Stack_Data_Len 100 // 每条消息的最大长度
typedef struct {
uint8_t RealData[Stack_Data_Len]; // 实际数据存储
uint32_t Sta; // 有效数据长度
} Stack_Data;
typedef struct Stack {
Stack_Data Data[Stack_Data_num]; // 数据存储数组
uint32_t P_Active; // 当前有效数据数量
} My_Stack_type;
这种设计允许应用程序在DMA忙于发送前一个数据包时,继续将新的数据包放入缓存区,实现非阻塞式发送。
3. DMA发送核心实现
3.1 非阻塞发送函数
核心发送函数USART1_Send_DMA_Nowait实现了非阻塞的DMA发送:
c复制void USART1_Send_DMA_Nowait(uint8_t* add, uint8_t len) {
uint8_t Stack_Write_Num = 0;
// 数据长度检查
if(len > Stack_Data_Len) {
len = Stack_Data_Len;
}
// 准备数据包
Stack_Data_Write_U1.Sta = len;
for(Stack_Write_Num = 0; Stack_Write_Num < Stack_Data_Write_U1.Sta; Stack_Write_Num++) {
Stack_Data_Write_U1.RealData[Stack_Write_Num] = add[Stack_Write_Num];
}
// 数据入栈
Stack_Push(&Stack_USART_T_U1, &Stack_Data_Write_U1);
// 检查串口状态并发送
if(USART1->SR & USART_SR_TXE) { // 串口空闲
if(Stack_USART_T_U1.P_Active) { // 有数据待发送
Stack_FIFO_Pop(&Stack_USART_T_U1, &Stack_Data_Read_U1);
HAL_UART_Transmit_DMA(&huart1, Stack_Data_Read_U1.RealData, Stack_Data_Read_U1.Sta);
}
}
}
3.2 DMA发送完成回调
当DMA发送完成时,会触发中断并调用回调函数:
c复制void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {
if (huart->Instance == USART1) {
if(Stack_USART_T_U1.P_Active) { // 检查缓存区是否还有数据
Stack_FIFO_Pop(&Stack_USART_T_U1, &Stack_Data_Read_U1);
HAL_UART_Transmit_DMA(&huart1, Stack_Data_Read_U1.RealData, Stack_Data_Read_U1.Sta);
}
}
}
这种设计实现了自动化的数据流管理,确保所有待发送数据都能按顺序完整发送。
4. 寄存器版本实现差异
对于不使用HAL库而直接操作寄存器的情况,核心逻辑类似,但配置方式不同:
4.1 DMA发送配置
c复制void DMA_USART1_T(void) {
uint8_t streamx;
// 启用DMA时钟
RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN;
// 等待DMA可配置
while(DMA2_Stream7->CR & 0X01);
// 清空中断标志
streamx = (((uint32_t)DMA2_Stream7-(uint32_t)DMA2)-0X10)/0X18;
if(streamx >= 6) DMA2->HIFCR |= 0X3D << (6*(streamx-6)+16);
else if(streamx >= 4) DMA2->HIFCR |= 0X3D << 6*(streamx-4);
else if(streamx >= 2) DMA2->LIFCR |= 0X3D << (6*(streamx-2)+16);
else DMA2->LIFCR |= 0X3D << 6*streamx;
// 配置DMA参数
DMA2_Stream7->PAR = (uint32_t)&USART1->DR; // 外设地址
DMA2_Stream7->M0AR = 0; // 内存地址(暂不设置)
DMA2_Stream7->NDTR = 0; // 数据长度(暂不设置)
// 配置DMA控制寄存器
DMA2_Stream7->CR = 0;
DMA2_Stream7->CR |= 1 << 6; // 内存到外设
DMA2_Stream7->CR |= 0 << 8; // 非循环模式
DMA2_Stream7->CR |= 0 << 9; // 外设非增量
DMA2_Stream7->CR |= 1 << 10; // 内存增量
DMA2_Stream7->CR |= 0 << 11; // 外设数据长度8位
DMA2_Stream7->CR |= 0 << 13; // 内存数据长度8位
DMA2_Stream7->CR |= 1 << 16; // 中等优先级
DMA2_Stream7->CR |= (uint32_t)4 << 25; // 通道选择
// 启用DMA和中断
DMA2_Stream7->CR |= 1 << 0; // DMA使能
DMA2_Stream7->CR |= DMA_SxCR_TCIE; // 传输完成中断
NVIC_EnableIRQ(DMA2_Stream7_IRQn); // 启用NVIC中断
}
4.2 DMA中断处理
c复制void DMA2_Stream7_IRQHandler(void) {
if(DMA2->HISR & DMA_HIFCR_CTCIF7) { // 传输完成中断
if(Stack_USART_T_U1.P_Active) { // 还有数据待发送
Stack_FIFO_Pop(&Stack_USART_T_U1, &Stack_Data_Read_U1);
// 重新配置DMA
DMA2_Stream7->CR &= ~(1 << 0); // 关闭DMA
DMA2_Stream7->M0AR = (uint32_t)Stack_Data_Read_U1.RealData;
DMA2->HIFCR |= DMA_HIFCR_CTCIF7 | DMA_HIFCR_CHTIF7 | DMA_HIFCR_CTEIF7;
DMA2_Stream7->NDTR = Stack_Data_Read_U1.Sta;
USART1->CR3 = USART_CR3_DMAT; // 使能串口DMA发送
DMA2_Stream7->CR |= 1 << 0; // 开启DMA
} else {
USART1->CR3 = USART_CR3_DMAR; // 发送完成后使能DMA接收
}
DMA2->HIFCR |= DMA_HIFCR_CTCIF7; // 清除中断标志
}
}
5. DMA接收实现
5.1 寄存器版本DMA接收配置
c复制void DMA_USART1_R(void) {
uint8_t streamx;
RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN; // 启用DMA2时钟
while(DMA2_Stream2->CR & 0X01); // 等待DMA可配置
// 清空中断标志
streamx = (((uint32_t)DMA2_Stream2-(uint32_t)DMA2)-0X10)/0X18;
if(streamx >= 6) DMA2->HIFCR |= 0X3D << (6*(streamx-6)+16);
else if(streamx >= 4) DMA2->HIFCR |= 0X3D << 6*(streamx-4);
else if(streamx >= 2) DMA2->LIFCR |= 0X3D << (6*(streamx-2)+16);
else DMA2->LIFCR |= 0X3D << 6*streamx;
// 配置DMA参数
DMA2_Stream2->PAR = (uint32_t)&USART1->DR; // 外设地址
DMA2_Stream2->M0AR = (uint32_t)USART1_DMA_RBUFF; // 内存地址
DMA2_Stream2->NDTR = USART1_DMA_RBUFF_LENTH; // 数据长度
// 配置DMA控制寄存器
DMA2_Stream2->CR = 0;
DMA2_Stream2->CR |= 0 << 6; // 外设到内存
DMA2_Stream2->CR |= 0 << 8; // 非循环模式
DMA2_Stream2->CR |= 0 << 9; // 外设非增量
DMA2_Stream2->CR |= 1 << 10; // 内存增量
DMA2_Stream2->CR |= 0 << 11; // 外设数据长度8位
DMA2_Stream2->CR |= 0 << 13; // 内存数据长度8位
DMA2_Stream2->CR |= 1 << 16; // 中等优先级
DMA2_Stream2->CR |= (uint32_t)4 << 25; // 通道选择
DMA2_Stream2->CR |= 1 << 0; // DMA使能
}
5.2 空闲中断处理
c复制void USART1_IRQHandler(void) {
if(USART1->SR & (1<<4)) { // 检测到总线空闲
USART1->SR; // 清除状态寄存器
USART1->DR; // 清除数据寄存器
DMA2_Stream2->CR &= ~DMA_SxCR_EN; // 关闭DMA
USART1_RX_STA = USART1_DMA_RBUFF_LENTH - DMA2_Stream2->NDTR; // 计算接收数据长度
// 重新配置DMA
DMA2->LIFCR |= 0X3D << 16;
DMA2_Stream2->NDTR = USART1_DMA_RBUFF_LENTH;
DMA2_Stream2->CR |= DMA_SxCR_EN; // 开启DMA
}
}
5.3 CubeMX版本DMA接收
使用HAL库的实现更为简洁:
c复制// 初始化时调用
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, USART1_DMA_RBUFF, USART1_DMA_RBUFF_LENTH);
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
// 空闲中断回调
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
if (huart->Instance == USART1) {
USART1_RX_STA = Size; // 保存接收数据长度
// 重新启动DMA接收
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, USART1_DMA_RBUFF, USART1_DMA_RBUFF_LENTH);
}
}
6. 实际应用与优化建议
6.1 使用示例
完整的初始化流程:
c复制// 初始化串口
USART1_Init(115200);
// 初始化DMA发送
DMA_USART1_T();
// 初始化DMA接收
DMA_USART1_R();
// 启用空闲中断
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
数据发送示例:
c复制uint8_t data[] = "Hello, DMA UART!";
USART1_Send_DMA_Nowait(data, sizeof(data)-1); // -1排除字符串结束符
数据接收处理:
c复制void USART1_RX_Handler(void) {
if(USART1_RX_STA) { // 有新数据到达
// 处理接收到的数据
Process_Received_Data(USART1_DMA_RBUFF, USART1_RX_STA);
// 重置接收状态
USART1_RX_STA = 0;
}
}
6.2 性能优化建议
-
缓存区大小调整:根据实际应用场景调整
Stack_Data_num和Stack_Data_Len的值。对于高频小数据包,可以增加Stack_Data_num;对于大数据包,可以增大Stack_Data_Len。 -
优先级设置:在复杂的系统中,合理设置DMA和串口中断的优先级,避免与其他高优先级中断冲突。
-
错误处理:增加对DMA传输错误的检测和处理,提高系统稳定性。
-
内存对齐:对于某些STM32系列,确保DMA使用的内存地址按照数据宽度对齐可以提高性能。
-
功耗考虑:在低功耗应用中,可以在DMA传输完成后进入低功耗模式,通过中断唤醒。
6.3 常见问题排查
-
数据发送不完整:
- 检查DMA缓存区是否足够大
- 确认DMA和串口时钟已正确使能
- 验证DMA通道与串口的对应关系是否正确
-
接收数据丢失:
- 确保空闲中断已正确启用
- 检查DMA接收缓存区是否被意外修改
- 确认波特率设置与发送端一致
-
系统卡死:
- 检查中断优先级是否冲突
- 确认DMA中断标志已正确清除
- 验证堆栈空间是否足够
-
DMA不启动:
- 确认DMA控制器时钟已使能
- 检查DMA通道是否被其他外设占用
- 验证外设到DMA的触发信号是否正常
7. 高级应用扩展
7.1 多串口管理
在实际项目中,经常需要同时管理多个串口。可以通过以下结构体扩展我们的设计:
c复制typedef struct {
UART_HandleTypeDef *huart;
My_Stack_type TxStack;
Stack_Data WriteBuffer;
Stack_Data ReadBuffer;
uint8_t DMA_Tx_Channel;
uint8_t DMA_Rx_Channel;
} UART_Manager;
UART_Manager UART1_Manager, UART2_Manager, UART3_Manager;
这样可以为每个串口维护独立的状态和缓存区,实现统一的管理接口。
7.2 动态内存分配
对于内存资源丰富的系统,可以使用动态内存分配来优化缓存管理:
c复制typedef struct {
uint8_t *Data; // 动态分配的内存指针
uint32_t Length; // 数据长度
} Dynamic_Data;
typedef struct {
Dynamic_Data *Items; // 动态分配的缓存数组
uint32_t Capacity; // 缓存容量
uint32_t Count; // 当前数据包数量
} Dynamic_Stack;
这种方法可以更灵活地适应不同大小的数据包,但需要谨慎处理内存分配和释放。
7.3 流量控制
在高负载情况下,可以实现硬件或软件流量控制:
- 硬件流控:启用RTS/CTS引脚,通过硬件信号控制数据流
- 软件流控:实现XON/XOFF协议,当缓存区快满时发送暂停信号
8. 测试与验证
8.1 单元测试建议
-
基本功能测试:
- 单次发送小数据包(1-10字节)
- 连续发送多个数据包
- 发送最大长度数据包
-
压力测试:
- 高频率连续发送
- 长时间稳定性测试
- 缓存区满情况测试
-
异常情况测试:
- 发送空指针
- 发送零长度数据
- 发送超长数据(超过缓存区大小)
8.2 性能评估指标
- 吞吐量:测量单位时间内可以成功传输的数据量
- 延迟:从调用发送函数到数据实际发送完成的时间
- CPU占用率:比较使用DMA和传统方式的CPU使用情况
- 稳定性:长时间运行是否会出现内存泄漏或死锁
8.3 调试技巧
- 利用串口打印调试信息:在关键位置添加状态输出
- 使用逻辑分析仪:捕获实际的串口波形和时序
- 内存监视:定期检查缓存区内容,确保数据正确性
- 错误计数器:统计各种错误发生的频率和条件
9. 不同STM32系列的适配
虽然基本原理相同,但不同系列的STM32在DMA配置上有些差异:
9.1 F1系列注意事项
- DMA控制器分为DMA1和DMA2
- 通道数量较少
- 配置寄存器较为简单
9.2 F4/F7/H7系列增强功能
- 支持双缓冲模式
- 更灵活的数据宽度设置
- 更高的传输速率
9.3 低功耗系列优化
- 在DMA传输期间可以保持低功耗模式
- 更精细的时钟门控
- 针对低功耗优化的唤醒机制
10. 替代方案比较
除了本文介绍的方法,还有其他实现串口高效通信的方式:
10.1 中断+环形缓冲区
优点:
- 实现简单
- 不依赖DMA控制器
- 适用于所有型号
缺点:
- CPU占用率较高
- 高波特率下可能出现数据丢失
10.2 纯DMA无缓存
优点:
- 实现最简单
- 资源消耗最少
缺点:
- 灵活性差
- 无法处理突发的大量数据
10.3 商业中间件
如FreeRTOS+CLI、emWin等库中集成的串口处理:
优点:
- 功能完善
- 经过充分测试
缺点:
- 资源占用大
- 学习成本高
相比之下,本文介绍的带缓存的DMA方案在资源占用、灵活性和性能之间取得了良好的平衡,适合大多数嵌入式应用场景。
