拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32串口DMA不定长收发实战:环形缓冲与空闲中断解析

简介一套基于STM32单片机的串口DMA不定长收发完整工程面向嵌入式开发者和STM32学习者解决传统串口中断方式在批量数据通信时CPU占用高、实时性差的问题。压缩包内共340个文件包括C源码、头文件、编译中间文件与生成物还包含工程配置文件便于在Keil中直接打开查看总大小11.12MB。已有693人学习下载。工程完整演示了串口初始化、DMA通道选择与方向配置、收发回调以及空闲中断/接收计数的不定长接收策略同时附带模数转换(ADC)采样与DS18B20温度传感器经串口发送的示例便于理解不同外设与DMA的协同。通过阅读代码与工程结构可快速掌握标准外设库下串口DMA收发的移植方法并套用到多字节协议帧解析场景。1. 串口 DMA 不定长收发的核心矛盾与选型判断串口通信在 STM32 嵌入式开发里属于人人都会、但做扎实不容易的模块。很多人第一版程序用的是中断逐字节接收配合一个状态机去拼接帧跑 Modbus 或自定义协议时单字节中断频率一高主循环就被打断得七零八落。换到 DMA 空闲中断的方案后CPU 只在一次完整接收完成后被通知一次载荷搬运全部交给 DMA 控制器。本文围绕用 DMA 收发不定长数据这一具体目标讨论环形缓冲管理、空闲中断触发时机、发送 DMA 的回调设计以及实际调试中容易踩的坑。这套做法适用于 STM32F1/F4/G0 等常见系列也适用 GD32/AT32 这些兼容片。适合已经能跑通裸机串口收发、但想提升实时性的开发者阅读。2. 先厘清 STM32 串口 DMA 收发的硬件行为与中断机制2.1 IDLE 空闲中断才是不定长的关键STM32 的 USART 外设在检测到接收线路上一个字节周期内没有新数据时会置位 IDLE 标志。这个标志不会自动清除需要软件先读 SR 寄存器再读 DR 寄存器才能复位。不定长接收的核心思路就是DMA 把数据不断搬到内存缓冲区当 IDLE 中断到来时从 DMA 当前计数寄存器算出本次收到了多少字节。这个过程不需要提前知道帧长度也不需要帧尾校验字节协议层自己决定怎么切分。DMA 搬运方向USART-DR -- 内存缓冲区循环模式或普通模式 触发源USART 接收数据事件RXNE 通知机制USART IDLE 中断 - 回调里读 DMA-CNDTR 计算已收字节数用普通模式非循环时每次 IDLE 后需要手动重设 CNDTR 并重新使能 DMA。用循环模式时 DMA 持续搬运IDLE 后只需记录读取位置但要注意缓冲区写指针的回绕处理。F1 系列的 USART 和 DMA 通道固定映射关系经常被忽略比如 USART1_TX 只能走 DMA1_Channel4USART1_RX 只能走 DMA1_Channel5配错后程序跑起来毫无反应。2.2 发送侧为什么也建议用 DMA发送侧的做法通常是CPU 把待发送数据写入内存缓冲区然后启动 DMA 搬运到 USART-DR最后由发送完成中断TC通知释放缓冲区。这样做的收益有几个一是在多字节连续发送时不会每发一个字节就进一次中断二是数据搬运和主循环任务可以流水线重叠三是在 RTOS 环境里可以减少临界区长度。F4 系列还支持 DMA FIFO 功能可以把多个字节打包成一次突发传输进一步减少总线占用。对比项中断收发DMA 收发CPU 参与度每字节进一次中断起始和结束各参与一次不定长支持需状态机解析配合 IDLE 中断缓冲区管理复杂度低中等需处理回绕Modbus/自定义帧协议适用性勉强能用推荐2.3 开启 DMA 前必须确认的时钟与映射关系配置串口 DMA 前要先在 GPIO 复用、USART 使能、DMA 时钟三处做一致性检查。F103 的 USART1 挂在 APB2USART2/USART3 挂在 APB1但 DMA1 挂在 AHB。这三类时钟没开全最容易出现的症状是 DMA 配置寄存器写不进去或者 DMA 一直是默认触发。移植到 GD32 时还要注意 DMA 通道的优先级寄存器和 F103 不完全一致直接引用 ST 标准库的DMA_InitTypeDef有可能编译通过但运行不符合预期。提示__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE)之后要在中断服务函数里主动调用__HAL_UART_CLEAR_IDLEFLAG(huart1)不清标志会导致每收一帧后串口断流。3. 用 STM32CubeMX 配置串口 DMA 并生成可编译工程3.1 CubeMX 里必须勾选的 DMA 选项与中断优先级使用 STM32CubeMX 配置时在 USART1 的 DMA Settings 标签里分别添加 RX 和 TX 两个 DMA 请求。RX 方向选择 Circular 模式TX 方向选择 Normal 模式。优先级建议 RX 高于 TX因为接收丢数据会破坏协议完整性发送慢一点顶多是延迟。中断优先级这里有个容易被忽略的点DMA 中断和 USART 全局中断是两个独立的中断源如果回调里既依赖 DMA 传输完成中断又依赖 USART IDLE 中断那么这两个中断的优先级要统筹考虑。一般建议把 DMA 中断设为比 USART 中断高尤其当 DMA 缓冲区较小、接收频率较高时。CubeMX 生成的HAL_UART_Receive_DMA()启动接收后DMA 中断默认是开启的。如果只想用 IDLE 中断来感知接收结束而不用 DMA 的半传输/全传输中断可以在初始化后把两个回调实现成空函数但不要禁用 DMA 中断本身。禁用 DMA 中断后 IDLE 仍然能触发但HAL_UART_RxCpltCallback不会在没有 DMA 中断时被调用这会给调试制造困惑。3.2 最小工程对应的关键初始化代码以下是一个基于 STM32F103C8T6 的最小串口 DMA 初始化假设 USART1 使用 PA9/PA10波特率 115200DMA 接收缓冲区 256 字节。// 缓冲区定义在全局区不要定义在函数内部栈上 static uint8_t dma_rx_buf[256]; static volatile uint16_t dma_rx_index 0; void Serial_DMA_Init(void) { // 串口基础配置已经由 CubeMX 生成这里补充 DMA 启动 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); // 使能空闲中断 HAL_UART_Receive_DMA(huart1, dma_rx_buf, sizeof(dma_rx_buf)); }这段代码逻辑说明__HAL_UART_ENABLE_IT打开便捷的 IDLE 中断使能宏HAL_UART_Receive_DMA启动 DMA 接收最后一个参数sizeof(dma_rx_buf)会写入 DMA 的 CNDTR 寄存器。使用循环模式的要点是CNDTR 会随 DMA 搬运自动递减并在回绕时被硬件自动重载不需要软件干预。这也是为什么接收缓冲区必须放在全局区——CubeMX 生成的 DMA 配置中外设和内存地址总线宽度需要匹配定义为局部数组会导致 MSP 指针地址超出内存到外设的合法映射范围。3.3 空闲中断回调里的数据长度计算IDLE 中断在 USART 的中断服务函数中处理处理逻辑是清除 IDLE 标志读取 DMA_CNDTR算出当前已接收到的总字节数减去上一次处理时记录的字节数就是本帧增量。void USART1_IRQHandler(void) { if (RESET ! __HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t remained __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t received sizeof(dma_rx_buf) - remained; if (received dma_rx_index) { process_frame(dma_rx_buf[dma_rx_index], received - dma_rx_index); } else { // 回绕情况从当前读位置到缓冲区末尾 缓冲区开头到写指针 process_frame(dma_rx_buf[dma_rx_index], sizeof(dma_rx_buf) - dma_rx_index); process_frame(dma_rx_buf[0], received); } dma_rx_index received; } HAL_UART_IRQHandler(huart1); }这段处理里有个细节HAL_UART_IRQHandler必须在 IDLE 分支之后调用因为 HAL 库自己的处理函数里没有 IDLE 相关的回调放在前面执行虽然不影响 IDLE但会让代码可读性变差。另一个细节是回绕处理必须分开两段调用帧解析否则环形缓冲区开头的数据会被错误地拼到上一段末尾。dma_rx_index建议声明为volatile uint16_t避免编译器优化导致读到的值滞后。4. 发送 DMA 的完整实现与缓冲区释放策略4.1 发送接口封装与等待机制发送侧使用 DMA 可以提高主循环效率但带来一个管理问题DMA 还在搬运时如果调用方再次发送新数据直接修改源缓冲区会导致数据错乱。常见做法是用一个tx_busy标志位配合HAL_UART_GetState判断或者用队列把待发送帧排队。static uint8_t tx_buffer[128]; static volatile uint8_t tx_busy 0; int Serial_Send(uint8_t *data, uint16_t len) { if (tx_busy || len sizeof(tx_buffer)) { return -1; // 发送忙或长度超限由调用方决定重试还是丢弃 } memcpy(tx_buffer, data, len); tx_busy 1; HAL_UART_Transmit_DMA(huart1, tx_buffer, len); return 0; } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { tx_busy 0; } }这段封装的核心是tx_busy标志确保同一时间只有一个发送任务在占用 DMA 通道。HAL_UART_TxCpltCallback在发送完成后由 HAL 库自动调用此时置tx_busy为 0后续发送请求才有机会获得通道使用权。使用memcpy复制数据而非直接让 DMA 操作调用方传入的指针是为了避免调用方使用栈上临时数组时DMA 还没搬运完函数就返回导致源数据被销毁。4.2 发送完成后一定要处理 TC 标志HAL 库的HAL_UART_Transmit_DMA内部会开启 TC 中断在回调函数中需要调用__HAL_UART_CLEAR_FLAG清理 TC 标志。不清理 TC 的后果是下一次发送时可能触发错误的完成中断导致tx_busy提前清零发送中的缓冲区被下一个任务覆盖。正确写法如下void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { __HAL_UART_CLEAR_FLAG(huart, UART_FLAG_TC); // 清除发送完成标志 tx_busy 0; } }有人会在裸机环境下用轮询__HAL_UART_GET_FLAG(huart, UART_FLAG_TC)来判断发送完成这在 DMA 发送场景下并不推荐。DMA 写完最后一个字节到 TC 置位之间有时间差轮询忙等会浪费这段时间而且在关闭中断的临界区内轮询长帧会有可感知的阻塞。依赖回调不仅让时序更清晰也保留了后续接 RTOS 信号量或事件标志组的可能性。4.3 与 Ring Buffer 结合实现多帧发送队列单帧缓冲加忙标志的缺陷是连续发送多帧时第二帧必须等第一帧完成回调后才能调用发送接口这会产生帧间间隙。对 Modbus 这样的协议帧间间隙要求在 3.5 个字符时间内必须开始下一帧DMA 忙时来不及调度就容易超时。解决方法是把发送缓冲升级为环形队列发送接口只做入队操作在发送完成回调里检查队列是否为空不为空就继续启动下一次 DMA。#define TX_QUEUE_SIZE 8 typedef struct { uint8_t data[TX_QUEUE_SIZE][128]; uint16_t len[TX_QUEUE_SIZE]; uint8_t head; uint8_t tail; uint8_t count; } TxQueue; static TxQueue txq; int Serial_Send_Queued(uint8_t *data, uint16_t len) { if (txq.count TX_QUEUE_SIZE) { return -1; } if (len 128) { return -2; } memcpy(txq.data[txq.tail], data, len); txq.len[txq.tail] len; txq.tail (txq.tail 1) % TX_QUEUE_SIZE; txq.count; if (!tx_busy) { tx_busy 1; HAL_UART_Transmit_DMA(huart1, txq.data[txq.head], txq.len[txq.head]); } return 0; }这个队列在tx_busy 0时直接启动当前帧发送在tx_busy 1时只入队发送完成回调里再取队头数据继续发送。head和tail用取模方式回绕注意缓冲区大小使用 2 的幂次方可以用位运算优化取模但不强制。队列长度和单帧缓冲大小的选择取决于业务帧长和发送频率最坏情况是队列满时拒绝发送因此调用方需要处理好返回值。5. 帧解析策略协议层如何从 DMA 环形缓冲切出完整帧5.1 基于帧头帧尾的切帧方法拿到 IDLE 中断给出的数据增量后下一步是把它交给协议解析层。最简单的做法是固定帧头加帧尾比如帧头0xAA 0x55帧尾0x0D 0x0A。解析函数从增量数据的开头搜索帧头找到后再向后搜索帧尾两者之间的数据载荷拷贝到帧结构体里。typedef struct { uint8_t payload[64]; uint8_t len; } Frame_t; int FrameParser_Push(uint8_t *data, uint16_t len) { static uint8_t state 0; static Frame_t frame; for (uint16_t i 0; i len; i) { switch (state) { case 0: // 等待帧头第一字节 if (data[i] 0xAA) state 1; break; case 1: // 等待帧头第二字节 if (data[i] 0x55) { state 2; frame.len 0; } else if (data[i] 0xAA) { /* 保持等待 */ } else state 0; break; case 2: // 接收载荷直到帧尾 if (data[i] 0x0D data[i1] 0x0A) { OnFrameReceived(frame); state 0; } else { frame.payload[frame.len] data[i]; } break; } } return 0; }状态机解析不会因为数据跨越 DMA 缓冲边界而出错因为 state 变量是静态的上一段数据切到的状态会保留到下一段继续处理。这里要注意data[i1]可能越界读取到缓冲外如果len恰好等于增量长度且最后两个字节是0x0D在数组边界上读data[i1]是未定义行为稳妥做法是单独记录上一字节值。5.2 帧长度字段与校验和结合的做法自定义协议通常有两种定帧方式显式长度字段配合 CRC 校验或者帧尾特征字符。工业场景里 Modbus RTU 依赖帧间空闲时间切帧但 DMA IDLE 中断天然符合这种空闲即帧尾的模式。收到 IDLE 中断后增量数据的长度可以认为是完整的一帧不需要再找帧头帧尾前提是波特率低、帧间隔明确大于 3.5 个字符时间。参考 Modbus 的时序要求9600 波特率以下 3.5 字符时间是 3.5 × 11 / 9600 ≈ 4.0ms115200 波特率下约 0.33ms。IDLE 中断本身不判断具体空闲了多少微秒只要接收线上有足够长的时间没有新字节就认为帧结束。这个时间是否精确匹配协议要求取决于 DMA 搬运速度和串口波特率的配合实际测试中一般不会出问题。但要注意的是如果发送方连续两帧之间间隔太短IDLE 中断不会触发第二帧两帧会拼接在一起。5.3 半传输中断配合 DMA 循环模式的进阶切帧单靠 IDLE 中断的缺陷是如果一帧数据在接收过程中 DMA 已经循环回绕到缓冲区开头IDLE 中断到达时received减dma_rx_index会得到错误长度。虽然上一章代码里做了回绕判断但更稳妥的方案是叠加 DMA 半传输中断HT来做边界对齐。当 DMA 传输达到一半缓冲区时触发一次中断处理前半部分数据当 IDLE 到来时再处理剩下的部分。void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart) { // 半缓冲数据处理常见做法是把前半段数据送给协议解析层 protocol_parse(dma_rx_buf, sizeof(dma_rx_buf) / 2); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // 全缓冲数据处理后半段数据 protocol_parse(dma_rx_buf[sizeof(dma_rx_buf) / 2], sizeof(dma_rx_buf) / 2); }这里有个实际操作指导如果业务帧最长不超过缓冲区一半大小可以保证任何一帧数据不会横跨半传输边界那么这两次回调各自处理的数据天然是完整帧。配合 IDLE 中断三种事件半满、全满、空闲分别覆盖不同场景解析逻辑只需要识别完整帧即可。缺点是缓冲区需要开得够大对内存受限的 F103C8T6 来说256 字节缓冲区占用已经不小需要在 DMA 效率和内存占用之间取平衡。注意裸机环境下HAL_UART_RxHalfCpltCallback和HAL_UART_RxCpltCallback的执行上下文是 DMA 中断服务函数不要在回调里做耗时操作比如浮点运算、大块数据拷贝到另一个数组。常见做法是置标志位在主循环里延迟处理。5.4 实测中发现的两个高频坑第一个坑是 DMA 接收缓冲区和帧解析的同步问题。主循环读取dma_rx_index时如果 DMA 正在写入这个位置的字节程序可能读到一半数据。解决方法是把帧解析放到中断里或者在主循环判断received ! dma_rx_index时认为数据没有稳定。第二个坑是HAL_UART_Receive_DMA在接收完一次后并不会自动恢复如果 CubeMX 里选的是普通模式必须在HAL_UART_RxCpltCallback里重新调用本函数重新启动接收。而循环模式下不存在这个问题但需要手动处理缓冲区的读写位置关系。配置项循环模式普通模式IDLE 中断后处理记录读指针位置需要重设 CNDTR 并重新启动半传输中断配合可用可用缓冲区回绕硬件自动处理每次重新启动需要手动赋值适合场景长时间持续接收短帧低频通信调试时建议先用逻辑分析仪或串口助手确认以下几个方面每次发送的数据能完整到达串口工具DMA 接收缓冲区首字节是否对齐IDLE 中断是否被正确清除。如果看到第一帧正常、第二帧开始丢字节优先怀疑 IDLE 标志未清除或者 DMA 中断优先级低于其他中断导致数据被覆盖。5.5 不定长收发在 Modbus 协议中的落地思路Modbus RTU 的从机接收通常是这样设计的主站发过来一帧指令从机判断功能码与寄存器地址处理后返回响应帧。使用 DMA IDLE 中断后从机只需要在 IDLE 回调中拿到完整一帧然后做 CRC 校验再走协议处理逻辑用 DMA 发送响应。帧间 3.5 字符时间的静默要求由 IDLE 触发时机天然满足前提是协议里没有更短的帧间隔要求。这里有个细节值得单独提出Modbus 从机在收到广播地址 0x00 时不回帧这就需要对tx_busy独立处理不能因为收到广播帧就占用发送通道。另外如果主站发送的帧中间有字符间小停顿比如调试助手逐字节发送IDLE 中断会提前触发导致收到半帧。实际做产品时通常会在协议层加一个超时重组机制收到第一段数据后启动一个软件定时器比如 5ms 内没有新数据则把累积数据当完整帧处理。// 伪代码定时器超时重组半帧 void Timer_Callback(void) { if (partial_frame_len 0) { process_frame(partial_buf, partial_frame_len); partial_frame_len 0; } } void IDLE_Handler(uint8_t *data, uint16_t len) { // 如果上一段半帧还保留着说明数据被 IDLE 切碎了 if (partial_frame_len 0) { memcpy(partial_buf[partial_frame_len], data, len); partial_frame_len len; restart_timer(5); } else { memcpy(partial_buf, data, len); partial_frame_len len; restart_timer(5); } }这段伪代码体现了IDLE 不一定代表完整帧的防御性设计。在大多数串口助手下连续发送不会触发这个问题但工业现场如果终端设备按字节间歇发送IDLE 就会大量误判。加软件定时器后即使单帧数据被 IDLE 切成多段最终也会在定时器超时后组帧处理。定时器可以用 STM32 的硬件定时器实现比如 TIM2 的更新中断每次接收数据时重载计数值。至于串口烧写失败这类搜索热词不在本文主题范围但可以顺带提一件事调试 DMA 收发时经常会遇到程序烧写失败。典型原因是上电后 DMA 已经启动调试器复位后 DMA 还在搬运数据占用了总线导致 SWD 连接不稳定。解决方法是长按复位键再尝试下载或者在代码里加一个延时让 CubeMX 生成的SystemInit完成前不让 DMA 启动。返回的响应帧用 DMA 发送时协议处理函数可以直接调用Serial_Send_Queued此时tx_busy可能为 1正在发送上一帧请求的响应队列机制就起作用了。响应帧优先级通常高于其他数据帧比如设备主动上报的遥测信息因此队列可以考虑增加优先级字段或单独的高优先级缓冲但这依赖具体业务需求不是通用做法。如果对 DMA 通道分配有疑问F103 系列可以从参考手册的 DMA 请求映射表查到每个外设对应的通道。USART1_RX 固定对应 DMA1_Channel5USART1_TX 固定对应 DMA1_Channel4这在 CubeMX 里已经自动匹配但手写寄存器时会经常犯错。GD32E230 这类国产替代型号的 DMA 映射虽然大体兼容但寄存器的位定义有差异遇到 ADC DMA 数据紊乱这类问题时优先确认外设请求序号是否一致。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门