ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RT-Thread STM32H7串口DMA接收发送完整实战指南

RT-Thread STM32H7串口DMA接收发送完整实战指南 最近在调一块基于STM32H743的采集板外接传感器通过串口上报数据一帧几百字节波特率115200。第一版偷懒用中断方式接收结果每来一个字节就触发一次中断等业务逻辑一多CPU占用率肉眼可见地往上走偶尔还丢包。后来把串口收发都改成DMA方式接收配上STM32H7的空闲中断跑在RT-Thread系统上数据过来DMA直接搬到内存应用线程用信号量唤醒处理整个CPU负载一下子降下来。这篇就把RT-Thread加stm32h7串口DMA这条完整链路从CubeMX配置到代码实现写一遍里面那些串口烧写失败、DMA发送不能连续、数据丢失之类的坑也一并整理给后面搞H7项目的兄弟少走点弯路。1. 为什么H7项目里建议直接用串口DMA1.1 数据搬运不该浪费CPU很多朋友一上来就把串口中断打开来一个字节进一次中断代码写起来确实简单中断里拼缓冲区拼够了扔给解析函数。但要知道STM32H7是一颗主频能跑到480MHz的Cortex-M7让它一趟趟去搬每个字节太浪费了。DMADirect Memory Access干的就是这件事外设和内存之间的数据搬运完全由DMA控制器完成搬完再给CPU发个通知这期间CPU该干嘛干嘛RT-Thread里其他线程还能继续跑。这里有个很容易忽视的本质串口波特率再高也就是一个字节一个字节地流转CPU如果负责搬运每处理一个字节都要做压栈、出栈、判状态、写缓冲指令开销远比想象大。尤其板上还有以太网、USB、文件系统、传感器解析这些任务时中断频繁抢占实时性一定受影响。1.2 DMA方案和轮询、中断的对比做串口传输基本就三条路轮询、中断、DMA。轮询最费CPU纯裸机小工程可以临时顶一下放到RT-Thread这种系统里基本不建议接收线程死等会拖垮整个调度。中断方式应用最普遍适合单字节触发或者低数据量但是如果每字节都进中断系统上下文切换的成本很高。DMA方式适合数据量大、频率高、帧长不固定的场景它把搬运工作完全交给硬件CPU只处理“一帧完整数据来了”这个事件。方式CPU占用实时性适合场景轮询极高差临时调试、一次收发几个字节中断中好数据量小、帧长短DMA空闲中断低好不定长帧、大数据量、高速串口如果你只用串口做日志输出那中断完全够用。但如果是数据采集、AT指令交互、Modbus、GPS NMEA这类不定长协议帧DMA加空闲中断可以说是最优解。1.3 什么场景值得上DMA结合我这边的情况DMA方案比较典型的场景有几个第一串口数据速率高比如921600甚至2Mbps中断高频触发让CPU吃紧第二帧长几十上百字节DMA一次搬完效率远高于逐字节中断第三RT-Thread系统里有多个高优先级任务在跑串口只是其中之一不能让它干扰整个系统调度第四RS485通信发送完后需要在DMA完成回调里精确控制方向引脚这个用DMA天然合适。2. CubeMX配置串口和DMA的初始化细节2.1 工程里的时钟和引脚准备我用的是STM32H743VIT6CubeMX建工程时RCC选外部晶振HSE时钟树按480MHz主频配置。H7的时钟树比F1/F4复杂总线分出来好几个域APB1和APB2各120MHz需要注意串口时钟源是挂在哪条总线上。比如USART1挂在APB2USART2/3挂在APB1DMA传输本身不参与波特率计算但DMA控制器位于D2域和串口在同一个时钟域里时钟配置不对DMA外设映射可能出问题所以还是建议先按标准480MHz主频模板生成。引脚方面USART1我用的PA9/PA10也就是TX和RX这个不复杂AF模式由CubeMX自动配置基本不用手改。需要注意H7的引脚功能复用比较多CubeMX一般会自动分配AF编号自己核验一眼就行。2.2 RX用CircularTX用Normal进入USART1配置页Mode选Asynchronous波特率按实际需求填我这里115200。重点在DMA Settings里点Add后会出现USART1_RX和USART1_TX两个DMA请求分别添加进去。RX的Mode强烈建议选Circular也就是循环模式DMA会不停地把接收到的数据写进环形缓冲区配合后面说到的空闲中断天然适合不定长接收。TX的Mode选Normal即可发送完一次就停下次发送前重新启动。数据宽度都用Byte对应串口字节流。Increment Address设置上外设地址不增内存地址增这个是CubeMX自动处理的不要手动去改。Priority建议RX设High或Very High因为接收丢数据比发送延迟更致命TX设Medium或者Low都行毕竟发送失败顶多重传接收丢帧可能造成协议整个错乱。之前看过不少工程RX和TX优先级都设成Very High这在多路串口同时启用时容易造成DMA仲裁冲突更合理的方式是给关键接收通道高优先级发送通道低一点。H7的DMA1和DMA2内部有仲裁机制多路一起跑时优先级分配不当会出现偶发延迟。2.3 中断配置和NVIC优先级DMA要配合串口空闲中断使用所以USART1的Global interrupt必须勾上否则IDLE中断进不来。DMA中断这里也要开发送完成会依赖DMA传输完成中断上报给驱动。NVIC优先级建议设置成中间档次比如4到7不要设成0这样的最高优先级。原因很简单RT-Thread在调度的时候有临界区保护如果串口中断优先级太高会频繁打断系统调度反而影响整体实时性设太低又怕数据丢失。你可能会问优先级到底多少合适。我的经验是在RT-Thread环境下外设中断优先级统一设在5-10之间并且同类型外设保持一个梯度保证中断不嵌套太深。H7的中断优先级用NVIC 4bit表示数值越小优先级越高具体分配没有绝对标准只要保证它比SysTick低一档比PendSV高一档就行。2.4 生成代码后必须检查的几个点CubeMX生成代码后很多人直接丢进工程编译结果跑起来各种诡异问题。我会重点检查三样东西。第一huart1的DMA句柄是否正常关联打开main.c能看到HAL_UART_MspInit里有一堆__HAL_LINKDMA确认RX和TX各关联一个DMA句柄。第二看stm32h7xx_hal_msp.c里DMA中断是否都开了有时候CubeMX会漏生成。第三也是H7最容易踩的坑如果开启了D-CacheDMA缓冲区的Cache一致性必须处理。细节后面单独说这里先记住一点DMA写内存和CPU读内存如果隔着一个Cache读到的可能是旧数据。3. RT-Thread侧把串口DMA驱动真正点亮3.1 RT-Thread Studio的方式如果你用RT-Thread Studio建工程流程非常顺。基于芯片选STM32H743系列创建项目后工程里会自带H7的BSP和串口驱动。要启用DMA直接双击工程里的RT-Thread Settings在弹出的配置界面里找到硬件相关的Serial选项把对应UART的RX和TX DMA勾上。这个操作的实质是往rtconfig.h里写入几个宏比如BSP_UART1_RX_USING_DMA和BSP_UART1_TX_USING_DMA同时会依赖RT_USING_SERIAL_DMA这个总的开关。STudio里勾选后会自动裁剪不用手动改头文件。重新构建后可以打开编译产物里的rtconfig.h确认宏是否生效。3.2 menuconfig方式如果用的是ENV或者Linux命令行那就是另一套流程。在BSP目录下执行scons --menuconfig进入Hardware Drivers Config-On-chip Peripheral Drivers找到UART相关配置选中DMA选项。注意menuconfig的选项文字可能不直接叫DMA而是类似“Enable UARTx DMA transmission”之类的描述看仔细一点。保存退出后紧跟着执行scons编译。如果改了配置没生效大概率是Kconfig的依赖没触发可以在menuconfig里先退出再重新进入确认选项状态保存上。3.3 驱动源码里如何判断DMA生效配置开关只是第一步实际跑起来之前最好看下驱动源码。拿RT-Thread H7 BSP的drv_usart.c来说里面会有大量以#ifdef BSP_UART1_RX_USING_DMA包起来的代码。打开文件搜一下这几个宏就能确认当前工程是否真的把DMA接收逻辑编译进去了。还要注意一点RT-Thread新版驱动对H7的DMA接收实现本质上是通过HAL库的HAL_UARTEx_RxEventCallback来上抛事件的。也就是说串口DMA接收时UART的空闲中断一触发HAL库就会调用这个回调RT-Thread驱动在这个回调里更新缓冲区状态并通知应用层。所以如果你在应用里发现对应的回调没执行先查中断标志是否配置正确再查DMA配置是否被宏裁剪掉。4. 接收链路的核心DMA加空闲中断4.1 为什么只有DMA还不够很多新手有个误解以为DMA打开了就能自动接收一帧完整数据。其实DMA只是个搬运工它不知道什么时候算“一帧结束”。比如你的协议帧不定长10到200字节都可能DMA会源源不断地把字节搬进缓冲区但永远不会告诉你“这一帧结束了”。如果用DMA传输完成中断那只有当缓冲区装满时才触发这也不符合不定长帧的需求。这时候必须靠串口本身的一个硬件功能空闲中断IDLE。UART在接收线上检测到持续一个字节时间的空闲后会自动置起IDLE标志。这个时机恰好就是“一帧数据发完了”的时刻。DMA负责把数据搬进内存IDLE负责告诉你有完整一帧数据到达两者配合才是完整的接收方案。4.2 接收长度怎么算、环形缓冲回绕问题开启Circular模式后DMA会循环往缓冲区里写数据。应用层拿到IDLE中断怎么知道这次来了多少字节很简单用这个公式uint16_t remaining __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t received_len RX_BUF_SIZE - remaining;因为DMA计数器是剩余待传输字节数缓冲区总大小减去剩余值就是已接收的字节数。这是在H7上处理串口DMA接收最核心的一句代码无论你是裸机还是RT-Thread原理都一样。但是环形缓冲有个坑当数据刚好跨过缓冲区末尾也就是DMA已经回绕了一圈这时候received_len计算出来的是“当前写指针到缓冲区末尾”的那部分长度而一帧数据的头部可能已经写到缓冲区开头去了。你可以连续判断两次received_len的大小关系如果后一次比前一次小说明发生了回绕把数据头尾拼接处理。RT-Thread的驱动已经处理了这部分逻辑应用层直接用rt_device_read拿数据即可但如果你是自己写裸机程序这个回绕就是必须处理的问题。4.3 应用层如何用信号量拿到数据RT-Thread的串口驱动封装得很好应用层不用自己去处理DMA计数器和回绕。配置好DMA之后我们在应用层注册一个接收完成回调rx_indicate当一帧数据到达时驱动会调用这个回调我们在回调里释放信号量唤醒读线程。static struct rt_semaphore rx_sem; static rt_size_t rx_len; static rt_err_t uart_rx_ind(rt_device_t dev, rt_size_t size) { rx_len size; rt_sem_release(rx_sem); return RT_EOK; }然后在接收线程里等待信号量信号量到位后调用rt_device_read把数据从驱动缓冲区读出来。这比中断里拼字节再丢进队列要清晰得多。需要注意回调是在中断上下文里执行的不能在里面做耗时操作释放信号量就够了。5. 发送链路的坑DMA连续发送问题5.1 发送DMA的基本流程和潜在风险串口发送走DMA流程很好理解调用rt_device_write把数据交给驱动驱动配置DMA寄存器启动搬运搬完后DMA触发完成中断驱动回调通知应用。这个流程本身不复杂但很多人在实际使用中遇到一个非常典型的问题rt_device_write调用一次没问题连续调用两次第二次的数据要么发不出去要么直接覆盖第一次的数据。这是因为DMA发送是异步的。第一次rt_device_write返回时DMA可能还在搬运数据你紧接着把下一个数据块丢给驱动驱动看DMA正忙要么返回错误要么直接把新数据写到同一个缓冲里覆盖了还没发完的旧数据。如果你用HAL库裸机编程直接调用HAL_UART_Transmit_DMA更会直接返回HAL_BUSY。5.2 HAL库“DMA发送不能连续发送”的根源回到热词里提到的那句“stm32串口hal库使用dma发送数据不能连续发送”根源就在这里。HAL库里串口发送有一个状态机在DMA发送过程中huart-gState会被标记为HAL_UART_STATE_BUSY_TX这时候再次调用HAL_UART_Transmit_DMA函数会检查状态并返回HAL_BUSY。解决思路不是去改HAL库状态而是让业务层保证“上一次发送完成后再启动下一次发送”。裸机可以通过TX完成回调置个标志或者发送前轮询__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC)。RT-Thread下则可以用completion机制发送完成回调里唤醒然后继续发下一包。5.3 RT-Thread下用completion做发送同步RT-Thread里提供了rt_completion非常契合这个场景。每次发送前初始化completion调用rt_device_write后阻塞等待completionDMA发送完成回调里调用rt_completion_done唤醒。这样外部调用发送接口时看起来就和同步发送一样永远不会出现连续发送覆盖问题。static struct rt_completion tx_done; static void uart_tx_done(rt_device_t dev, void *buffer) { rt_completion_done(tx_done); }这里有个细节rt_device_set_tx_complete注册的回调是驱动在DMA发送完成后调用的实际就是DMA传输完成中断的下半部分。如果你的串口只用来调试输出可以用RT-Thread默认的rt_kprintf但那是轮询方式不走DMA这点要区分清楚。6. 完整实操RT-Thread串口DMA收发示例6.1 初始化与应用线程创建讲完原理直接给一份能跑的示例代码。假设USART1已配置好DMART-Thread的DMA宏已开启接下来就是纯应用层的事。初始化串口设备、创建信号量、配置回调、创建接收线程。#include rtthread.h #include rtdevice.h #define UART_DEVICE_NAME uart1 #define RX_BUF_SIZE 256 static rt_device_t serial_dev; static struct rt_semaphore rx_sem; static struct rt_completion tx_done; static rt_uint8_t rx_buf[RX_BUF_SIZE]; static rt_size_t rx_len; static rt_err_t uart_rx_ind(rt_device_t dev, rt_size_t size) { rx_len size; rt_sem_release(rx_sem); return RT_EOK; } static void uart_tx_done(rt_device_t dev, void *buffer) { rt_completion_done(tx_done); }初始化函数里重点是把设备打开、注册回调、创建接收线程。static int uart_dma_init(void) { rt_err_t ret; serial_dev rt_device_find(UART_DEVICE_NAME); if (serial_dev RT_NULL) { rt_kprintf(find %s failed\n, UART_DEVICE_NAME); return -RT_ERROR; } rt_sem_init(rx_sem, uart_rx_sem, 0, RT_IPC_FLAG_FIFO); rt_completion_init(tx_done); ret rt_device_open(serial_dev, RT_DEVICE_FLAG_RDWR); if (ret ! RT_EOK) { rt_kprintf(open %s failed\n, UART_DEVICE_NAME); return ret; } rt_device_set_rx_indicate(serial_dev, uart_rx_ind); rt_device_set_tx_complete(serial_dev, uart_tx_done); rt_thread_t t rt_thread_create(uart_reader, uart_reader_entry, RT_NULL, 2048, 15, 10); if (t ! RT_NULL) { rt_thread_startup(t); } return RT_EOK; } INIT_APP_EXPORT(uart_dma_init);这里用INIT_APP_EXPORT系统启动后会自动执行初始化。注意一点如果BSP默认配置了DMA模式rt_device_open用RT_DEVICE_FLAG_RDWR即可驱动内部会根据编译宏走DMA接收路径。如果你遇到open失败可以检查打开时传入的flag和驱动底层DMA初始化是否匹配有些BSP版本要求显式传RT_DEVICE_FLAG_DMA_RX。6.2 接收处理线程代码接收线程的逻辑非常简单等信号量读数据处理帧。static void uart_reader_entry(void *param) { rt_size_t len; while (1) { rt_sem_take(rx_sem, RT_WAITING_FOREVER); len rt_device_read(serial_dev, 0, rx_buf, rx_len); if (len 0) { /* 这里拿到一帧完整数据交给协议解析处理 */ rt_kprintf(rx %d bytes\n, len); } } }有一点要和你说明白rt_device_read读出的数据量不一定会等于刚才rx_indicate里上报的size因为在你等待信号量的过程中又可能有新数据进来。所以最好不要假设read返回的就是一帧完整数据而是在实际业务里自行定义帧头帧尾或者超时判断。如果严格按帧协议来建议把数据接到自己的帧解析缓冲区再逐步处理。6.3 发送接口代码发送接口我用completion来做同步阻塞发送业务层直接调用这个函数即可保证稳定发送。void uart_dma_send(const rt_uint8_t *data, rt_size_t len) { rt_completion_init(tx_done); if (rt_device_write(serial_dev, 0, data, len) ! len) { rt_kprintf(uart write failed\n); return; } rt_completion_wait(tx_done, RT_WAITING_FOREVER); }如果你做的是RS485通信那发送完成回调里还需要再加上拉高或拉低DE引脚的逻辑因为只有DMA真正把最后一位发出去了才能切换方向。这个点用中断发送很难做的精确用DMA完成回调就非常自然。7. 常见问题与排查技巧实录7.1 串口烧写失败多半是这三个原因串口烧写失败这个问题热词里出现了说明很多人卡在这。H7下载程序时连接不上我经验里最常见的就三件事。第一BOOT0引脚没有拉高H7默认从Flash启动你想要用串口下载必须让它从系统存储器启动也就是BOOT0电平置高下载完再拉低复位。第二CH340驱动有问题Win10/11系统有时自动装的CH340驱动不对劲设备管理器里能看到串口但实际收发不通去官方装一个驱动基本能解决。第三USB转串口模块质量问题CH340本身便宜但稳定性参差不齐换CP2102或者FT232会有改善下载波特率也可以降到115200或者更保守的57600。还有一种容易被忽略的情况板子用的是自动下载电路DTR和RTS控制BOOT0和NRST时序如果你的USB转串口模块不支持或者电平不匹配也会导致连接超时。7.2 接收丢数据或截帧遇到接收丢数据第一反应看缓冲区大小对不对。如果DMA缓冲区只有64字节而你的协议帧动不动一百多字节Circular模式下数据早就覆盖了丢数据是必然的。第二反应看中断优先级UART的优先级如果设得比RT-Thread调度临界区低太多高负载下中断可能被延迟导致IDLE中断响应不及时DMA缓冲区被后续数据覆盖。第三就是之前说的Cache一致性问题。H7打开D-Cache后CPU读DMA缓冲区时读到的可能是Cache里的旧内容。这是H7和F1/F4最大的区别。解决办法有两个要么把DMA缓冲区所在内存区域配置成non-cacheable要么在读取前调用SCB_InvalidateDCache_by_Addr强行无效化D-Cache。如果你使用的是RT-Thread BSP默认配置尤其要注意它默认有没有开D-Cache开了就按这个思路去处理。7.3 DMA发送卡死、线程阻塞如果发送函数卡在rt_completion_wait里不出来大概率是发送完成回调没触发。可能原因DMA中断没开或者驱动里对应的DMA中断服务函数没映射。H7的DMA中断函数比较多DMA1_Stream0_IRQHandler、DMA1_Stream1_IRQHandler这样的在BSP里有些需要手动映射到HAL库的HAL_DMA_IRQHandler。如果已经用了RT-Thread驱动认真确认中断向量表里DMA中断有定义。另外别把rt_completion_wait的等待时间设成RT_WAITING_FOREVER去等一个永远不会来的回调调试阶段建议加个超时比如rt_completion_wait(tx_done, rt_tick_from_millisecond(100))超时后打印错误定位问题会快很多。我见过一个案例发送回调逻辑里直接调用了rt_kprintf结果发出来的数据变成了死循环输出因为rt_kprintf本身可能是轮询发送在DMA完成中断里调用导致中断被打断各种怪问题。原则就是中断里的回调越短越好。7.4 数据乱码和时钟配置串口打印出来乱码十有八九是波特率对不上。H7的时钟树复杂度高你在CubeMX里改了PLL分频但忘了看UART的时钟源波特率偏差就会比较大。尤其有些工程改完时钟后HAL_UART_Init里的UART_CLOCK参数没有同步更新HAL库拿到的波特率分频系数计算出来是错的。排查办法很简单发一串连续递增的十六进制数比如01 02 03 04 05 06用逻辑分析仪抓UART线上的波形量一下实际波特率。逻辑分析仪能直接识别出实际波特率然后回过来改CubeMX配置重新生成代码。7.5 快速排查表问题现象主要原因处理方式串口烧写失败连接超时、无法识别COMBOOT0未拉高、CH340驱动、波特率过高BOOT0拉高再复位换官方驱动下载波特率降到115200接收丢数据偶发字节丢失、截帧缓冲区过小、回绕未处理、Cache一致性加大缓冲用CircularIDLEInvalidate DCache发送卡死线程阻塞在completionDMA中断未打开、回调未触发检查DMA中断映射给wait加超时乱码数据内容错乱时钟源配置错误、波特率偏差核对CubeMX时钟树用逻辑分析仪测实际波特率数据不出来完全无收发串口设备未open、DMA宏未开检查rtconfig.h宏确认rt_device_open返回值最后再分享一个我实际项目里的体会不要一上来就想着把所有东西都塞进DMA。串口真的是个慢外设如果你只是打个日志、调试一下普通中断方式完全够用。但一旦你确认了自己的场景确实需要DMA那就把链路从CubeMX到RT-Thread的配置一次性理清楚。H7这颗芯片性能强、资源多但坑也多尤其是Cache和DMA的一致性记好这一点能帮你省下至少一半的调试时间。建议第一次做H7串口DMA时哪怕CubeMX已经生成了DMA配置也去翻一下H7参考手册的DMA请求映射表对照自己用的串口确认DMA通道有没有选错这个习惯非常值得养成。
返回列表