调试过单片机串口的人应该都碰过这种场景:协议帧长度不固定,短帧可能只有一两个字节,长帧能到上百字节。之前我用轮询接收,主循环被串口拖死;换成传统单字节中断接收,115200波特率下每秒要触发上万次中断,CPU大量耗在中断进出上。这个项目里我基于GD32H7xx重新设计了串口DMA收发方案,接收侧用DMA持续把串口数据搬到内存,再用IDLE空闲中断判断一帧结束;发送侧同样交给DMA搬运。整条链路CPU只在帧边界介入一次,既不丢数据,也不卡流程。
这个方案的适用面很广:设备调试、Bootloader固件升级、Modbus RTU主从通信、和PC上位机的高频数据交互,都可以直接套用。下面我会从方案选型、外设初始化、代码实现到缓存一致性等坑位逐一拆开讲清楚,代码示例基于GD32标准固件库,你拿到手之后换成自己工程的接口就能用。
1. 为什么常规接收方式在高速串行链路上不够用
1.1 轮询接收的CPU代价
很多人写串口接收时会先试轮询,我也一样,最早在一台从机上用最朴素的方式接收,死循环里不断读状态寄存器,有数据就取出来。这个方式逻辑上最简单,但代价非常直观:一个函数如果长时间卡在等数据的循环里,主程序其他任务全被堵住。为了让出CPU,只能在主循环的某个固定频率下去查询接收寄存器,比如每毫秒查一次。一旦串口数据速率上去,或者协议帧之间的间隔比较短,轮询就容易漏数据。
我当时做的设备要同时处理按键扫描、显示刷新、还有几个传感器的数据,主循环轮询周期没法做到很稳定。实测在115200波特率下,如果一帧有100个字节,用轮询方式接收,即使每次主循环都能及时查到,处理一帧数据也要分散到几十次查询里,逻辑稍一复杂就会出问题。轮询适合什么场合呢?只有在数据量非常小、收发频率极低、对实时性没有严格要求的情况下才够用,但凡协议稍微复杂一点,它就很吃力。
1.2 单字节中断接收的“字节风暴”
后来我改成串口接收中断,每个字节到达时触发一次中断,在中断里把数据放进环形缓冲区。这个方案比轮询好很多,不会漏数据,也能保证实时性。但代价是中断频率太高,尤其在高波特率下,每秒钟的中断次数就是波特率除以10。115200波特率对应约11520字节/秒,也就是说每秒要触发上万次中断,平均每个字节的间隔只有大约87微秒。
87微秒听起来不算短,但如果中断处理函数里做了稍微复杂一点的事,比如解析帧、查表、打印调试信息,就会严重影响主循环实时性。更麻烦的是,当多个串口同时工作,或者系统里还有定时器、外部中断在跑,中断嵌套和排队会带来很大的时序抖动,个别丢失数据就发生了。我之前用逻辑分析仪抓过接收脚的电平,两个字节之间的间隔其实很稳定,但程序最终却出现丢帧,问题就出在CPU来不及响应中断上。
1.3 引入DMA能解决什么
DMA的全称是Direct Memory Access,它最大的作用就是让外设数据不经过CPU干预,直接在硬件层面搬运到内存。对串口接收来说,只要给DMA配好源地址、目标地址和传输长度,它就能自动把串口数据寄存器里到达的字节写进内存缓冲区,整个过程完全独立于CPU。CPU需要做的只是在DMA缓冲区满了或者检测到某种帧结束条件时,去处理缓冲区里的数据。
这个思路有个天然好处:数据量越大,DMA优势越明显。因为无论是一帧100字节还是500字节,DMA都是默默搬运,CPU只需要在帧结束时介入一次。对比单字节中断,你在高波特率下节省的CPU开销是压倒性的。而且GD32H7xx的DMA支持循环模式和普通模式,配合串口的空闲中断使用,就能解决“不定长数据”这个最关键的问题。
2. 方案选型:IDLE中断与DMA的协同思路
2.1 为什么用IDLE空闲中断来界定帧
做串口通信时最头疼的问题之一,就是怎么判断“一帧数据结束”。如果协议里所有帧都定长,事情很简单,DMA配置成对应长度,收到指定字节数后触发完成中断即可。但实际协议库里很多时候帧长是变化的,比如帧头+帧长字段+数据体+校验,这种情况下接收方必须知道一条帧的结尾在哪里。
IDLE空闲中断恰好解决这个问题。它检测的是串口接收线处于空闲状态的时间超过了一个字节的传输时间,也就是说,当接收线上最后一个字节到达后,再过一个字节的时间没有新数据,硬件就会把IDLE标志位置位并触发中断。因为我们通常认为协议发送方会在一条帧结束后停一小段间隔,这个间隔自然会触发IDLE中断,所以可以用它作为帧结束边界。
有一个细节要注意,IDLE中断和接收数据中断不一样,它不是在每个字节到达时触发的,而是整条线上空闲了才触发。所以它在高波特率下也不会造成高频率中断,哪怕一秒钟收发几百帧数据,每帧最多触发一次IDLE中断。这个特性和DMA配合起来非常合适:DMA负责把数据搬进缓冲区,IDLE中断负责通知CPU“数据已经搬完了,快来处理”。
2.2 为什么不用超时定时器判断帧结束
也有人会想到用定时器做帧超时判断,比如每个字节到达时重置一个定时器,如果定时器超时了,就认为一帧数据结束了。这个方案在逻辑上完全可行,但它有一个致命弱点:需要额外的定时器资源,而且超时时间很难选择一个对波特率变化鲁棒的值。波特率高了,字节间隔短,超时时间设大了会降低响应速度;波特率低的话,超时时间设短了又会误判“帧中间隙”为“帧结束”。
相比之下,IDLE空闲中断是串口外设内部的硬件逻辑,它根据当前波特率自动计算空闲时间,不需要额外配置定时器,响应也是硬件级别的,精确度和可靠性都更高。我自己在调试时对比过,使用IDLE中断后代码少了差不多两百行,还没有定时器超时那种“差一点就误判”的紧张感。除非你确实需要一个独立于串口状态的通用超时机制,否则IDLE就是最合适的选择。
2.3 接收缓冲区与帧边界管理思路
确定了用IDLE判断帧边界之后,接收缓冲区的设计就很关键了。我的做法是给每个串口准备一块连续的内存数组作为DMA接收缓冲区,DMA配置成普通模式,一帧数据到达后停在缓冲区里,等待IDLE中断到来。中断处理里会读取DMA当前剩余传输计数,用缓冲区总长度减去这个剩余计数,就得到这一帧实际接收了多少字节。
这里我特意没有用循环模式,而是用普通模式。普通模式的好处是逻辑清晰:DMA一次性搬运指定的最大长度,收到IDLE中断后,判断这一帧数据的长度,处理完,再重新配置传输计数并重新使能DMA通道,等待下一帧。循环模式虽然不用手动复位,但缓冲区满了之后会从头覆盖,如果应用层处理数据的时间较长,新数据可能在缓冲区里把旧数据覆盖掉,容易出问题。普通模式加IDLE中断,是对“不定长”场景最直观可靠的组合。
3. 工程搭建与关键初始化细节
3.1 时钟、引脚复用与串口初始化
GD32H7xx内外设资源很丰富,我用的是GD32H757这颗料,Cortex-M7内核,主频可以跑到550MHz,外设规模比GD32F4系列提升了一个档次。但外设复杂也意味着初始化步骤更多,串口引脚需要根据数据手册配置对应的复用功能号,不同的复用功能AF号对应不同的外设映射,这一步千万不能想当然。
下面给出一个串口0的初始化示例,代码用的是GD32标准固件库,不同版本的库函数名会有些差异,但核心流程是一致的:
void uart0_gpio_init(void) { rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART0); /* 根据数据手册把PA9和PA10映射到USART0 */ gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_9); /* TX */ gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_10); /* RX */ gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_PULLUP, GPIO_PIN_9); gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_PULLUP, GPIO_PIN_10); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9); } void uart0_config(void) { 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); usart_receive_config(USART0, ENABLE); usart_transmit_config(USART0, ENABLE); usart_enable(USART0); }提示:PA9和PA10对应的复用功能号,要以当前型号的数据手册和库函数头文件为准。引脚AF映射一旦配错,串口就会一直收不到任何数据,而且这种问题用示波器都不太容易察觉,因为引脚根本就没接上串口外设。
3.2 DMA接收通道配置
串口初始化完成后,下一步是配置DMA接收通道。GD32H7xx的DMA和外设请求之间的映射关系已经不像GD32F1系列那样是固定的了,而是可以在初始化时指定具体的请求源。这里我配置DMA0通道0作为USART0的接收通道,绑定的是串口接收请求。
接收缓冲区定义为全局数组,长度先按协议最大值来,我预留了256字节,实际应用中如果帧长能到512字节甚至更长,直接改这个宏就行。配置代码如下:
#define UART0_RX_BUF_SIZE 256 uint8_t uart0_rx_buf[UART0_RX_BUF_SIZE]; void uart0_dma_rx_config(void) { dma_parameter_struct dma_rx_init; dma_deinit(DMA0, DMA_CH0); dma_struct_para_init(&dma_rx_init); dma_rx_init.direction = DMA_PERIPHERAL_TO_MEMORY; dma_rx_init.periph_addr = (uint32_t)&USART_DATA(USART0); dma_rx_init.memory_addr = (uint32_t)uart0_rx_buf; dma_rx_init.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_rx_init.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_rx_init.periph_width = DMA_PERIPHERAL_WIDTH_8BIT; dma_rx_init.memory_width = DMA_MEMORY_WIDTH_8BIT; dma_rx_init.number = UART0_RX_BUF_SIZE; dma_rx_init.priority = DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH0, &dma_rx_init); dma_channel_enable(DMA0, DMA_CH0); }在配置DMA接收时,有个很容易出错的地方是数据宽度。USART的数据寄存器虽然在内存地址上占了32位,但实际有效数据只有低8位。如果DMA外设宽度配成32位,它就会一次性把寄存器里的32位数据都搬到内存,内存里就会出现大量无效数据。所以我这里明确把外设宽度和内存宽度都设置成8位,这样DMA每次只搬一个有效字节,内存布局和串口实际收发的数据完全一致。
3.3 DMA发送通道配置
发送通道的配置逻辑和接收类似,方向反过来了,变成内存到外设。发送缓冲区不需要那么大,我留了128字节,注意要把发送缓冲区定义为全局数组,因为它要在DMA搬运过程中一直有效,不能是函数内部的局部变量。
#define UART0_TX_BUF_SIZE 128 uint8_t uart0_tx_buf[UART0_TX_BUF_SIZE]; void uart0_dma_tx_config(void) { dma_parameter_struct dma_tx_init; dma_deinit(DMA1, DMA_CH0); dma_struct_para_init(&dma_tx_init); dma_tx_init.direction = DMA_MEMORY_TO_PERIPHERAL; dma_tx_init.periph_addr = (uint32_t)&USART_DATA(USART0); dma_tx_init.memory_addr = (uint32_t)uart0_tx_buf; dma_tx_init.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_tx_init.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_tx_init.periph_width = DMA_PERIPHERAL_WIDTH_8BIT; dma_tx_init.memory_width = DMA_MEMORY_WIDTH_8BIT; dma_tx_init.number = 0; dma_tx_init.priority = DMA_PRIORITY_HIGH; dma_init(DMA1, DMA_CH0, &dma_tx_init); }发送DMA的传输数量先配置成0,等实际需要发送时再设置长度并使能通道。这样初始状态下不会误触发发送,也比较干净。
4. 核心代码实现:不定长帧的完整收发链路
4.1 IDLE中断处理:拿到一帧数据的长度
串口中断里处理IDLE标志,是整个方案的核心。我使能了USART0的接收中断和IDLE中断,其中接收中断用来处理错误和DMA发送完成等标志,IDLE中断专门处理帧结束。中断服务函数里,IDLE标志被触发后,先清除标志,然后读取DMA的剩余传输计数,计算帧长度。
volatile uint8_t uart0_frame_ready = 0; volatile uint16_t uart0_frame_len = 0; uint8_t uart0_frame_buf[UART0_RX_BUF_SIZE]; void USART0_IRQHandler(void) { /* IDLE空闲中断:一帧数据接收完成 */ if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE) != RESET) { usart_interrupt_flag_clear(USART0, USART_INT_FLAG_IDLE); /* 计算已经接收到的字节数 */ uint32_t remain_cnt = dma_transfer_number_get(DMA0, DMA_CH0); uint32_t rx_len = UART0_RX_BUF_SIZE - remain_cnt; if (rx_len > 0) { memcpy(uart0_frame_buf, uart0_rx_buf, rx_len); uart0_frame_len = rx_len; uart0_frame_ready = 1; } /* 重新启动DMA接收,准备下一帧 */ dma_channel_disable(DMA0, DMA_CH0); dma_transfer_number_config(DMA0, DMA_CH0, UART0_RX_BUF_SIZE); dma_channel_enable(DMA0, DMA_CH0); } /* 发送完成中断:由串口的TC标志触发 */ if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_TC) != RESET) { usart_interrupt_flag_clear(USART0, USART_INT_FLAG_TC); uart0_tx_done = 1; } }这里有几个细节必须解释清楚。首先,dma_transfer_number_get读到的值是指DMA还剩余多少个字节没有传输,而不是已经传输了多少个字节。所以用缓冲区总长度减去剩余值,得到的是本次已经搬运进缓冲区的字节数。其次,我在IDLE中断里把接收到的数据从DMA缓冲区拷贝到应用层缓冲区,这样做是为了让DMA缓冲区能够马上被重新启用,不用等主循环慢慢处理。如果数据量大,可以只拷贝一个指针并做双缓冲切换,但对于小于256字节的帧来说,直接拷贝的开销非常小。
4.2 缓冲区拷贝与帧解析策略
上面代码里把数据拷到了uart0_frame_buf,并置了uart0_frame_ready标志。主循环里只需要检查这个标志,为1就去处理数据:
while (1) { if (uart0_frame_ready) { uint16_t len = uart0_frame_len; /* 在这里做协议解析,比如提取帧头、长度字段、校验等 */ process_protocol_frame(uart0_frame_buf, len); uart0_frame_ready = 0; } /* 其他业务代码 */ }这个设计本质上是一个典型的“中断采集数据+主循环处理业务”模型,好处是中断里只做最轻量级的操作,避免在中断里处理耗时逻辑。只要主循环在下一帧到达之前把数据拷贝走,就不会发生数据覆盖。
如果主循环处理速度跟不上数据到达速度,最简单的改善办法是使用双缓冲,也就是说准备两块接收缓冲区,DMA交替使用,IDLE中断触发时只切换指针,而不是memcpy。GD32H7xx的资源足够宽裕,双缓冲在复杂协议栈下是个很稳健的方案。
4.3 DMA发送与发送完成处理
发送侧的逻辑相对简单一些。上层需要发送一帧数据时,先把数据拷贝到发送缓冲区,然后设置DMA传输长度并启动发送。因为发送过程是异步的,必须等上一次发送完成才能开始下一次发送,我用uart0_tx_done这个标志来管理。
volatile uint8_t uart0_tx_done = 1; uint8_t uart0_dma_send(const uint8_t *data, uint16_t len) { uint16_t copy_len = len; if (copy_len > UART0_TX_BUF_SIZE) { copy_len = UART0_TX_BUF_SIZE; } /* 等上一次发送完成 */ if (uart0_tx_done == 0) { return 0; } memcpy(uart0_tx_buf, data, copy_len); dma_channel_disable(DMA1, DMA_CH0); dma_transfer_number_config(DMA1, DMA_CH0, copy_len); dma_channel_enable(DMA1, DMA_CH0); uart0_tx_done = 0; /* 使能串口发送,并等待TC中断后置位完成标志 */ usart_interrupt_enable(USART0, USART_INT_TC); return 1; }要注意,串口的发送DMA请求需要在初始化时使能,否则即使DMA通道配置好了,串口也不会主动请求DMA搬运数据。对应的库函数是usart_dma_transmit_config(USART0, ENABLE),接收侧则使用usart_dma_receive_config(USART0, ENABLE)。这两个使能开关经常被遗忘,检查代码时往往要花不少时间才能找出来。
5. Cortex-M7缓存一致性:最容易翻车的隐藏坑
5.1 为什么M7有D-Cache反而出问题
GD32H7xx使用Cortex-M7内核,和M3/M4最大的区别之一就是L1 Cache。DMA搬运数据是直接写到内存的,它不经过CPU的Cache。而CPU读取数据时,如果缓冲区被设置成了Cacheable,CPU可能直接从Cache里读旧的缓存内容,而不是去内存里取DMA刚刚写入的新数据。后果就是:串口明明收到了完整一帧,DMA也把数据写进了缓冲区,但CPU解析时读到的却是一堆旧数据或者0。
这个问题在GD32F103那种M3内核上几乎不存在,因为M3没有D-Cache。但在H7系列上只要打开了D-Cache,就一定会碰到。很多从F1迁移到H7的人,第一步就是在这里翻车。我最初调试时也遇到类似情况:用串口助手发送数据,板卡串口返回的log却总是打印出固定长度的旧数据,直到关闭D-Cache后才正常,确认就是缓存一致性问题。
5.2 用MPU把DMA缓冲区配置为不可缓存
解决缓存一致性的标准方案,是用MPU把DMA缓冲区所在内存区域配置为Non-cacheable(不可缓存)。这样CPU直接读写内存,DMA写入的数据就能被CPU第一时间看到。GD32固件库提供了MPU配置接口,配置思路如下:
void mpu_dma_buffer_config(void) { /* 以固件库函数名为准,核心是把缓冲区所在区域配成非缓存、非缓冲 */ mpu_region_enable(); /* uart0_rx_buf 和 uart0_tx_buf 都放在这个区域里 */ mpu_config_region( MPU_REGION_0, (uint32_t)uart0_rx_buf, MPU_REGION_SIZE_512B, MPU_ACCESS_PERMISSION_PRIV_RW_USER_RW, MPU_ACCESS_NOT_CACHEABLE, MPU_ACCESS_NOT_BUFFERABLE, MPU_ACCESS_NOT_SHAREABLE ); mpu_region_disable(); }如果缓冲区数组刚好不在同一块MPU区域里,可以分别配置两个MPU region。注意MPU region的起始地址必须是对齐的,不同尺寸有不同对齐要求,一般把数组放到一个独立的section里,或者直接使用特定内存地址,这样配置起来更干净。
5.3 不折腾MPU时的Cache维护补救方案
如果你不想配置MPU,也有一个补救办法:在使用接收数据之前,手动刷新Cache。读取DMA缓冲区前执行SCB_InvalidateDCache_by_Addr,让缓存里的旧数据失效;发送DMA缓冲区之前执行SCB_CleanDCache_by_Addr,把缓存里的数据写回到内存。
这两个函数在CMSIS里都有,调用方式大致如下:
/* 接收方向:DMA写完内存后,让CPU读到最新数据 */ SCB_InvalidateDCache_by_Addr((uint32_t *)uart0_rx_buf, rx_len); /* 发送方向:启动DMA之前,把缓存数据刷到内存 */ SCB_CleanDCache_by_Addr((uint32_t *)uart0_tx_buf, tx_len);这个方案的优点是不用配置MPU,缺点是每次收发都要手动维护,而且调用Cache维护函数也有一定的时间开销。我个人的建议是:如果产品要对性能做极致优化,直接上MPU配置;如果只是调试期间想快速验证功能,先手动维护Cache也完全够用。
6. 调试实操与问题排查实录
6.1 用串口助手验证不定长收发的完整步骤
代码写完以后,验证是整个过程中最重要的一环。我习惯用XCOM串口调试助手来模拟上位机发送不定长数据,配合一块USB转TTL小板,把板卡的USART0接到PC上。
验证步骤非常固定:
- 先用串口助手按固定波特率打开端口,我一般先用115200。
- 在发送区里输入一条短帧,比如只有5个字节,比如
AA 01 00 05 55,点击发送。 - 观察板卡返回的数据,这条返回数据应该和接收到的长度信息一致,比如返回“RX 5 bytes”。
- 再输入一条长帧,长度拉到100字节以上,继续发送,确认板卡还能正确解析出帧长。
- 反复切换短帧和长帧,再间隔随机时间发送,确认IDLE判断稳定。
这里有个小技巧:串口助手里有一个“定时发送”功能,通常以毫秒为单位。我建议把定时发送的间隔分别设置为50ms、20ms、10ms,测试连续发送时是否会出现丢帧或解析长度错误。正常情况下,只要间隔大于一个字节的传输时间,IDLE中断就能准确区分每一帧。
6.2 常见问题速查表
调试过程中一定会有问题,我把常见现象、原因和解决办法整理成一张表,方便快速排查。
| 异常现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 完全收不到数据 | DMA通道未绑定串口请求 | 检查dma_request_config是否配置了USART0_RX对应的请求源 |
| 收不到数据且IDLE标志未触发 | 串口DMA接收请求未使能 | 调用usart_dma_receive_config(USART0, ENABLE) |
| 能收到数据,但长度不对 | DMA数据宽度配置错误 | 将外设和内存宽度都设为8位 |
| IDLE中断反复触发 | IDLE标志清除不彻底 | 确保按库函数方式清除标志,必要时先读状态寄存器再读数据寄存器 |
| 开启D-Cache后数据全乱 | 缓存一致性问题 | 配置MPU为不可缓存,或手动失效/刷写Cache |
| 发送DMA后没数据出 | 串口DMA发送请求未使能 | 调用usart_dma_transmit_config(USART0, ENABLE) |
| 连续发送时第二帧发不出 | 上一次发送未完成 | 检查TC标志清除时序,确认uart0_tx_done置位时机 |
6.3 实测性能与个人体会
在GD32H757上,我把主频跑在550MHz,串口波特率拉到115200,反复测试不同长度的帧。最直观的感受是CPU占用率大幅下降。之前用单字节中断接收时,主循环的响应偶尔会被中断抖动影响,换成DMA加IDLE之后,主循环的循环周期稳定了很多,平均一个长帧只需要在IDLE中断里做一次数据搬运,CPU占用率从大概8%降到了不到0.5%。
这个方案里最值得留意的不是代码怎么写,而是对“帧”这个概念的理解。IDLE中断帮你确定了一帧的结束位置,但前提是协议发送方确实在帧之间留了足够的时间间隔。如果发送方是边生成边发送,帧与帧之间没有任何停顿,IDLE标志就不会触发,数据会连续进入缓冲区,这时就必须改用DMA传输完成中断或者串口超时寄存器来做边界判断。
当然,如果项目里的协议帧长完全固定,DMA传输完成中断会更直接,不需要IDLE参与。但不定长数据始终是工业通信和调试场景里的常态,把DMA加IDLE这套方案吃透,后续不管换到哪个平台,核心思路都能平移。我在实际使用中发现,判断一帧结束的硬件机制可能各不一样,但DMA加中断协同配合的设计逻辑,是所有高性能串口收发方案绕不开的基础。