news 2026/9/7 13:24:48

STM32G071 USART3不定长收发:DMA+空闲中断与环形缓冲设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32G071 USART3不定长收发:DMA+空闲中断与环形缓冲设计

简介:STM32G071CBT6微型开发板串口3不定长可变长数据报文收发程序,是一份面向嵌入式开发者、围绕STM32G071CBT6的USART3通信实践而整理的源码工程。资源以Keil MDK工程为主体,包含663个C源文件、261个头文件,以及IAR/STM32CubeMX相关配置与编译脚本,涵盖HAL库驱动、串口中断、环形缓冲与报文分包解析等关键实现,压缩包共1278个文件、约35.09MB。已有67人学习下载。资料中附带的链接指向pda2002.com与adixm.com相关参考资源,同时包含编译生成的hex、axf、map等产物与工程备份文件,便于直接烧录验证和二次移植。对正在调试STM32G0串口不定长收发、需要参考HAL库工程结构的开发者,这份资源能提供较为完整的代码框架与排错切入点。 拿到一块STM32G071CBT6核心板,我最先做的不是点灯,而是把串口3调通,做成一条能收发不定长数据报文的通道。串口不定长收发听着基础,真正下手做的时候会发现一堆细节:怎么判断一帧结束、DMA怎么配合才能不丢数据、环形缓冲区怎么设计才不容易踩坑。这篇文章把我这套基于STM32G071CBT6的USART3不定长收发程序拆开讲,从方案选型、接收机制、协议设计,到CubeMX配置和实际调试问题,一次说清楚。

这套程序解决的核心问题很简单:上位机通过串口发送长度不固定的数据报文,ST32端能准确区分每一帧的边界,完整接收、缓存、解析,并且能按协议回包。适合正在做单片机串口通信、想用DMA+空闲中断处理不定长数据的朋友参考,也适合刚接触STM32G0系列、想找一份能直接套用的串口驱动模板的人。G071CBT6虽然是个Cortex-M0+内核的小芯片,但外设搭配相当齐全,串口资源也不少,USART3只是其中一个,搞清楚一套之后,其他串口照葫芦画瓢就能迁移。

1. 项目概述与方案选型

1.1 为什么选STM32G071CBT6做串口报文处理

STM32G071CBT6是意法半导体G0系列里性价比很高的一颗料,Cortex-M0+内核,主频能到64MHz,Flash 128KB,RAM 36KB。从资源上看它不算强,但处理串口报文这种工作完全够用。更关键的是这颗芯片的USART外设做了不少增强,比如支持自动波特率检测、8倍过采样、以及红外和智能卡模式,串口通信相关的硬件功能很完善,对于做工业控制、传感器采集这类需要长时间稳定跑串口协议的场景非常合适。

我这次选它还有一个实际原因:项目里需要用串口3而不是默认的串口1。很多开发板例程都优先用USART1,但实际产品中USART1往往被占用,比如接了调试打印、Bootloader跳转或者其他外设,这时候USART3反而是更合理的通道。G071CBT6提供多个串口,可以从容切换,这也是选这颗料的一个加分项。核心板上的USART3引脚引出比较合理,不用飞线,直接就能接USB转串口模块。

从功耗角度看,G0系列本身是低功耗定位,在做电池供电的设备时,串口收发的功耗优化空间比老F1系列好很多。如果你的设备需要在低功耗模式下通过串口唤醒,G071CBT6这颗芯片的表现也会更从容。

1.2 不定长报文的三条路,我为什么走DMA+空闲中断

处理不定长报文,圈子里最常见的方案有三种。第一种是固定帧长,每帧固定N个字节,判断接收长度达到N就算一帧完整。这种最简单,但灵活性太差,工业协议里帧长经常变化,不适合做通用收发。第二种是帧头帧尾匹配,比如规定帧头0xAA 0x55,帧尾0x0D 0x0A,接收时逐字节搜索帧头帧尾。这种方案通用性强,但逐字节中断接收在高波特率下占CPU资源严重,而且如果数据内容里恰好出现帧头帧尾字节,还得做转义处理,协议复杂度会上升。第三种就是我现在用的方案:串口空闲中断(IDLE)配合DMA接收,空闲中断硬件会自动检测总线上的空闲状态,一帧数据传完,总线空闲时间够了,硬件就触发中断,CPU在中断里把DMA收到的数据取走。

