首页 / 资讯中心 / 文章详情

STM32串口DMA Normal模式收发实战:变长帧接收与踩坑记录

STM32串口DMA Normal模式收发实战:变长帧接收与踩坑记录 ★ FEATURED ARTICLE
有人说串口DMA调试像玄学尤其是“UART TX/RX with DMA in Normal mode”这种组合看起来简单真上手却总在“发一次就罢工”“收不到完整帧”之间反复折腾。这篇文章就是我实际调试这类项目时的完整记录从DMA的Normal模式到底和Circular模式差在哪到发送、接收分别怎么封装才不容易踩坑再到变长包接收的几种处理思路全是一次一次试出来的经验。适合正在用STM32 HAL库做串口通信、被DMA收发搞到头大的朋友参考新入门的人也能照着手册思路走通流程。1. 项目整体设计与思路拆解1.1 核心需求UART收发为什么要配DMA做嵌入式通信串口是最常用的调试和通信接口。最原始的做法是轮询主循环里不停地查接收标志位或者发送时死等一个字节发完。这样做在小数据量、单任务的场景下没问题数据量一上来就尴尬了——比如主循环里要处理显示刷新、按键扫描、电机控制再插一个耗时几毫秒的串口发送整个系统的实时性立刻下降。中断方式解决了阻塞问题但每收发一个字节都要进一次中断进中断要压栈出栈高频数据下CPU开销依然不小。DMA就是专门解决这个痛点的硬件模块它能在没有CPU参与的情况下把数据从内存搬到外设发送方向或从外设搬到内存接收方向传输完再通知CPU做善后工作。这里要明确一点DMA不是替代串口而是替代“CPU搬运数据”这个过程。串口的电平转换、帧格式解析还是UART外设自己完成DMA做的只是帮你把数据从缓冲区搬到UART的数据寄存器或者反过来。CPU只负责在DMA传输启动时配置一下传输结束后处理结果。那“Normal mode”又是什么DMA传输模式分两种Normal正常模式和Circular循环模式。Normal模式的意思是一次传输配置好之后DMA按照设定好的数据长度搬运搬运完就停下来传输完成标志置位不再自动开始下一次。Circular模式则是搬运完自动重新加载配置从头开始继续搬形成一个环形循环适合连续不断的数据流。1.2 为什么选Normal mode而不是Circular mode很多人在选DMA模式时直接默认用Normal因为CubeMX里默认就是Normal。但这里要想清楚Normal和Circular各有适用场景不是哪个更高级的问题。Circular模式的典型应用是ADC连续采样外设不断产生数据DMA不断搬运到内存数组数组满了自动从头覆盖。这种模式适合“源源不断的均匀数据流”CPU定期去拿数据就行。但如果把它用在UART接收上会有个麻烦——一帧数据长度不固定CPU根本不知道当前收了多少数据也不知道DMA写到数组的哪个位置了只能靠“暂停DMA查当前NDTR寄存器”这种手段去推算接收长度处理起来很绕。Normal模式相反虽然每次只能搬运一包但逻辑清晰配置好长度启动DMA传输完成进中断然后再重新配置下一包。尤其是发送场景UART发送天然是分包进行的一帧数据发完就完成一次用Normal模式再合适不过。接收场景只要我们配合“接收完一包重新配置”的思路同样可以用Normal模式。从资源占用角度看Normal模式也更节省。Circular模式的DMA在后台永远运行会持续占用总线和DMA通道资源而Normal模式只在实际传输期间占用传完就释放。对同时使用多个DMA通道的项目来说这一点很关键。1.3 完整数据通路设计我这次做的是一个典型的STM32UART收发项目整体数据通路设计如下发送方向应用层准备好一个字节数组 → 调用DMA发送函数把数据缓冲区的首地址、长度写入DMA配置 → DMA逐个字节把数据搬到UART的数据寄存器 → UART外设把数据移位发送出去 → 所有字节发送完毕DMA产生传输完成中断 → 在中断回调里释放发送完成信号如信号量、事件标志。接收方向应用层设定一个接收缓冲区配置DMA接收启动DMA后DMA不断监控UART接收数据寄存器 → 收到一字节RXNE标志置位DMA自动把它搬到内存缓冲区 → 缓冲区满了或通过其他方式判断一帧数据接收完毕DMA产生中断或应用层收到指示 → 处理数据后重新配置DMA接收。注意Normal模式下接收有个天生的麻烦DMA的传输长度是固定的但串口数据是异步到达的什么时候到、到多少字节大多是未知的。这导致“DMA配置接收长度100字节但实际只收到10字节”的尴尬场景。解决思路一般有三种定长协议、空闲中断辅助判断超时、或者接收一定时间后停止DMA并检查当前计数这点在后面专门讲。2. UART DMA工作原理与关键配置细节2.1 UART触发DMA的机制要让DMA和UART配合工作得先弄清楚UART是怎么“叫”DMA干活的。以STM32为例UART外设有两个关键的DMA请求信号发送方向是TXE发送数据寄存器空接收方向是RXNE接收数据寄存器非空。拿发送举例CPU启动DMA后DMA首先把第一个字节写入UART的数据寄存器DR。此时UART开始把这个字节移位发送出去DR空了之后TXE标志置位硬件自动向DMA发出请求DMA收到请求马上把下一个字节写入DR循环往复。整个过程不需要CPU参与DMA和UART之间通过硬件握手信号自己配合。接收方向刚好反过来UART收到一个字节RXNE标志置位硬件发DMA请求DMA响应请求把这个字节从DR搬到内存缓冲区然后RXNE标志自动清除继续等下一字节。这里有个容易忽略的前提DMA传输不是凭空进行的它需要UART配置里开启DMA请求功能。在HAL库里如果通过HAL_UART_Receive_DMA启动接收库内部会设置USART_CR3的DMAR位和DMAT位这两个位分别控制接收、发送方向的DMA请求是否使能。如果用寄存器操作就必须手动配置这两个位漏了这一步DMA永远不会有动作。2.2 Normal模式传输何时停止要理解Normal模式最关键的是搞清楚DMA怎么判断自己该停了。DMA寄存器里有个NDTR数据计数器寄存器表示还剩余多少字节没传输。Normal模式下每次传输一字节NDTR就减一减到0时传输停止不再重新加载。此时DMA的传输完成中断标志TC置位如果使能了传输完成中断就能进中断处理善后。发送场景这个机制很完美要发多少字节配置NDTR为多少发完自然停中断里标记发送完成。接收场景就有问题了——配置NDTR为100意味着DMA必须收到100字节才停。如果实际只来了10字节DMA就一直挂在那等不到100字节永远不产生“传输完成”中断。所以Normal模式下的接收核心问题就是“长度未知”和“传输完成条件固定”之间的矛盾。后面会讲怎么绕开这个矛盾。2.3 DMA方向、地址自增和数据宽度配置这部分是很多莫名其妙问题的根源配置错一个数据全乱。内存地址自增Memory Increment必开。发送场景DMA要从内存缓冲区依次读取每个字节接收场景DMA要把收到的字节依次写入内存缓冲区。不开内存自增数据永远只写到数组第一个位置后面的数据要么丢要么覆盖。外设地址自增Peripheral Increment必关。UART的数据寄存器DR地址是固定的DMA每次读写都是操作同一个寄存器不能让外设地址自增。方向设置要分清发送方向是Memory To Peripheral内存到外设接收方向是Peripheral To Memory外设到内存。有人会在代码里把这两个方向搞混导致DMA启动后数据乱飞要么发出去的全是垃圾要么收到的数据找不到。数据宽度建议设为Byte8位。UART数据寄存器低8位有效DMA每次搬运一字节数据宽度设成Byte对齐最合理。如果设成HalfWord或Word虽然也能工作但效率反而低还容易出现高字节内容不正确的问题。初始化时还需要确定DMA通道优先级。如果项目里还有其他DMA通道比如ADC DMA、SPI DMAUART的DMA优先级要结合业务场景定——串口数据如果丢字节会导致严重问题就设为High优先级如果只是调试输出Low优先级就行。优先级对系统整体的影响会在第5章详细展开。3. 核心代码实现基于HAL库的Normal模式收发3.1 初始化配置流程下面以STM32F4系列 HAL库为例展示完整的UART DMA初始化流程。CubeMX中的配置项我就不一一截图了直接给寄存器级别理解后的关键代码。// 全局DMA句柄 DMA_HandleTypeDef hdma_usart1_tx; DMA_HandleTypeDef hdma_usart1_rx; // UART句柄 UART_HandleTypeDef huart1; // 接收缓冲区定义 uint8_t uart1_rx_buffer[128]; // 接收完成标记 volatile uint8_t uart1_rx_complete 0; // 当前接收到的数据长度 volatile uint16_t uart1_rx_len 0; static void MX_DMA_Init(void) { __HAL_RCC_DMA2_CLK_ENABLE(); // USART1_TX 使用 DMA2 Stream7 hdma_usart1_tx.Instance DMA2_Stream7; hdma_usart1_tx.Init.Channel DMA_CHANNEL_4; hdma_usart1_tx.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_usart1_tx.Init.PeriphInc DMA_PINC_DISABLE; hdma_usart1_tx.Init.MemInc DMA_MINC_ENABLE; hdma_usart1_tx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart1_tx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart1_tx.Init.Mode DMA_NORMAL; hdma_usart1_tx.Init.Priority DMA_PRIORITY_HIGH; hdma_usart1_tx.Init.FIFOMode DMA_FIFOMODE_DISABLE; HAL_DMA_Init(hdma_usart1_tx); __HAL_LINKDMA(huart1, hdmatx, hdma_usart1_tx); // USART1_RX 使用 DMA2 Stream5 hdma_usart1_rx.Instance DMA2_Stream5; hdma_usart1_rx.Init.Channel DMA_CHANNEL_4; hdma_usart1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_usart1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_usart1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode DMA_NORMAL; hdma_usart1_rx.Init.Priority DMA_PRIORITY_HIGH; hdma_usart1_rx.Init.FIFOMode DMA_FIFOMODE_DISABLE; HAL_DMA_Init(hdma_usart1_rx); __HAL_LINKDMA(huart1, hdmarx, hdma_usart1_rx); // 注册DMA中断 HAL_NVIC_SetPriority(DMA2_Stream7_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA2_Stream7_IRQn); HAL_NVIC_SetPriority(DMA2_Stream5_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA2_Stream5_IRQn); }UART自身的初始化这里省略了标准的波特率、帧格式配置但要注意在UART初始化完成后不能马上启动接收DMA需要在业务初始化阶段调用HAL_UART_Receive_DMA。发送DMA不需要提前启动是每次发送时才配置。3.2 发送实现标准Normal模式发送发送方向在Normal模式下非常自然一次性配置一次性完成// 发送接口封装 uint8_t uart1_send_data(uint8_t *data, uint16_t len) { if (uart1_tx_busy) { return 0; // 上次发送还没结束拒绝新请求 } uart1_tx_busy 1; if (HAL_UART_Transmit_DMA(huart1, data, len) ! HAL_OK) { uart1_tx_busy 0; return 0; } return 1; } // 发送完成回调 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart1_tx_busy 0; // 清忙标志允许下一次发送 } }这里有几个细节值得注意。第一发送忙标志必不可少。Normal模式下DMA一次只能处理一个任务如果上一次DMA还没发完你再次调用HAL_UART_Transmit_DMA它会返回错误或者更糟——覆盖前一次的配置导致数据只发一半。加上忙标志就能在上一次发送完成前拒绝新请求从逻辑上避免这个问题。如果业务上需要排队发送多包数据可以维护一个发送队列发送完成回调里从队列里取下一包继续发这里就不展开了。第二HAL_UART_Transmit_DMA内部会把UART的发送DMA请求使能然后把缓冲区地址和长度写入DMA相关寄存器最后启动DMA。启动后函数立刻返回实际发送是后台进行的。所以在函数返回后、DMA完成前不能修改data指向的缓冲区内容否则DMA搬的是被修改后的数据。第三发送完成回调发生在所有字节都从DMA搬到了UART的数据寄存器之后但最后一个字节可能还没从UART移位发送出去。严格的讲此时“DMA完成了对UART的搬运”但物理层面的数据线可能还在发送最后一个字节的停止位。如果紧接着要关闭串口、进入低功耗模式、或者切换RS485的方向引脚需要额外等待最后一字节发送完毕——可以用HAL_UART_GetState查询或者稍作延迟。3.3 接收实现定长数据包直接用Normal模式如果通信协议是定长的Normal模式接收就很简单了。假设每帧固定16字节// 启动接收接收16字节后DMA停止 HAL_UART_Receive_DMA(huart1, uart1_rx_buffer, 16); // 接收完成回调 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart1_rx_complete 1; uart1_rx_len 16; // 处理数据... // 处理完后重新启动接收 HAL_UART_Receive_DMA(huart1, uart1_rx_buffer, 16); } }流程很简单DMA收到16字节后自动停止并触发中断。重点在回调里处理完数据后必须重新调用HAL_UART_Receive_DMA来准备下一次接收因为Normal模式不会自动重启。这个“重新启动”操作如果忘了就表现为串口只收第一包数据后面再也收不到。这里有个隐藏的时序问题如果在重新启动DMA之前串口又收到了数据这些数据就会因为RXNE没人管而丢失。严谨的做法是在硬件上配合UART的空闲中断IDLE或者RXNE超时机制进行处理但定长协议下通常不会丢数据因为接收方和发送方约定好帧长度一帧之间不会出现多余的数据到达。如果是高负载通信场景建议把数据处理逻辑放到主循环中处理回调只置标志位减少关DMA的时间窗口。3.4 NDTR寄存器读取当前接收进度的钥匙在Normal模式下做变长接收NDTR寄存器是最有用的工具之一。DMA的NDTR寄存器保存着“还剩多少字节没传输”通过它就能反推出已经收到了多少字节uint16_t uart1_get_rx_count(void) { // 当前接收的字节数 配置长度 - 剩余未传输字节数 return 16 - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); }这个函数能实时返回当前DMA已经接收了多少字节。它的妙处在于配合超时判断就可以实现变长接收每过一段时间查询一次如果两次查询之间接收计数没有变化就认为这一帧接收完了可以处理数据了。在实际项目中我经常把NDTR配合定时器中断使用// 定时器1ms中断里执行 void timer_1ms_callback(void) { uint16_t now_len uart1_get_rx_count(); if (now_len ! uart1_last_len) { // 数据还在进来更新计数并清零超时计数 uart1_last_len now_len; uart1_idle_cnt 0; } else { // 数据没变化超时计数累加 uart1_idle_cnt; if (uart1_idle_cnt 5) { // 5ms没有新数据认为一帧结束 if (uart1_last_len 0) { uart1_rx_complete 1; uart1_rx_len uart1_last_len; } } } }这种超时接收方案虽然能用但要意识到定时器一直在打扰CPU实时性也不算好——数据到了要等到下一个定时器周期才会发现。更优雅的是直接用UART的空闲中断事件到来时硬件直接通知CPU零延迟这个方案单独讲。4. 变长帧接收Normal模式下的三种实用方案4.1 方案一UART空闲中断 Normal DMA这是我在公开项目中见过最多、自己也在用的方案。UART有一个空闲IDLE中断当总线上出现一个字节时间长度以上的空闲状态时硬件自动触发。一帧数据发完后数据线停在高电平自然触发空闲中断完美标记“一帧结束了”。配置步骤如下先使能UART的空闲中断再调用HAL_UART_Receive_DMA配置接收缓冲区然后在UART中断处理函数里判断IDLE标志位// UART中断处理 void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { // 先清IDLE标志再读取当前数据长度 __HAL_UART_CLEAR_IDLEFLAG(huart1); // 停止DMA取走数据也可以不停止只是Reset一下 HAL_UART_DMAStop(huart1); uart1_rx_len UART1_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uart1_rx_complete 1; // 重新配置接收缓冲区 // 注意重新调用初始化时DMA计数器要复位 HAL_UART_Receive_DMA(huart1, uart1_rx_buffer, UART1_RX_BUF_SIZE); } HAL_UART_IRQHandler(huart1); }这里的关键坑有两个。第一个清IDLE标志的顺序。在HAL库中清IDLE标志的正确方式是先读SR寄存器再读DR寄存器老库版本或者直接调用__HAL_UART_CLEAR_IDLEFLAG宏。如果先调用HAL_UART_Receive_DMA再清IDLE可能会把刚启动的接收状态搞乱导致RXNE中断异常。我的习惯是先在中断开头清IDLE再执行DMA相关操作。第二个DMA计数器的复位问题。调用HAL_UART_DMAStop后DMA的NDTR会保持剩余的计数此时重新调用HAL_UART_Receive_DMA会把NDTR重新配置为缓冲区长度。但如果在DMA还没完全停止时重新配置可能出现“新旧配置交错”的问题表现为收到的数据里多几个或少几个字节。稳妥的流程是DMAStop → 读取长度 → 清理 → 重新启动。空闲中断方案的优点是实时性极好收到一帧结束立刻知道不需要定时器反复查询。缺点是需要进UART中断和DMA中断两个中断而且UART中断里要做的事情稍多。4.2 方案二定时器超时判断 Normal DMA这个方案前面已经展示了核心代码。适合不想用空闲中断、但又需要变长接收的场景。实现思路是用一个基础定时器例如1ms周期在定时器中断中检查DMA的NDTR变化来判断数据是否到达。优点是对UART外设资源要求低纯靠DMA自身计数。缺点是数据到达后要延迟一个超时周期才能确认帧结束对实时性要求高的场景不太合适。实际应用中超时周期一般设在3-5ms兼顾判断可靠性和响应速度。这个方法还有一个坑值得提醒如果一帧数据的两个字节之间间隔超过了超时时间会被误判为两帧数据。所以在实现时超时时间要大于发送方两个字节的最大间隔。一般串口传输中字节间隔远小于1ms所以5ms超时已经非常保守。4.3 方案三双缓冲区交替接收Double Buffer如果数据量特别大一帧接一帧不停单缓冲区的“停止→处理→重启”模式来不及可以考虑DMA的双缓冲区模式。双缓冲区模式下DMA有两个内存缓冲区地址接收数据时交替填充两个缓冲区第一个缓冲区写满后DMA硬件自动切换写入第二个缓冲区同时触发“第一个缓冲区已满”的中断。CPU可以在中断里处理第一个缓冲区的数据而DMA继续在后台写第二个缓冲区互不干扰。不过要注意双缓冲区模式并不完全等同于两个缓冲区它要求两个缓冲区大小必须相同而且地址按DMA的地址对齐要求排列。Normal模式下双缓冲区依然是一次性的——两个缓冲区都填满后DMA停止需要重新配置。如果配合空闲中断做变长接收处理复杂度也会上升。所以双缓冲区更适合“大数据量连续接收且帧长度固定”的场景做变长接收建议还是优先空闲中断方案。5. 常见问题与排查技巧实录5.1 发送一次后第二次就不发送了这是Normal模式发送最常见的问题。原因很简单第一次传输完成后DMA的状态还停留在“传输完成”NDTR为0。如果此时再次配置DMA发送HAL库检测到DMA已经在忙或者状态不对返回HAL_BUSY就没有实际启动传输。排查思路确认有没有在发送完成回调中做善后处理。如果是用HAL_UART_Transmit_DMA发送发送完成回调HAL_UART_TxCpltCallback是必须实现的否则调用HAL_UART_Transmit_DMA时DMA未复位第二次发送会因为状态残留而失败。在回调里可以调用HAL_UART_DMAStop或直接调用HAL_UART_Transmit_DMA库内部会重新配置NDTR但简单可靠的做法是置一个标志位在下次调用发送接口前先判断这个标志。另外还有一种特殊情况在中断里调用发送函数。如果发送函数在中断上下文执行回调里的标志位会被后续中断立即清除而DMA配置还没真正启动导致发送逻辑错乱。这种问题排查起来很隐蔽建议发送接口尽量在工作线程或主循环中调用不在中断里直接发数据。5.2 接收数据间歇性丢字节数据时有时无很多人的第一反应是“串口配置有问题”但实际多半是DMA处理不及时。Normal模式下如果接收DMA启动成功后UART又收到数据而当前DMA还没准备好接收这些数据会触发硬件溢出错误OREUART进入错误状态后续数据被丢弃。这类问题的典型诱因有几种在接收完成回调里处理数据耗时过长导致重新启动DMA太晚。串口中断优先级比某些影响较长时间的临界区低短时间内无法响应。设置了流控但线路未接对。解决的思路一是把数据处理逻辑从回调中移出回调里只做置标志和重启DMA这类极简操作二是检查UART中断和DMA中断的优先级确保DMA中断优先级高于或等于UART中断优先级防止接收过程中DMA中断被UART长时间抢占。特别是使用空闲中断时UART中断处理里包含DMA操作这个中断的优先级要配置得当别让无关中断干扰。5.3 DMA通道优先级冲突导致数据传输错乱当工程中同时使用UART DMA、ADC DMA、SPI DMA时多个DMA通道会争抢总线带宽。如果优先级配置不合理可能导致高优先级通道持续占用总线低优先级通道长时间得不到服务表现为串口数据偶尔乱码或ADC采样值跳变。处理原则根据业务实时性要求分配优先级。比如ADC连续采样丢了几个点无关紧要可以把ADC DMA优先级设为Medium串口通信丢一帧可能导致协议错误就设为High。但这也不是绝对的——如果系统有多个串口DMA全部设为High可能导致优先级一样此时DMA仲裁按通道号顺序轮流传输也可能会互相干扰。实测中F4系列的DMA2比DMA1总线带宽更大USART1的RX/TX如果接到DMA2上在高负载下的表现通常优于接在DMA1上的USART2/3。这不是玄学是因为DMA2挂载在AHB1总线上与CPU访问外设寄存器的冲突更少。在做硬件引脚分配时优先把高流量串口接到DMA2通道上是一个性价比很高的优化。5.4 接收完成后DMA计数器和实际不符有不少人遇到过空闲中断触发后通过__HAL_DMA_GET_COUNTER读取NDTR算出来的接收长度比实际数据多了几个字节或者少了几个字节。这个问题的根源是时序。空闲中断触发时UART已经检测到总线空闲一个字节周期但此时DMA的NDTR更新可能还没完成尤其是在高速波特率下硬件动作有流水线延迟。还有一种情况是空闲中断触发时最后一个字节已经写入DR但DMA还没来得及把这个字节搬到内存此时读NDTR会少一个字节。解决办法在读取数据前先做一次微小的同步等待。最稳妥的做法是在空闲中断中先读取一次串口状态寄存器再读取NDTR中间加几条空指令或者做一次数据同步。实测中在空闲中断里先读SR再读NDTR偏差基本消除。如果仍不准确可以把UART的DMA请求暂时禁用等NDTR稳定再读__HAL_DMA_DISABLE(hdma_usart1_rx); uint16_t remain __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 再重新启动DMA不过禁用DMA会带来一个空窗口期间数据可能丢失。在Normal模式下接收完成后本身就需要重新配置短暂禁用在空闲中断场景下是可以接受的。5.5 常见问题速查表现象直接原因解决办法发送一次后不再发送DMA状态残留NDTR为0实现发送完成回调在回调中复位DMA或置标志接收不到数据未调用HAL_UART_Receive_DMA启动接收初始化后调用接收函数收到一帧后重新调用只能收第一帧收到一帧后没有重新配置DMA在完成回调或空闲中断中调用HAL_UART_Receive_DMA数据乱码数据宽度位设置错误或地址自增配置错误检查DMA配置外设地址不自增内存地址自增Byte宽度数据丢失中断处理不及时回调中只做轻量操作把数据解析移到主循环发送DMA启动失败上一帧未发送完DMA忙加发送忙标志或维护发送队列接收长度不准空闲中断时NDTR还没更新完读SR之后再读NDTR或短延时偶尔接收数据错位DMA优先级配置不当调整DMA优先级高流量串口放DMA26. 从Demo到工程化还要注意的事情6.1 缓冲区管理和Memory Barrier在DMA收发项目中数据缓冲区的管理是工程化最容易出错的地方。DMA是不经过CPU的CPU看到的数据和DMA正在操作的数据有可能存在不一致。在ARM Cortex-M系列上如果使用普通SRAMCPU读写和DMA读写之间由总线仲裁保证一致性一般不需要特殊的Cache操作。但如果芯片带D-Cache例如STM32H7系列就麻烦了——CPU写入的数据在Cache里DMA直接访问SRAM时看到的是旧数据或者DMA写入SRAMCPU读的是Cache里的旧数据。这时必须做Cache维护使用Cortex-M7的SCB_InvalidateDCache和SCB_CleanDCache或者使用MPU配置将缓冲区内存区域配置为Non-cacheable。我踩过H7的坑缓冲区的Cache没有InvalidateDMA收到数据后CPU读出来全是零排查了整整一天。如果用的是F1/F4/G4系列没有D-Cache可以跳过这一节但也要记住这个知识点后续换芯片时非常有用。6.2 超时保护与异常恢复在工业设备中串口通信链路可能因为干扰或对端异常而长时间没有数据。Normal模式下DMA接收如果一直等不到满长度就永远挂在那里。这会导致后续的DMA请求无法启动整个通信链路死锁。解决办法是为接收加一个看门狗定时器。每启动一次接收DMA就设置一个超时时间比如100ms定时器。如果超时触发时还没有收到完整的一帧或还没有空闲中断触发就强制停止当前DMA重新启动接收void uart1_rx_timeout_handler(void) { // 强制停止当前DMA HAL_UART_DMAStop(huart1); // 读取已接收数据 uart1_rx_len UART1_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (uart1_rx_len 0) { // 处理残留数据 process_uart1_frame(uart1_rx_buffer, uart1_rx_len); } // 重新启动接收 HAL_UART_Receive_DMA(huart1, uart1_rx_buffer, UART1_RX_BUF_SIZE); }这个机制能保证DMA通道永远处于可用状态不会因为协议异常让整个串口通信瘫痪。6.3 多字节协议帧的边界处理如果用空闲中断做变长接收有一个细节当两条命令连续到达中间没有任何空闲间隔时空闲中断不会触发两条命令会被当成一条超长帧。这在工业总线上很常见——主机连续下发两个命令从机可能就懵了。解决方法有两个思路。一是协议层面加上帧头帧尾和长度校验收到数据后先校验检验失败丢弃或重新同步。二是在空闲中断处理中如果接收长度超过最大帧长度强制按最大帧长度切割处理。实际上这两种思路可以结合空闲中断确保大部分情况下的帧边界正确协议校验兜底防御边界刺漏的情况。6.4 功耗和低功耗模式下的DMA处理设备进入低功耗模式时DMA和外设通常会被关闭或挂起。此时如果串口还有数据没发送完或者DMA还在接收中进入低功耗会直接导致数据丢失。正确做法是进入低功耗前检查DMA传输完成状态确认发送队列为空、接收DMA已停止退出低功耗后重新初始化DMA再启动接收。如果是STM32L系列低功耗模式下的串口唤醒通常建议用中断方式而不是DMA方式因为DMA在低功耗模式下可能无法正常工作。不同的项目有不同的低功耗策略但这个检查动作是共通的。6.5 移植到其他MCU的注意点最后说下代码移植。UART DMA在STM32上写好的逻辑移植到GD32、AT32或者N32系列时大部分代码可以复用但有几个地方要格外注意DMA通道映射表不同。STM32F4的USART1_TX在DMA2_Stream7但GD32F4已经改成了不同的DMA通道映射移植时必须查目标芯片的参考手册逐个核对DMA通道。中断函数名不同。HAL库的中断处理函数命名为USART1_IRQHandlerGD32是USART1_IRQHandler但内部寄存器结构不同不能直接复用HAL库的UART_IRQHandler。NDTR寄存器的可读写属性有些不同。在部分芯片上DMA传输过程中NDTR是只读的想清零必须通过复位DMA或者重新配置。具体以芯片手册为准。移植最快的办法是先在CubeMX里重新生一个空白工程把外设初始化部分让工具生成自己只迁移业务逻辑和中断处理部分。这样最省事也最不容易出错。7. 一份可以直接抄的Normal模式封装参考把前面所有经验整合起来这里给出一份可以直接用于实际项目的UART DMA Normal模式封装。以STM32F1/F4的HAL库为例包含发送、接收、空闲中断三部分。/* 定义 */ #define UART1_RX_BUF_SIZE 256 typedef struct { uint8_t buffer[UART1_RX_BUF_SIZE]; uint16_t len; volatile uint8_t frame_ready; volatile uint8_t tx_busy; } UART1_Handle; UART1_Handle uart1; /* 发送接口 */ int uart1_send(uint8_t *data, uint16_t len) { if (uart1.tx_busy) { return -1; } uart1.tx_busy 1; if (HAL_UART_Transmit_DMA(huart1, data, len) ! HAL_OK) { uart1.tx_busy 0; return -1; } return 0; } /* 接收启动 */ void uart1_rx_start(void) { uart1.len 0; uart1.frame_ready 0; HAL_UART_Receive_DMA(huart1, uart1.buffer, UART1_RX_BUF_SIZE); } /* 主循环中调用 */ void uart1_poll(void) { if (uart1.frame_ready) { // 处理一帧数据 process_uart1_frame(uart1.buffer, uart1.len); // 重新准备下一帧 uart1_rx_start(); } } /* 发送完成回调 */ void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart1.tx_busy 0; } } /* UART中断处理 */ void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 读取当前收到的长度 uart1.len UART1_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 只有收到数据才置标志 if (uart1.len 0) { uart1.frame_ready 1; // 停止DMA防止后续写入覆盖数据 HAL_UART_DMAStop(huart1); } } HAL_UART_IRQHandler(huart1); }这个封装的要点是frame_ready由中断置位由主循环的poll函数处理并清零重新启动接收必须在处理完上一帧之后进行否则同一缓冲区被覆盖。这种“中断采集 主循环处理”的模式能保证中断处理时间极短也不会丢数据。如果想提高效率可以把UART1_RX_BUF_SIZE设为定长协议支持的帧长度上限接收时配合协议帧头做二次校验能过滤掉噪声数据带来的假帧。踩过几次坑之后我的体会是Normal模式做好一件事就够了——每次传输都是一个明确、完整的任务。不要把Normal当Circular去凑牛油也不要硬给Circular装定长协议。先想清楚数据流的边界在哪再选模式。UART DMA收发看似简单真正调出稳定可靠的效果靠的是对DMA状态机、NDTR、中断优先级、缓冲区生命周期这些细节的把控。希望这篇经验记录能帮你少走一些弯路。
阅读完成 · 觉得有帮助?
咨询建站