简介面向嵌入式开发者与STM32初学者的USART3 DMA通信工程围绕STM32F103平台和标准库实现串口高效收发重点解决轮询、普通中断方式占用CPU较多以及实时性不足的问题。压缩包共171个文件、约3.41MB包含30个c源文件、31个h头文件、33个s启动文件以及o、d、crf等编译中间产物和Keil工程配置目录结构完整适合直接打开工程对照调试。已有3315人学习下载。工程内容覆盖DMA通道选择、传输方向与传输量设置USART3时钟、波特率、数据位、停止位和校验参数初始化发送接收缓冲区管理以及传输完成中断和错误中断处理等关键环节。配套工程模板和可编译文件可帮助读者理解标准库下DMA自动搬运数据的工作机制掌握外设初始化顺序与中断回调组织方式并据此改造或搭建自己的串口通信模块减少CPU干预、提升系统响应能力。1. 为什么 USART3 要挂 DMA一个 115200 波特率下的真实痛点某次做一块多路串口采集板STM32F103 的 USART1 留给调试口USART3 接一路 115200 波特率、每 10 ms 来一帧 64 字节的传感器。最初用接收中断逐字节搬主循环里还跑着 ADC 采样和 Flash 页擦除结果只要一进擦除USART3 就连续丢两三帧ORE 标志一置位后面全乱套。改成 DMA 之后CPU 只在整帧到位时被叫醒一次同一个工程压测两小时没再丢过包。这篇就把 STM32F103 标准库下 USART3 DMA 的收发写法拆开讲通道怎么选、代码怎么写、参数怎么改、哪几个坑几乎必然踩到。2. 把通道关系理清USART3 和 DMA1 的映射不能靠猜STM32F103 的 DMA 请求不是想挂哪就挂哪每个外设的收发请求都固定绑在 DMA1 的某个通道上而且发送和接收是两个独立方向、两个独立通道。写代码之前先把这张对应表确认清楚比事后抱着逻辑分析仪怀疑人生要省事得多。很多人第一次写 USART3 的 DMA卡了半天发现是把 USART1 的通道号抄了过来症状是 DMA 配好了、串口也开了就是纹丝不动。2.1 USART3 的引脚、时钟与 DMA 通道归属先看硬件层。STM32F103 的 USART3 默认引脚是 PB10TX和 PB11RX重映射之后才是 PD8/PD9。大部分小板子用的是默认引脚如果你的原理图上 PB10/PB11 还挂着 I2C2 的上拉电阻要确认上拉不会把空闲电平拉出问题常见做法是接收脚配成上拉输入靠串口本身的推挽能力去驱动。时钟一共要开三条而且分属三个不同的总线漏一条就全哑GPIOB 挂在 APB2用RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE)USART3 挂在 APB1用RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE)DMA1 挂在 AHB用RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE)最容易翻车的是第二条USART1 在 APB2USART2/3 在 APB1不少人习惯性写成RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART3, ...)这个宏在标准库里根本不存在编译能过但外设不工作的情况多半是把 USART1 的宏抄过来了。通道对应关系记牢这一组方向外设请求DMA 控制器通道DIR 配置推荐模式发送USART3_TXDMA1Channel2PeripheralDST内存到外设Normal接收USART3_RXDMA1Channel3PeripheralSRC外设到内存Circular顺手把容易混淆的几组也列出来对照USART1_TX 是 DMA1_Channel4USART1_RX 是 DMA1_Channel5USART2_TX 是 DMA1_Channel7USART2_RX 是 DMA1_Channel6。也就是说USART3 是唯一一对落在 Channel2/Channel3 上的串口记成“三号串口用二三通道”反而好记。提示如果工程里同时开了 SPI1SPI1_RX 也占 DMA1_Channel2SPI1_TX 占 DMA1_Channel3。这时 USART3 和 SPI1 会抢通道必须在设计阶段二选一或者改用软件模拟/换串口。2.2 为什么收发必须拆成两个通道DMA 通道的几个关键配置项是一次性写死的数据方向位 DIR、外设地址、传输模式、地址自增方式。一个通道在同一时刻只能服务一个方向的请求发送是内存到外设接收是外设到内存DIR 位就是反的物理上没法复用。有人会想“那我发送完了在中断里把通道重配成接收”理论上能跑实际代价是把 DMA 最大的优势丢掉了每次方向切换都要关通道、改 CMAR、改 CNDTR、改 DIR、再开通道这几步在 115200 波特率下就是几十微秒的空窗连续帧场景里正好卡在帧间隔上丢包率反而比纯中断还高。所以只要芯片给了两个通道就不要省这一个。模式选择也有讲究。发送用 Normal 模式发送长度每次都可能不同Normal 模式下 CNDTR 减到 0 就停TC 中断触发下次发送前重设长度和内存地址即可逻辑干净。接收用 Circular 模式接收长度事先不知道帧什么时候来、多长都不知道Circular 模式让 CNDTR 自动回绕、DMA 永不停机配合串口空闲中断读一次当前计数就能算出这一帧收了多少字节这是 STM32F103 上最省心的组合。如果接收也用 Normal 模式每次空闲中断里都得关通道、重设 CNDTR、再开通道这段窗口里来的字节就直接丢了短帧高频率的场景下这个问题非常明显。2.3 中断优先级与 NVIC 分组怎么排DMA 收发的骨架搭好之后中断怎么排直接决定高负载下稳不稳。这里只开两个中断源别贪多USART3_IRQn只开 IDLE 中断以及 ORE 等错误标志的轮询检查绝对不要开 RXNE。开了 RXNE 等于每个字节都进一次中断DMA 就白配了。DMA1_Channel2_IRQn只开发送完成 TC 中断用来标记一帧发送结束。优先级上串口空闲中断要高于 DMA 发送完成中断。原因是 IDLE 到来时如果被长时间挂起DMA 的写指针会继续往后走等你进中断时算出来的帧长就偏了而发送完成中断晚个几微秒处理没有实质影响。static void NVIC_Config(void) { NVIC_InitTypeDef NVIC_InitStructure; /* 2 位抢占 2 位子优先级够用且好算 */ NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); NVIC_InitStructure.NVIC_IRQChannel USART3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); NVIC_InitStructure.NVIC_IRQChannel DMA1_Channel2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_Init(NVIC_InitStructure); }NVIC_PriorityGroupConfig只能在整个工程里调用一次放在 main 开头最稳妥如果工程里还有 SysTick 或其他外设中断抢占优先级要统一编号别在不同文件里各写各的否则实际生效的优先级和你想的完全不一样。子优先级在没有抢占冲突时只影响同级排队顺序一般给 0 就行。3. 发送端先跑通DMA 搬运加发送完成判据发送比接收简单因为数据长度是你自己定的不存在“不知道来了多少字节”的问题。但发送端有两个地方特别容易出玄学一是通道一开就立刻进完成中断二是 DMA 报告“传完了”但总线上最后一个字节还没出去。这两个问题不解决回环测试会给你非常迷惑的结果。3.1 发送通道的 DMA 初始化五个必须改的参数先把 USART3 和发送 DMA 一起初始化。这段代码里每一项都有明确用意抄的时候别漏注释里标出来的那几项。static void USART3_TX_DMA_Init(uint32_t baud) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; DMA_InitTypeDef DMA_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* PB10 复用推挽输出50MHz 足够 115200 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOB, GPIO_InitStructure); /* PB11 上拉输入空闲态确定为高电平 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOB, GPIO_InitStructure); USART_InitStructure.USART_BaudRate baud; 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(USART3, USART_InitStructure); DMA_DeInit(DMA1_Channel2); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART3-DR; /* 固定为数据寄存器 */ DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)0; /* 每次发送前重设 */ DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; /* 内存 - 外设 */ DMA_InitStructure.DMA_BufferSize 0; /* 每次发送前重设 */ 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_Medium; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; /* 不是内存到内存 */ DMA_Init(DMA1_Channel2, DMA_InitStructure); DMA_ITConfig(DMA1_Channel2, DMA_IT_TC, ENABLE); DMA_Cmd(DMA1_Channel2, DISABLE); USART_Cmd(USART3, ENABLE); }五个必须逐项确认的参数DMA_DIR必须是PeripheralDST写反了 DMA 会从 DR 往外读DMA_PeripheralInc必须 Disable数据寄存器地址不变DMA_MemoryInc必须 Enable否则所有字节都从同一个内存地址取DMA_Mode选 Normal发送长度不定不能用 CircularDMA_M2M必须 Disable这个位一开就变成软件触发跟串口请求没关系了。DMA_BufferSize和DMA_MemoryBaseAddr初始化时给 0 是故意的标准库的DMA_Init会把它写进 CNDTR真正发送前再改。这里还有一个隐藏细节USART_Cmd放在最后如果提前使能了 USART 但 DMA 通道还没配好中间这段时间来的接收数据会因为没开 DMA 请求而走中断路径容易在调试时看到莫名其妙的数据。3.2 发送完成判据TC 标志的清除顺序发送函数本身不长但有两个坑必须在这里处理掉否则第一次调用就会看到“没发数据却进了完成中断”。void USART3_SendBytes(const uint8_t *buf, uint16_t len) { uint32_t timeout; if (buf 0 || len 0) { return; } /* 等上一次 DMA 搬完加超时防止死等 */ timeout 0x000FFFFF; while (DMA_GetCurrDataCounter(DMA1_Channel2) ! 0) { if (--timeout 0) { break; } } DMA_Cmd(DMA1_Channel2, DISABLE); /* 关键TC 位复位值为 1不清就会一开通道立刻进完成中断 */ USART_ClearFlag(USART3, USART_FLAG_TC); DMA_SetCurrDataCounter(DMA1_Channel2, len); DMA1_Channel2-CMAR (uint32_t)buf; /* 标准库没有单独改内存地址的接口 */ USART_DMACmd(USART3, USART_DMAReq_Tx, ENABLE); DMA_Cmd(DMA1_Channel2, ENABLE); }USART_ClearFlag(USART3, USART_FLAG_TC)这一行是血泪经验。USART 的 TC 位在复位后默认是 1表示“发送完成”如果不清就直接开 DMADMA 传输完成时会去检查这个标志第一次必然误判。更隐蔽的一种情况是上一次发送的 TC 还没清这次一开通道 TC 中断立刻进来你的状态机就会以为这帧已经发完了后面再发什么都不对。DMA1_Channel2-CMAR直接写寄存器是因为标准库只提供DMA_Init一次性配置没有单独修改内存地址的函数。每次发送都调DMA_Init也不是不行但那要连外设地址、方向、模式全部重填一遍代码长且容易漏项直接改 CMAR 更干净。DMA 的 TC 中断只能说明“数据全部搬进了 DR”不代表最后一个字节已经从移位寄存器发到总线上。如果后面紧接着要切换 RS485 方向脚或者要保证帧间隔就必须再等 USART 的 TC 标志void DMA1_Channel2_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC2) ! RESET) { DMA_ClearITPendingBit(DMA1_IT_TC2); DMA_Cmd(DMA1_Channel2, DISABLE); USART_DMACmd(USART3, USART_DMAReq_Tx, DISABLE); g_tx_dma_done 1; /* 此时最后一个字节可能还在移位寄存器里 */ } }判总线真正空闲还是要轮询USART_GetFlagStatus(USART3, USART_FLAG_TC)。清 TC 的顺序是先读 SR 再读 DR标准库的USART_ClearFlag内部已经这么做了直接用就行别自己写USART3-SR 0;那是无效写法。3.3 变长数据发送时怎么避免半包发送长度每次不同这是 DMA 发送最容易出事的地方。核心原则只有一条DMA 启动之后那块内存就不属于你了直到 TC 中断到来。最常见的错误是把局部数组传进去/* 错误示范函数返回后栈被复用DMA 还在搬 */ void Bad_Send(void) { uint8_t tmp[32]; memset(tmp, 0x55, sizeof(tmp)); USART3_SendBytes(tmp, sizeof(tmp)); /* 函数一返回tmp 就没了 */ }在 115200 波特率下32 字节要搬约 2.8 ms函数早就返回了栈上的内容被别的调用覆盖总线上出现的就是一段拼凑出来的乱码。正确做法是让待发数据待在一个生命周期足够长的静态缓冲区里或者用双缓冲把“正在发的”和“准备发的”隔开。我一般用一个简单的发送队列来兜底上层只管往环形缓冲里塞底层发送函数判断 DMA 空闲就从环形缓冲里取一段连续数据发出去。#define TX_RING_SIZE 512 static uint8_t s_tx_ring[TX_RING_SIZE]; static uint16_t s_tx_head 0; static uint16_t s_tx_tail 0; /* 返回实际入队的字节数满了就丢弃多余的 */ uint16_t USART3_TxRing_Push(const uint8_t *buf, uint16_t len) { uint16_t i; for (i 0; i len; i) { uint16_t next (s_tx_head 1) % TX_RING_SIZE; if (next s_tx_tail) { break; /* 队列满 */ } s_tx_ring[s_tx_head] buf[i]; s_tx_head next; } return i; } /* 在空闲时调用从队列头取连续一段启动 DMA */ void USART3_TxRing_Kick(void) { uint16_t len; if (DMA_GetCurrDataCounter(DMA1_Channel2) ! 0 || s_tx_head s_tx_tail) { return; } if (s_tx_head s_tx_tail) { len s_tx_head - s_tx_tail; } else { len TX_RING_SIZE - s_tx_tail; /* 先发到缓冲区末尾那一段 */ } USART3_SendBytes(s_tx_ring[s_tx_tail], len); s_tx_tail (s_tx_tail len) % TX_RING_SIZE; }USART3_TxRing_Kick在主循环里调用也可以在 DMA 完成中断里置个标志后由主循环调用不要在中断里直接递归调用发送函数。环形缓冲的大小按最坏情况算115200 波特率下 1 ms 约 11.5 字节如果主循环最长可能被阻塞 10 ms512 字节的余量就比较舒服了。跨过缓冲区末尾的那一段会被拆成两次发送接收端如果按帧解析这里会在两段之间产生一个微小的总线间隔需要接收端用超时机制兼容。4. 接收端才是重头戏空闲中断加 DMA 计数的取包逻辑接收端没有“长度”可以用必须靠总线本身的特征来判断一帧到哪结束。STM32F103 的 USART 提供了 IDLE 空闲中断配合 DMA 的当前剩余计数能精确算出这一帧收了多少字节。这一套组合是这套方案里最值得花时间打磨的部分写对了长期稳定写错了就是偶发丢帧而且极难复现。4.1 IDLE 中断配合 DMA 计数的取包逻辑接收 DMA 用 Circular 模式初始化时把 CNDTR 设成缓冲区大小之后就让 DMA 自己跑。#define RX_BUF_SIZE 256 static uint8_t s_rx_buf[RX_BUF_SIZE]; static uint16_t s_last_pos 0; static void USART3_RX_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART3-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)s_rx_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; /* 外设 - 内存 */ DMA_InitStructure.DMA_BufferSize 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_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel3, DMA_InitStructure); USART_DMACmd(USART3, USART_DMAReq_Rx, ENABLE); DMA_Cmd(DMA1_Channel3, ENABLE); /* 只开 IDLE不要开 RXNE */ USART_ITConfig(USART3, USART_IT_IDLE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel USART3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }中断里的写法决定了长期稳定性。核心是三步清 IDLE、读剩余计数、算出增量。void USART3_IRQHandler(void) { if (USART_GetITStatus(USART3, USART_IT_IDLE) ! RESET) { volatile uint16_t dummy USART3-DR; /* 读 SR 之后再读 DR才能真正清 IDLE */ (void)dummy; uint16_t remain DMA_GetCurrDataCounter(DMA1_Channel3); uint16_t pos RX_BUF_SIZE - remain; /* 当前写指针位置 */ if (pos ! s_last_pos) { if (pos s_last_pos) { RingBuf_Push(s_rx_buf[s_last_pos], pos - s_last_pos); } else { /* 跨过缓冲区末尾拆成两段 */ RingBuf_Push(s_rx_buf[s_last_pos], RX_BUF_SIZE - s_last_pos); if (pos 0) { RingBuf_Push(s_rx_buf[0], pos); } } s_last_pos pos; } } /* ORE 溢出必须单独清否则会一直进中断 */ if (USART_GetFlagStatus(USART3, USART_FLAG_ORE) ! RESET) { volatile uint16_t tmp USART3-DR; (void)tmp; g_ore_count; } }USART_GetITStatus内部已经读过 SR所以后面再读一次 DR 就完成了清 IDLE 的完整序列。有些库版本里这一步不保险直接读 DR 最稳。RingBuf_Push是把数据从 DMA 缓冲区搬到应用层的环形队列应该在中断里只做 memcpy不做解析、不打印、不发信号量任何耗时操作都会让下一次 IDLE 迟到。pos和s_last_pos的比较要处理回绕。Circular 模式下 CNDTR 减到 0 会自动重装成 RX_BUF_SIZE写指针从 255 回到 0如果直接用减法会得到一个巨大的无符号数必须分成两种情况处理。4.2 环形缓冲与指针复位的时机s_last_pos的更新时机只有一处在中断里把数据搬进应用层队列之后立刻更新。不要放到主循环里更新否则两次 IDLE 之间主循环还没处理完第二次进来算出来的是累计长度数据会重复。应用层的环形队列建议做成读写指针分离写指针只在中断里动读指针只在主循环里动#define APP_RING_SIZE 1024 static uint8_t s_app_ring[APP_RING_SIZE]; static volatile uint16_t s_app_wr 0; static uint16_t s_app_rd 0; void RingBuf_Push(const uint8_t *p, uint16_t len) { uint16_t i; for (i 0; i len; i) { uint16_t next (s_app_wr 1) % APP_RING_SIZE; if (next s_app_rd) { g_ring_overflow; /* 应用层来不及处理丢弃并计数 */ return; } s_app_ring[s_app_wr] p[i]; s_app_wr next; } } uint16_t RingBuf_Count(void) { return (s_app_wr APP_RING_SIZE - s_app_rd) % APP_RING_SIZE; }s_app_wr加volatile是因为它会在中断里被修改编译器不能把它优化进寄存器。RingBuf_Count用加模长再取模的写法避免无符号下溢。溢出计数器必须留着它是判断缓冲区够不够的唯一依据比事后猜要靠谱得多。缓冲区大小怎么定按“最大帧长 × 主循环最长阻塞时间内的帧数 × 2”估。115200 波特率下如果主循环最坏 20 ms 才轮询一次这期间最多来 230 字节取 1024 字节比较安全。缓冲区太大也不好会掩盖主循环响应慢的问题。4.3 一帧数据的定界与校验IDLE 中断给出的只是一段“总线安静之前的字节”它不保证这段就是一个完整帧。如果两帧之间间隔太短或者发送端分两次写IDLE 可能把半帧切出来。所以应用层必须自己定界。常见的三种定界方式按可靠性从高到低方式做法适用场景缺点帧头 长度 帧尾找帧头读长度字段校验帧尾协议自定义、变长帧帧头需转义否则数据里出现帧头会误判固定帧长按固定字节数切传感器定长上报不灵活超时定界距离上一字节超过 N ms 视为帧结束类 Modbus 的 3.5 字符间隔需要额外定时器我一般用第一种加 CRC16 校验。校验函数用 Modbus 那套查表和非查表都行非查表的够用static uint16_t CRC16_Modbus(const uint8_t *p, uint16_t len) { uint16_t crc 0xFFFF; uint8_t i; while (len--) { crc ^ *p; for (i 0; i 8; i) { if (crc 0x0001) { crc 1; crc ^ 0xA001; } else { crc 1; } } } return crc; }解析时先找帧头 0xAA 0x55再读长度字节判断剩余长度 长度字段 4帧头 2 长度 1 CRC 2视协议而定凑够就校验校验不过就把窗口往后滑一个字节重新找帧头。这个“滑动重找”的动作很重要很多实现校验失败就直接丢弃整段结果一次误码会连带丢掉后面好几帧。CRC16 的初值、多项式、字节序要和发送端严格一致。Modbus 是低字节在前有些传感器是高字节在前接错的表现是“数据都对但校验永远不过”非常浪费时间。第一次接新设备时先抓一帧原始字节手工算一遍比在代码里反复试要快。5. 避坑与排查五个让我返工的 USART3 DMA 问题这套方案我从最早用中断搬字节到后来改成 DMA中间返工过好几次下面这几条都是实际踩出来的。症状写得具体一点方便你对号入座。5.1 初始化阶段踩过的三个坑现象一DMA 通道一使能立刻进发送完成中断串口上一个字节都没出去。原因是 USART 的 TC 标志复位默认是 1。很多初始化代码只调了DMA_ITConfig(..., DMA_IT_TC, ENABLE)就开通道DMA 一看 TC 有效立刻上报完成。解决方式是在每次启动发送之前调USART_ClearFlag(USART3, USART_FLAG_TC)位置要放在DMA_Cmd(ENABLE)之前放在之后没用因为中断已经进去了。现象二DMA 配置看着全对CNDTR 也在减但示波器上 TX 脚一动不动。大概率是漏了USART_DMACmd(USART3, USART_DMAReq_Tx, ENABLE)。这个函数设置的是 USART_CR3 里的 DMAT 位不开这个位USART 根本不会向 DMA 发请求DMA 通道在那儿空转。对应的接收侧是 DMAR 位两个位要分别开只开一个是很常见的疏漏。查这个问题最快的办法是在调试器里看 USART3-CR3 的第 6、7 位。现象三接收 DMA 一开就进 HardFault或者数据写到了莫名其妙的地方。原因通常是 DMA 的内存地址给了一个局部变量或者被编译器优化掉的数组。s_rx_buf必须是文件作用域的静态数组不能是函数内的局部数组。另外 DMA 的地址对齐也要注意字节传输本身不要求 4 字节对齐但如果工程里开了某些优化局部数组被分配到栈上再取地址传给 DMA函数返回后栈空间被复用DMA 还在往里写就会踩到别的变量。把接收缓冲区声明成static uint8_t s_rx_buf[RX_BUF_SIZE]是最省事的做法有条件的话再加__align(4)。5.2 运行阶段踩过的两个坑现象四短时间测试正常跑十几分钟后偶发丢一帧ORE 计数缓慢增长。第一次遇到时我以为是波特率误差换了晶振也没用。真正的原因是 IDLE 中断里调了调试打印。串口打印本身是阻塞的在 115200 下输出一行日志要几毫秒这几毫秒里下一帧已经到了DMA 还在往后写应用层没及时取走环形队列满后到的数据被丢掉同时 ORE 置位。解决办法是把中断里所有打印删掉只保留计数器主循环里定期把计数器打出来。中断服务函数里只做三件事清标志、memcpy、更新指针其他一律不做。现象五发送端连续发两帧第二帧数据错乱第一帧末尾多出几个字节。这是典型的“DMA 还在搬运时缓冲区被改写”。发送函数返回不代表数据发完了如果上层紧接着复用同一个缓冲区准备下一帧DMA 读到的一半是旧数据一半是新数据。解决办法有两个一是在USART3_SendBytes里加上等待上一次 DMA 完成的循环前面代码里那个带超时的 while二是用发送队列让每一帧的数据在队列里排队DMA 一次只取一段连续区域。前者简单后者吞吐更高。我一般先用前者把功能跑通压力上来了再换队列。还有一种变体发送长度是奇数但缓冲区按半字对齐配置了DMA_MemoryDataSize_HalfWord。这时 DMA 会按半字搬实际发出去的字节数和预期不一致末尾多一个 0x00。串口通信一律用DMA_PeripheralDataSize_Byte和DMA_MemoryDataSize_Byte别为了“看起来快一点”改成半字。6. 压测与验证用回环和计数器把收发钉死功能跑通只是开始真正要确认的是长时间运行下不丢帧。我的做法分三步每一步都能独立定位问题。第一步硬件回环。把 PB10 和 PB11 用一根短接线直接短接让 USART3 自己发自己收。这一步能排除外部设备和线序的问题把范围收缩到芯片和代码。回环测试要覆盖不同长度1 字节、2 字节、63 字节、64 字节、65 字节重点测缓冲区边界附近的值跨末尾回绕的 bug 只有在这种长度下才暴露。第二步加计数器做长时间压测。用定时器每 5 ms 触发一次发送跑两小时统计发送帧数、接收帧数、CRC 错误数、环形队列溢出数。四个数对得上才算过。void USART3_Loopback_SelfTest(uint32_t rounds) { uint8_t tx[64]; uint32_t ok 0, fail 0, i, r; for (i 0; i sizeof(tx); i) { tx[i] (uint8_t)(i * 3 1); /* 用非规律数据便于发现地址错位 */ } for (r 0; r rounds; r) { USART3_SendBytes(tx, sizeof(tx)); uint32_t t0 GetTick(); while (GetTick() - t0 50) { /* 50 ms 超时防止死等 */ if (RingBuf_Count() sizeof(tx)) { uint8_t *p RingBuf_Peek(); if (memcmp(p, tx, sizeof(tx)) 0) { ok; } else { fail; } RingBuf_Drop(sizeof(tx)); break; } } } printf(ok%lu fail%lu ore%lu ovf%lu\r\n, ok, fail, g_ore_count, g_ring_overflow); }这段代码里的GetTick用 SysTick 的毫秒计数实现即可。测试数据故意用i * 3 1而不是全 0 或递增是为了让地址错位、字节顺序颠倒这类问题在比对时立刻暴露。如果用全 0x55跨缓冲区回绕错了你也看不出来。第三步上逻辑分析仪看时序。重点看两个指标一帧之内相邻字节的间隔是否均匀不均匀说明 DMA 被更高优先级的中断打断了以及两帧之间的间隔是否稳定不稳定说明发送队列的调度有问题。115200 下一字节约 87 us如果看到某些字节间隔到了几百微秒就要去查是不是有 Flash 擦写或者长中断在抢 CPU。这套流程走下来我对这块板子的结论是115200 波特率、64 字节帧、5 ms 间隔连续跑 8 小时零丢帧ORE 和溢出计数都是 0。后来把波特率提到 460800同样的配置开始出现偶发 ORE查下来是主循环里有一段 3 ms 的阻塞延时把轮询间隔拉长了把延时拆掉之后恢复正常。从那以后我每次做 DMA 串口都强制走一遍“短接回环 → 边界长度 → 两小时计数”这三步不管功能看起来多正常。很多问题不是写错而是没测到。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?