三种方案对比下来,固定帧长适合数据格式单一的内部通信;帧头帧尾适合协议本来就定义好帧边界的场景;而IDLE+DMA是芯片资源允许时的最优解,硬件帮我们完成了断帧核心工作,CPU负担极轻,代码也不复杂。我这次选择IDLE+DMA,还有一个原因是STM32G071的串口接收FIFO和DMA请求映射都做得比较完善,用起来很顺手,实测在115200波特率下连续收发几千帧都没有丢数据。

当然IDLE+DMA不是没有缺点。如果发送端在帧内部字节之间存在过大间隔,比如用某个上位机软件逐字节延时发送,硬件会把这帧数据误判成多帧。这个问题我后面在调试部分会详细讲,解决思路也有,比如搭配帧头帧尾双保险。

2. 接收机制核心拆解

2.1 空闲中断IDLE到底做了什么

很多刚接触的朋友会把IDLE中断理解为“没数据就触发一次中断”,这个说法不准确。IDLE中断是串口接收线在一个字节传输完成后继续保持空闲电平超过一个完整字节时间时触发的,换句话说,它标志的是“一个连续传输过程结束了”,这正是我们判断一帧数据结束的好时机。

举个例子,上位机通过串口发送一帧报文,假设内容是14个字节,这14个字节如果通过DMA连续发送,字节与字节之间几乎没有缝隙,那么USART接收完最后一个字节后,接收线进入空闲状态,大约一个字节时间后IDLE标志置位,触发中断。在中断里,我只需要从DMA接收缓冲区里读出“本次一共收到了多少个字节”,这就是当前帧的长度。

在STM32G071上,用HAL库实现并不复杂,核心是把接收函数切换成HAL_UARTEx_ReceiveToIdle_DMA,这个函数在G0系列上被支持得很好。它内部把IDLE中断和DMA接收结合起来,收到一帧后回调HAL_UARTEx_RxEventCallback,回调参数Size就是本次接收到的字节数。我在这套程序里就是用这个回调作为帧接收完成的入口。

如果你不愿意用HAL库,想直接操作寄存器,也是可行的。在USART3的IRQHandler里读ISR寄存器,判断IDLE标志位,然后清标志,再用DMA当前计数值反算出接收长度。两种方式我都试过,HAL库方式代码更简洁,寄存器方式更适合理解底层原理。我在这篇文章里以HAL库为主,但在关键位置会补一句寄存器层面的原理,方便有需要的朋友深入。

2.2 环形缓冲区:让中断和主循环互不干扰

接收中断得到一帧数据后,如果直接在中断服务函数里做协议解析、CRC校验、逻辑处理,会带来两个问题。第一,中断服务时间太长,可能影响下一次串口接收,造成丢字节;第二,如果解析逻辑比较重,比如要查表、要处理业务状态,把CPU长时间占用在中断里非常危险。正确做法是中断只负责把原始数据丢进缓冲区,主循环再从缓冲区取出来做协议解析。

这里我就用了一个经典的环形缓冲队列。缓冲区分成读写两个指针,中断写入时只动写指针,主循环读取时只动读指针,只要保证单生产者单消费者模型,读写指针本身不会冲突。缓冲区大小设为256字节,如果单帧数据超过这个长度,就需要做分帧或者加大缓冲区,这个要根据实际协议帧的最大长度来定,不能盲目塞一个很大的数组浪费RAM。

环形队列的关键代码并不复杂,但有几个细节要特别注意。写指针和读指针的更新要放在临界区保护下,最稳妥的办法是先保存全局中断状态,再操作指针,操作完恢复。另外同时记录当前队列里的数据量,取帧时先判断数据量够不够一帧,避免读出一半数据造成解析错乱。我这套程序里,环形队列操作和协议解析是拆开的,队列只负责存取字节,不关心帧格式,后续想换协议只要动解析层就行。

