ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

GD32F303 USART+DMA收发详解:从原理到代码实现

GD32F303 USART+DMA收发详解:从原理到代码实现 简介这套资源是“GD32F303固件库开发”系列第9篇的配套工程代码面向使用兆易创新GD32F303进行嵌入式开发的工程师与学生重点演示通过DMA方式实现USART串口收发可有效降低串口通信时的CPU占用并减少数据量大时可能出现的丢包问题。压缩包共包含1251个文件以C源码和H头文件为主体同时提供Keil、IAR等常见IDE的工程文件以及编译生成的hex、bin固件便于直接对照学习或烧录验证整体仅4.18MB结构紧凑、易于下载使用。目前已有3329人学习下载。代码中已配置好串口与DMA通道并给出清晰的初始化与中断处理流程读者可结合配套CSDN文字教程和B站教学视频快速理解外设时钟、DMA请求映射、缓冲区管理等关键知识点进而轻松迁移到自己的实际项目中。1. 为什么USART要配DMA先搞清楚这件事值不值得做1.1 串口收发的痛点到底在哪做GD32F303开发的朋友应该都有过这种经历用轮询方式发一串日志数据CPU就在那死等一个字节一个字节往数据寄存器里塞期间什么事情都干不了用中断方式稍微好一点但来一个字节进一次中断如果波特率跑到115200以上加上系统里还有定时器、ADC、显示刷新这些任务中断嵌套一多整个系统的实时性很容易被拖垮。我之前有一个项目用GD32F303做数据采集和上报串口跑到460800波特率每秒大约46000字节如果用传统中断方式每字节进一次中断意味着每秒几万次中断响应光是保存现场、恢复现场就得消耗大量CPU周期。后来我把接收改成DMA空闲中断发送改成DMA完成中断从那个版本开始CPU占用率肉眼可见地降下来了。所以这个标题里USART通过DMA收发这件事本质上解决的问题是怎么用最少的CPU介入把串口数据从外设搬到内存再从内存搬到外设。GD32F303的DMA控制器就是专门干这个的它不需要CPU逐字节搬运配置好源地址、目的地址、传输长度之后硬件自己就把活儿干完干完了再通知你一声。1.2 DMA在这里扮演的角色GD32F303内置的DMA控制器有多个通道每个通道可以关联不同的外设请求USART的发送和接收都有独立的DMA请求线。当串口接收寄存器里有数据时硬件会自动触发DMA搬运把数据从串口数据寄存器搬到你在内存里开辟的缓冲区当你的程序往发送缓冲区写入数据并启动DMA后DMA会一个个字节把数据发出去全部发完产生一个传输完成中断。你可以把DMA理解成一个跑腿的你告诉它把这筐苹果从A搬到B它就一趟一趟搬搬完回来跟你说一声。搬运期间你自己该干嘛干嘛完全不用管。这对于需要频繁收发、或者数据量不固定的场景效果立竿见影。不过GD32F303的USARTDMA有一个很关键的细节USART的接收DMA在接收到固定长度数据后才能触发完成中断。也就是说如果你不知道对方会发多少字节单纯靠DMA传输完成来判断一帧数据结束了很可能等不到这个中断。常规做法是开启串口的空闲中断IDLE用DMA搬运空闲中断组合判定一帧接收完成。后面我会详细说这个实现。2. 动手前的硬件与固件库基础2.1 引脚与串口初始化我用的是GD32F303系列不同型号引脚和串口号有差异但用固件库初始化串口的写法基本一致。以USART0为例它默认复用PA9作为TX、PA10作为RX需要使能GPIOA和USART0的时钟然后配置引脚为复用推挽输出和浮空输入或者上拉输入看硬件电路。/* 使能时钟 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART0); rcu_periph_clock_enable(RCU_DMA0); /* 配置PA9为USART0_TXPA10为USART0_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);然后配置串口参数9600、115200、460800都可以根据自己的需求来。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, USART_RECEIVE_ENABLE); usart_transmit_config(USART0, USART_TRANSMIT_ENABLE); usart_enable(USART0);这里有个细节如果只做发送不使能接收寄存器非空中断接收功能仍然可以正常由DMA搬运。接收DMA的触发条件是串口接收数据寄存器非空这个和中断使能是独立的不要混淆。2.2 DMA通道映射关系GD32F303有DMA0和DMA1两个控制器不同串口对应的请求通道号不一样我一开始就没看手册想当然地用了DMA0_Channel3结果数据根本不进缓冲区查了半天才发现通道映射错了。以USART0为例发送请求DMA0的通道4接收请求DMA0的通道3如果是USART1通道映射又不一样。这个一定要去看GD32F303用户手册里的DMA请求映射表不要凭经验猜。固件库提供了dma_request_config之类的接口来设置请求来源但这个并不代表通道一定匹配通道和请求的对应关系是芯片厂家设计死的选错了就收不到数据。2.3 固件库配置DMA的常规写法无论是发送还是接收DMA的配置项都差不多外设基地址、存储器地址、传输方向、缓冲区大小、传输宽度、优先级、是否开启循环模式。发送和接收的区别主要在传输方向和是否循环上。发送DMA的方向是存储器到外设接收DMA的方向是外设到存储器。发送通常不开启循环模式一帧数据发完就停接收通常会开启循环模式这样DMA在缓冲区满了之后会自动回到起始地址继续写配合空闲中断可以连续接收不定长数据。3. 代码实现DMA发送与DMA接收完整流程3.1 DMA发送的实现与注意事项发送的逻辑相对简单。定义一个发送缓冲区当需要发送数据时把数据拷到缓冲区然后启动一次DMA传输传输完成中断里做清理工作。#define USART0_TX_BUFFER_SIZE 256 uint8_t usart0_tx_buffer[USART0_TX_BUFFER_SIZE]; volatile uint8_t usart0_tx_busy 0; void usart0_dma_send(uint8_t *data, uint16_t len) { if (len 0 || len USART0_TX_BUFFER_SIZE) { return; } while (usart0_tx_busy) { /* 等待上一次发送完成 */ } memcpy(usart0_tx_buffer, data, len); dma_channel_disable(DMA0, DMA_CH4); dma_memory_address_config(DMA0, DMA_CH4, DMA_MEMORY_0, (uint32_t)usart0_tx_buffer); dma_transfer_number_config(DMA0, DMA_CH4, len); dma_channel_enable(DMA0, DMA_CH4); usart0_tx_busy 1; } void DMA0_Channel4_IRQHandler(void) { if (dma_interrupt_flag_get(DMA0, DMA_CH4, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH4, DMA_INT_FLAG_FTF); dma_channel_disable(DMA0, DMA_CH4); usart0_tx_busy 0; } }这里有个细节值得说发送DMA的触发条件是串口的发送数据寄存器为空DMA会自动把下一个字节写入到串口数据寄存器。但需要在启动DMA之前先确保串口的发送功能已经使能。另外DMA发出第一个字节之前串口数据寄存器需要处于空状态如果之前有残留数据最好先清一下USART_STAT里的TC标志。实际调试中我踩过一个坑如果DMA传输完成中断里不先失能通道再次启动前直接改传输长度会出现第一次正常、第二次卡死的问题。原因是DMA还在使能状态时配置通道寄存器会冲突所以每次启动前都要先失能通道配置完成后再使能这个顺序不能省。3.2 DMA接收固定长度与不定长两种思路接收分两种情况一种是你提前知道对方会发多少字节比如读取传感器时固定返回8个字节那直接配好DMA传输长度为8接收完成中断来了就处理另一种是不知道对方发多少字节比如串口调试指令、Modbus报文、GPS数据这种就需要空闲中断来辅助判断一帧结束。固定长度的实现dma_channel_disable(DMA0, DMA_CH3); dma_periph_address_config(DMA0, DMA_CH3, (uint32_t)USART_DATA(USART0)); dma_memory_address_config(DMA0, DMA_CH3, DMA_MEMORY_0, (uint32_t)usart0_rx_buffer); dma_transfer_number_config(DMA0, DMA_CH3, 8); dma_channel_enable(DMA0, DMA_CH3);只要收到8个字节DMA通道3的传输完成中断就会触发在中断里处理数据即可。但问题来了如果对方发了7个字节就不发了DMA永远等不到第8个字节你这个接收就卡死了。所以严格来说固定长度接收只适合帧长度完全固定的协议。实际项目中更实用的是不定长接收DMA开启循环模式缓冲区大小定义为最大帧长度比如256字节然后使能串口的空闲中断。每当总线上出现一个字节间隔空闲状态就认为一帧数据发完了在空闲中断里根据DMA当前写到了缓冲区的哪个位置算出收到的数据长度然后把数据拷贝走。3.3 空闲中断DMA循环模式不定长接收的标准姿势先看一下初始化部分#define USART0_RX_BUFFER_SIZE 256 uint8_t usart0_rx_buffer[USART0_RX_BUFFER_SIZE]; volatile uint16_t usart0_rx_last_pos 0; void usart0_dma_rx_init(void) { dma_channel_disable(DMA0, DMA_CH3); dma_deinit(DMA0, DMA_CH3); dma_periph_address_config(DMA0, DMA_CH3, (uint32_t)USART_DATA(USART0)); dma_memory_address_config(DMA0, DMA_CH3, DMA_MEMORY_0, (uint32_t)usart0_rx_buffer); dma_transfer_number_config(DMA0, DMA_CH3, USART0_RX_BUFFER_SIZE); dma_priority_config(DMA0, DMA_CH3, DMA_PRIORITY_HIGH); dma_transfer_direction_config(DMA0, DMA_CH3, DMA_PERIPH_TO_MEMORY); dma_memory_width_config(DMA0, DMA_CH3, DMA_MEMORY_WIDTH_8BIT); dma_periph_width_config(DMA0, DMA_CH3, DMA_PERIPHERAL_WIDTH_8BIT); dma_circulation_enable(DMA0, DMA_CH3); dma_channel_enable(DMA0, DMA_CH3); /* 使能串口空闲中断 */ usart_interrupt_enable(USART0, USART_INT_IDLE); /* 注意清空闲标志的方法是先读USART_STAT再读USART_DATA */ if (usart_flag_get(USART0, USART_FLAG_IDLE)) { usart_flag_clear(USART0, USART_FLAG_IDLE); } nvic_irq_enable(USART0_IRQn, 1, 0); nvic_irq_enable(DMA0_Channel3_IRQn, 0, 0); }接收处理的重点在串口中断服务函数里。GD32F303的清空闲标志和STM32一样需要先读状态寄存器再读数据寄存器否则清不掉。void USART0_IRQHandler(void) { if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE)) { /* 先读状态再读数据清除IDLE标志 */ usart_flag_clear(USART0, USART_FLAG_IDLE); /* 计算当前DMA写到了哪个位置 */ uint16_t cur_pos USART0_RX_BUFFER_SIZE - dma_transfer_number_get(DMA0, DMA_CH3); /* 处理一帧新数据 */ if (cur_pos ! usart0_rx_last_pos) { uint16_t recv_len; if (cur_pos usart0_rx_last_pos) { recv_len cur_pos - usart0_rx_last_pos; handle_uart_frame(usart0_rx_buffer[usart0_rx_last_pos], recv_len); } else { /* 缓冲区回绕了 */ recv_len USART0_RX_BUFFER_SIZE - usart0_rx_last_pos cur_pos; handle_uart_frame(usart0_rx_buffer[usart0_rx_last_pos], USART0_RX_BUFFER_SIZE - usart0_rx_last_pos); handle_uart_frame(usart0_rx_buffer[0], cur_pos); } usart0_rx_last_pos cur_pos; } } }这段代码里有几个关键逻辑dma_transfer_number_get返回的是DMA剩余要传输的字节数拿缓冲区总大小减去剩余数就能算出DMA已经写了多少个字节。因为是循环模式DMA写完256字节后会自动回绕到0所以计算新数据长度时要考虑当前位置小于上次位置的回绕情况。帧处理函数里建议先把数据拷贝到自己的协议缓冲区再做解析不要直接在DMA缓冲区上解析。不然数据还没处理完DMA又写入新数据把缓冲区覆盖了。3.4 发送完成中断里该做什么发送DMA完成中断的优先级通常不需要很高因为发送本身不着急。但要注意一点发送完成中断触发的前提是DMA把所有字节都搬到了串口的发送数据寄存器但这时候最后一个字节可能还没有完全从移位寄存器发出去。如果紧接着就要切换RS485的收发方向或者关闭串口你需要额外等待USART的TC标志置位。对于RS485方向切换我习惯的做法是在DMA发送完成中断里不清DMA而是等TC标志void DMA0_Channel4_IRQHandler(void) { if (dma_interrupt_flag_get(DMA0, DMA_CH4, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH4, DMA_INT_FLAG_FTF); dma_channel_disable(DMA0, DMA_CH4); /* 等待最后一个字节完全发送出去 */ while (!usart_flag_get(USART0, USART_FLAG_TC)); usart0_tx_busy 0; /* 在这里切换RS485方向或做其他收尾操作 */ } }等待TC标志这个操作很快一般也就几微秒但对RS485通信来说这一个细节决定了你最后一字节会不会被截断。4. 实测遇到的那些坑与排查技巧4.1 常见问题速查表我整理了这段时间调试USARTDMA遇到的高频问题按症状→原因→解法列个表症状大概率原因解决办法DMA接收完全没数据DMA通道和串口请求不匹配查手册确认USART0对应DMA0的通道3/4收到数据是乱的外设/内存宽度配置不一致统一配置为8bit宽度第一帧正常后续卡死DMA未关闭就重新配置先dma_channel_disable再配置再enable空闲中断一直触发IDLE标志没清干净读状态寄存器后再读数据寄存器DMA发送完成但数据不完整没有等待USART TC标志在DMA完成中断里轮询TC数组越界/跑飞接收数据长度超过缓冲区缓冲区大小要按最大帧长留余量处理函数做长度校验循环模式接收乱序未处理缓冲区回绕用cur_pos last_pos判断回绕分两段处理4.2 关于USART空闲标志的清除顺序GD32固件库里清IDLE标志有一个很隐蔽的坑。如果你直接调用usart_flag_clear(USART0, USART_FLAG_IDLE)有些库版本会正常清掉但有些版本因为库的封装方式问题清不掉导致一进中断就出不来的假象。稳妥写法是usart_interrupt_flag_clear(USART0, USART_INT_FLAG_IDLE);或者用最原始的寄存器操作uint32_t temp USART_STAT(USART0); temp USART_DATA(USART0); (void)temp;先读STAT再读DATA这是GP32和STM32系通用的清IDLE逻辑。我建议在中断里直接用寄存器读的方式最不容易踩坑。4.3 缓冲区大小与DMA传输计数器的配合还有一个容易被忽视的地方DMA循环模式下dma_transfer_number_get返回的值在传输过程中是动态变化的但它是递减计数初始化时是缓冲区大小每搬运一个字节减1搬完一轮后重新装载为初始值。所以计算已接收字节数时用缓冲区大小-当前传输计数在大多数时刻是对的。但如果恰好赶上DMA刚完成一轮回绕、计数器重新装载而数据还没来得及写入缓冲区你算出来的位置可能暂时是0或缓冲区大小。这种极短时间内偏差可以接受因为你的处理函数有长度判断最多把一帧数据切分成两段处理。自己写协议时建议用帧头帧尾长度字段的方式做二次校验不要把DMA位置计算当成100%准确的帧边界。4.4 实测中的性能提升体验项目里我实际测过一组对比数据MCU主频120MHz串口460800波特率每50ms向主机发送一帧约200字节的波形数据。用传统逐字节中断发送时CPU在发送期间的占用率大约能到30%中间还夹杂着其他中断改成DMA发送后CPU只需把数据拷到缓冲区并启动DMA剩下的时间全部释放给主循环做算法处理CPU占用率在发送期间掉到5%以内。接收侧更是明显。之前是每字节进一次接收中断在高速数据流场景下经常丢字节因为CPU来不及读改成DMA空闲中断后数据直接进内存中断频率从每秒几万次降为每帧一次几乎不存在丢字节的问题了。5. 最后想分享的一个排障技巧调试DMA类问题最忌凭感觉改代码。我调试USART收不到数据时一定会先用逻辑分析仪或者串口助手确认串口本身有没有波形数据进来这个确认完之后再分两步排查先关掉DMA用轮询方式读取串口数据确认串口外设工作正常再配DMA但把DMA中断关了只在主循环里查询DMA传输计数是否在变化。如果计数在变说明DMA搬运是通的问题在中断处理上如果计数不变说明DMA根本没被触发基本就是通道映射或者外设请求配置的问题。这个排查思路帮我避开了很多弯路。你如果第一次调USARTDMA建议也按这个顺序走一遍比自己瞎猜哪里的配置不对要高效得多。本文还有配套的精品资源点击获取
返回列表