简介:这份资源面向使用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控制器 | Stream | Channel |
|---|---|---|---|---|
| USART1 | TX | DMA2 | Stream7 | Channel4 |
| USART1 | RX | DMA2 | Stream5 | Channel4 |
| USART2 | TX | DMA1 | Stream6 | Channel4 |
| USART2 | RX | DMA1 | Stream5 | Channel4 |
| USART3 | TX | DMA1 | Stream3 | Channel4 |
| USART3 | RX | DMA1 | Stream1 | Channel4 |
| UART4 | TX | DMA1 | Stream4 | Channel4 |
| UART4 | RX | DMA1 | Stream2 | Channel4 |
| UART5 | TX | DMA1 | Stream7 | Channel4 |
| UART5 | RX | DMA1 | Stream0 | Channel4 |
注意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 Stream7,RX用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,计数器还是0,DMA认为无事可做。
解决:在每次处理完一帧后,执行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的最高主频是168MHz,F407也是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中断的触发点;最后上压力测试。三步走完,基本就稳了。希望帮到你。
本文还有配套的精品资源,点击获取