typedef struct { uint8_t *buf; uint16_t size; volatile uint16_t head; volatile uint16_t tail; } ring_buf_t; int16_t ring_write(ring_buf_t *rb, const uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { rb->buf[rb->head] = data[i]; rb->head = (rb->head + 1) % rb->size; if (rb->head == rb->tail) { return -1; } } return 0; } int16_t ring_read(ring_buf_t *rb, uint8_t *data, uint16_t len) { if (ring_used(rb) < len) { return -1; } for (uint16_t i = 0; i < len; i++) { data[i] = rb->buf[rb->tail]; rb->tail = (rb->tail + 1) % rb->size; } return len; }

2.3 为什么DMA在这里是刚需

串口接收有两种模式,一种是逐字节中断,收到一个字节进一次中断,CPU把数据搬到内存;另一种就是DMA方式,串口硬件收到数据后由DMA控制器自动搬运到内存缓冲区,收满指定长度或者发生空闲时才通知CPU。前者代码简单,但每收一个字节就要进一次中断,115200波特率下大约86微秒就有一个字节,意味着CPU有相当比例的时间在响应串口中断,会挤压主循环的运行时间。

DMA方式下,CPU全程不参与字节搬运,收到一帧数据后CPU只需要知道“这帧有多长”,然后处理整块数据,效率高一个量级。用DMA接收不定长数据,缓冲区长度一般设为单帧可能的最大长度,DMA计数器会随着接收自动递减,触发空闲中断后,用缓冲区总长度减去DMA剩余计数器值,就是实际收到的字节数。

这里有个容易踩的坑:DMA接收缓冲区大小和环形队列大小要匹配。如果DMA缓冲区设成256字节,环形队列也建议至少256字节,否则一帧数据到达时DMA缓冲区装得下,但环形队列放不下,就会出现数据被丢弃的情况。我在第一版程序里就吃过这个亏,DMA缓冲区256,环形队列只开了128,上位机一次发长帧时逻辑就出错了。

3. 报文协议设计与代码框架

3.1 帧格式设计:只看字节怎么识别一帧

IDLE中断帮忙解决了物理层面的断帧问题,但实际应用中,只靠IDLE还是不够稳。比如前面提到的,如果上位机发送时字节间有较大间隔,IDLE会把一帧拆成多帧,这时就需要协议层有二次校验能力。所以这套程序在IDLE断帧的基础上,仍然在报文格式里保留了帧头帧尾和长度字段,形成双保险。

我定义了一套简单实用的帧格式:

帧头0xAA 帧头0x55 LEN CMD DATA[0..n] CRC8

LEN字段表示从CMD开始到CRC之前的字节数,CRC8是对LEN、CMD和DATA做异或和计算的结果。接收端拿到一帧数据后,先检查帧头是否匹配,再看LEN和实际长度是否一致,最后校验CRC,三层检查通过后才认为这是一帧有效数据,进入业务处理。

这种帧格式在电力、工业通信里很常见,简单直接,适合大多数场景。如果你的项目对安全要求更高,可以把CRC8换成CRC16或CRC32,或者干脆用芯片自带的硬件CRC外设来计算。我用CRC8主要是考虑报文长度都不大,异或和的计算量极小,解析起来也快。

应答机制方面,设备收到一帧合法指令后,处理完再回一帧ACK报文,ACK里带上原始指令的CMD和一个小序号字段,上位机靠这个判断指令是否执行成功。如果上位机发出指令后一段时间内没收到ACK,就重发。这套机制是很多串口协议的基本形态,实现成本很低,但对排查丢帧非常有帮助。

3.2 程序结构与关键代码实现

整个程序结构上分成三层。第一层是uart_driver,负责串口和DMA的初始化,以及帧接收回调,这是和硬件强相关的一层;第二层是ring_buffer,提供环形队列读写接口,这层不关心数据内容,只做存取;第三层是protocol,负责从环形队列里取出原始字节流,校验帧头、长度、CRC,然后分发到业务处理函数。

