做嵌入式串口通信这些年,我踩过最多的坑就是在不定长数据的接收上:要么用逐字节中断,把CPU吃干榨净;要么用固定长度接收,十次里有八次对不上帧。后来转到GD32H7xx之后,我把串口收发整体切到了DMA通道,再用串口本身的IDLE中断作为帧结束判定,算是彻底解决了这个老大难。这套玩法并不复杂:DMA负责把数据从外设寄存器搬到内存,IDLE中断负责在数据流空闲时告诉你“这一帧已经断流了”,两者配合,无论对端一次发几个字节还是几百字节,都能干净利落地收到完整帧。这篇文章把我自己从选型、初始化、中断处理到联调踩坑的完整过程整理出来,适合手里有GD32H7xx开发板、正在做UART通信上位机互联、或者想彻底告别串口中断风暴的工程师参考。
1. 方案选型:为什么偏偏是DMA加IDLE中断这套组合
1.1 传统串口接收的痛点
先说说我以前用传统中断接收的体验。串口每来一个字节,硬件就产生一次RXNE中断,CPU进中断、读数据、存数组、退出中断。115200波特率看起来不高,换算下来大约每毫秒一个字节,单路串口的中断频率不算吓人。但工程里往往不止一路串口,高速串口还有460800甚至1M波特率,这个时候中断频率就上来了,CPU上下文切换成本、cache失效成本都会暴露出来。更麻烦的是,如果接收数据期间正好有其他高优先级中断在跑,串口数据寄存器里的字节没来得及取走,下一个字节一到,硬件就会报溢出错误,直接丢字节。
除了中断风暴,还有一个“帧边界”的问题。市面上大量的串口协议都是不定长的,比如AT指令、Modbus RTU、自定义私有协议。传统做法往往是给缓冲区塞满一帧之前完全不知道对端什么时候停止发送,只能靠“固定长度”去猜,或者用昂贵的定时器做超时判断。固定长度方案的缺点很直观:对端这次发了5个字节,下回发了50个字节,你的接收逻辑根本没法复用。而定时器超时方案倒是能应对不定长,但它要求每个字节进中断都重置定时器,本质上还是逐字节中断的老路。
所以核心矛盾是:既不想让CPU频繁进中断,又想知道一帧数据在哪里结束。DMA加IDLE中断就是冲着这个矛盾去的。
1.2 IDLE中断的原理:一个“字节时间”的空闲判断
GD32H7xx串口的IDLE中断,全称是线路空闲中断。它的触发条件是串口接收线上检测到连续的高电平,而且持续的时间超过一个字符的传输时间。换句话说,接收引脚已经“闲”了至少一个字节的时间,硬件就认为这一波数据传输结束了。
很多人第一次用IDLE中断会误以为它是“串口收到完整一帧”的标准信号,严格来说这个理解不完全准确。硬件并不懂你的协议,它只知道线路空闲了。但这对接收不定长数据来说已经足够了:对端连续发完一帧数据后,发送驱动会停在空闲电平,管脚上很快就会满足“持续一个字符时间的空闲”条件,IDLE标志置位,进入中断。在中断里我们配合DMA的当前计数,就能算出这一帧到底有多少个字节。
在GD32H7xx的寄存器里,这个标志是USART_STAT0寄存器中的IDLEF位,对应的中断使能位在USART_CTL0寄存器里。代码上通常通过类似usart_interrupt_enable(USART0, USART_INT_IDLE)这样的接口打开。不同固件库版本的函数名可能会有差异,但硬件机制是一样的:IDLE标志只在空闲时置位,它是边沿性质的事件,不是电平状态,所以处理完一帧后要记得把标志清掉,否则下一帧到来时可能触发误判断。
1.3 为什么不用“DMA完成中断”来定帧
既然已经用了DMA,有人会问:干脆让DMA搬满缓冲区后产生完成中断,不也能知道接收了多少数据吗?这里有个关键问题:DMA完成中断代表的是“搬运了预设数量的字节”,而不是“对端发完了一帧”。如果你把DMA传输数量配置成256,那么DMA只有在收到256字节后才会触发完成中断。如果对端只发了10个字节,DMA计数器压根到不了0,完成中断永远不会来。真正的帧结束信号,只能从串口外设本身去判断。
IDLE中断和DMA完成中断的关系,可以这么理解:DMA是“搬运工”,IDLE是“监工”。搬运工只管埋头搬,搬多少算多少;监工看到工地上一段时间没人往上送料,就喊一嗓子“停了,该结算了”。结算的结果就是:用DMA剩余的传输数量反推出已经搬了多少数据。这种协同设计的好处是接收过程完全由硬件接管,CPU只在IDLE中断里做一次长度计算和缓冲重装,其余时间都在干正事。
2. 整体设计:串口、DMA与内存三者怎么搭
2.1 先搞清楚GD32H7xx的串口和DMA资源
GD32H7xx系列串口资源非常丰富,常规型号有多个USART和UART。但并不是所有串口都支持DMA,或者说不是所有串口的DMA请求都映射到同一个DMA通道上。拿到板子的第一步,一定要打开对应的芯片数据手册,找到DMA request mapping那张表,确认你自己的串口收发各对应哪个DMA控制器、哪个通道。这一步偷懒,后面大概率要返工。
以USART0为例,它的发送和接收DMA请求通常分别对应一个DMA通道,比如接收走DMA0的CH4,发送走DMA0的CH5,具体以手册为准。因为一个DMA通道在同一时刻只能服务一条数据流,所以哪怕同一个串口的发送和接收,也需要两个不同的通道。这样设计还能让收发独立配置传输方向和传输数量,互不影响。
在设计整体方案时,我习惯给接收通道配置高优先级,发送通道中优先级。原因是接收侧一旦DMA没有及时响应,串口数据寄存器被新数据覆盖就会产生溢出错误,这是不可逆的损失;发送侧晚几个时钟响应,影响并不大。
2.2 接收链路设计:DMA正常模式加IDLE重装
接收DMA有两种工作模式可以选:正常模式(normal)和循环模式(circular)。如果数据帧长度不会超过你配置的缓冲区大小,我建议先上正常模式,理解成本最低。
正常模式下的接收流程是这样的:DMA配置成外设到内存,传输数量设为缓冲区的容量,比如256。开启之后,串口每收到一个字节,DMA自动把它搬到缓冲区,计数器减1。此时如果对端连续发送,DMA会一直搬;当对端停止发送,串口空闲超过一个字符时间,IDLE中断触发。我们在中断里读取DMA剩余的传输数量,用256减去这个数,就是这一帧实际收到的字节数。处理完数据后,把DMA通道关闭,重新写入256,再重新使能,等待下一帧。
这套逻辑非常直白,唯一的注意点是“重装DMA”的操作顺序:必须先关闭通道,再配置传输数量,最后重新使能。如果顺序反了,写入的计数器值不会被硬件正确装载,下一帧的长度计算就会出错。
2.3 发送链路设计:DMA单次模式加完成通知
发送侧的设计比接收简单,因为发送的边界是已知的——你要发多少字节,自己心里有数。DMA配置成内存到外设,传输数量就是待发送长度,发送完成后DMA通道自动停止。
但真正的坑在“发送完成”的判定。DMA的完成中断代表它把数据从内存搬到了串口的数据寄存器,可串口数据寄存器到移位寄存器再到引脚的发送,还有一个过程。如果只等DMA完成就立刻改变串口状态,最后一个字节很可能会在移位寄存器里被截断。正确的做法是在DMA完成中断里,再等待串口的TDC标志(Transmission Data Complete),这个标志表示移位寄存器已经把最后一个字节完整发完了。
发送侧还有一个常见的工程问题:发送接口不能重入。上一帧还没发完,上层又调用发送接口,如果直接把缓冲区地址和长度塞给DMA,会把上一次发送的数据冲乱。所以我一般维护一个tx_busy标志,发送完成中断里才会清除它,发送接口每次进入先判断这个标志,忙则直接返回失败。
3. 核心代码实现:接收不定长数据
3.1 初始化顺序:DMA先就位,串口后开闸
接收初始化这一步,很多新手容易把顺序搞反,最常见的问题就是“第一个字节丢了”或者“一上来就收到一堆乱码”。我的做法是先把DMA通道和缓冲区准备好,再使能串口的DMA请求,最后才把串口接收打开。逻辑很简单:串口一旦开始接收,数据随时会到,DMA必须在此之前就已经在值守状态。
以下代码基于GD32H7xx官方固件库的命名习惯编写,不同SDK版本字段名可能略有差异,但核心顺序和配置思路是通用的。
#define USART0_DMA_RX_CH DMA_CH4 #define USART0_DMA_RX_CTL DMA0 #define RX_BUF_SIZE 256U uint8_t g_rx_dma_buf[RX_BUF_SIZE]; volatile uint8_t g_rx_complete = 0; volatile uint32_t g_rx_len = 0; void uart0_rx_dma_idle_init(void) { /* 时钟使能 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART0); rcu_periph_clock_enable(RCU_DMA0); /* GPIO复用:USART0_TX/PA9, USART0_RX/PA10,复用功能编号以手册为准 */ gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_9); gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_10); gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_PULLUP, GPIO_PIN_9 | GPIO_PIN_10); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9); /* 串口基础参数:115200/8-N-1 */ usart_deinit(USART0); usart_baudrate_set(USART0, 115200U); usart_word_length_set(USART0, USART_WL_8BIT); usart_stop_bit_set(USART0, USART_STB_1BIT); usart_parity_config(USART0, USART_PM_NONE); usart_hardware_flow_rts_config(USART0, USART_RTS_DISABLE); usart_hardware_flow_cts_config(USART0, USART_CTS_DISABLE); /* 配置DMA接收:外设到内存,正常模式 */ dma_deinit(USART0_DMA_RX_CTL, USART0_DMA_RX_CH); dma_single_data_parameter_struct dma_init_struct; dma_init_struct.direction = DMA_PERIPHERAL_TO_MEMORY; dma_init_struct.periph_addr = (uint32_t)(&USART_DATA(USART0)); dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.memory_addr = (uint32_t)g_rx_dma_buf; dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.periph_memory_width = DMA_PERIPHERAL_WIDTH_8BIT; dma_init_struct.number = RX_BUF_SIZE; dma_init_struct.circular_mode = DMA_CIRCULAR_MODE_DISABLE; dma_init_struct.priority = DMA_PRIORITY_HIGH; dma_init(USART0_DMA_RX_CTL, USART0_DMA_RX_CH, &dma_init_struct); /* 先使能DMA通道 */ dma_channel_enable(USART0_DMA_RX_CTL, USART0_DMA_RX_CH); /* 打开串口的DMA接收请求 */ usart_dma_receive_config(USART0, USART_DMA_RECEIVE_ENABLE); /* 打开串口接收和发送 */ usart_receive_config(USART0, USART_RECEIVE_ENABLE); usart_transmit_config(USART0, USART_TRANSMIT_ENABLE); usart_enable(USART0); /* 使能IDLE中断与NVIC */ usart_interrupt_enable(USART0, USART_INT_IDLE); nvic_irq_enable(USART0_IRQn, 0, 0); }这里要注意,我特意把usart_enable放在了DMA通道使能之后。先开DMA,再开串口,能确保第一个字节到达时,DMA链路已经畅通。关于GPIO的复用功能编号,GD32H7xx不同型号、不同封装可能不一样,务必查手册确认,别照抄我一刀切的AF7。
3.2 IDLE中断处理与DMA计数计算
初始化完成之后,核心逻辑就集中在USART0的中断服务函数里。IDLE标志置位后,要做四件事:清标志、读DMA剩余计数、算长度、重装DMA。清标志这个动作容易踩坑,GD32的手册里通常会写“通过软件写序列清除”,实际操作中就是先读一次状态寄存器,再读一次数据寄存器。
void USART0_IRQHandler(void) { if (RESET != usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE)) { /* 清除IDLE标志:先读STAT,再读DATA */ (void)USART_STAT0(USART0); (void)USART_DATA(USART0); /* 先关闭DMA通道,避免搬运过程被打断 */ dma_channel_disable(USART0_DMA_RX_CTL, USART0_DMA_RX_CH); /* 读取DMA剩余计数并计算本帧长度 */ uint32_t remain = dma_transfer_number_get(USART0_DMA_RX_CTL, USART0_DMA_RX_CH); uint32_t len = RX_BUF_SIZE - remain; if (len > 0) { g_rx_len = len; g_rx_complete = 1; } /* 重装DMA传输数量,再重新使能 */ dma_transfer_number_config(USART0_DMA_RX_CTL, USART0_DMA_RX_CH, RX_BUF_SIZE); dma_channel_enable(USART0_DMA_RX_CTL, USART0_DMA_RX_CH); } }有人会问,为什么要先关闭DMA再读计数?因为DMA计数寄存器在传输过程中会持续变化,如果IDLE中断里不先停掉DMA,读到的剩余计数可能刚好赶上下一字节到来而变化,导致长度算错。IDLE本身代表线路空闲,按理说不会有新字节,但稳妥起见,先暂停通道再读取状态,逻辑上更干净。
主循环里处理数据的写法也很简单:检测到g_rx_complete置位后,就把g_rx_dma_buf里前g_rx_len个字节取出,处理完再清标志。因为正常模式下,新的数据是从缓冲区头部开始覆盖的,应用层必须保证在一帧数据到来之前完成上一帧的拷贝和处理,否则旧数据被冲掉只能自认倒霉。
3.3 循环模式下怎么算区间偏移
如果数据帧较长,或者对端发送频繁,正常模式每次重装DMA会带来短暂的空窗期,这个窗口内到达的字节会丢失。这时可以考虑循环模式。循环模式下,DMA永远在缓冲区内周而复始地搬运,计数器从配置值递减到0后自动重新装载,不需要人工干预。
对应地,IDLE中断里计算长度就不能简单用“缓冲区大小减剩余计数”了。因为DMA可能已经循环了一整圈,需要记录上一次IDLE时的搬运位置。假设缓冲区大小是RX_BUF_SIZE,每次IDLE中断时读取当前计数位置:
uint32_t cur_pos = RX_BUF_SIZE - dma_transfer_number_get(DMA0, DMA_CH4); uint32_t len = (cur_pos + RX_BUF_SIZE - g_rx_dma_last_pos) % RX_BUF_SIZE; g_rx_dma_last_pos = cur_pos;这就是一个典型的环形缓冲区偏移算法。cur_pos表示DMA刚刚写到的位置,g_rx_dma_last_pos是上一帧结束时记录的位置,两者做差再取模,就是这一帧的实际字节数。但这种做法有个隐含风险:如果应用层处理不及时,这一帧的数据会追着上一帧的尾部写入缓冲区,把还没读走的数据覆盖掉。所以循环模式虽然省去了重装动作,却对应用层处理速度提出了更高要求,而且数据可能跨越缓冲区尾部,拷贝时要分两段处理。实际项目里我更推荐在比较有把握处理帧间隔的前提下,先用正常模式跑通,再按需升级到循环模式。
4. 核心代码实现:DMA发送与完成通知
4.1 发送初始化与发送接口封装
发送DMA的初始化与接收类似,只是方向改成了内存到外设,且传输数量在每次调用时动态写入。初始化阶段可以先将传输数量配置为0,等发送函数里去设置。关键是使能DMA通道的传输完成中断,这样每发完一帧,DMA就能通知CPU去判断是否真的发完了。
#define USART0_DMA_TX_CH DMA_CH5 #define USART0_DMA_TX_CTL DMA0 #define TX_BUF_SIZE 256U uint8_t g_tx_dma_buf[TX_BUF_SIZE]; volatile uint8_t g_tx_busy = 0; void uart0_dma_tx_init(void) { dma_deinit(USART0_DMA_TX_CTL, USART0_DMA_TX_CH); dma_single_data_parameter_struct dma_init_struct; dma_init_struct.direction = DMA_MEMORY_TO_PERIPHERAL; dma_init_struct.periph_addr = (uint32_t)(&USART_DATA(USART0)); dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.memory_addr = (uint32_t)g_tx_dma_buf; dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.periph_memory_width = DMA_PERIPHERAL_WIDTH_8BIT; dma_init_struct.number = 0; dma_init_struct.circular_mode = DMA_CIRCULAR_MODE_DISABLE; dma_init_struct.priority = DMA_PRIORITY_MEDIUM; dma_init(USART0_DMA_TX_CTL, USART0_DMA_TX_CH, &dma_init_struct); /* 使能DMA传输完成中断 */ dma_interrupt_enable(USART0_DMA_TX_CTL, USART0_DMA_TX_CH, DMA_INT_FTF); nvic_irq_enable(DMA0_Channel5_IRQn, 1, 0); /* 打开串口的DMA发送请求 */ usart_dma_transmit_config(USART0, USART_DMA_TRANSMIT_ENABLE); } uint8_t uart0_dma_send(const uint8_t *data, uint32_t len) { if (g_tx_busy || len == 0 || len > TX_BUF_SIZE) { return 0; } /* 拷贝到独立发送缓冲区,防止调用者后续改写 */ memcpy(g_tx_dma_buf, data, len); g_tx_busy = 1; /* 重装DMA传输数量并启动 */ dma_channel_disable(USART0_DMA_TX_CTL, USART0_DMA_TX_CH); dma_transfer_number_config(USART0_DMA_TX_CTL, USART0_DMA_TX_CH, len); dma_channel_enable(USART0_DMA_TX_CTL, USART0_DMA_TX_CH); return 1; }发送接口这里我做了两层保护:第一,g_tx_busy标志防止重入;第二,把数据拷贝到独立的g_tx_dma_buf里再交给DMA。为什么不直接用data指针?因为上层传入的缓冲区可能是栈上的临时数组,也可能在异步任务里被改写,DMA搬运是异步的,等它搬完时,原缓冲区可能早就变了。拷贝一份到专属缓冲区,相当于给DMA吃了一颗定心丸。
4.2 DMA完成标志和USART TDC,到底该等谁
DMA发送完成中断触发时,只能确定一件事:DMA已经把指定数量的字节从内存写到了串口的数据寄存器。但串口的发送器并不是直通引脚的,数据先进入发送移位寄存器,然后按波特率一位一位往外送。如果DMA完成中断里不做任何处理,操作系统立刻把串口TX引脚改作他用,或者直接关串口,那么最后一个字节极有可能只写了一半个位周期就断了。
所以发送完成中断里,必须继续等待TDC标志。TDC是“传输数据完成”的意思,只有移位寄存器完全发送完毕,这个标志才会置位。在实际代码里,我通常这样处理:
void DMA0_Channel5_IRQHandler(void) { if (RESET != dma_interrupt_flag_get(USART0_DMA_TX_CTL, USART0_DMA_TX_CH, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(USART0_DMA_TX_CTL, USART0_DMA_TX_CH, DMA_INT_FLAG_FTF); /* 等待移位寄存器真正发完最后一个字节 */ while (RESET == usart_flag_get(USART0, USART_FLAG_TDC)) { } /* 清除TDC标志,供下一次发送使用 */ usart_flag_clear(USART0, USART_FLAG_TDC); g_tx_busy = 0; } }有的固件库把TDC叫作TC,名字不同,含义一致。这个while循环等待的时间很短暂,一般只有几个波特时钟周期,不会造成明显卡顿。但如果你在低功耗场景或者对实时性要求极高的系统里,可以考虑用状态机方式在后续上下文里查询TDC,而不是在中断里死等。不过对于大多数串口场景,这种写法完全够用。
4.3 连续发送时最容易犯的错
连续发送最大的问题就是重入。比如主循环里调用uart0_dma_send("hello", 5),紧接着定时器回调里又调用uart0_dma_send("world", 5),如果g_tx_busy保护没做好,第二次调用会直接把发送通道的传输数量改成5,但DMA可能正在搬运第一帧的数据,两个帧的内容就会交叉错乱。
另一个容易被忽视的点是:关闭DMA通道后再开启,必须重新确认通道已经处于disabled状态。GD32的DMA通道使能位是硬件控制的,传输完成后自动清零;但如果上一帧还在搬运中,你贸然配置传输数量,可能被硬件忽略。所以发送接口里的“先disable、再配置number、再enable”三步不能省。有次我在调试时省了disable,结果每次跑到第二次发送就卡死,查了大半天才发现是通道状态没重置。
5. 实战演示:与串口调试助手联调
5.1 联调环境和工具准备
代码写完,总要上板实测。我常用的调试组合是:一块GD32H7xx开发板、一个USB转TTL模块、一根杜邦线、电脑上开一个串口调试助手。USB转TTL模块很多,比如常见的CH340芯片方案,驱动装好之后在设备管理器里能看到虚拟串口。串口调试助手我用过xcom、sscom、友善串口助手,功能大同小异,挑一个顺手的就行。
接线一定不要搞反:USB转TTL模块的TXD接板子的RXD,RXD接板子的TXD,GND接GND。很多新手第一次把TXD和TXD接在一起,结果收发全无反应,还以为是代码问题。另外电平要确认一致,GD32H7xx串口是3.3V电平,USB转TTL模块如果支持3.3V就切到3.3V输出,乱接5V有烧引脚的风险。
打开串口助手后,波特率设置成115200,数据位8,停止位1,无校验,和代码里的配置保持一致。然后用手动发送功能,任意输入几个字符,点发送,观察板子回传或者板上指示灯,确认链路是通的。
5.2 不定长收发测试的具体步骤
我的测试流程一般分三步。第一步先做回环确认:板子收到什么就原样发回什么。在代码里,如果g_rx_complete置位,就直接把接收缓冲区的数据调用一次uart0_dma_send发回去。串口助手里发送字符串“hello GD32”,正常情况下马上能收到同样的字符串,说明接收路径和发送路径都已经工作了。
第二步测不定长。手动发送几个不同长度的串,注意观察接收长度是否和发送长度一致。比如发送一个字符“A”,接收长度应该是1;发送“GD32H7xx DMA UART Test”共23个字符,长度就应该显示23。这一步主要验证IDLE中断里读取DMA计数算出的长度是否准确。
第三步测连续发送。在串口助手里勾选“定时发送”选项,把发送间隔设成比如100毫秒,分别用固定长度和随机长度连续发送一段时间,看板子是否每一帧都能正确处理,有没有丢帧、错位、长度错乱。如果帧间隔设置得太短,比如5毫秒,你会发现两帧数据被合并成一帧。这不是代码bug,而是IDLE机制决定的:线路空闲时间没超过一个字符时间,硬件不认为这是两帧。遇到这种情况,要么让上位机加大帧间隔,要么在你的协议里加入帧头帧尾做二次判断。
5.3 实测中值得注意的现象
在我自己调试的过程中,印象最深的是缓冲区大小和数据帧长度的关系。缓冲区设成256字节,一般协议帧都够用,但有一次我测试了一个近300字节的报文,DMA搬运超过256字节后,正常模式的DMA通道已经因计数归零而停止,后续字节全部溢出丢失,IDLE中断里算出来的长度却仍然是256,看起来像“收到了完整帧”,实际数据已经是残缺的。所以缓冲区大小必须大于协议允许的最大帧长,最好留出两倍余量。
另一个有意思的现象是,串口助手里“发送新行”选项默认可能会在字符串末尾追加回车换行。你以为发了4个字符,实际收到6个。这个不算问题,但在长度测试时要留意,别把\r\n当成意外数据。处理AT指令协议时,回车换行往往是命令的一部分,接收端应该保留它们,而不是主动剔除。
6. 排障锦囊:从项目里踩过的坑说起
6.1 常见问题速查表
这套方案在实际项目里跑起来之后,遇到的问题基本上集中在下面几类:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 完全收不到数据 | GPIO复用AF配置错,或DMA请求方向配置错 | 对照数据手册确认引脚复用编号,检查DMA的direction字段 |
| 收到长度总是0 | IDLE标志没清干净,或DMA剩余计数读错 | 确认清标志时先读STAT再读DATA;重装DMA前先关闭通道 |
| 第一帧数据看起来完整,后续全乱 | DMA重装顺序不对 | 严格按disable、config number、enable三步走 |
| 发送最后一字节总丢 | 只等了DMA完成,没等USART TDC | 在DMA完成中断里增加TDC等待 |
| 连续发送两次,第二次不出去 | tx_busy标志没在上次完成后清零 | 检查DMA发送完成中断是否正常触发 |
| 数据内容对,但隔一段时间收到脏数据 | 缓冲区被应用层和DMA同时访问 | 建立读写同步机制,确保在处理完成前不重新使能DMA |
| 一帧超过缓冲区大小,静默截断 | 缓冲区容量小于最大帧长 | 扩大缓冲区,或改用循环模式并增加溢出判定 |
6.2 GD32H7xx上不能忽视的Cache一致性问题
这一点特别想单独拿出来说,因为很多从STM32F1转过来的工程师刚上手Cortex-M7内核,根本想不到还有Cache这回事。GD32H7xx是Cortex-M7内核,内部有D-Cache。如果启动代码里把D-Cache打开了,那么DMA往内存里写数据,CPU从Cache里读数据,两边看到的可能不是同一份内容。DMA写入的接收缓冲区,CPU读到的全是旧值,这就是典型的Cache一致性问题。
解决思路有两种。第一种是配置MPU,把DMA接收和发送缓冲区所在的地址区域设置为不可缓存(non-cacheable),让CPU和DMA都直接访问SRAM,从根上避开了缓存一致性问题。第二种是每次使用缓冲区之前手动做Cache无效化,比如在g_rx_complete置位后,用CMSIS的SCB_InvalidateDCache_by_Addr函数把接收缓冲区的Cache行作废,让CPU重新从SRAM读取真实数据。发送侧则反过来,在DMA启动搬运之前,先对发送缓冲区做Cache清理,确保内存里的数据真的同步到SRAM。
如果你不确定自己的启动代码有没有开Cache,最简单的判断方法是:下载一份官方例程,看启动文件或者system_init里有没有调用开启Cache的代码。如果不处理Cache问题,你会发现“明明DMA配置全部正确,数据就是不动”,这个问题我当年排查了整整一天。
6.3 边界情况:零长度帧、溢出和并发访问
最后一个想提醒的,是边界条件的处理。IDLE中断触发时,DMA剩余计数可能刚好等于缓冲区大小,也就是说这一帧长度为0。比如对端只发送了一个停止位,或者上游时序抖动产生了伪空闲,就会出现零长度帧。应用层要过滤掉len == 0的情况,别把空帧当成有效数据上报。
另一个边界是并发访问。主循环正在处理g_rx_dma_buf里的数据,此时新的IDLE中断到来,把缓冲区和g_rx_len改掉了,就会读到不完整的数据。一种简单可靠的策略是在IDLE中断里只置位g_rx_complete标志并保存长度,同时确保在应用层还没有读取完上一帧数据之前,不允许DMA重装到同一个缓冲区。如果对端发送频率确实很高,就需要上双缓冲或者环形队列,把“DMA正在写的缓冲区”和“应用层正在读的缓冲区”彻底分开。
根据我自己的项目经验,把接收缓冲区加大、帧标志处理果断、DMA重装顺序严格,这套方案在Modbus轮询、传感器数据上报、AT指令解析这些场景里都表现得非常稳定。后续如果再用到GD32H7xx做更复杂的通信,我会把接收缓冲升级成双缓冲加空闲中断的架构,进一步降低数据覆盖风险,这也算是在这轮实战之后总结出的下一步演进方向。