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

STM32双串口DMA互透传实战:空闲中断、发送队列与错误恢复

STM32双串口DMA互透传实战:空闲中断、发送队列与错误恢复 ★ FEATURED ARTICLE
简介面向嵌入式开发者与学习STM32串口通信的学生这份STM32CUBE双串口DMA互透传工程聚焦UART1与UART2之间基于DMA的高效数据转发可用于连续、不定长串口数据的透明传输实验与项目原型验证。压缩包约3.62MB共143个文件包含大量.c与.h源码、Keil工程文件及.o、.crf、.d等编译中间文件并带有.ioc配置、.uvprojx工程和HAL库串口、DMA、定时器相关驱动便于直接打开对照CubeMX初始化、DMA通道设置、收发启动与中断回调。已有1872人学习下载。工程呈现了双串口DMA互透传的完整组织方式读者可据此理解HAL_UART_Receive_DMA与发送接口的配合、串口错误回调处理以及数据块接收后的转发思路适合用来拆解外设配置、排查DMA收发问题并快速搭建类似串口透传应用。1. 两块板子对插才跑通双串口 DMA 互透传装不对时的典型现场有个开发者拿着一块最小系统板找我说双串口 DMA 互透传的工程能编译能下载就是一个字节都过不去。他把 USART1 和 USART2 各自初始化得好好的DMA 也加了仿真器里看寄存器全是对勾可 USB 转串口发出去的数据像进了黑匣子。后来发现问题出在发送端HAL_UART_Transmit_DMA在上一帧还没发完时返回HAL_BUSY他直接忽略了返回值于是第二帧开始全部丢。这种问题在 CubeMX 里点几下就能生成框架但真正让两路串口稳定互透传卡人的从来不是能不能初始化而是 DMA 通道映射、空闲中断重挂、发送队列和错误恢复这几件事。下面按我实际装这套环境的顺序写先选版本、再配 CubeMX、再落代码最后给出验证和踩坑记录。适合刚上手 HAL 库、准备做协议网关或串口桥的开发者照着走一遍。2. 环境准备与版本组合CubeMX、固件包和工具链先对齐2.1 硬件与软件的最小清单这套环境不需要多贵的板子但对线和电平很挑。我把常用配置列成一张表照着备齐基本不会卡在硬件上。类别推荐配置说明主控板STM32F4 系列最小系统板如 F407或 F103 系列F4 的 DMA 通道多、支持双缓冲调试透传更宽裕F103 资源紧但够用调试器ST-Link V2 或 DAP-Link必须支持 SWD别用只有 JTAG 的老仿真器串口模块两路 USB 转 TTL3.3V 电平5V 电平直连会打坏引脚先量一下模块的 VCC 输出连线TX-RX 交叉、RX-TX 交叉、GND 共地两路串口各接一个模块GND 必须和板子共地图形化工具STM32CubeMX 6.x6.x 自带 JRE不用单独装 Java固件包STM32Cube FW_F4 / FW_F1 对应系列包版本决定有没有HAL_UARTEx_ReceiveToIdle_DMA这个接口IDEKeil MDK 或 STM32CubeIDE两者生成的代码结构一致函数名不变表格里最容易被忽视的是固件包版本。我见过有人照着网上教程写HAL_UARTEx_ReceiveToIdle_DMA编译报未定义翻回去看自己装的是很老的固件包。判断方法很简单用编辑器打开Drivers/STM32F4xx_HAL_Driver/Inc/stm32f4xx_hal_uart.h搜索ReceiveToIdle搜不到就是版本偏旧要么升级固件包要么退回自己写 IDLE 中断的老路子。我一般会先确认这一点再决定后面代码怎么写省得写到一半推倒重来。2.2 安装 CubeMX 并先跑通一个空工程CubeMX 的安装本身没什么坑真正坑人的是路径。安装目录和工程目录都别带中文和空格这是血泪经验——某些版本的代码生成器在拼接路径时对空格处理不好生成到一半报错工程目录里只剩半个文件树。装完先别急着配串口按下面的顺序做一次最小验证。# 1. 检查 CubeMX 能否正常启动部分精简系统缺图形库会闪退 # 启动后 Help - About 看版本号确认是 6.x # 2. 新建工程只做三件事 # System Core - SYS - Debug 选 Serial Wire # System Core - RCC - HSE 选 Crystal/Ceramic Resonator # 直接 Generate Code工具链选 MDK-ARM # 3. 打开生成的工程编译、下载、复位 # 如果没有报错且板子能正常停在 main 的 while(1)工具链就是通的这三步的意义在于把下载通道和代码生成链路先固定下来。很多透传调不通的案例根子在 SYS 的 Debug 没选 Serial Wire程序第一次下载进去后把 SWD 引脚复用成了普通 GPIO第二次就连不上了只能靠复位时机抢连接或者用 BOOT0 救。先选好调试口等于给自己留了后悔药。提示生成代码前在 Project Manager 里勾上 Generate peripheral initialization as a pair of .c/.h files这样 USART 和 DMA 的初始化会单独放在usart.c里改配置时不会和main.c里的用户代码混在一起。2.3 芯片型号与 DMA 请求映射的对应关系同一个 USART 在不同系列上挂的 DMA 通道完全不一样这一点必须在动手前查清楚否则 CubeMX 里点出来的通道是错的代码跑起来外设根本不触发传输。以最常用的两路串口为例外设STM32F407 上的 DMA 流/通道STM32F103 上的 DMA 通道USART1_RXDMA2 Stream2 Channel4DMA1 Channel5USART1_TXDMA2 Stream7 Channel4DMA1 Channel4USART2_RXDMA2 Stream5 Channel4DMA1 Channel6USART2_TXDMA2 Stream6 Channel4DMA1 Channel7CubeMX 默认会按芯片型号自动选但如果同一路 DMA 流被别的外设占了比如你同时还开了 ADC 或 SPI它会弹冲突提示让你手动换。遇到冲突优先换串口的 DMA 流别去动已经调好的其他外设——串口的 DMA 组合一般有备选其他外设未必有。3. CubeMX 图形化配置从时钟树到两路 DMA 请求3.1 新建工程与调试口设置打开 CubeMX选择 Access to MCU Selector输入具体型号比如 STM32F407VGT6双击进入引脚图。第一件事不是点串口而是进 System Core 把两件事做掉SYS → Debug 选Serial Wire。这一步决定后面能不能反复下载务必在生成代码前完成。RCC → HSE 选Crystal/Ceramic ResonatorLSE 按板子实际情况选。做完这两步右侧引脚图上 PA13/PA14 会变成 SWDIO/SWCLK 的绿色标记说明调试口被保留了。很多人习惯先配外设最后配时钟我不推荐这个顺序——时钟树一旦配错后面所有外设的波特率都是错的排查时会被误导到串口配置上去。3.2 时钟树HSE 频率必须和板上晶振一致进 Clock Configuration 页先看左上角 Input frequency 是不是你板子上实际焊的晶振值。F4 最小系统板常见 8MHz也有 25MHz 的F103 常见 8MHz。这个值填错整条时钟链都会偏串口表现出来的现象是波特率明明设的 115200收出来全是乱码。我一般按这个顺序配确认 HSE 数值与实物一致PLL Source 选 HSE直接在 HCLK 框里输入目标主频F407 填 168F103 填 72回车让工具自动算分频看最终 HCLK 是不是刚好等于目标值出现红色就是超频或分频除不尽。配完时钟右侧会显示各总线的实际频率比如 APB1 是 42MHz、APB2 是 84MHz。这两路串口如果挂在不同的 APB 上波特率寄存器算出来的分频值不一样但 HAL 库已经帮你处理了只要你填的波特率一致实际输出就是一致的。3.3 两路 USART 的参数配置在 Connectivity 里分别点开 USART1 和 USART2Mode 都选Asynchronous。参数页只需要改几项Baud Rate先统一填 115200验证通了再往上调Word Length8 BitsParityNoneStop Bits1Oversampling16默认高速率时可试 8但对时钟精度要求更高。两个串口参数必须完全一致否则一端发出去的数据在另一端被解析成乱码。这一点在互透传场景里尤其致命——数据从 A 口进、B 口出如果两口的字长或校验位不一样转发出去的数据对端设备根本不认。引脚分配上USART1 默认落在 PA9/PA10USART2 默认落在 PA2/PA3。如果板子上这几个脚被占用了比如接了 LED 或按键可以在引脚图上点住引脚拖到备选脚位CubeMX 会同步更新外设配置。3.4 DMA 请求的添加与循环模式选择这是整套配置的核心。在 USART1 的配置页里切到DMA Settings标签点 Add添加两条请求请求DirectionModeData Width (P/M)PriorityUSART1_RXPeripheral To MemoryNormal入门/ Circular高速Byte / ByteMediumUSART1_TXMemory To PeripheralNormalByte / ByteMediumUSART2 同样加两条。四张表填完后Increment Address 这一列的规则是内存侧勾上地址递增外设侧不勾外设数据寄存器地址固定。如果两边都勾或者都不勾DMA 搬运的地址就错了表现出来是只收到第一个字节后面全是 0或者数据错位。接收方向的 Mode 选择是分水岭NormalDMA 搬满指定长度就停需要软件重新启动。配合空闲中断使用时每收完一帧都要重挂一次中间有几个字节的空窗。CircularDMA 搬满一圈自动回到起点继续永不停止。适合高速连续流但软件必须靠缓冲区指针差值和半传输/传输完成事件来划分帧边界。我一般入门先用 Normal 模式把链路跑通确认数据能过去再根据吞吐需求决定要不要切 Circular。上来就用 Circular 加环形缓冲出问题时很难判断是模式选错还是索引算错。3.5 NVIC 优先级与代码生成设置切到 NVIC Settings 标签把四路 DMA 中断和两路 USART 全局中断都勾上 Enable。优先级这么分USART1/USART2 全局中断抢占优先级 1四路 DMA 流中断抢占优先级 2。这样空闲中断IDLE能第一时间被响应DMA 传输完成的处理排在其后。注意 HAL 默认的优先级分组是 4 位抢占优先级、0 位子优先级也就是说你填的数字越小优先级越高别填反了。最后在 Project Manager 里设置工程名、路径、工具链把 Generate peripheral initialization as a pair of .c/.h files 勾上点 GENERATE CODE。生成完后先编译一次确认没有报错再往下写业务代码。4. 生成代码后的透传实现空闲中断接收加发送队列4.1 接收端ReceiveToIdle_DMA的启动与重挂生成代码里MX_DMA_Init()一定在MX_USART1_UART_Init()之前被调用这个顺序是 CubeMX 自动排的别手动调换——DMA 时钟没开就去初始化串口的 DMA 句柄HAL_DMA_Init会直接返回错误。接收部分我在main.c的用户代码区加一个启动函数和一个回调。核心思路是让 DMA 一直挂在接收上靠空闲中断判定这一帧结束了然后在回调里把长度和数据交给发送队列。/* main.c 用户代码区 */ #define RX_BUF_SIZE 512 static uint8_t u1_rx_buf[RX_BUF_SIZE]; static uint8_t u2_rx_buf[RX_BUF_SIZE]; /* 启动两路接收DMA 模式为 Normal每帧结束后需要重挂 */ void BSP_Uart_Start(void) { HAL_UARTEx_ReceiveToIdle_DMA(huart1, u1_rx_buf, RX_BUF_SIZE); HAL_UARTEx_ReceiveToIdle_DMA(huart2, u2_rx_buf, RX_BUF_SIZE); /* 关掉半传输中断Normal 模式下用不到开着反而会多一次回调 */ __HAL_DMA_DISABLE_IT(hdma_usart1_rx, DMA_IT_HT); __HAL_DMA_DISABLE_IT(hdma_usart2_rx, DMA_IT_HT); }HAL_UARTEx_ReceiveToIdle_DMA这个接口做了两件事启动 DMA 接收同时打开串口的 IDLE 中断。这样只要总线上出现一个字节时间以上的静默就会触发回调参数Size就是这一帧的实际字节数——不需要你自己去算缓冲区长度 - __HAL_DMA_GET_COUNTER()。回调里的处理要小心Size是这一帧的长度缓冲区指针是固定的。如果直接把u1_rx_buf的地址塞给发送函数下一次接收会覆盖它所以必须拷贝一份或者排队。/* 接收事件回调IDLE 或 DMA 传输完成时进入 */ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { tx_ring_push(ring_to2, huart2, u1_rx_buf, Size); /* 1 收 - 2 发 */ HAL_UARTEx_ReceiveToIdle_DMA(huart1, u1_rx_buf, RX_BUF_SIZE); /* 重挂 */ } else if (huart-Instance USART2) { tx_ring_push(ring_to1, huart1, u2_rx_buf, Size); /* 2 收 - 1 发 */ HAL_UARTEx_ReceiveToIdle_DMA(huart2, u2_rx_buf, RX_BUF_SIZE); } }重挂这一步是 Normal 模式下最容易漏的。漏了的现象是上电后能透传一帧之后彻底没反应因为 DMA 已经搬完一次进入停止状态串口那边还在等数据但搬运工已经下班了。4.2 发送端为什么必须自己写一个队列直接调HAL_UART_Transmit_DMA的问题是这个函数在gState不是 READY 时返回HAL_BUSY而 DMA 发送一帧需要的时间远大于 CPU 处理一帧的时间。115200 波特率下发 100 字节大约要 8.7ms如果这期间对向又来了一帧第二次调用必然失败。忽略返回值就是丢数据检查返回值就得在回调里阻塞等待两头都不讨好。我一般用一个小的发送环形队列队列里每格自带存储push 时把数据拷进去发送完成中断里推进指针。/* 发送队列每格自带缓冲避免接收缓冲区被覆写 */ #define TX_QUEUE_LEN 6 #define TX_SLOT_SIZE 256 typedef struct { uint8_t data[TX_SLOT_SIZE]; uint16_t len; } tx_slot_t; typedef struct { tx_slot_t slot[TX_QUEUE_LEN]; volatile uint8_t head; /* 写入位置 */ volatile uint8_t tail; /* 当前发送位置 */ volatile uint8_t sending; } tx_ring_t; static tx_ring_t ring_to1, ring_to2; /* 分别对应发往 USART1 / USART2 */ /* 启动队列里下一帧的 DMA 发送 */ static void tx_ring_kick(tx_ring_t *r, UART_HandleTypeDef *huart) { if (r-tail r-head) { /* 队列空标记空闲 */ r-sending 0; return; } r-sending 1; if (HAL_UART_Transmit_DMA(huart, r-slot[r-tail].data, r-slot[r-tail].len) ! HAL_OK) { r-sending 0; /* 启动失败等下一次 push 再试 */ } } /* 入队队列满时丢掉最旧一帧保证新数据能进 */ static void tx_ring_push(tx_ring_t *r, UART_HandleTypeDef *huart, const uint8_t *data, uint16_t len) { uint8_t next (r-head 1) % TX_QUEUE_LEN; if (next r-tail) { r-tail (r-tail 1) % TX_QUEUE_LEN; /* 覆盖最旧帧 */ } if (len TX_SLOT_SIZE) len TX_SLOT_SIZE; memcpy(r-slot[r-head].data, data, len); r-slot[r-head].len len; r-head next; if (!r-sending) tx_ring_kick(r, huart); } /* 发送完成中断推进 tail继续发下一帧 */ void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { ring_to1.tail (ring_to1.tail 1) % TX_QUEUE_LEN; tx_ring_kick(ring_to1, huart1); } else if (huart-Instance USART2) { ring_to2.tail (ring_to2.tail 1) % TX_QUEUE_LEN; tx_ring_kick(ring_to2, huart2); } }几个参数要解释清楚。TX_QUEUE_LEN定的是积压帧数6 格在 115200 下大约能吸收 50ms 的突发流量速率升到 921600 就要加大到 12 格以上。TX_SLOT_SIZE是单帧最大长度超过就截断——如果对端会发长包要么把这个值调到 1024要么在 push 里做分片别指望 HAL 帮你处理。队列满时的策略我选择丢旧帧而不是丢新帧因为透传场景里新数据通常比旧数据更有价值。如果业务上不能接受丢数据就得加流控信号或者把队列做在堆上动态分配那是另一个复杂度了。4.3 错误回调把串口从溢出状态里拉回来DMA 加空闲中断的组合在长时间运行时最常遇到的是溢出错误ORE。一旦 ORE 置位且不清理串口的接收状态机会卡在错误态之后的ReceiveToIdle_DMA全部返回 BUSY表现为跑几个小时后突然不通了重启就好。/* 串口错误回调清标志并重新挂接收 */ void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { /* 先读 SR 再读 DR这是清 ORE 的标准动作 */ volatile uint32_t tmp; tmp huart-Instance-SR; tmp huart-Instance-DR; (void)tmp; huart-ErrorCode HAL_UART_ERROR_NONE; if (huart-Instance USART1) { huart-RxState HAL_UART_STATE_READY; /* 解除卡死状态 */ HAL_UARTEx_ReceiveToIdle_DMA(huart1, u1_rx_buf, RX_BUF_SIZE); } else if (huart-Instance USART2) { huart-RxState HAL_UART_STATE_READY; HAL_UARTEx_ReceiveToIdle_DMA(huart2, u2_rx_buf, RX_BUF_SIZE); } }手动把RxState掰回 READY 这一步看起来粗暴但比等 HAL 自己恢复可靠得多。HAL 库在错误处理上的状态机逻辑比较保守某些版本在 ORE 之后不会自动回到就绪态不手动复位就会一直卡着。代价是如果错误发生时 DMA 里还有半个帧的数据那半个帧会被丢掉——透传场景下可以接受。5. 避坑与排查六个让透传断流或丢字节的现场5.1 编译链接阶段的三个报错现象一DMA_HandleTypeDef has no member named hdma_usart1_rx。原因是 CubeMX 里只配了 USART 没加 DMA 请求生成的usart.c里根本没有这个句柄。解决方法是回 CubeMX 的 DMA Settings 标签把 RX/TX 两条请求加上重新生成代码。别手动去usart.c里补定义下次生成会被覆盖。现象二undefined reference to HAL_UARTEx_ReceiveToIdle_DMA。这是固件包版本偏旧老版本 HAL 库里没有这个接口。两个选择在 CubeMX 的 Manage embedded software packages 里升级到当前系列的最新固件包或者退回老写法自己开 IDLE 中断在USARTx_IRQHandler里判断__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)然后手动算长度。我一般选前者少写一堆寄存器操作。现象三编译通过但下载后不运行或者跑一次就死。八成是 SYS 的 Debug 没选 Serial WireSWD 引脚被复用后调试器连不上。补救办法是把 BOOT0 拉高进系统存储器启动模式重新下载一个 Debug 口正确的工程或者在复位释放的瞬间抢连接。这个坑踩一次就够记一辈子我现在新建工程的第一件事永远是配 Debug 口。5.2 运行阶段的丢字节、断流与乱码现象一上电后能透传第一帧之后彻底没反应。原因接收 DMA 用的是 Normal 模式搬完指定长度或空闲触发一次后 DMA 就停了回调里没有重新调用ReceiveToIdle_DMA。解决在HAL_UARTEx_RxEventCallback里处理完数据后立即重挂两路都要挂漏一路就是一路不通。现象二低速测试正常一提高波特率就大量丢包。原因发送端HAL_UART_Transmit_DMA返回HAL_BUSY被忽略或者发送队列深度不够。还有一个隐蔽原因是 DMA 流中断的优先级低于串口中断接收事件频繁抢占发送完成中断迟迟进不来。解决确认发送走了队列而不是裸调 HAL 函数把 DMA 流中断优先级调到和串口同级或更高队列深度按波特率 × 最大帧时间 / 单帧时间估算后留一倍余量。现象三数据能过去但每隔一段就错几个字节。原因通常是时钟不准。HSE 频率填的和板上晶振不一致或者 PLL 分频后 HCLK 有偏差波特率误差累积到几个百分点就会采样错位。解决用示波器量一下 USART 的 TX 波形算实际波特率和设定值的偏差超过 2% 就要回头查时钟树。另一个可能是两路串口的字长、校验位配置不一致转发出去的数据对端解析不了。现象四跑几十分钟到几小时后突然不通断电重启恢复。原因ORE 溢出错误没有被清理串口接收状态机卡死。高速率下 CPU 忙于其他中断、没及时响应 DMA 请求时特别容易触发。解决实现HAL_UART_ErrorCallback按 4.3 节的方式清标志并把RxState复位到 READY然后重挂接收。同时在主循环里加一个看门狗式的计数监控收帧数长时间不增长就主动重挂一次。现象五两路串口互相干扰A 口的回声串到 B 口。原因是共地没做好或者两个 USB 转串口模块的地线通过电脑主板形成了回路地电位差把信号耦合过去。解决确认板子和两个模块的 GND 都可靠连接测试时把两个模块插在同一台电脑的相邻 USB 口上有时会更糟换成板子独立供电、模块只接 TX/RX/GND 三根线往往能消掉。现象六仿真器单步时正常全速跑就丢数据。原因断点停住时 DMA 还在搬恢复运行时缓冲区的状态和软件记录的对不上。解决调试透传这类 DMA 相关逻辑时尽量用串口打印计数代替断点或者只在初始化阶段打断点。真要观察数据流用逻辑分析仪抓 TX 引脚波形比单步靠谱得多。6. 装完怎么验证三组计数加一次对拷就能定性6.1 先看计数再谈波形透传工程最怕看起来在跑。我的习惯是在主循环里每 500ms 打印一次三组计数USART1 收帧数、USART2 收帧数、发送队列溢出次数。前两个数应该同步增长第三个必须长期为 0。只要溢出计数在涨就说明发送侧跟不上接收侧先加队列深度或者降波特率别急着怀疑硬件。/* 主循环里的统计打印重定向到调试串口或复用一路 USART */ static uint32_t last_rx1 0, last_rx2 0; if (HAL_GetTick() - last_tick 500) { uint32_t r1 stat_rx1, r2 stat_rx2; printf(U1%lu(%lu) U2%lu(%lu) DROP%lu\r\n, r1, r1 - last_rx1, r2, r2 - last_rx2, stat_drop); last_rx1 r1; last_rx2 r2; last_tick HAL_GetTick(); }stat_rx1和stat_rx2在接收回调里自增stat_drop在队列满覆盖旧帧时自增。这三个数一摆出来链路是否稳定一目了然两个增量长期为 0 是接收没挂上一个涨一个不涨是单向转发断了两个都涨但 DROP 也涨是发送侧拥塞。计数正常之后再做一次对拷。用两个 USB 转串口模块分别接 USART1 和 USART2PC 侧脚本同时打开两个口一端发随机数据、另一端收比对哈希值。import serial, os, hashlib # 两个串口参数必须和固件里配置的一致 p1 serial.Serial(COM5, 115200, timeout3) p2 serial.Serial(COM6, 115200, timeout3) payload os.urandom(2048) p1.write(payload) got p2.read(len(payload)) print(len:, len(got)) print(match:, hashlib.md5(payload).digest() hashlib.md5(got).digest())timeout要给足2048 字节在 115200 下需要接近 180ms再算上转发延迟设 3 秒比较稳。match为 True 只说明单向通了把两个串口对调再跑一次双向都通过才算这套环境真正装成。想测吞吐就逐步加大payload到 8KB、32KB观察 DROP 计数和 match 结果找到这套配置下的稳定上限。6.2 边界情况再补两个动作对拷通过后别急着收工补两个动作能省掉后面很多麻烦。一是拔掉其中一路的 TX 线模拟对端掉线观察另一路是否还在正常收发、有没有因为发送失败卡住队列——这验证的是错误恢复逻辑。二是把波特率往上调一档比如 115200 升到 460800重跑对拷脚本如果这时开始丢包说明瓶颈在发送队列或者中断优先级而不是串口本身。我现在的习惯是每次改完串口或 DMA 配置都强制走一遍三组计数 双向对拷 拔线测试三个都过才算数。这套流程看着繁琐但比起在现场靠重启一下试试来定位问题前面多花十分钟太值了。希望这套配置和验证顺序能帮你把双串口 DMA 互透传一次装稳。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站