串口接收的核心代码不长,主要是配置好DMA和IDLE中断,然后在回调里把数据存入环形队列,并立刻重新开启下一次接收。这里一定要记住:回调函数返回之前必须重新调用HAL_UARTEx_ReceiveToIdle_DMA,否则后续数据不会再触发接收,程序会“假死”在等待状态。我之前漏过一次,体现在上位机那边就是只能收一次数据,之后怎么发都不回包。

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART3) { ring_write(&rx_ring, rx_dma_buf, Size); HAL_UARTEx_ReceiveToIdle_DMA(&huart3, rx_dma_buf, RX_DMA_BUF_SIZE); __HAL_UART_CLEAR_IDLEFLAG(&huart3); } }

主循环里的解析逻辑则是从环形队列不断取字节,用状态机识别帧头,然后积累数据到本地缓冲区,长度够了做校验。这个状态机写法看起来简单,但比一次取一帧判断要稳,因为它能应对环形队列里残留半帧数据的情况。我把协议解析做成独立的process_uart_frame函数,业务逻辑只在这个函数里处理,以后要加新指令,改这一个位置就行。

void protocol_parse(ring_buf_t *rb) { while (ring_used(rb) > 0) { uint8_t b; ring_read(rb, &b, 1); switch (parse_state) { case WAIT_AA: if (b == 0xAA) parse_state = WAIT_55; break; case WAIT_55: if (b == 0x55) parse_state = WAIT_LEN; else parse_state = WAIT_AA; break; case WAIT_LEN: frame_len = b; frame_index = 0; parse_state = WAIT_DATA; break; case WAIT_DATA: frame_buf[frame_index++] = b; if (frame_index >= frame_len) { parse_state = WAIT_AA; protocol_handle_frame(frame_buf, frame_len); } break; } } }

4. CubeMX配置与工程落地

4.1 时钟和串口引脚配置的注意事项

STM32CubeMX配置这种工程基本是固定套路,但有几个地方值得单独说。时钟树方面,我建议把系统时钟配到64MHz,也就是G071CBT6的最高主频,同时注意USART3挂载的时钟树分支,在CubeMX里它会自动处理,但保存生成工程后最好确认一下USART3的时钟源是不是我们预期的频率,这直接影响波特率误差。波特率我选的是115200,这也是最常用的调试波特率。

引脚方面,USART3的TX和RX复用功能需要手动指定,我用的是PC4和PC5这对引脚,初始化时把GPIO模式设置成AF_PP即复用推挽输出,速度选High。这里有个细节:RX引脚也需要配置成复用模式,而不是输入模式,很多新手在这里会卡住,CubeMX里USART3选择异步模式后会自动配置好引脚,但如果你手改过引脚,一定要重新检查GPIO的复用AF号是否正确。

另外不要忘记打开USART3和DMA对应的中断,以及设置好中断优先级。串口空闲中断和DMA中断优先级不需要太高,放在比系统滴答低一级的位置即可,但一定要保证它们能被CPU及时响应,否则高负载下可能出现帧接收超时的问题。

4.2 DMA通道选择和中断分组

STM32G071的DMA请求映射,USART3_TX和USART3_RX分别对应DMA1的某两个通道,具体通道号打开CubeMX配置时界面上会直接列出来。有一点必须注意:STM32G0系列和F1系列不太一样,外设的DMA请求映射方式有差别,老项目代码里的DMA配置不能直接套用,需要对照数据手册里的DMA request table确认。这也是很多老工程师第一次用G0系列时容易翻车的地方。

DMA配置项里,数据方向选择PeripheralToMemory,外设地址不需要手动填,HAL库会根据串口句柄自动处理,内存地址就是我们的接收缓冲区。传输模式选择Circular循环模式,这样一轮DMA接收完成后,如果IDLE中断没有及时触发,也不会停在那里,数据还能继续循环接收。虽然我们靠IDLE断帧,但循环模式是一个保护垫,能防止硬件DMA停在缓冲区末尾造成后续数据丢失。

