资讯动态

STM32F407/F405串口DMA收发实战:从丢包到稳定通信

发布时间:2026/10/16 15:17:01 来源:尧图企业网站定制
简介这份资源面向使用STM32F405/407进行串口开发的嵌入式工程师与学生聚焦标准库下UART与DMA协同工作的实现方式解决高吞吐量串口收发时CPU占用过高、实时性不足的问题。压缩包共2个文件包含1个.c源文件与1个.h头文件体积约2KB源码文件承载GPIO复用、UART参数配置、DMA流与通道初始化、收发关联及中断处理等核心逻辑头文件则提供函数原型、结构体与常量声明便于直接移植到工程中调用。目前已有2878人学习下载说明该方案在步进电机控制等实时性要求较高的场景中具有参考价值。读者可据此掌握DMA减轻CPU负担的完整配置思路理解内存与外设间数据传输的启动、完成与错误处理流程并对照源码快速搭建自己的串口收发框架提升系统整体运行效率。1. 串口DMA收发为什么你的中断方式总在丢包如果你用STM32F407或F405做过串口通信大概率经历过这样的场景波特率一上115200主循环稍微忙一点接收数据就开始丢字节或者发送一大帧数据时CPU被死死占住其他任务全部卡顿。这不是你代码写得差而是中断方式本身的天花板——每来一个字节就进一次中断波特率越高、数据量越大CPU被切碎得越厉害。DMA直接存储器访问就是来解决这个问题的。它让串口的数据搬运不经过CPU硬件自动把DR寄存器的数据搬到内存或者把内存数据搬到DR寄存器。STM32F407/F405的USART外设都支持DMA请求配合标准库Standard Peripheral Library配置起来并不复杂但坑也不少——DMA通道选错、接收长度不确定、发送完成判断不准每一个都能让你调半天。这篇笔记面向的是已经能用标准库点灯、能配串口中断的开发者目标是让你把DMA收发真正跑通并且知道在F407和F405上哪些参数必须改、哪些地方容易翻车。下面从外设和DMA的对应关系讲起一路落到可复现的代码和排错方法。2. 先搞清楚F407和F405的DMA通道映射2.1 为什么USART1和USART2用的DMA不一样STM32F4系列的DMA控制器有两个DMA1和DMA2每个控制器有8个数据流Stream每个数据流有8个通道Channel。外设的DMA请求是固定映射到某个数据流的某个通道上的不能随便选。这是第一个容易翻车的地方——很多人看别人代码里USART1_TX用DMA2 Stream7自己换成USART2也照抄结果完全没反应。F407和F405在DMA映射上基本一致常见串口的对应关系如下串口方向DMA控制器StreamChannelUSART1TXDMA2Stream7Channel4USART1RXDMA2Stream5Channel4USART2TXDMA1Stream6Channel4USART2RXDMA1Stream5Channel4USART3TXDMA1Stream3Channel4USART3RXDMA1Stream1Channel4UART4TXDMA1Stream4Channel4UART4RXDMA1Stream2Channel4UART5TXDMA1Stream7Channel4UART5RXDMA1Stream0Channel4注意USART1挂在DMA2上其他大部分在DMA1上。如果你用的是UART4或UART5还要注意这两个串口只有异步模式没有同步时钟引脚配置时别去开同步模式。2.2 标准库里的DMA初始化结构体怎么填标准库配置DMA的核心是DMA_InitTypeDef关键字段有七个。我一般按下面的顺序填每个字段的含义和取值理由都标清楚#include stm32f4xx.h #include stm32f4xx_dma.h #include stm32f4xx_usart.h // 以USART1 RX为例DMA2 Stream5 Channel4 void USART1_RX_DMA_Init(u32 buffer_addr, u16 buffer_size) { DMA_InitTypeDef DMA_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 使能DMA2时钟F407/F405的DMA2挂在AHB1总线上 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); DMA_DeInit(DMA2_Stream5); // 先复位避免残留配置干扰 DMA_InitStructure.DMA_Channel DMA_Channel_4; // USART1固定Channel4 DMA_InitStructure.DMA_PeripheralBaseAddr (u32)USART1-DR; // 外设地址数据寄存器 DMA_InitStructure.DMA_Memory0BaseAddr buffer_addr; // 内存缓冲区地址 DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralToMemory; // 外设到内存即接收 DMA_InitStructure.DMA_BufferSize buffer_size; // 传输数量单位由下面的数据宽度决定 DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; // 外设地址不自增始终读DR DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; // 内存地址自增 DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; // 串口一次8位 DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; // 内存也是8位 DMA_InitStructure.DMA_Mode DMA_Mode_Normal; // 普通模式收完就停 DMA_InitStructure.DMA_Priority DMA_Priority_High; // 优先级高减少丢包 DMA_InitStructure.DMA_FIFOMode DMA_FIFOMode_Disable; // 串口字节流不需要FIFO DMA_InitStructure.DMA_FIFOThreshold DMA_FIFOThreshold_HalfFull; DMA_InitStructure.DMA_MemoryBurst DMA_MemoryBurst_Single; DMA_InitStructure.DMA_PeripheralBurst DMA_PeripheralBurst_Single; DMA_Init(DMA2_Stream5, DMA_InitStructure); // 开启传输完成中断用于知道一帧收完了 DMA_ITConfig(DMA2_Stream5, DMA_IT_TC, ENABLE); NVIC_InitStructure.NVIC_IRQChannel DMA2_Stream5_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); DMA_Cmd(DMA2_Stream5, ENABLE); // 使能数据流 }这段代码里有几个参数值得展开说。DMA_BufferSize的单位不是字节而是“数据项”的个数具体一个数据项多少位由DMA_PeripheralDataSize和DMA_MemoryDataSize决定。这里两边都设成Byte所以BufferSize就等于字节数。如果你设成HalfWord那实际传输字节数要乘以2这个坑后面还会提。DMA_Mode_Normal表示传输完BufferSize个数据后自动停止适合定长接收。如果要循环接收改成DMA_Mode_Circular但循环模式下缓冲区会一直被覆盖需要配合其他机制判断新数据位置。DMA_FIFOMode_Disable在串口场景下是合理的因为串口是字节流FIFO的打包优势用不上关掉还能降低延迟。F407/F405的DMA支持FIFO但只有在外设和内存数据宽度不一致、或者需要突发传输时才建议开启。2.3 串口侧的DMA请求怎么使能DMA配好了还得告诉USART去发DMA请求。标准库用USART_DMACmd// 使能USART1的接收DMA请求 USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); // 如果是发送用USART_DMAReq_Tx // USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE);这一步很容易漏。有人DMA配置全对中断也开了就是收不到数据最后发现是没调USART_DMACmd。另外注意USART的DMA请求使能要在USART使能之后做顺序反了在某些情况下会不生效。3. 接收不定长数据的两种可靠方案3.1 定长接收最简单但最不实用定长接收就是提前知道对方会发多少字节DMA配好BufferSize收满触发中断在中断里处理。这种方式代码最简单但实际项目里几乎不会用——你没法保证对方每次发的长度都一样。不过定长接收是理解DMA收发的基础先把它跑通再改不定长会顺很多。定长接收的中断处理#define RX_BUFFER_SIZE 64 u8 rx_buffer[RX_BUFFER_SIZE]; volatile u8 rx_complete_flag 0; void DMA2_Stream5_IRQHandler(void) { if (DMA_GetITStatus(DMA2_Stream5, DMA_IT_TCIF5) ! RESET) { DMA_ClearITPendingBit(DMA2_Stream5, DMA_IT_TCIF5); rx_complete_flag 1; // 通知主循环 // 注意Normal模式下DMA已经停了要重新接收必须重新配置 } }这里有个关键点Normal模式下传输完成DMA自动关闭EN位清零。下次接收前必须重新设置DMA_BufferSize并重新使能否则收不到任何数据。很多人第一次用DMA接收第一帧正常第二帧就没反应就是忘了这一步。3.2 空闲中断DMA不定长接收的经典组合不定长接收的常见做法是利用串口的空闲中断IDLE。空闲中断的触发条件是总线在接收完一个字节后保持空闲状态超过一个字节的传输时间。也就是说一帧数据发完、总线安静下来就会触发IDLE中断。这时候去读DMA还剩多少个数据没搬就能算出这一帧收了多少字节。标准库没有直接提供空闲中断的使能函数需要操作寄存器// 使能USART1空闲中断 USART1-CR1 | USART_CR1_IDLEIE; // 空闲中断处理 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 清除空闲中断标志先读SR再读DR volatile u32 temp; temp USART1-SR; temp USART1-DR; (void)temp; // 计算本帧接收到的字节数 u16 received_len RX_BUFFER_SIZE - DMA_GetCurrDataCounter(DMA2_Stream5); if (received_len 0) { // 处理rx_buffer中的received_len个字节 // 注意处理要快或者拷贝到另一个缓冲区再慢慢处理 } // 重新配置DMA以便接收下一帧 DMA_Cmd(DMA2_Stream5, DISABLE); DMA_SetCurrDataCounter(DMA2_Stream5, RX_BUFFER_SIZE); DMA_Cmd(DMA2_Stream5, ENABLE); } }DMA_GetCurrDataCounter返回的是剩余未传输的数据项个数。用总大小减去剩余就是已经收到的字节数。这个计算在Normal模式下成立因为DMA从缓冲区头部开始写写到哪剩余就减少到哪。清空闲中断标志的方式比较特殊必须“先读SR再读DR”这是F4系列串口的硬件要求。只读SR不清标志只读DR也不清顺序反了也不行。这个细节在参考手册里有写但很容易被忽略。注意在空闲中断里重新配置DMA时要先DISABLE再改计数器再ENABLE。直接改计数器在DMA使能状态下行为不确定可能不生效。3.3 双缓冲方案高波特率下的进阶选择如果波特率很高比如921600甚至更高或者帧与帧之间间隔极短空闲中断可能来不及处理。这时候可以用DMA的双缓冲模式Double Buffer Mode。F4的DMA支持两个内存地址硬件在其中一个填满后自动切到另一个并触发中断。双缓冲的配置比普通模式多两个字段DMA_InitStructure.DMA_Memory0BaseAddr (u32)buffer0; DMA_InitStructure.DMA_Memory1BaseAddr (u32)buffer1; // 第二个缓冲区 DMA_InitStructure.DMA_Mode DMA_Mode_Circular; // 双缓冲必须配合循环模式 DMA_InitStructure.DMA_BufferSize BUFFER_SIZE; // 每个缓冲区的大小然后在中断里用DMA_GetCurrentMemoryTarget判断当前在填哪个缓冲区处理另一个。双缓冲的代价是内存占用翻倍而且逻辑更复杂一般项目用空闲中断就够了。我个人的习惯是波特率115200以下用空闲中断再高才考虑双缓冲。4. DMA发送什么时候算“发完了”4.1 发送完成中断和TC标志的区别DMA发送的配置和接收类似方向改成DMA_DIR_MemoryToPeripheral外设地址还是USARTx-DR。但“发送完成”的判断有个容易混淆的地方DMA的传输完成中断TC表示DMA已经把内存里的数据全部搬到了USART的DR寄存器但不代表最后一个字节已经从TX引脚移出去了。USART的TC标志Transmission Complete才表示移位寄存器空了、总线真正空闲。如果你在DMA的TC中断里立刻切换RS485方向或者关闭电源最后一个字节可能还没发完就被截断了。正确的做法是DMA的TC中断里等USART的TC标志或者直接开USART的TC中断。我一般用后者void USART1_TX_DMA_Start(u8 *data, u16 len) { // 等待上一次发送完成 while (DMA_GetCmdStatus(DMA2_Stream7) ! DISABLE); DMA_Cmd(DMA2_Stream7, DISABLE); DMA_SetCurrDataCounter(DMA2_Stream7, len); DMA2_Stream7-M0AR (u32)data; // 标准库没有直接改源地址的函数操作寄存器 DMA_Cmd(DMA2_Stream7, ENABLE); // 使能USART的TC中断用于判断真正发完 USART_ITConfig(USART1, USART_IT_TC, ENABLE); } void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_TC) ! RESET) { USART_ClearITPendingBit(USART1, USART_IT_TC); USART_ITConfig(USART1, USART_IT_TC, DISABLE); // 到这里才是真正发完了可以切方向或做其他操作 } }标准库没有提供单独修改DMA内存地址的函数DMA_Init会重配整个结构体。如果只是改地址直接操作M0AR寄存器更高效但要注意在DMA关闭状态下改。4.2 发送期间的缓冲区生命周期DMA发送是异步的USART1_TX_DMA_Start返回时数据还没发完。如果传入的data是局部变量或者栈上的数组函数返回后内存被回收DMA还在读那块内存发出去的就是乱码。这个坑非常隐蔽因为有时候“看起来正常”——那是因为栈内存还没被覆盖。解决办法只有两个要么用全局缓冲区或静态缓冲区要么在发送完成前不释放。我一般要求调用者传入的缓冲区至少在整个发送期间有效并在文档里写清楚。如果实在要用局部数据就先拷贝到全局发送缓冲区再启动DMA。4.3 发送和接收同时用会不会冲突USART1的TX用DMA2 Stream7RX用DMA2 Stream5是两个独立的数据流可以同时工作互不干扰。但要注意中断优先级如果TX和RX中断优先级相同同时触发时会按中断号顺序处理。DMA2_Stream5_IRQn的中断号比Stream7小会先处理RX。这个顺序一般无所谓但如果你的RX处理里有耗时操作可能影响TX的实时性。另外USART1的全局中断USART1_IRQHandler同时处理空闲中断和TC中断如果两个同时挂起要在处理函数里分别判断标志位不能只处理一个就返回。5. 避坑与排查那些让我熬夜的DMA问题5.1 收不到数据但DMA配置看起来全对现象DMA初始化没报错USART也能正常中断收发但DMA接收缓冲区始终是初始值。原因最常见的是忘了调USART_DMACmd(USARTx, USART_DMAReq_Rx, ENABLE)。其次是DMA时钟没开——DMA1和DMA2分别挂在RCC_AHB1Periph_DMA1和DMA2上开错了时钟DMA完全不工作。还有一种情况是Stream和Channel选错比如把USART1_RX配到了DMA2 Stream0硬件根本不会响应。解决按“时钟→Stream/Channel→外设DMA请求→DMA使能”的顺序逐项检查。可以在初始化后读DMA2_Stream5-CR寄存器的EN位确认DMA真的使能了。5.2 第一帧正常后续帧全丢现象DMA接收第一帧数据完全正确但之后再也收不到。原因Normal模式下DMA传输完成自动关闭没有重新使能。或者虽然重新使能了但没有重新设置DMA_BufferSize计数器还是0DMA认为无事可做。解决在每次处理完一帧后执行DMA_Cmd(DISABLE)→DMA_SetCurrDataCounter(BUFFER_SIZE)→DMA_Cmd(ENABLE)。顺序不能反先改计数器再关DMA可能不生效。5.3 数据错位偶尔多一个或少一个字节现象接收到的数据整体偏移或者帧头对不上。原因空闲中断的触发时机和DMA计数器读取之间存在竞争。如果空闲中断触发时DMA还在搬运最后一个字节DMA_GetCurrDataCounter读到的值可能还没更新。另外如果波特率较高中断响应延迟也会导致计数器读取偏晚。解决在空闲中断里先关DMA再读计数器读完再重新配置。关DMA的瞬间DMA会停止搬运计数器值就稳定了。代价是可能丢掉关DMA那一瞬间到来的字节但在空闲中断场景下总线已经空闲不会有新字节。5.4 发送数据出现乱码或截断现象DMA发送的数据前面正确后面几个字节乱码或者最后一个字节丢失。原因缓冲区生命周期问题前面讲过或者DMA的TC中断里过早操作了硬件。还有一种情况是DMA_BufferSize设成了字节数的一半——比如要发100字节BufferSize填了50那只发前50字节。解决确认BufferSize和实际字节数的关系确认缓冲区在发送期间有效确认用USART的TC标志而不是DMA的TC标志判断真正发完。5.5 F405上能跑F407上不行或反过来现象同一份代码在F405上正常换到F407就出问题。原因F405和F407虽然同属F4系列但F405是精简版某些型号的DMA请求映射或外设数量有差异。比如F405可能没有DMA2的某些Stream或者USART数量不同。另外F405的最高主频是168MHzF407也是168MHz但F405的RAM和Flash配置不同如果缓冲区定义在特定地址段可能越界。解决查对应型号的参考手册和数据手册确认DMA映射表。不要假设F405和F407完全兼容尤其是用到DMA2或UART4/5的时候。6. 用状态机把DMA收发封装成可复用的模块6.1 为什么裸写DMA初始化会越来越乱项目里如果有三四个串口都要用DMA每个都写一遍初始化、中断处理、重配置代码会迅速膨胀。而且每个串口的DMA Stream和Channel不同中断函数名也不同复制粘贴很容易改漏。我一般会抽一个结构体把串口、DMA流、缓冲区、状态都包进去用统一的状态机驱动。6.2 一个精简的DMA串口对象设计typedef enum { UART_DMA_IDLE 0, UART_DMA_RX_BUSY, UART_DMA_TX_BUSY } uart_dma_state_t; typedef struct { USART_TypeDef *uart; // USART1/2/3... DMA_Stream_TypeDef *rx_stream; DMA_Stream_TypeDef *tx_stream; u8 *rx_buf; u16 rx_buf_size; volatile u16 rx_len; // 本帧实际接收长度 volatile u8 rx_flag; // 接收完成标志 uart_dma_state_t state; } uart_dma_handle_t; // 统一的中断处理入口在各自的IRQHandler里调用 void uart_dma_rx_idle_handler(uart_dma_handle_t *h) { if (USART_GetITStatus(h-uart, USART_IT_IDLE) ! RESET) { volatile u32 tmp h-uart-SR; tmp h-uart-DR; (void)tmp; DMA_Cmd(h-rx_stream, DISABLE); h-rx_len h-rx_buf_size - DMA_GetCurrDataCounter(h-rx_stream); h-rx_flag 1; DMA_SetCurrDataCounter(h-rx_stream, h-rx_buf_size); DMA_Cmd(h-rx_stream, ENABLE); } }这个结构体把串口和DMA流的对应关系数据化初始化时填一次之后所有操作都通过handle走。中断函数里只需要调用uart_dma_rx_idle_handler不用关心具体是哪个串口。这样加一个新串口只需要填一个结构体不用复制中断代码。6.3 用状态机避免发送冲突发送侧加一个简单的状态判断防止在上一次发送还没完成时启动新的发送u8 uart_dma_send(uart_dma_handle_t *h, u8 *data, u16 len) { if (h-state UART_DMA_TX_BUSY) { return 1; // 忙拒绝本次发送 } h-state UART_DMA_TX_BUSY; DMA_Cmd(h-tx_stream, DISABLE); DMA_SetCurrDataCounter(h-tx_stream, len); h-tx_stream-M0AR (u32)data; DMA_Cmd(h-tx_stream, ENABLE); USART_ITConfig(h-uart, USART_IT_TC, ENABLE); return 0; }在TC中断里把state改回UART_DMA_IDLE。调用者根据返回值决定是重试还是丢弃。这个简单的状态判断能避免很多“发送数据串帧”的问题。6.4 验证DMA是否真的在工作的三个手段第一个手段是看寄存器。在调试器里观察DMA2_Stream5-NDTR如果接收时这个值在减小说明DMA在搬运。如果一直是初始值不变DMA没工作。第二个手段是翻转GPIO。在空闲中断里翻转一个空闲引脚用示波器或逻辑分析仪看波形能直观看到每帧接收的时机和间隔。第三个手段是压力测试。用上位机以最高波特率连续发送随机长度数据跑几个小时统计丢帧率和错帧率。DMA在Normal模式下的重配置如果不够快高负载时会丢帧这个测试能暴露出来。我自己的习惯是新调一个串口DMA先用定长接收跑通确认DMA在搬数据再改空闲中断用逻辑分析仪看IDLE中断的触发点最后上压力测试。三步走完基本就稳了。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