GD32串口数据收进内存之后DMA如何处理?

串口助手偶尔发一行文字,开发板能原样回显,并不难。真正容易出问题的是下一行紧跟着到来时,上一行还没有打印完。DMA 已经把数据搬进内存,程序却可能读到一半新、一半旧的内容,甚至在清空数组时擦掉刚收到的字节。
以梁山派 GD32F450 版的 USART0 接收链路为例,把连接器、引脚复用、DMA 搬运和主循环串起来,才能看清这类问题。本文分析 F450 版电路和对应接收示例,其他主控版本应按各自电路与外设映射核对。
USB 插口后面还有两条路
电脑通过 USB 接入调试器,再由调试器的串口功能连接 USART0,这条路径和开发板自身的 USB 接口不同。F450 版核心电路把 USART0 的 TX、RX 分别接到 PA9、PA10,经 CN1 调试连接器引出。CN1 的第 1、2、3 脚分别是 USART0_TX、USART0_RX 和 GND,连接外置 USB 转串口模块时要交叉接收发线,并保证共地和逻辑电平兼容。
CN1 还引出了 PA13 的 SWDIO 和 PA14 的 SWCLK,用于程序下载与调试。SWD 与串口可以经过同一个连接器,但两者承担的功能不同。板载 USB-C 的数据线则接到 PA11、PA12,走 MCU 原生 USB 外设。只插这一路 USB,不会因为程序使用了 USART0 就自动出现串口设备,仍需要对应的 USB 设备固件。

电路接通以后,还要把引脚交给正确的外设。示例中的配置节选如下。
#define BSP_USART_TX_PIN GPIO_PIN_9
#define BSP_USART_RX_PIN GPIO_PIN_10
#define BSP_USART_AF GPIO_AF_7
#define BSP_USART USART0
gpio_af_set(BSP_USART_TX_PORT, BSP_USART_AF, BSP_USART_TX_PIN);
gpio_af_set(BSP_USART_RX_PORT, BSP_USART_AF, BSP_USART_RX_PIN);
这几行把 PA9、PA10 选择到 AF7。完整初始化还包括 GPIOA 与 USART0 的时钟、GPIO 复用模式、串口收发使能,以及 115200、8 位数据、无校验、1 位停止位的参数。引脚复用正确,只能说明外设有机会控制引脚;时钟没开或接收没使能,DMA 同样等不到数据。
一次接收跨过三个硬件环节
字节先沿 PA10 进入 USART0,串口硬件完成位采样和字符接收,再通过 DMA 请求让数据进入内存。CPU 不必为每个字节执行一次数组写入,但依然要管理缓冲区的起点、容量和生命周期。
这个示例使用 DMA1 的通道 2,并选择 DMA_SUBPERI4 对应请求。关键配置语句如下,分别节选自搬运参数与请求使能部分。
dma_init_struct.direction = DMA_PERIPH_TO_MEMORY;
dma_init_struct.memory0_addr = (uint32_t)g_recv_buff;
dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE;
dma_init_struct.periph_memory_width = DMA_PERIPH_WIDTH_8BIT;
dma_init_struct.number = ARRAYNUM(g_recv_buff);
dma_init_struct.periph_addr = (uint32_t)&USART_DATA(BSP_USART);
dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE;
dma_channel_subperipheral_select (BSP_DMA, BSP_DMA_CH, DMA_SUBPERI4);
usart_dma_receive_config(BSP_USART, USART_DENR_ENABLE);
外设地址固定,因为每次都从 USART0 的数据寄存器取值。内存地址递增,因为连续字节要依次写入数组。传输宽度设为 8 位,与这里的字符配置一致。若误把外设地址也设成递增,后续访问就会偏离数据寄存器;若内存不递增,收到的字节会反复覆盖同一位置。

示例数组有 4096 个字节。设初始传输数为 N,剩余计数为 R,当前已搬运数量就是 N−R。它描述的是内存写入进度,并不包含报文语义。DMA 的 FTF 事件意味着设定的传输数已经用完,既不能代表一个短报文结束,也不能保证上层已经读走这些字节。
这里讨论的是非循环接收。上述语句只是配置节选,完整初始化仍需正确初始化参数结构体、明确关闭循环模式并使能通道。公开配置片段没有展示 circular_mode 的赋值,不能据此假定结构体中的未赋值字段已经处于正确状态。
空闲中断并不知道报文长度
不知道本次会收到多少字节时,可以借助 IDLE 事件观察接收线的空闲。115200、8N1 下,一个字符包含起始位、8 个数据位和停止位,共 10 位,对应约 86.8 μs。IDLE 检测采用字符时间尺度,和应用层的换行、长度字段或 CRC 没有直接联系。
这意味着发送端在一条长报文中暂停,可能产生一次空闲事件;两条报文紧密连续发送,又可能只表现为一段连续数据。USB 转串口的发送节奏也会影响线上间隔。因此,IDLE 更适合通知程序处理一段已到达的数据,报文边界仍应由协议解析确定。