中断分组上,串口全局中断和DMA中断都要在NVIC设置里勾选使能,优先级可以都给为2,实测没有问题。如果系统中还有定时器、外部中断等其他任务,建议串口中断优先级不要设成最高,避免长时间占用中断环境,但也不要太低,否则接收时序会受影响。这个数值没有绝对标准,要根据项目实际情况试出来。

4.3 工程落地后发现的几处细节坑

第一处是清IDLE标志的时序。使用HAL_UARTEx_ReceiveToIdle_DMA时,在RxEventCallback里最好再调用一次__HAL_UART_CLEAR_IDLEFLAG,虽然某些版本HAL库内部处理过,但我遇到过一次回调连续触发两次的异常,手动清一次标志之后就稳定了。

第二处是缓冲区大小和DMA传输长度的匹配。RX_DMA_BUF_SIZE我定义为256,这是协议允许的最大单帧长度。如果上位机发来超过256字节的帧,DMA循环模式会覆盖前面未取走的数据,数据自然就是错的。所以要么把DMA缓冲区定义得比协议最大帧还大,要么在协议层对超长帧做丢弃处理。

第三处是低功耗和串口的联动。G071主打低功耗,但如果你开启了低功耗模式,串口接收前要注意配置唤醒源。我在调低功耗模式时发现,停止模式下串口中断不能直接唤醒,需要在RTC或者外部中断先唤醒,再继续处理串口数据。这个坑不涉及具体代码,但要是做到了低功耗部分,几乎必踩。

5. 调试实录与常见问题

5.1 用串口调试助手造帧测试

程序烧录之后,调试阶段我主要用串口调试助手来模拟上位机。先把USB转串口模块插到电脑上,确认驱动正常,这一步我在Windows上遇到过CH340驱动没安装好的情况,设备管理器里能识别到COM口但打开就报错,重新安装驱动后解决。打开串口助手配置好波特率115200、8位数据、1位停止位、无校验,就能开始发数据了。

测试的第一步是验证下位机回包。用串口助手按十六进制方式发送一帧报文,比如AA 55 05 01 11 22 33 C9,其中AA 55是帧头,05是长度,01是命令,11 22 33是数据,C9是CRC异或和。正常的话,串口助手数据接收区里应该在几毫秒内出现ACK回包,这能确认中断接收、DMA传输、协议解析整条链路是通的。

测试不定长功能时,可以准备几种不同长度的帧,从3字节到100字节分别发几组,每一组都确认回包正确。串口调试助手有定时发送功能,可以设置每隔50毫秒或100毫秒自动发送,连续跑几分钟看统计有没有丢包。实测115200波特率下,50毫秒发一次100字节长的报文,连续跑了10分钟,丢包率为零。这个压力测试结果说明这套方案在常规应用场景下稳定性是被验证过的。

5.2 调试中踩过坑的几个现象

第一个坑是只能收到第一帧后续全部无响应。排查后确认是回调函数里没有重新调用HAL_UARTEx_ReceiveToIdle_DMA。这个函数在触发一次RxEvent回调后,DMA接收就停止了,如果不重新启动,串口不会再进入接收状态。所以每次回调里重新启动接收是固定的,不能漏。

第二个坑是收到的数据总是多出几个字节或者断成两截。这个典型的IDLE断帧过度问题,经常出现在用串口助手的手动发送功能时,因为手动发送通常是软件循环写入,字节间可能被系统调度打断,产生超过一个字节的空隙,硬件就认为帧结束了。解决思路是上位机用DMA或整包发送方式,如果协议本身无法保证连续发送,就要在协议层增加帧头帧尾检查,把IDLE判断作为辅助手段。

第三个坑是长时间运行后偶发一帧数据校验错误。排查后发现环形队列的缓冲区在极端情况下存在被覆盖的风险,比如上位机连续快速发帧,主循环还没来得及取走队列数据,下一帧又写入进来了。解决办法是把环形队列容量扩大一倍,同时在协议解析层加入长度字段校验,校验不过的直接丢弃,不影响后续帧。这套组合做下来,长时间运行再没有出现校验错误。

5.3 常见问题速查表

