ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

GD32F405串口DMA收发实战:空闲中断实现不定长数据接收

GD32F405串口DMA收发实战:空闲中断实现不定长数据接收 简介面向GD32F405RGT6与STM32嵌入式开发者这份资源围绕串口UART接收发送与DMA传输展开涵盖寄存器配置、固件库API调用及中断处理适合需要提升串口通信效率、降低CPU负载的单片机工程师。压缩包共286个文件以C源码、头文件及Keil工程文件.uvprojx为主另含可执行镜像.axf和内存映射.map可直接打开工程查看初始化与中断逻辑资源整体约5.52MB。已有4050人学习下载可见其具备不错的实践参考价值。通过研读示例能掌握UART_DMACmd、DMA_ITConfig等关键函数用法厘清收发DMA配置差异并快速移植到自身项目缩短串口模块开发周期。 做嵌入式如果没有被串口卡过几次都不好意思说自己调过板子。GD32F405RGT6这颗Cortex-M4内核的国产MCU主频能拉到200MHz外设资源也够丰富尤其串口带DMA这件事用好了CPU占用率能降一大截。这阵子我在一个工业数据采集项目里重新把它的串口DMA收发捋了一遍踩了几个老坑也把接收不定长数据那套方案彻底搞顺了今天就把整个思路和可复现的代码逻辑整理出来。1. 串口DMA的需求拆解与应用场景1.1 为什么会用到DMA收发先说说为什么放着好好的串口中断不用非要去折腾DMA。之前我做过一个和多个从机通信的网关设备一帧报文少则八字节多则上百字节主频虽然不低但每收一个字节就进一次中断一次中断里还要做标志位判断、数据搬移、缓存更新这些操作加上系统里还跑着协议栈和浮点运算串口速率一提到921600CPU占用率肉眼可见地涨。更要命的是高速中断下如果代码里某段临界区没处理好接收缓冲区溢出丢字节就是家常便饭。使用DMA以后数据搬运这件事完全交给了DMA控制器。它就像一个专职的搬运工数据从外设数据寄存器搬到内存全程不需要CPU参与。CPU这边只需要在整帧数据接收完成后去内存缓冲区里取数据就行。在这个项目里我把串口的收发全部改成DMA方式CPU占用率降下来不说数据丢包的情况也基本消失了。1.2 接收不定长数据是刚需串口通信里最常遇到的问题就是“我怎么知道这帧数据什么时候收完了”。对于Modbus这类协议报文是规定好长度的但更多场景下比如和GPS模块、指纹模块、上位机自定义协议通信每一帧长度是不固定的。最简单粗暴的做法是用“单字节中断超时判断”每收到一个字节就重置定时器超时认为一帧结束。这种方式代码简单但高速数据下容易判断失误而且CPU开销大。另一个方案就是本文要细说的“DMA接收空闲中断”后面整个方案的基石就靠它。2. GD32F405串口DMA的关键原理2.1 DMA工作流程与通道映射GD32F405的DMA控制器有DMA0和DMA1两个每个DMA有8个通道每个通道可以服务多个外设请求但同一时刻只能有一个外设占用该通道。串口接DMA的时候需要查数据手册确认对应的DMA通道请求映射表。我用的是USART1和USART2它们的收发通道分别是DMA0的通道2/3和通道4/5之类的映射关系。具体到GD32F405RGT6下面这个映射关系在实际配置时最常用串口外设DMA控制器接收通道发送通道USART0DMA0通道3通道2USART1DMA0通道5通道4USART2DMA0通道7通道6UART3DMA1通道1通道0UART4DMA1通道3通道2注意不同型号GD32F4系列同一串口对应的DMA通道不一定完全相同动手前务必参照对应型号的《参考手册》DMA请求映射表核对一遍我就见过有人把F103的映射习惯直接套到F405上导致数据根本收不到的情况。2.2 空闲中断DLE/IDLE的判定逻辑接收不定长数据的时候空闲中断是整个方案的触发器。GD32的串口模块里当接收总线上一段时间没有收到新数据时会触发空闲中断。以USART1举例数据从起始位开始到最后一个停止位结束如果在停止位之后检测到总线处于空闲状态就会拉高空闲标志。GD32标准库里这个标志在3.x版本的库里面叫USART_INT_FLAG_IDLE更新的库版本里也有地方写成USART_INT_FLAG_DLE本质是一个东西。清除方法比较特殊要先读一次USART_STAT0寄存器再读一次USART_DATA寄存器这个顺序错了或者少一步中断就会一直触发或者触发错乱。很多新手在这块卡很久原因就是没理解它和普通中断标志的清除方式不一样。2.3 接收缓冲区的双缓冲思路DMA接收数据是连续往内存地址里写的如果接收缓冲区只有一个数组处理完一帧数据、清空缓冲区、再等下一帧期间只要有新数据进来就会覆盖。我这里的做法是用两个数组做双缓冲DMA当前往缓冲区A写数据CPU解析完缓冲区B的数据后再把DMA的目标地址切换到缓冲区A。两个缓冲区交替使用数据不会冲突。当然GD32的DMA也支持循环模式可以在一个缓冲区里循环写用读指针和写指针来管理数据。这种做法的好处是缓冲区可以做得大一些坏处是如果CPU处理不及时写指针追上读指针还是会丢数据。双缓冲更直观配合空闲中断效果最好。3. 实操环节从初始化到收发实现3.1 引脚与时钟配置在GD32F405RGT6上USART1的引脚是PA9(TX)和PA10(RX)挂在APB2总线上GPIOA挂在AHB1总线上。这里尤其要注意GD32的GPIO时钟使能函数和STM32不一样rcu_periph_clock_enable里面要传入RCU_GPIOA、RCU_USART1、RCU_DMA0这三个宏。配置引脚时TX要配置为复用推挽输出GPIO_MODE_AF_PPRX配置为复用开漏或者复用推挽都可以关键是复用功能号要选对USART1的复用功能号在F405上是GPIO_AF_7。有的例程里用的GPIO_AF_1是F1系列的映射直接抄过来就是坑。3.2 串口参数与DMA通道初始化串口的基本参数波特率9600、数据位8位、无校验、1位停止位直接通过usart_init配置。注意GD32的库函数里usart_init的第一个参数是USART_COM这个结构体传地址进去这和STM32标准库的传参方式一致但结构体成员的排列顺序略有区别最好是按结构体定义逐个赋值不要用整体初始化避免版本兼容问题。DMA通道初始化是重头戏。以USART1的接收DMA举例我使用的是DMA0的通道3配置成外设到内存传输外设地址是USART_DATA(USART1)内存地址是缓冲区数组地址传输方向是外设到内存缓冲区大小是接收长度。运行模式这里选循环模式DMA_CIRCULAR_MODE这样接收完一轮数据后DMA会自动回到起点继续接收不用每次接收完都重新配置一次。发送DMA通道我配置成单次模式DMA_NORMAL_MODE内存到外设外设地址同样是数据寄存器地址。这个区别很重要接收用循环模式是为了不间断地等数据发送用单次模式是因为每次发送的内容、长度都可能不同发完一次就该停。3.3 接收空闲中断配置与处理配置完DMA后需要使能串口的空闲中断。GD32库里调用usart_interrupt_enable(USART1, USART_INT_IDLE)同时使能DMA的接收通道也就是调用dma_channel_enable(DMA0, DMA_CH3)这样数据流就打通了。中断优先级这里我要多说一句。如果裸机编程空闲中断最好配置成最高优先级或者次高优先级因为它是判断“一帧数据结束”的关键信号。如果系统里有其他频繁的中断源比如定时器、外部中断而且优先级比它高很有可能在DMA接收完一整帧之后空闲中断迟迟挤不进去导致下一帧数据来了还没处理完上一帧。用NVIC配置时抢占优先级0~3我一般给串口空闲中断配抢占优先级1、子优先级0。中断处理函数里面做三件事第一判断空闲标志是否置位也就是读USART_STAT0寄存器的对应位第二按照前面说的顺序清除标志位第三关闭DMA通道读取当前DMA剩余数据量寄存器DMA_CHCNT计算出实际接收了多少字节然后处理数据再重新开启DMA通道。计算接收长度的公式是uint16_t data_len RX_BUFFER_SIZE - dma_transfer_number_get(DMA0, DMA_CH3);dma_transfer_number_get返回的是DMA当前还剩多少数据没传用缓冲区总长度减去这个值就是已经收到的数据字节数。3.4 完整代码框架下面是整理好的核心代码框架可以直接往工程里套。粗体部分是需要根据实际串口号和通道号修改的地方。#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; uint8_t tx_buffer[TX_BUFFER_SIZE]; void uart_dma_init(void) { /* 时钟使能 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART1); rcu_periph_clock_enable(RCU_DMA0); /* GPIO配置PA9 TX, PA10 RX */ gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_9 | 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 | GPIO_PIN_10); /* USART1配置 */ usart_deinit(USART1); usart_baudrate_set(USART1, 115200U); usart_word_length_set(USART1, USART_WL_8BIT); usart_stop_bit_set(USART1, USART_STB_1BIT); usart_parity_config(USART1, USART_PM_NONE); usart_hardware_flow_rts_config(USART1, USART_RTS_DISABLE); usart_hardware_flow_cts_config(USART1, USART_CTS_DISABLE); usart_receive_config(USART1, USART_RECEIVE_ENABLE); usart_transmit_config(USART1, USART_TRANSMIT_ENABLE); usart_enable(USART1); /* 接收DMADMA0通道3循环模式 */ dma_deinit(DMA0, DMA_CH3); dma_parameter_struct dma_rx_struct; dma_struct_para_init(dma_rx_struct); dma_rx_struct.direction DMA_PERIPHERAL_TO_MEMORY; dma_rx_struct.periph_addr (uint32_t)(USART_DATA(USART1)); dma_rx_struct.periph_inc DMA_PERIPH_INCREASE_DISABLE; dma_rx_struct.memory_addr (uint32_t)rx_buffer; dma_rx_struct.memory_inc DMA_MEMORY_INCREASE_ENABLE; dma_rx_struct.periph_width DMA_PERIPHERAL_WIDTH_8BIT; dma_rx_struct.memory_width DMA_MEMORY_WIDTH_8BIT; dma_rx_struct.number RX_BUFFER_SIZE; dma_rx_struct.priority DMA_PRIORITY_HIGH; dma_circulation_config(DMA0, DMA_CH3, DMA_CIRCULATION_MODE); dma_channel_config(DMA0, DMA_CH3, dma_rx_struct); dma_channel_enable(DMA0, DMA_CH3); /* 使能串口空闲中断 */ usart_interrupt_enable(USART1, USART_INT_IDLE); nvic_irq_enable(USART1_IRQn, 1, 0); } void USART1_IRQHandler(void) { if (usart_interrupt_flag_get(USART1, USART_INT_FLAG_IDLE) ! RESET) { /* 读状态寄存器再读数据寄存器清除空闲标志 */ usart_interrupt_flag_clear(USART1, USART_INT_FLAG_IDLE); volatile uint8_t dummy usart_data_receive(USART1); (void)dummy; /* 关闭DMA计算本次接收长度 */ dma_channel_disable(DMA0, DMA_CH3); uint16_t rx_len RX_BUFFER_SIZE - dma_transfer_number_get(DMA0, DMA_CH3); if (rx_len 0) { process_rx_frame(rx_buffer, rx_len); } /* 清空缓冲区重启接收 */ memset(rx_buffer, 0, RX_BUFFER_SIZE); dma_channel_enable(DMA0, DMA_CH3); } }3.5 DMA发送的实现细节发送端稍微简单一些但有一个点必须注意DMA发送完以后不会主动通知你你需要自己判断发送是否真正结束才能安全地释放缓冲区或者修改数据。如果发送的数据放在局部数组里发送函数返回后数组里的数据可能还没被DMA搬完这是极其隐蔽的bug。我的做法是开启DMA发送完成中断或者干脆查询串口的发送完成标志USART_FLAG_TC。最简单的发送函数如下void uart_dma_send(uint8_t *data, uint16_t len) { if (len 0) return; dma_channel_disable(DMA0, DMA_CH2); dma_memory_address_config(DMA0, DMA_CH2, DMA_MEMORY_INCREASE_ENABLE, (uint32_t)data); dma_transfer_number_config(DMA0, DMA_CH2, len); dma_channel_enable(DMA0, DMA_CH2); }发送完成后如果是发了一帧modbus报文想立刻切换收发模式不要直接去改方向先确认USART_FLAG_TC置位了再操作while (usart_flag_get(USART1, USART_FLAG_TC) RESET);这个等待循环在波特率低、数据长的时候会卡很久所以有条件的话建议放到发送DMA完成中断里做状态标记。4. 常见问题与排查技巧实录4.1 空闲中断一直触发收不到正常数据这个问题的原因绝大多数出在标志位清除顺序不对。GD32的IDLE标志清除必须是“先读STAT0寄存器再读DATA寄存器”如果只清中断挂起标志而不读数据寄存器中断标志并不会真正消失。调试时可以打印中断标志寄存器的值如果在没有数据的情况下它一直是置位状态基本就是这个原因。4.2 DMA接收数据错位、收一帧后会分段DMA配置成循环模式每收到一帧数据后DMA地址会自动从头开始。如果在处理数据期间没有关闭DMA通道新来的数据就会写到缓冲区头部把还没处理完的数据覆盖掉。解决方法是严格按前面的流程操作进中断后第一件事就是关闭DMA通道计算完长度、备份完数据后再重新打开。顺序不能反。分段的现象还要注意是不是波特率不匹配。上位机和下位机波特率不一致时收到的数据往往是错乱的这种错乱不是简单的丢字节而是夹杂着乱码。排除DMA代码之前先用最简单的轮询收发发一个固定数据确认链路本身没问题。4.3 DMA发送后数据发不出去先查发送DMA的通道是否被其他外设占用再查DMA的传输方向配置。还有一个很容易踩的坑DMA发送完成后要等TC标志很多人把这个等待放在某次发送的数据缓冲区是栈上的局部变量场景下结果DMA还没来得及搬完数据栈已经被释放了。这个问题的表象很奇怪有时候发得出去有时候发不出去而且改变优化等级后表现还不一样。稳定的做法是准备两块发送缓冲区交替使用每次发送后立刻切换模板确保CPU往缓冲区里填数据的时候DMA不会同时在读这块区域。4.4 用串口调试助手测试时的注意事项串口助手这端的PC串口如果是USB转TTL模块大概率用的CH340或者FTDI芯片第一次使用需要装驱动。Win10以上的系统一般能自动识别CH340但FTDI芯片如果是山寨版驱动会有问题这时候换一根正规的线最有性价比。测试时还要确认串口助手的打开速度不会影响DMA接收。上位机打开串口时会自动拉低RTS/DTR有些模块会因此复位导致MCU端重新初始化看起来就像DMA没配置成功。焊板子调试的时候尽量把RTS和DTR信号断开只用TXD/RXD/GND三根线。严格按照我的实操经验来调这几个问题基本上一两个小时就能把串口DMA这块稳定跑起来。4.5 缓冲区大小和数据速率怎么搭配缓冲区大小的设计直接决定了高波特率下的稳定性。简单估算一下波特率115200一个字节10位每秒就是11520字节一毫秒约11.5字节。如果业务上要求100ms内处理完一帧数据缓冲区至少要能装1152字节。实际项目我一般预留3倍余量取整到2的幂次这样后续扩展协议长度不用担心。DMA的number寄存器是16位的最大值65535如果缓冲区超过这个值就得拆成多段或者用链表结构。GD32F405RGT6内部SRAM有192KB串口缓冲区开4KB甚至8KB都不成问题。关键是其他业务也要用RAM你得算清楚账。5. 中断处理里的小技巧和性能优化5.1 空闲中断里尽量少做事中断服务函数讲究“快进快出”。我在项目里空闲中断只做一件事把接收到的数据拷贝到一个全局的帧缓冲区然后置一个标志位。真正的协议解析放在主循环或者一个优先级较低的任务里做。这样做的原因很简单如果协议解析占用的时间过长比如整个Modbus CRC计算和响应帧组包在中断里做的话下一帧数据来了DMA照样往后写如果缓冲区不够大很容易把正在处理的数据冲到。volatile uint8_t rx_frame_ready 0; uint8_t frame_buffer[FRAME_BUFFER_SIZE]; uint16_t frame_len 0; void process_rx_frame(uint8_t *buf, uint16_t len) { if (len FRAME_BUFFER_SIZE) { len FRAME_BUFFER_SIZE; } memcpy(frame_buffer, buf, len); frame_len len; rx_frame_ready 1; }5.2 直接操作寄存器也可以GD32的库函数封装得还算友好但追求极致性能时可以直接操作寄存器。比如获取DMA剩余字节数库函数dma_transfer_number_get底层就是读DMA_CHCNT寄存器这个读操作很快但加了一层函数调用开销。在中断频繁的场合直接把寄存器操作写在中断里也没问题。uint16_t remain_cnt DMA_CHCNT(DMA0, DMA_CH3);类似地清除空闲标志也可以用寄存器操作usart_interrupt_flag_clear(USART1, USART_INT_FLAG_IDLE);这句库函数就会展开成USART_STAT0和USART_DATA的两次读取。保持可读性优先的考虑我一般还是用库函数毕竟代码是写给下一个人看的。5.3 多个串口同时用DMA怎么调度GD32F405RGT6一共有5个串口如果全部跑DMA一共要占用10个DMA通道正好覆盖DMA0和DMA1的全部通道。这时候DMA带宽就变成瓶颈了。DMA0和DMA1是独立的控制器理论上可以并行工作但同一控制器内的多个通道有优先级仲裁。我建议把波特率高的串口配高优先级比如调试串口和主通信串口用115200以上优先级配HIGH低速日志串口配LOW避免高速数据抢占不到DMA总线。5.4 实际项目中的一个技巧空缓冲区回滚做接收处理时如果rx_len等于缓冲区大小说明数据已经填满了整个缓冲区这种情况下需要额外警惕有可能是缓冲区太小导致数据没接收完就发生回绕。处理办法是检查串口的过载错误标志USART_FLAG_ORERR如果是过载导致的溢出错误要把这个标志清掉并且考虑是否加大缓冲区或者降低单帧数据量。if (usart_flag_get(USART1, USART_FLAG_ORERR) ! RESET) { usart_flag_clear(USART1, USART_FLAG_ORERR); }这个错误标志不清理的话后续串口接收会一直处于异常状态。6. 调试工具与辅助手段6.1 串口助手的正确选型调试串口DMA离不开PC上的串口调试助手。SSCOM和老版XCOM是工程师用得最多的两个都支持定时发送和hex显示。我用下来SSCOM最稳的一点是可以自定义发送间隔方便模拟不定长帧。如果需要在Mac上调试minicom和SerialTools都行但注意macOS对CH340驱动支持一般需要单独装驱动。6.2 逻辑分析仪才是排查利器遇到uart时序问题的时候串口助手只能看到数据对不对看不到波形细节。这时候一个几十块钱的8通道逻辑分析仪比你调三天代码都管用。把TX和RX两根线分别挂在两个通道上采样率调到1MHz以上抓一帧数据出来看起始位、停止位电平变化马上就能判断是波特率偏差、信号干扰还是DMA配置错乱。我在调RS485通信的时候还遇到过一种情况发送完数据后没等发送移位寄存器完全结束就切换了方向导致最后一个字节半截卡在总线上挂逻辑分析仪才看清。这个经验也建议你们加到项目检查清单里。6.3 打印调试信息的正确姿势调试DMA接收的时候直接在中断里加printf不太现实串口助手看输出也会把收到的数据弄乱。我的做法是在串口DMA空闲中断里记录一个计数变量主循环里定时把这个计数打出来配合一个灯闪能快速定位中断有没有正常触发。volatile uint32_t idle_interrupt_count 0; // 在中断里 idle_interrupt_count; // 主循环里每1秒打印一次 printf(idle count: %lu\\r\\n, idle_interrupt_count);这个方法虽然土但真的比打断点、仿真器单步调试高效得多尤其是缓冲区数据量大的时候。7. 实际测试记录与效果对比这次调通以后我专门做了个简单测试上位机以115200波特率同时向USART1和USART2发数据每帧长度随机在5字节到200字节之间连续跑30分钟统计丢帧和错误字节率。最终测试结果非常干净DMA方式下CPU占用率远低于传统中断方式总帧数上万次错误率几乎为零。对比项传统中断方式串口DMA空闲中断115200下接收1000字节CPU占用约62%约18%921600下是否有丢字节偶发丢字节稳定不定长帧处理复杂度需软件超时判断硬件空闲中断自动完成多串口并发扩展性中断处理极易冲突DMA独立通道互不干扰这个测试数据说明了一件事DMA收发不是花架子对于频繁收发且数据量大的场景收益是实打实的。8. 做串口DMA时我最想提醒的几件事第一件事GD32F405的库和STM32的库代码长得像但寄存器和标志位名字不一样比如空闲中断标志从IDLE到DLE的演进不同版本库也可能有差异用之前一定要看清楚你当前用的库版本对应的头文件定义。我以前把标准库的代码直接搬到GD32上编译通过、运行不发数据最后查了一个下午发现DMA通道号映射压根对不上。第二件事一定要把“清除空闲标志的顺序”刻在脑子里先读状态寄存器再读数据寄存器。这个顺序错了外设就一直报空闲错误数据还收不进来而且这种问题特别难排查因为代码逻辑看起来是通的。第三件事接收缓冲区的大小不是随便拍的。结合你实际项目里的最大帧长度、CPU处理速度、波特率按我前面4.5节的估算方法算一个值出来再乘上1.5到3倍的余量。宁大勿小SRAM够用的话就多分配点不必心疼那几KB内存。缓冲区溢出导致的偶发丢帧比代码逻辑bug难查得多。最后再分享一个思路这套“DMA接收空闲中断”的方案不止适用于GD32F405同系列的GD32F103、GD32F303以及STM32的F1/F4系列原理完全相同差别只在外设库函数名称和DMA通道映射上。把这套逻辑理解透彻后换平台换型号基本一天以内就能把代码迁移过去。这大概就是串口DMA这套方案最值得投入时间的原因学会一次处处复用。本文还有配套的精品资源点击获取
返回列表