STM32串口DMA不定长收发实战:帧边界、缓冲管理与性能优化
简介针对STM32串口DMA不定长收发的完整实现资源包基于HAL库编写面向嵌入式开发者与STM32学习者重点解决串口高数据量传输时CPU占用高、不定长数据接收管理难等实际问题。资源共340个文件以C/H源码和Keil工程文件为主同时包含编译中间文件、HEX/AXF目标文件、MAP映射文件与启动文件压缩包整体11.12MB可直接导入Keil查看运行效果或进行二次开发。已有693人学习下载内容涵盖UART初始化、DMA通道选择与配置、底层MSP初始化、收发回调函数、中断处理以及接收缓冲区动态管理并提供了专门的串口DMA接收与发送模块代码。还附有DS18B20温度读取和ADC采集上报等综合示例便于将串口DMA技术应用到传感器数据采集与传输场景中整体代码结构清晰适合入门与工程参考。1. 串口 DMA 收发先定好“帧边界”再谈效率之前帮一个做数据采集的朋友调一块 STM32F103 的板子上位机每次下发的指令帧长度在 7 字节到 60 字节之间浮动用的还是最常见的字节中断接收。波特率一提到 460800主循环跑一圈的时间从 0.2ms 被拉长到 3ms 以上按键扫描都开始丢事件。后来把接收全部改到 DMA直接内存访问上用“DMA 搬数据 空闲中断IDLE划帧尾”主循环耗时掉到了 0.15ms 附近。这个改动给到一位做 ADAS 域控的朋友参考他在 Cortex-A53 的小核上用了同一套思路处理 U-Boot 串口日志效果同样明显——DMA 省掉的不是中断次数而是 CPU 被零散字节反复打断的时间对高频消息交互影响很直接。这篇文章围绕一个 STM32 串口 DMA 不定长收发工程写顺带讲了工程里 adc、ds18b20 这些外设文件怎么挂到同一条 DMA 链路上。读者对象是做嵌入式硬件的工程师用的是 CubeMX HAL 库颗粒度适合 STM32F1/F4思路也能迁移到 GD32/AT32 这类兼容芯片上。2. UART 请求线与 DMA 通道先看懂传输协作模型再上手2.1 DMA 与外设的绑定关系一张表说清通道映射串口收发从驱动模型上拆解流程是这样的UART 外设收到字节时会把 RXNE 标志位置 1CPU 查询或中断模式下都要亲自去读 DR 寄存器才能让标志清零而 DMA 模式本质上是把“读 DR 再搬运到内存”这个动作交给 DMA 控制器执行。DMA 控制器监视外设发出的请求信号线Request Line检测到有效请求后启动一次总线传输把外设数据寄存器Peripheral Address的数据写到内存地址或反过来写向外设。工程文件里没有明确给出通道号但 STM32F103 上 USART1 的 DMA 接收固定在 DMA1_Channel5、发送固定在 DMA1_Channel4。有些参考代码里写成 Channel2/3那是把 USART3 的通道记混了USART3_RX 才是 DMA1_Channel3。通道映射关系直接影响代码能不能跑通常见的对照表如下外设发送 TX 通道接收 RX 通道备注USART1DMA1_Channel4DMA1_Channel5F103 系列最常用USART2DMA1_Channel7DMA1_Channel6常用在 RS485 通信USART3DMA1_Channel2DMA1_Channel3与正文文件里的通道记录对应USART1_TX 重映射到 PB6/PB7 时通道不变通道不变仅引脚变化DMA 通道不变上表是 F1 系列的标准映射规则。在 STM32F4 系列上则改为 DMA2_Stream7 这类流Stream加通道Channel的双层结构CubeMX 配置接口也不一样后面章节以 F1 的 DMA1 为例展开。2.2 Normal 与 Circular 模式是收一帧还是无限循环收DMA 控制器在每次工作前需要配置好数据方向、外设地址固定/递增、内存地址递增、传输模式以及一次传输的总字节数。其中 Mode 参数决定了传输结束后 DMA 通道的状态Normal按设定的 Buffer Size 传输完成一次后通道停止不再响应外设请求。适合一帧一收接收完成信号明确代码逻辑简单。Circular计数器从 0 到设定值再回到 0循环往复接收永远不产生“完成”事件。适合连续音频流、传感器数据监控这类没有帧结构的数据但需要配合半传输中断/传输完成中断由软件在回调里拷贝数据走。做不定长收发时我更倾向用 Normal。不定长协议的特点是帧尾位置不确定每次接收开始时给它一个最大缓冲长度数据进来后 DMA 持续搬运直到串口总线空闲触发 IDLE 中断这时假设一帧结束从缓冲区按实际长度取走数据再重启一次 DMA 接收。Normal 模式保证缓冲空间在每一帧开始时保持干净而且“接收完成”语义和帧语义是对齐的Circular 模式则需要额外维护索引数据会被后一帧覆盖团队里其他人接手时容易误读代码。2.3 空闲中断IDLE与 DMA 完成中断的捕获时机串口 IDLE 标志位是在线路空闲一段时间即一个字节的时间内没有收到新起始位后由硬件置位的。它与 DMA 的传输完成中断是两个独立事件源。一帧数据到达路径是DMA 逐字节搬运当总线空闲时串口产生 IDLE 事件此时可选的方案有两种第一种最简单开启串口全局中断 (USART1_IRQn)在中断服务函数里检查__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)若置位则读取__HAL_DMA_GET_COUNTER(hdma_usart1_rx)用初始缓冲长度减去剩余计数得到实际帧长。第二种是使用 HAL 库新的扩展接口HAL_UARTEx_ReceiveToIdle_DMA()该接口把 IDLE 事件挂到 DMA 接收通道底层的轮询回调上CubeMX 生成后可以直接在HAL_UARTEx_RxEventCallback里处理省去手写 ISR 判断。建议优先学第二种因为后续 STM32CubeF4/F7/H7 库都保留了同样的回调接口代码迁移成本低第一种方案虽然看起来很底层但中断里要自己处理标志清理稍不留神就漏掉一次 IDLE 触发的机会。3. 从 CubeMX 时钟树到 DMA 参数把配置做成可复现步骤3.1 时钟树与波特率误差APB2 分频不能拍脑袋STM32F103 的 USART1 挂在 APB2 总线上系统时钟 72MHz 时 APB2 也是 72MHzUSART2/3 挂在 APB1 上最大 36MHz。CubeMX 里配置串口时一定要先确认 RCC 时钟树。很多人只改 USART1 的波特率没有看 APB2 有没有被误开到过分频最终波特率误差超过 2% 时板子之间收发就会偶发乱码。常见串口参数配置见下表CubeMX 中 UART1 配置页参数项推荐值说明ModeAsynchronous异步收发方式Baud Rate115200 或 460800工程实测 460800 必须用 DMA否则 CPU 占用过高Word Length8 Bits标准 8N1ParityNone无校验刮掉一个字节的对齐负担Stop Bits1足够DMA RequestUSART1_TX / USART1_RX打开两个 DMA 请求NVIC SettingsUSART1 global interrupt 使能IDLE 中断依赖串口全局中断需要注意的是在 CubeMX 的 DMA Settings 里添加 USART1_RX 后系统会自动分配 DMA1_Channel5再添加 USART1_TX 会分配 DMA1_Channel4不需要手动指定通道。这是很多从标准外设库转到 HAL 库的人容易犯的错直接在初始化代码里写死通道号结果和 CubeMX 生成的外设句柄冲突。3.2 缓冲区大小与对齐预留 2 倍冗余是基本素养在 HAL 库下接收缓冲区大小决定了 DMA 传输的计数上限。不定长接收的业务特点是单帧最大长度通常远小于缓冲区大小缓冲区大小按最大帧的 2 倍以上规划这样可以留出余量应对错误帧或堆叠帧。比如设备协议定义单帧最多 128 字节缓冲区就用 256 字节。缓冲区还要按uint32_t边界对齐原因是 ARM Cortex-M3/M4 内核上 DMA 访问非对齐地址会损失带宽极端情况下触发总线错误。在 Keil 里可以用align(4) uint8_t usart1_rx_buf[256];或通过编译器属性__attribute__((aligned(4)))实现。这段声明的作用在于把数组首地址对齐到 4 字节边界DMA 对内存的搬运按字访问时不会因为地址对齐问题打乱数据传输顺序。3.3 HAL_UART_MspInit 绑定 DMA 的代码走读CubeMX 生成的代码会把串口引脚的 GPIO 复用、DMA 句柄和 NVIC 优先级都放在HAL_UART_MspInit()中。它由HAL_UART_Init()内部自动调用不需要用户在主函数中显式执行。关键的绑定逻辑如下void HAL_UART_MspInit(UART_HandleTypeDef *huart) { GPIO_InitTypeDef GPIO_InitStruct {0}; if (huart-Instance USART1) { __HAL_RCC_USART1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_9 | GPIO_PIN_10; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); hdma_usart1_rx.Instance DMA1_Channel5; 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; HAL_DMA_Init(hdma_usart1_rx); __HAL_LINKDMA(huart, hdmarx, hdma_usart1_rx); HAL_NVIC_SetPriority(USART1_IRQn, 2, 0); HAL_NVIC_EnableIRQ(USART1_IRQn); } }代码说明DMA_PERIPH_TO_MEMORY表示数据从外设寄存器搬到内存外设地址不递增DMA_PINC_DISABLE内存地址递增DMA_MINC_ENABLE数据宽度设置成字节是因为串口 DR 寄存器是 8 位有效Mode为DMA_NORMAL保证一帧一停Priority设为HIGH让串口数据在 DMA1 的多个请求中优先拿到总线权。__HAL_LINKDMA宏把 DMA 接收句柄挂到 UART 句柄的hdmarx成员上这一步不写HAL_UARTEx_ReceiveToIdle_DMA()找不到可用的 DMA 通道会直接返回错误。优先级HAL_NVIC_SetPriority一般设置成串口优先级高于 DMA 通道优先级因为 IDLE 中断比 DMA 中断对实时性更敏感。当然不同项目对中断嵌套的要求不一样如果串口回调里要做的处理非常轻甚至可以仅仅开 DMA 中断IDLE 事件由 DMA 的回调识别减少一层打断。4. 不定长收发的回调状态机与缓冲复位策略4.1 用 RxEventCallback 识别帧边界并计算帧长顶层接收逻辑尽量简洁把状态判断留在回调函数内部。先定义接收缓冲和帧完成标志#define USART1_RX_BUF_SIZE 256 align(4) uint8_t usart1_rx_buf[USART1_RX_BUF_SIZE]; volatile uint16_t usart1_rx_len 0; volatile uint8_t usart1_rx_new_frame 0;初始化完成后在主函数或外设初始化末尾启动第一轮 DMA 接收HAL_UARTEx_ReceiveToIdle_DMA(huart1, usart1_rx_buf, USART1_RX_BUF_SIZE);该函数的作用是配置 DMA 接收通道并开启 IDLE 事件监听。接着实现回调void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { uint16_t dma_counter __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t received_len USART1_RX_BUF_SIZE - dma_counter; if (Size ! received_len) { // 半传输中断触发时应忽略等待完整帧到达 return; } usart1_rx_len Size; usart1_rx_new_frame 1; // 重新启动 DMA 接收否则后续数据无法入库 HAL_UARTEx_ReceiveToIdle_DMA(huart1, usart1_rx_buf, USART1_RX_BUF_SIZE); } }这里的Size是 HAL 库在触发 IDLE 事件时自动计算出的已接收字节数而dma_counter是从 DMA 硬件寄存器中读到的剩余未传输字节。如果 Size 等于 received_len说明本次是完整的 IDLE 帧结束事件如果不相等则是半传输中断产生的回调此时不应该取走数据。这种双保险的判断能够过滤掉 DMA 半传输事件对帧边界的干扰也是初学者最容易忽略的细节。执行完取数据逻辑后必须马上重启HAL_UARTEx_ReceiveToIdle_DMA()否则 DMA 通道停留在停止状态。若此时串口又来数据先进来的字节会直接堆在硬件数据寄存器中无法及时搬走下一个字节一到就触发溢出错误ORE。4.2 主循环怎么消费这一帧锁标志位比锁回调更稳new_frame标志置位后主循环中通过轮询判断处理if (usart1_rx_new_frame) { usart1_rx_new_frame 0; process_usart_frame(usart1_rx_buf, usart1_rx_len); }这段处理逻辑在回调外执行避免在中断服务函数里做帧解析。usart1_rx_len 和 usart1_rx_buf 的访问区域在回调返回后是静止的主循环内对缓冲区直接读取不需要加锁但必须保证两边的编译器优化等级一致必要时把相关变量声明为 volatile。帧解析可以按项目协议处理。工程里有 adc 和 ds18b20 的源码文件常见做法是主循环把 ADC 通道采集到的电压值编码成固定结构体再由发送 DMA 输出到串口助手而 DS18B20 的采样结果按自定义协议拼成不定长报文发送端同样走 DMA这样发送长度即使每次不同DMA 发送函数只需传长度参数。4.3 发送侧同样要维护一个“忙标志”串口发送 DMA 有个隐藏坑如果上一次发送还没完成马上调用HAL_UART_Transmit_DMA()HAL 库会返回 HAL_BUSY后面的数据全部丢失。因此发送侧也需要一个发送完成标志在HAL_UART_TxCpltCallback中置位static volatile uint8_t uart1_tx_busy 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart1_tx_busy 0; } } uint8_t usart1_send_dma(uint8_t *data, uint16_t len) { uint32_t tick HAL_GetTick(); while (uart1_tx_busy) { if (HAL_GetTick() - tick 100) return 0; // 超时 } uart1_tx_busy 1; return HAL_UART_Transmit_DMA(huart1, data, len) HAL_OK; }调用时数据缓冲区必须保持有效直到发送完成不能传临时栈变量然后立刻返回这属于 DMA 异步操作的常识问题但依旧每隔几个月就能在论坛上看到一遍。这个发送函数在 RTOS 环境下也可以直接扩成带信号量的版本只是超时等待机制要做好。5. 进阶乒乓缓冲、RTOS 信号量与数据错位排查5.1 乒乓缓冲把“拷贝帧数据”的开销也消掉如果业务要保留每一帧的原始数据做离线分析同时又要保证下一帧接收不被阻塞可以采用双缓冲。两个接收缓冲交替使用DMA 当前帧落在 A处理函数读 A 时下一帧由恢复的 DMA 接收写入 Balign(4) uint8_t usart1_rx_buf_a[128]; align(4) uint8_t usart1_rx_buf_b[128]; volatile uint8_t *active_rx_buf usart1_rx_buf_a; void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { volatile uint8_t *done_buf active_rx_buf; usart1_rx_len Size; usart1_rx_frame (uint8_t *)done_buf; if (active_rx_buf usart1_rx_buf_a) { active_rx_buf usart1_rx_buf_b; } else { active_rx_buf usart1_rx_buf_a; } HAL_UARTEx_ReceiveToIdle_DMA(huart1, (uint8_t *)active_rx_buf, 128); usart1_rx_new_frame 1; } }这种方式的核心收益是减少数据拷贝。帧处理在主循环进行时缓冲 A 被占用但 DMA 已在向缓冲 B 搬运下一帧两边互不阻塞。代价是内存占用增加一倍对 STM32F103 的 20KB SRAM 来说需要评估而对 F407 这类 128KB 内存的芯片几乎没有压力。双缓冲场景下要额外注意usart1_rx_frame指针指向的是上一次帧数据当前帧未处理完前不能因为 DMA 写入 B 而覆盖 A。代码里通过active_rx_buf的切换保证两帧不会同时落在同一个缓冲区。5.2 与 FreeRTOS 对接时的同步方式DMA 回调和任务之间的交互通常用二进制信号量。回调里不能直接调用osSemaphoreRelease之外的复杂函数因为调度器可能在中断上下文中被阻塞。常见写法是回调置usart1_rx_new_frame 1并释放一个信号量UART 接收任务osSemaphoreAcquire等待信号量等到后处理帧数据帧数据处理完任务自己负责再次启动HAL_UARTEx_ReceiveToIdle_DMA()这一句放在任务上下文中执行比放在回调中执行更安全避免回调里的重启和任务中的读取产生竞态。任务内处理数据时还可以直接调用usart1_send_dma发送 ACK 响应发送忙标志保证 DMA 发送队列不会堆积。整套流程下来CPU 占用率几乎与波特率无关这实际上就是工业设备里主控间通信用“DMA 加空闲中断加双缓冲”跑大数据量的原因。5.3 排错排查DMA 收发错位时按顺序查这几个点串口 DMA 排错最有效的办法不是盯日志而是量波形。重点关注这几点检查 CubeMX 生成的 DMA 通道是否与外设匹配查表见 2.1 节。通道配错时接收中断不触发但串口中断正常这种情况下HAL_UARTEx_ReceiveToIdle_DMA返回 HAL_OK实际 DMA 却不工作需要用调试器查看huart-hdmarx的State是否为 BUSY。内存地址对齐用align(4)声明缓冲区不定期出现首字节丢失时优先怀疑对齐。半传输回调必须和 IDLE 回调区分开否则会把半帧数据当成一帧提交。串口助手发送时如果勾选了“发送新行”回车符号会作为帧尾进入缓冲区导致帧长度比预期多 1~2 字节不定长协议应关闭发送新行让 IDLE 判断总线空闲。排查优先级RX DMA 中断和串口中断都开启的情况下若主循环被其他任务阻塞超过一个帧间隔可以调高 DMA 通道优先级到VERY_HIGH并把串口中断优先级设为高于 DMA 中断。最后一种验证手段是抓帧间隔用逻辑分析仪夹在 TX 和 RX 线上量出两帧数据之间的间隔时间。如果项目使用的协议对响应时间敏感比如 Modbus 要求 3.5 字符时间作为帧间隔那么 IDLE 中断的触发位置必须紧跟在最后一个字节之后。实测中在 115200 波特率下设置 1 位停止位DMA 回调到主循环识别标志位的时间通常在 30us 以内逻辑分析仪上能看到帧尾到应答帧起始之间约 80us 的间隔这个测量值就是后续调整超时参数和缓冲区大小的依据。本文还有配套的精品资源点击获取