现象可能原因解决方案
串口助手打不开COM口CH340驱动未装或端口被占用重装驱动,关闭占用端口的其他软件
一帧都不收,无任何回包回调里没重启ReceiveToIdle回调末尾重新调用HAL_UARTEx_ReceiveToIdle_DMA
只能收固定长度,变长就错DMA缓冲区被写满覆盖增大DMA接收缓冲区到协议最大帧以上
数据断成两截IDLE断帧过于灵敏上位机整包发送,或在协议层增加帧头帧尾校验
偶发校验错误环形队列覆盖扩大环形队列容量,丢弃异常帧后继续工作
接线正确但收不到数据TX/RX接反或GND没接交叉连接TTL电平,确保共地
波特率不准导致乱码时钟树配置或外置晶振选择有误检查CubeMX时钟树和串口时钟源,实测波特率误差

调试时还有一个实用技巧:串口调试助手里打开时间戳显示,每个回包都带时间,这样能直接看到指令发出到回包的时间差。正常情况应该在几毫秒内,如果超过10毫秒,说明主循环任务比较多,或者协议解析效率有优化空间。我调程序时习惯把这个时间差作为性能指标记录,方便后续优化。

这套代码我后来也移植到了同系列的STM32G030和G070上,只改了一句时钟配置和引脚映射,串口部分完全复用,说明G0系列串口外设的通用性很强。如果有朋友想把USART3换成其他串口,只需要复制一份驱动,修改外设基地址、IRQHandler名字和DMA通道请求即可,其余逻辑不用变。这套驱动框架现在还在项目里跑着,稳定性经过了一轮又一轮的考验。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 13:24:45

Python调用Simulink模型DLL:从配置到ctypes绑定的完整指南

简介&#xff1a;python_SimulinkDLL是一套面向Simulink开发者和自动化测试工程师的示例工程&#xff0c;展示如何将Simulink模型编译为DLL&#xff0c;再通过Python调用并复用其算法。资源共138个文件&#xff0c;大小仅876KB&#xff0c;虽然精简但覆盖了核心链路&#xff1a…

作者头像 李华
网站建设 2026/9/7 13:24:39

音乐现场技术解析:从音频处理到视觉呈现的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 13:23:58

Xilinx MMCM/PLL动态重配置实战:Verilog实现与调试指南

简介&#xff1a;针对FPGA设计中PLL/MMCM动态频率配置的实际需求&#xff0c;这套资料提供了一个基于Verilog的完整Vivado仿真工程。工程围绕pll_cfg_project_1展开&#xff0c;演示了如何用Verilog模块实时计算PLL_M、PLL_D、PLL_N等关键参数&#xff0c;进而按需动态改变输出…

作者头像 李华
网站建设 2026/9/7 13:23:21

MATLAB quadprog二次规划实战:从标准形式到投资组合优化

简介&#xff1a;面向MATLAB优化学习者和工程应用人员的二次规划&#xff08;QP&#xff09;求解源码包&#xff0c;适合希望从代码层面理解约束优化算法实现的读者。资源围绕二次规划的标准形式展开&#xff0c;涉及Hessian矩阵、梯度向量及不等式/等式约束的设置&#xff0c;…

作者头像 李华
网站建设 2026/9/7 13:22:43

嵌入式简历加分项:电机控制5个实战项目全解析

为什么我把“电机控制”当作简历里最值钱的项目方向做嵌入式这行有几年了&#xff0c;面试过的候选人不少&#xff0c;也被面试过很多轮。电机控制这个方向&#xff0c;说实话是嵌入式里少有的“既看得见摸得着、又能把软硬件全链路打通”的领域。你做一个温湿度传感器项目&…

作者头像 李华
网站建设 2026/9/7 13:22:35

2026年9月城市分站GEO公司哪家好?地域站群五大实力横评

一、本地流量争夺战打响&#xff0c;但90%的城市分站是"无效摆设" 2026年&#xff0c;生成式引擎优化&#xff08;GEO&#xff09;的竞争正在从全国通用词转向城市细分赛道。据中国互联网络信息中心&#xff08;CNNIC&#xff09;第54次《中国互联网络发展状况统计报…

作者头像 李华