接收示例在空闲中断里读取剩余计数,置位完成标志,然后重新配置 DMA。下面保留了这一处理次序。
if (usart_interrupt_flag_get(BSP_USART, USART_INT_FLAG_IDLE) == SET)
{
usart_data_receive(BSP_USART);
g_recv_length = ARRAYNUM(g_recv_buff) - dma_transfer_number_get(BSP_DMA, BSP_DMA_CH);
g_recv_buff[g_recv_length] = '\0';
g_transfer_complete = SET;
dma_channel_disable(BSP_DMA, BSP_DMA_CH);
dma_config();
}
IDLE 的清除涉及读取状态寄存器后读取数据寄存器,不能简单当成普通标志位写零处理。示例通过标志查询和随后读取数据完成相应操作。若新字节恰好在处理中到达,CPU 读取数据寄存器、DMA 搬运和剩余计数快照之间仍要协调,不能把那次读取一概理解为永远无效的空读。
还有一个直接的数组边界问题。若 4096 个位置全部写满,g_recv_length 就等于 4096,而数组最后一个有效下标是 4095。继续补 \0 会越界。文本接收应为结束符预留空间,并限制 DMA 可写长度;二进制接收则应携带明确长度,不依赖字符串结束符,也不能用 %s 判断内容完整性。
同一块缓冲区不能同时交给两个人
空闲中断结束前调用 dma_config(),会把同一块数组重新用于接收。完成标志随后由主循环处理,相关语句如下。
if (SET == g_transfer_complete)
{
g_transfer_complete = RESET;
printf("DMA recv:%s\r\n", g_recv_buff);
memset(g_recv_buff, 0, g_recv_length);
g_recv_length = 0;
}
当主循环执行 printf 时,DMA 可能已经从数组开头写入下一批字节。打印结果于是取决于两边的先后顺序。接着执行的 memset 更危险,它可能清掉下一批已经接收的内容。单个完成标志也无法排队保存多次到达事件,长度变量还可能在主循环处理期间被中断改写。

把标志和共享变量按需要声明为 volatile,只能约束编译器对相应访问的优化,不能阻止 DMA 覆盖数组,也不能让缓冲区、长度和标志自动组成一次原子交接。这里首先需要解决的是谁可以写、谁可以读,以及何时归还。
进一步改进可以采用两块缓冲区。完成的 A 区连同长度交给消费者,DMA 使用 B 区;消费者处理完 A 区,才把它归还。连续数据更适合循环 DMA 配合软件读写位置,利用半传输、全传输和空闲事件推动消费,并设置队列容量、溢出策略及协议解析。它们属于升级方向,上面的单缓冲示例尚未实现这些机制。
切换也有代价。停止通道、重设地址和计数、重新使能之间存在接收窗口,不能只画两个数组就承诺不丢数据。若消费者长期慢于输入,缓冲区迟早会满,还需要降低处理负担、扩大队列,或在两端都支持时引入流控。
把边界条件带进串口测试
回显还会改变处理时长。示例的字符输出等待 TBE 标志,表示发送数据缓冲区可以接收下一个字节,并不等于最后一个停止位已经离开引脚。按同样的 115200、8N1 发送 4096 字节,仅线路发送时间就约为 356 ms,尚未计入格式化等开销。接收若持续进行,主循环就可能在这个量级的时间内占用待处理数据。
调试时应同时观察串口错误状态、DMA 剩余计数、提交长度和消费者处理进度。下面这些输入,比反复发送一行短文本更能暴露边界。
| 输入或观察条件 | 要验证的行为 |
|---|---|
| 先发送少量已知字节 | 核对 PA10、115200 与 8N1,确认剩余计数随接收减少 |
| 单次发送 4095、4096、4097 字节 | 检查结束符越界、缓冲区耗尽和超出容量后的处理 |
| 两批数据连续发送,再逐渐增加间隔 | 区分 IDLE 通知和真实协议边界,检查是否覆盖未消费数据 |
| 发送包含零字节的二进制内容 | 比较长度和逐字节内容,避免字符串打印提前结束 |
| 持续输入,同时增加打印负担 | 观察队列积压、错误计数和溢出策略是否生效 |
这些是针对代码边界设计的验证方法,实际吞吐、切换窗口和丢包情况仍要在对应硬件与发送端条件下测定。若结果异常,先区分电平与引脚问题、USART 接收问题、DMA 配置问题和消费者处理问题,再沿链路定位。
串口接收可靠与否,取决于每一个交接点是否明确。引脚把信号送进 USART,DMA 把字节放进内存,协议解析确定报文,消费者处理完才归还空间。让搬运完成、报文完成和消费完成各有清楚的含义,比单纯把逐字节中断换成 DMA 更能接近稳定的连续接收。



