STM32F103串口DMA中断收发实战:从配置到避坑全解析(附完整代码)

📅 发布时间:2026/7/10 10:07:59 👁️ 浏览次数:
STM32F103串口DMA中断收发实战:从配置到避坑全解析(附完整代码)
STM32F103串口DMA中断收发实战从配置到避坑全解析附完整代码最近在做一个基于STM32F103的工业传感器数据采集项目遇到了一个挺典型的问题主控芯片需要同时处理多个传感器的数据还要响应上位机的指令。最开始我用的是传统的串口中断收发结果发现CPU时间被大量占用数据量大时甚至出现了丢包。后来把方案换成了串口DMA中断收发CPU占用率直接从30%多降到了个位数整个系统流畅多了。这让我意识到对于很多嵌入式开发者尤其是刚接触STM32的朋友DMA这个“直接内存访问”功能用好了绝对是性能提升的利器但初次配置时那些寄存器、通道、中断标志位也确实容易让人头大。这篇文章我就结合自己踩过的坑和项目里的实际经验跟你聊聊怎么在STM32F103上玩转串口DMA中断收发。我不会只给你一堆代码让你复制粘贴而是会拆开揉碎了讲清楚为什么这么配、配置时要注意什么、以及出了问题怎么调。目标是让你看完后不仅能实现功能更能理解背后的逻辑下次遇到类似需求能自己举一反三。1. 理解DMA为什么它是串口通信的“性能加速器”在深入代码之前我们得先搞明白DMA到底是干什么的以及它为什么能解放CPU。你可以把CPU想象成一个公司的CEO而串口接收数据就像前台不断有快递数据字节送来。传统的中断方式是每来一个快递前台就打电话给CEO“老板有个快递您来签收一下。”CEO就得放下手头的重要工作比如处理核心算法跑去前台拿快递再回来继续工作。如果快递特别多CEO就基本别想干正事了。DMA则相当于雇了一个专门的助理DMA控制器。CEO只需要在开始时给助理交代一句“去前台把所有快递搬到这个储物柜内存缓冲区里搬完了告诉我一声。”然后助理就会自动完成搬运工作期间CEO可以专心处理公司战略。只有当一整批快递比如缓冲区满了或者指定数量的快递搬完了都处理完毕助理才会触发一个“完成中断”通知CEO“老板货都齐了您来处理吧。”对于STM32F103的USART1这个“助理”有两条固定的“搬运路线”DMA通道通道4专门负责从内存搬运数据到串口发送寄存器USART1-DR也就是发送。通道5专门负责从串口接收寄存器USART1-DR搬运数据到内存也就是接收。理解了这个比喻再看下面的配置就会清晰很多。DMA的核心优势就在于将CPU从频繁的、底层的字节搬运工作中解脱出来让它能专注于更复杂的业务逻辑。2. 硬件与工程框架搭建在写第一行代码前合理的工程结构和硬件连接是基础。这一步做扎实了后面的调试能省一半的力气。2.1 硬件连接与引脚确认以最常用的USART1为例它的发送TX和接收RX脚通常是固定的USART1_TX: PA9 (复用推挽输出)USART1_RX: PA10 (浮空输入或上拉输入)如果你的板子使用了RS-485通信这在工业现场很常见还会多出一个方向控制引脚如PG8用于切换芯片的收发模式。在软件上我们通常这样控制// 假设方向控制引脚为 PG8高电平发送低电平接收 #define RS485_DIR_PORT GPIOG #define RS485_DIR_PIN GPIO_Pin_8 void RS485_SetTxMode(void) { GPIO_SetBits(RS485_DIR_PORT, RS485_DIR_PIN); // 设置为发送模式 Delay_us(50); // 短暂延时等待收发器稳定 } void RS485_SetRxMode(void) { GPIO_ResetBits(RS485_DIR_PORT, RS485_DIR_PIN); // 设置为接收模式 Delay_us(50); }注意这个延时很关键RS-485收发器从发送切换到接收状态需要一定的稳定时间通常几微秒到几十微秒如果切换后立即操作可能导致前几个字节发送不出去。具体延时需要参考你所使用的收发器芯片的数据手册。2.2 工程文件规划一个清晰的文件结构能让代码维护性大大提升。我建议为串口DMA功能单独建立文件而不是把所有代码都堆在main.c里。你的工程目录/ ├── CMSIS/ 系统核心文件标准库或HAL库自带 ├── StdPeriph_Driver/ 标准外设驱动文件 ├── User/ │ ├── main.c │ ├── stm32f10x_it.c 中断服务函数集中地 │ ├── uart_dma/ │ │ ├── uart_dma.c │ │ └── uart_dma.h │ └── ...其他模块 └── ...在uart_dma.h中我们定义模块对外的接口和关键参数#ifndef __UART_DMA_H #define __UART_DMA_H #include stm32f10x.h // 缓冲区大小定义 - 这是DMA定长传输的关键 #define UART_RX_BUF_SIZE 64 // 接收缓冲区大小 #define UART_TX_BUF_SIZE 128 // 发送缓冲区大小通常可以设大一些 // 外部可访问的缓冲区与标志位 extern uint8_t g_uart_rx_buffer[UART_RX_BUF_SIZE]; extern volatile uint8_t g_uart_rx_complete_flag; extern volatile uint16_t g_uart_rx_data_len; // 函数声明 void UART1_DMA_Init(uint32_t baudrate); void UART1_DMA_SendData(uint8_t *pData, uint16_t len); void UART1_DMA_StartReceive(void); uint8_t UART1_DMA_IsRxComplete(void); #endif /* __UART_DMA_H */这里有一个关键点g_uart_rx_complete_flag和g_uart_rx_data_len这两个变量被声明为volatile。这是因为它们会在中断服务程序中被修改而在主循环中被读取。volatile关键字告诉编译器不要对这个变量进行优化每次都必须从内存中重新读取它的值确保主循环能及时看到中断里更新的状态。3. 核心配置详解一步步点亮DMA配置DMA就像给那位“助理”制定一份详细的工作说明书。说明书越清晰他干活越不出错。我们分步来看。3.1 时钟与GPIO初始化任何外设使用前必须先打开它的时钟。STM32的时钟树比较复杂但对于我们用的USART1和DMA1记住以下两条就行void UART1_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; // 1. 开启GPIOA和AFIO如果需要重映射的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 2. 配置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); // 3. 配置PA10为上拉输入 (RX) GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入抗干扰更好 GPIO_Init(GPIOA, GPIO_InitStructure); }对于RX引脚我更喜欢设置为上拉输入GPIO_Mode_IPU而不是浮空输入GPIO_Mode_IN_FLOATING。在电路板上悬空的引脚容易受到电磁干扰产生误触发。内部上拉电阻通常几十kΩ可以提供一个稳定的默认高电平增强抗噪能力。3.2 串口参数与DMA通道绑定接下来配置串口本身的工作模式和波特率并把它和DMA通道关联起来。void UART1_DMA_Init(uint32_t baudrate) { USART_InitTypeDef USART_InitStructure; DMA_InitTypeDef DMA_InitStructure; // 1. 开启USART1时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); // 2. 配置串口基本参数 USART_InitStructure.USART_BaudRate baudrate; 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_Cmd(USART1, ENABLE); // 使能串口 // 3. 开启DMA1时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // 4. 配置DMA接收通道通道5外设-内存 DMA_DeInit(DMA1_Channel5); // 复位通道5寄存器 DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(USART1-DR); // 外设地址串口数据寄存器 DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)g_uart_rx_buffer; // 内存地址我们的缓冲区 DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; // 传输方向外设是源读 DMA_InitStructure.DMA_BufferSize UART_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_Normal; // 正常模式非循环 DMA_InitStructure.DMA_Priority DMA_Priority_VeryHigh; // 优先级设为最高 DMA_InitStructure.DMA_M2M DMA_M2M_Disable; // 内存到内存模式禁用 DMA_Init(DMA1_Channel5, DMA_InitStructure); // 5. 配置DMA发送通道通道4内存-外设 DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(USART1-DR); DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)g_uart_tx_buffer; // 发送缓冲区 DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; // 传输方向外设是目标写 DMA_InitStructure.DMA_BufferSize 0; // 初始为0发送时再设定 DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_Init(DMA1_Channel4, DMA_InitStructure); // 6. 使能串口的DMA请求 USART_DMACmd(USART1, USART_DMAReq_Rx | USART_DMAReq_Tx, ENABLE); // 7. 先使能接收DMA让它随时准备搬数据 DMA_Cmd(DMA1_Channel5, ENABLE); }这里有几个参数需要特别关注参数接收通道DMA1_Ch5发送通道DMA1_Ch4解释与注意事项DMA_DIRPeripheralSRCPeripheralDST方向千万别配反。接收是外设到内存发送是内存到外设。DMA_BufferSize缓冲区大小如64初始为0发送时设定这是定长传输的根源。接收DMA会一直搬数据直到搬满这个数量。DMA_ModeNormalNormal正常模式传输完指定数量后停止。还有Circular循环模式适合持续数据流。DMA_PriorityVeryHighHigh接收优先级通常设得比发送高确保数据不丢失。DMA_M2MDisableDisable我们用的是外设DMA不是内存到内存的DMA。“定长”的奥秘就藏在DMA_BufferSize和DMA_Mode里。在Normal模式下DMA控制器会从CNDTR寄存器当前数据数量开始递减计数减到0时产生传输完成中断TC。所以如果你设置BufferSize为64那么只有当串口收到恰好64个字节时才会进入接收完成中断。多一个、少一个都不会触发。这是与“空闲中断不定长”方案最根本的区别。3.3 中断配置与服务函数编写配置好DMA通道后我们需要告诉CPU当DMA工作完成时请中断一下我来处理数据。这就需要配置NVIC嵌套向量中断控制器和编写中断服务函数。首先在初始化函数里加上中断配置// ... 接上面的UART1_DMA_Init函数 NVIC_InitTypeDef NVIC_InitStructure; // 配置DMA接收完成中断通道5 NVIC_InitStructure.NVIC_IRQChannel DMA1_Channel5_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; // 抢占优先级 NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; // 子优先级 NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); DMA_ITConfig(DMA1_Channel5, DMA_IT_TC, ENABLE); // 使能传输完成中断 // 配置DMA发送完成中断通道4 NVIC_InitStructure.NVIC_IRQChannel DMA1_Channel4_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 2; // 发送优先级可以低一些 NVIC_Init(NVIC_InitStructure); DMA_ITConfig(DMA1_Channel4, DMA_IT_TC, ENABLE);优先级设置是个学问。基本原则是数据接收的及时性高于发送。因为接收的数据是外部输入的不及时处理可能被新数据覆盖而丢失。发送的数据是咱们自己控制的晚一点发出去通常问题不大。所以我把接收中断的抢占优先级设得比发送高。接下来在stm32f10x_it.c文件中编写中断服务函数// DMA1通道5中断服务函数接收完成 void DMA1_Channel5_IRQHandler(void) { if(DMA_GetITStatus(DMA1_IT_TC5) ! RESET) { DMA_ClearITPendingBit(DMA1_IT_TC5); // 1. 必须首先清除中断标志 // 2. 计算实际接收到的数据长度 // CNDTR寄存器在传输过程中递减其值代表剩余要传输的数据量 g_uart_rx_data_len UART_RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 3. 设置接收完成标志通知主循环 g_uart_rx_complete_flag 1; // 4. 关键一步重新配置DMA接收准备下一次传输 // 必须先关闭通道设置传输数量再开启 DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, UART_RX_BUF_SIZE); // 重置传输数量 DMA_Cmd(DMA1_Channel5, ENABLE); } } // DMA1通道4中断服务函数发送完成 void DMA1_Channel4_IRQHandler(void) { if(DMA_GetITStatus(DMA1_IT_TC4) ! RESET) { DMA_ClearITPendingBit(DMA1_IT_TC4); // 发送完成后的处理例如释放缓冲区、切换RS485为接收模式等 g_uart_tx_busy_flag 0; // 清除发送忙标志 // 如果是RS485这里需要切换回接收模式 // RS485_SetRxMode(); } }在接收中断里DMA_ClearITPendingBit必须放在最前面。这是一个好习惯可以避免在中断函数执行期间同一中断标志再次被置位而可能引发的问题。另外重新配置DMA接收通道是保证连续通信的关键。一次传输完成后CNDTR变成了0如果不重新设置它DMA就不会再搬运新数据了。4. 应用层封装与实战技巧底层驱动写好了我们还需要封装几个好用的应用函数让主程序调用起来更简单、更安全。4.1 发送函数的健壮性设计一个健壮的发送函数需要考虑很多边界情况发送数据太长怎么办上一次还没发完又要求发新的怎么办// 发送缓冲区 static uint8_t s_uart_tx_buffer[UART_TX_BUF_SIZE]; static volatile uint8_t s_uart_tx_busy 0; // 发送忙标志 uint8_t UART1_DMA_SendData(uint8_t *pData, uint16_t len) { // 1. 参数检查 if(pData NULL || len 0) { return 0; // 参数错误 } // 2. 检查DMA发送是否空闲 if(s_uart_tx_busy) { // 可以根据需求选择返回错误、等待、或丢弃新数据 return 0; // 发送忙直接返回 } // 3. 处理数据超长情况截断或分包 uint16_t send_len len; if(send_len UART_TX_BUF_SIZE) { send_len UART_TX_BUF_SIZE; // 这里可以添加日志或调试信息提示数据被截断 } // 4. 复制数据到发送缓冲区避免原数据被修改 memcpy(s_uart_tx_buffer, pData, send_len); // 5. 如果是RS485切换为发送模式 // RS485_SetTxMode(); // 6. 配置并启动DMA发送 s_uart_tx_busy 1; // 设置忙标志 DMA_Cmd(DMA1_Channel4, DISABLE); // 先关闭通道 DMA_SetCurrDataCounter(DMA1_Channel4, send_len); // 设置本次传输长度 DMA_Cmd(DMA1_Channel4, ENABLE); // 启动传输 return send_len; // 返回实际发送的长度 }这个函数里我引入了一个s_uart_tx_busy标志位。因为DMA发送是异步的主函数调用UART1_DMA_SendData后函数返回了但数据可能还在发送中。这个标志位可以防止在发送完成中断触发前主程序又发起新的发送请求导致数据混乱。4.2 主循环中的数据处理范式在主函数main.c里我们的逻辑会变得非常清晰int main(void) { // 系统初始化、时钟配置等 SystemInit(); NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // 设置中断优先级分组 // 初始化串口DMA波特率115200 UART1_DMA_Init(115200); // 初始化其他外设... while(1) { // 1. 检查是否收到一帧完整数据 if(UART1_DMA_IsRxComplete()) { // 2. 处理数据 ProcessReceivedData(g_uart_rx_buffer, g_uart_rx_data_len); // 3. 可选回复数据或进行下一步操作 uint8_t ack_msg[] Data Received OK\r\n; UART1_DMA_SendData(ack_msg, sizeof(ack_msg)-1); // 注意减去字符串结尾的\0 // 4. 清除接收标志准备下一次接收 UART1_DMA_ClearRxFlag(); } // 其他后台任务... DoBackgroundTask(); } }这种**“标志位-查询”**的方式是裸机编程中处理异步事件的经典模式。主循环不断轮询标志位一旦发现数据就绪就进行批处理。这比在中断里处理大量数据要安全得多因为中断服务函数应该尽可能短小精悍。4.3 应对“定长”限制的协议设计DMA中断是定长的但实际应用中的数据包长度可能变化。怎么办这就需要我们在应用层协议上动脑筋了。一个简单实用的方法是设计一个包含长度字段的帧结构。例如我们可以定义这样一个数据帧| 帧头 (1字节) | 数据长度 (1字节) | 数据 (N字节) | 校验和 (1字节) |帧头一个固定的值比如0xAA用于标识帧的开始。数据长度指明后面“数据”字段的实际字节数。数据有效载荷。校验和对前面所有字节进行累加和或CRC校验用于判断数据在传输中是否出错。在代码中我们将DMA接收缓冲区大小设置为比最大可能帧长度稍大。当DMA接收完成中断触发时我们检查缓冲区里的数据寻找帧头0xAA。找到帧头后根据“数据长度”字段判断一帧完整的数据是否已经接收完毕。如果接收完毕就校验如果校验通过就处理这帧数据。如果数据不完整比如长度字段显示需要50字节但DMA只收到30字节就触发了中断说明这包数据被拆成了多个DMA块。我们需要在内存中建立一个更大的缓冲区进行数据拼接直到凑齐一帧完整的数据再处理。虽然这比不定长接收复杂一些但它带来了一个好处天然的帧同步能力。在干扰严重的环境中不定长接收可能因为一个干扰字节就导致帧头识别错误而定长接收结合协议解析抗干扰能力更强。5. 调试排坑指南常见问题与解决方案即使代码逻辑正确实际调试中还是会遇到各种稀奇古怪的问题。下面是我总结的几个典型“坑点”和解决办法。5.1 数据收不到或收不全这是最常见的问题。可以按照以下清单逐一排查检查硬件连接用万用表测量TX、RX引脚电压确保没有虚焊。对于RS-485检查方向控制引脚电平是否正确。确认波特率确保单片机与上位机如串口助手的波特率、数据位、停止位、校验位完全一致。一个9600一个115200肯定对不上。验证时钟配置STM32的USART时钟来源于APB2总线。如果系统时钟配置错了USART的波特率也会不准。可以用一个简单的GPIO翻转语句结合示波器测量实际输出的波特率。// 在发送函数里发送一个字节0x55 (二进制01010101) // 用示波器测量PA9引脚一个位的时间应该是 1/波特率 秒检查DMA通道是否使能在初始化后和每次接收完成中断后确认DMA_Cmd(DMA1_Channel5, ENABLE)被执行了。查看CNDTR寄存器在调试器中观察DMA1_Channel5-CNDTR的值。如果它一直是初始值比如64且不变说明DMA根本没启动搬运。如果它在递减说明DMA在工作但可能没触发中断。5.2 中断不触发如果数据似乎收到了缓冲区里有数据但接收完成标志位始终没被置位中断标志未清除这是最可能的原因。确保在中断服务函数里第一行就清除了对应的中断标志DMA_ClearITPendingBit。NVIC配置错误检查NVIC_Init函数是否被正确调用中断通道号DMA1_Channel5_IRQn是否正确。全局中断未开启在main函数开始调用了__enable_irq()或相关函数吗有些启动文件会默认开启但最好确认一下。优先级分组问题确保NVIC_PriorityGroupConfig只在程序开始调用一次。STM32的中断优先级分组设置影响整个系统重复设置可能导致混乱。5.3 数据错乱或覆盖表现为收到的数据不是发送的数据或者新旧数据混在一起。缓冲区溢出这是定长DMA的固有风险。如果上位机发送数据过快一包数据还没处理完下一包数据又来了DMA就会覆盖旧的缓冲区。解决方案双缓冲区乒乓操作准备两个接收缓冲区A和B。DMA当前使用A当A满触发中断后在中断里迅速将DMA的目标地址切换到B并处理A中的数据。这样实现数据接收和处理的并行。提高处理速度优化ProcessReceivedData函数让它执行得更快。增加缓冲区大小在内存允许的情况下增大UART_RX_BUF_SIZE降低中断频率。内存地址不对齐虽然字节传输一般没问题但如果涉及更复杂的数据结构确保DMA访问的内存地址是对齐的。发送接收竞争在RS-485半双工网络中发送和接收不能同时进行。确保在发送完成后有足够的延时再切换回接收模式并等待总线稳定。5.4 性能优化建议当系统复杂起来多个外设都在用DMA时可以考虑这些优化点使用循环模式Circular Mode对于持续不断的数据流如音频采集可以将DMA配置为循环模式。这样DMA在传输完缓冲区数据后会自动从头开始形成一个环无需在中断中重新配置CNDTR减少了中断开销和配置出错的概率。只需要在初始化时把DMA_Mode改为DMA_Mode_Circular即可。合理设置DMA优先级DMA控制器内部也有仲裁器。如果有多个DMA通道同时请求优先级高的先被服务。确保最关键的数据流如实时控制指令所在的DMA通道拥有最高优先级。减少中断服务函数工作量中断里只做最必要的事设置标志、切换缓冲区。耗时的处理数据解析、存储、响应放到主循环中基于标志位去处理。这就是“前台中断后台处理”的思想。调试嵌入式系统逻辑分析仪和调试器是最好的朋友。遇到问题时别光盯着代码看用工具实际抓一下TX、RX引脚上的波形用调试器单步跟踪一下中断是否进入寄存器的值是否符合预期往往能更快地定位问题根源。从最开始的GPIO、时钟配置到DMA通道、中断的细节设置再到应用层的协议设计和健壮性封装最后到实战中的调试排坑这套流程走下来你应该对STM32F103的串口DMA中断收发有了一个立体而深入的理解。我最后再分享一个小心得在项目初期不妨先用串口中断实现功能确保通信逻辑正确。然后再移植到DMA方案上做性能优化。这样分层推进遇到问题更容易定位。所有的代码片段我都尽量给出了上下文和解释你可以根据自己的板子和需求进行调整和整合。嵌入式开发就是这样理论和参考代码只是地图真正的路还得自己一步步调试走出来。