
GD32F303调试小记(一)之USART(接收中断、接收空闲中断DMA、发送DMA)GD32F303这块芯片最近在我一个项目里顶替了原来的STM32F103。板子打样回来其他外设都还算老实唯独USART这一块让我折腾了几个晚上。相信很多从STM32转过来的朋友都有同感GD32的库函数风格跟标准外设库很像但又处处透着一股微妙的不同一不小心就被它带沟里去了。这篇小记就从USART最常用的三种玩法说起——接收中断、空闲中断接DMA、发送DMA把我在调试中踩过的坑和最终跑通的代码都摊开来讲。先说清楚这篇东西适合谁看正在用GD32F303做串口通信、在固件库开发方式下被接收丢数据或者DMA配合问题困扰的嵌入式开发者。当然如果你只是想把收发逻辑跑通这篇文章也能帮你少走不少弯路。1. 为什么是USART而不是UART先厘清几个基础概念很多刚从8位单片机转过来的朋友会对USART和UART这两个缩写感到困惑更不用说I2C和SPI这些同门兄弟了。我在实际调试GD32F303的过程中觉得把这几个概念先理清楚非常有必要因为后面配置寄存器的时候你会频繁面对它们之间的差异。USART的全称是Universal Synchronous/Asynchronous Receiver/Transmitter也就是通用同步/异步收发器。它和UARTUniversal Asynchronous Receiver/Transmitter最大的区别在于USART多了一个同步模式可以额外输出一个时钟信号用于和外部设备进行同步通信。但在绝大多数实际应用中我们就是拿它当异步串口用跑115200或者9600的波特率跟PC、蓝牙模块、WiFi模块或者传感器通信。GD32F303上每个USART的引脚和中断向量都是独立的这一点在初始化时要特别留意别把USART0和USART1的中断服务函数搞混了。在实际项目中USART的使用频率远远高于I2C和SPI。这主要是因为串口通信协议简单、调试方便——一根TX一根RX就能通信连逻辑分析仪都不需要一个USB转TTL小板就能解决大部分调试问题。I2C和SPI虽然速度更快但接线更多调试起来就没有那么直观。这么说吧USART在嵌入式系统中的地位很像螺丝刀在工具箱里的地位——不是最先进的工具但绝对是使用频率最高的工具。GD32F303有多个USART外设具体型号不同数量有差异F303系列一般有USART0、USART1、USART2等每个都是独立的外设有自己独立的寄存器组和中断。2. GD32F303的USART初始化固件库方式下的最基础配置GD32的固件库开发方式跟STM32的标准外设库很像但函数名、结构体名都有不少差异。我刚开始用的时候总是不自觉地敲出USART_InitTypeDef这种STM32风格的代码然后在编译错误里一边骂一边改成GD32的写法。下面这套初始化代码是我在实际项目中验证过的可以直接抄。void usart_gpio_config(void) { /* 使能GPIO时钟和USART时钟 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART0); /* 配置GPIOTX为推挽复用输出RX为浮空输入或上拉输入 */ gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_9); /* PA9 - USART0_TX */ gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_10); /* PA10 - USART0_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 usart_config(void) { /* 配置USART参数 */ 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); }这里有几个STM32转过来的朋友容易犯的错误我逐个说一下。时钟使能这块GD32并没有像STM32那样把USART的时钟统一放在RCC_APB2Periph_ClockCmd这种函数里管而是用rcu_periph_clock_enable统一处理外设挂在哪条总线上就传对应的RCU标志。比如USART0挂APB2就是RCU_USART0USART1挂APB1就是RCU_USART1。如果按STM32的习惯只开了GPIO时钟而忘了开USART时钟寄存器操作会毫无反应而且不一定会报错这点排查起来最费时间。引脚复用这块GD32的gpio_af_set函数需要指定复用功能编号GPIO_AF_7对应USART0这个跟STM32的GPIO_PinAFConfig差不多但参数顺序不一样。如果你在STM32的代码基础上改很容易把引脚号和AF号写反编译不会报错但串口就是不出数据。波特率设置也有一点小坑。usart_baudrate_set内部会计算USARTDIV寄存器值这个计算依赖于__SYSTEM_CLOCK这个宏。如果你在系统时钟配置里把主频改了却没同步更新这个宏串口的实际波特率就会跟预期偏很多表现就是收到的全是乱码。我调试的时候遇到过一模一样的情况后来检查发现系统时钟配的是120MHz但宏定义还停留在108MHz改过来之后乱码立刻消失。2.1 关于引脚复用和重映射的注意事项GD32F303比STM32F103方便的一点是引脚复用功能更加灵活。比如USART0的TX/RX不一定非要PA9/PA10可能是PA9/PA10也可能是PB6/PB7具体取决于复用功能映射表。配置时务必查阅对应型号的数据手册在gpio_af_set里选择正确的AF编号。调试时如果串口完全没反应我建议顺着这个顺序排查先量引脚电平是否正常再看GPIO模式配置是否正确然后看AF复用编号对不对最后查系统时钟宏是否与实际一致。90%的串口不工作问题都出在这几步而不是出在USART寄存器配置上。3. 接收中断方式最朴素的收数据方案接收中断是最基础、最直观的串口数据接收方式——每收到一个字节硬件就触发一次中断你在中断服务函数里把数据读出来存进缓冲区。这种方式代码简单、逻辑清晰CPU能实时响应每一个字节的到来特别适合数据量不大、数据帧短的应用场景。GD32F303跟STM32一样接收中断使能的位置在usart_interrupt_enable函数里接收缓冲区非空对应的标志是USART_INT_RBNE。注意这里不是USART_INT_RXNEGD32的库把中断标志位命名从RXNE改成了RBNEReceive Buffer Not Empty一开始我在网上抄代码时抄到老版本或者STM32风格的代码编译直接报错。下面是我实际用的接收中断配置代码/* 初始化时使能接收中断 */ usart_interrupt_enable(USART0, USART_INT_RBNE); nvic_irq_enable(USART0_IRQn, 0, 0); /* 中断服务函数 */ void USART0_IRQHandler(void) { if (RESET ! usart_interrupt_flag_get(USART0, USART_INT_FLAG_RBNE)) { uint8_t ch usart_data_receive(USART0); /* 存入自己的环形缓冲区 */ ring_buffer_write(rx_ring, ch); } }使用接收中断的核心要点是每次进中断必须读取数据寄存器把RBNE标志清掉否则中断会反复触发系统直接卡死在中断里出不来。在实际调试中我发现如果只是简单地把收到的数据往缓冲区里塞这个方案确实够用。但面对以下两种场景接收中断就不够优雅了第一是高速率大数据量传输。假设波特率是921600每秒钟约有92KB的数据进来每个字节都触发一次中断CPU频繁进出中断导致主循环的实时性大打折扣其他任务比如电机控制、屏幕刷新就会受到明显影响。第二是帧结构不固定的场景。如果通信协议里有不定长的数据帧单纯靠接收中断你需要自己维护一套状态机去判断一帧数据什么时候收完既有状态判断的代码复杂度也很难保证所有边界条件都处理到位——比如半帧到达、超时判定的问题。数据量小、逻辑简单时可以选这个方式但做正经项目我建议直接考虑下一节讲的方式空闲中断DMA。4. 空闲中断DMA接收不定长数据的正确打开方式这是我在实际项目中最常用的方案也是GD32F303串口接收的核心玩法。它在接收中断的基础上引入两个进阶机制DMA负责把数据从串口数据寄存器搬到内存缓冲区空闲中断负责在一帧数据结束后通知CPU来取走数据。这两个机制配合起来的效果是整个接收过程几乎不消耗CPU时间直到一帧完整数据到达才触发一次中断CPU只需要在中断里把缓冲区里的数据拿走处理即可。4.1 DMA工作逻辑与配置DMADirect Memory Access直接存储器访问可以理解为CPU的专职快递员。你把收发地址和搬运数量告诉它它自己就能完成搬运工作搬完了再告诉你一声。CPU只需要在开始和结束时参与中间完全可以去忙别的事情。GD32F303的DMA有DMA0和DMA1两个控制器每个控制器有多个通道每个通道可以对应某个外设的特定请求。USART0的接收对应DMA0的通道4具体映射关系可以查阅参考手册中的DMA请求映射表配置前一定要核对清楚不然数据就是不动。下面是我在项目中实际用到的初始化代码#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_len 0; void dma_rx_config(void) { /* 使能DMA时钟 */ rcu_periph_clock_enable(RCU_DMA0); /* 初始化DMA */ dma_deinit(DMA0, DMA_CH4); dma_parameter_struct dma_init_struct; dma_struct_para_init(dma_init_struct); dma_init_struct.direction DMA_PERIPHERAL_TO_MEMORY; /* 外设到内存 */ dma_init_struct.memory_addr (uint32_t)rx_buffer; /* 内存地址 */ dma_init_struct.memory_inc DMA_MEMORY_INCREASE_ENABLE; /* 内存地址自增 */ dma_init_struct.memory_width DMA_MEMORY_WIDTH_8BIT; /* 数据宽度8位 */ dma_init_struct.number RX_BUFFER_SIZE; /* 搬运数量 */ dma_init_struct.periph_addr (uint32_t)USART_DATA(USART0); /* 外设地址注意不是USART0基址 */ dma_init_struct.periph_inc DMA_PERIPH_INCREASE_DISABLE; /* 外设地址不自增 */ dma_init_struct.periph_width DMA_PERIPHERAL_WIDTH_8BIT; /* 外设数据宽度8位 */ dma_init_struct.priority DMA_PRIORITY_HIGH; /* 优先级 */ dma_init(DMA0, DMA_CH4, dma_init_struct); /* 使能DMA通道 */ dma_circulation_enable(DMA0, DMA_CH4); /* 开启循环模式否则收满就停 */ dma_channel_enable(DMA0, DMA_CH4); }这个配置里没有任何寄存器直接跟USART通信的地方因为DMA的触发机制是串口每收到一个字节硬件会自动产生一个DMA请求DMA控制器响应请求后把数据从USART_DATA(USART0)搬到指定内存地址。这个联系不需要软件干预前提是你使能了USART的DMA请求功能。4.2 USART侧必须补上的DMA使能这一步是STM32用户踩坑的重灾区。很多人配好了DMA却发现数据不动就是因为漏了USART侧的DMA请求使能。GD32库里对应函数是usart_dma_receive_config(USART0, USART_DENR_ENABLE);这行代码必须在usart_enable()之后调用或者至少在使能USART之前配置好。它的作用是把USART接收路径跟DMA控制器接上线让串口收到数据后自动向DMA发出搬运请求。很多人把DMA配好、NVIC配好发现数据不进来检查半天最终发现漏了这行配置白白浪费半天时间。这个坑我印象太深了当时调试日志打印了一堆RX DMA not work后来查资料才反应过来GD32与STM32在这行的函数名和参数定义存在明显差异STM32是USART_DMACmd很容易惯性写错。使能DMA后还需要使能串口的空闲中断。空闲中断IDLE的定义是总线上出现一个字节时间的空闲电平也就是一帧数据结束后硬件检测到线路空闲了就会置位IDLE标志。这个标志不会自动清除需要软件操作USART_STAT0寄存器来清除。配置代码usart_interrupt_enable(USART0, USART_INT_IDLE); nvic_irq_enable(USART0_IRQn, 0, 0);4.3 中断服务函数如何正确读取数据与清标志空闲中断DMA方案的核心处理逻辑全在中断服务函数里。关键步骤有三个读一下USART状态寄存器让IDLE标志复位、计算DMA已经搬运了多少数据、把这部分数据交给上层协议处理。void USART0_IRQHandler(void) { if (RESET ! usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE)) { /* 读取状态寄存器使IDLE标志位复位 */ usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE); /* 数据接收完成计算DMA搬运了多少数据 */ uint32_t remain_count dma_transfer_number_get(DMA0, DMA_CH4); uint16_t received_len RX_BUFFER_SIZE - remain_count; if (received_len 0) { /* 把数据交给上层协议处理注意防止覆盖 */ protocol_process(rx_buffer, received_len); } /* 重新配置DMA搬运长度准备下一帧数据 */ dma_transfer_number_config(DMA0, DMA_CH4, RX_BUFFER_SIZE); dma_channel_enable(DMA0, DMA_CH4); } }这里面有几个关键细节直接关系到方案能不能稳定工作。关于清除IDLE标志GD32的数据手册有两种说法实践中我是通过重新读取状态寄存器来确认标志被清掉。如果不清除这个标志中断会反复进入CPU直接被钉死在中断里。如果清除方式不对也可能导致标志残留。稳妥的做法是调用一次usart_interrupt_flag_get读取状态如果标志仍然存在就再读一次数据寄存器确保标志被硬件清除。关于DMA剩余计数dma_transfer_number_get返回的是DMA还要搬多少字节才完成设定值。初始设定搬256字节如果当前已经搬了100字节这个函数返回156那么本次收到的数据长度就是256-156100字节。这个公式一定要记牢用反了会让数据长度变成负值。关于DMA循环模式与手动重配的选择我上面代码用的是非循环模式配合手动重配因为我需要知道每帧数据的确切长度再重新配置DMA的搬运长度。如果用了dma_circulation_enable开启循环模式DMA会自己在缓冲区里转圈写你还得额外处理缓冲区写指针与读指针的追及问题反而复杂化了。还有一个容易忽视的点dma_transfer_number_config重新配置之后如果DMA通道此前因为搬完而自动关闭了需要再次调用dma_channel_enable把它重新打开。如果不重新使能下一帧数据到了之后DMA根本没在工作数据全丢。4.4 为什么空闲中断DMA让我少写一百行代码对比一下两种方案就明白为什么我强烈推荐空闲中断DMA了。如果用纯接收中断来处理一帧不定长数据你需要定义帧头、帧尾、长度字段还要维护接收状态机处理半包、粘包、超时等问题。一个严谨的协议解析模块写下来几百行代码是少不了的。而用空闲中断DMA你只需要关心一次空闲代表一帧数据结束了然后在中断里把DMA缓冲区里的数据拿出来解析即可。帧边界由硬件帮你划分省掉了大量状态判断。当然这个方案也不是没有弱点。如果两个设备通信时发送方在一帧数据内部有毫秒级的间隔比如嵌入式设备在循环里多次调用usart_data_transmit两个字节之间隔了超过一个字节时间的空闲硬件就会认为这一帧结束了产生一次IDLE中断。这会导致原本的一帧被拆成两帧甚至多帧。解决办法有两个方向一是上层协议在数据里加入帧头帧尾和长度校验接收端做粘包解析二是在发送端把一帧数据一次性填入发送缓冲区后连续发送避免字节间的空闲间隔。做项目时我一般两者都用双保险。5. 发送DMA把CPU从逐字节搬运工的角色里解放出来串口发送如果不做特殊处理最笨的办法是查询发送数据寄存器为空然后一个字节一个字节地往外写。这在数据量小的时候无可厚非但一旦数据量大了比如要发送一个几百字节的日志或者固件升级包CPU大部分时间就耗在等待和搬运上。发送DMA的思路是把要发送的数据放到内存缓冲区告诉DMA把这段数据搬到串口数据寄存器去然后CPU就可以去干别的事了。DMA搬完后会触发传输完成中断你在中断里做后续处理。5.1 配置代码与触发流程void dma_tx_config(void) { rcu_periph_clock_enable(RCU_DMA0); dma_deinit(DMA0, DMA_CH5); /* USART0_TX在DMA0的通道5 */ dma_parameter_struct dma_init_struct; dma_struct_para_init(dma_init_struct); dma_init_struct.direction DMA_MEMORY_TO_PERIPHERAL; /* 内存到外设 */ dma_init_struct.memory_addr (uint32_t)tx_buffer; /* 待发送数据地址 */ dma_init_struct.memory_inc DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.memory_width DMA_MEMORY_WIDTH_8BIT; dma_init_struct.number 0; /* 初始不搬运 */ dma_init_struct.periph_addr (uint32_t)USART_DATA(USART0); dma_init_struct.periph_inc DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.periph_width DMA_PERIPHERAL_WIDTH_8BIT; dma_init_struct.priority DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH5, dma_init_struct); /* 使能DMA传输完成中断 */ dma_interrupt_enable(DMA0, DMA_CH5, DMA_INT_FTF); nvic_irq_enable(DMA0_Channel5_IRQn, 0, 0); /* 使能USART发送DMA请求 */ usart_dma_transmit_config(USART0, USART_DENT_ENABLE); } /* 发送一帧数据的入口函数 */ void usart_send_dma(uint8_t *data, uint16_t len) { /* 等待上一次发送完成 */ while (tx_busy); /* 拷贝数据到DMA发送缓冲区 */ memcpy(tx_buffer, data, len); /* 配置DMA搬运长度并启动 */ dma_transfer_number_config(DMA0, DMA_CH5, len); dma_channel_enable(DMA0, DMA_CH5); tx_busy 1; } /* DMA发送完成中断 */ void DMA0_Channel5_IRQHandler(void) { if (RESET ! dma_interrupt_flag_get(DMA0, DMA_CH5, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH5, DMA_INT_FLAG_FTF); dma_channel_disable(DMA0, DMA_CH5); tx_busy 0; } }发送DMA的完整流程是上层调用usart_send_dma把数据拷到DMA缓冲区配置好搬运长度使能DMA通道DMA开始工作把数据逐字节搬到USART数据寄存器硬件自动把数据从移位寄存器发出去搬完后DMA产生传输完成中断在中断里清标志、关通道、置tx_busy0。5.2 一个容易被忽略的时序问题发送DMA里有一个常见的时序问题DMA传输完成中断触发时数据其实已经从内存搬到串口数据寄存器了但串口移位寄存器可能还没把最后一个字节完全发出去。如果这时候你把USART关了、或者修改了串口配置、或者进入了低功耗模式最后一个字节就丢了。我自己就踩过这个坑。之前在一个OTA升级功能里发送完一包数据后马上进入bootloader跳转结果最后一包偶尔会丢最后一个字节导致校验失败。排查了半天才发现问题不在校验算法而是DMA完成中断触发太早最后一个字节还在移位寄存器里没出去。解决办法有两个一是在DMA完成中断里加一个短暂延时等最后一个字节发送完毕再继续后面的操作二是查询USART_STAT0的TCTransmission Complete标志等它置位后再做后续操作。void DMA0_Channel5_IRQHandler(void) { if (RESET ! dma_interrupt_flag_get(DMA0, DMA_CH5, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH5, DMA_INT_FLAG_FTF); dma_channel_disable(DMA0, DMA_CH5); /* 等待最后一个字节真正发送完成 */ while (RESET usart_flag_get(USART0, USART_FLAG_TC)); usart_flag_clear(USART0, USART_FLAG_TC); tx_busy 0; } }查TC标志比延时可靠得多因为它跟波特率无关无论9600还是921600都能精确等待到发送完成那一刻。6. 调试工具与常见问题定位方法串口调试这东西光靠眼睛看波形是不够的手里必须有趁手的工具。我在调试GD32F303的USART过程中常用的工具主要有三个逻辑分析仪、USB转TTL模块和串口调试助手。逻辑分析仪是最有价值的排查工具。它能直接看到TX和RX引脚上的电平变化从波形里一眼就能判断出波特率对不对、数据位和停止位有没有配错、有没有额外的毛刺干扰。有一回我的串口在115200波特率下通信正常但换成460800就全是乱码用逻辑分析仪一看波形的单个bit宽度明显不对最后查明是系统时钟配置的宏跟实际时钟不一致导致的跟我在第2节说的问题是同一类坑。USB转TTL模块时要注意电平匹配GD32F303是3.3V供电的如果用5V的TTL模块直接连长期使用有烧引脚的风险。建议选带电平转换的模块或者确认模块工作在3.3V模式。串口调试助手方面常见的工具有SSCOM、XCOM、MobaXterm的串口插件等。调试时建议打开Hex显示模式这样可以清楚地看到每个字节的原始值特别适合排查协议解析问题。6.1 接收中断不触发的排查链路如果接收中断完全不触发我会按以下顺序排查检查USART时钟和GPIO时钟是否都使能了用rcu_periph_clock_enable分别使能缺一不可。确认引脚复用配置正确用万用表或示波器测量RX引脚在空闲状态下是否为高电平如果被拉低可能是外部设备没接对。确认NVIC配置正确中断优先级分组有没有设置。GD32和STM32一样要先设置优先级分组再使能NVIC。检查中断服务函数名是否跟启动文件里的向量表一致。GD32的USART0中断服务函数是USART0_IRQHandler如果写成USART1_IRQHandler编译不报错但中断永远进不去。用逻辑分析仪确认外部设备确实在发数据这一步经常能排除单片机没问题对端根本没发的情况。6.2 DMA搬运不动的排查链路DMA配好了但数据不搬这个问题的排查链路跟接收中断不太一样确认DMA通道和外设的映射关系是否正确。GD32F303里USART0的接收对应DMA0的通道4发送对应DMA0的通道5不同型号可能不同务必查手册。确认USART侧的DMA请求已经使能。这一步是最容易忽略的usart_dma_receive_config(USART0, USART_DENR_ENABLE)这行代码不能少。确认DMA初始化参数正确特别是periph_addr。我看到有些代码直接把USART0基址传进去那是错的应该传USART_DATA(USART0)也就是数据寄存器的地址。用调试器查看DMA通道的控制寄存器确认通道是使能状态并且CHXCTL寄存器里的外设地址和内存地址是你预期的值。在中断里打印DMA剩余计数判断DMA到底有没有被触发过。如果剩余计数一直是初始值说明DMA根本没收到请求如果计数在减少说明DMA在工作问题可能在中断处理逻辑上。6.3 乱码问题根因分析乱码是串口调试里最让人头疼的问题之一它的根因通常跑不出这几类波特率失配是最大的嫌疑。发送端和接收端的波特率公差超过正负2%到3%就会出错所以一定要确认双方的波特率配置完全一致并且系统时钟的频率计算准确。数据格式不匹配也很常见。发送端配了8位数据位、无校验、1位停止位接收端却配了9位数据位或者偶校验收到的数据必然乱套。共地问题也可能导致乱码。如果两个设备各自供电没有共地参考电平不一致串口通信就会不稳定。特别是用USB转TTL模块调试时模块的地和板子的地一定要连在一起。针对乱码我建议用逻辑分析仪抓波形把波特率算出来跟设置的对比比瞎猜效率高太多。7. 环形缓冲区让接收数据不丢的保险柜在接收中断方案中我提到了用环形缓冲区来暂存数据。很多人觉得这只是个小技巧但在我的项目经验里它的重要性不亚于DMA配置本身。环形缓冲区本质上是一个固定大小的数组配合读写指针的移动来实现先进先出的队列效果。读指针指向下一次要读取的数据位置写指针指向下一次要写入的位置。当写指针追上读指针时缓冲区满了可以选择丢弃新数据或者覆盖旧数据当读指针追上写指针时缓冲区空了读取操作应返回无数据。typedef struct { uint8_t buffer[RX_BUFFER_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; void ring_buffer_write(ring_buffer_t *rb, uint8_t data) { uint16_t next (rb-head 1) % RX_BUFFER_SIZE; if (next ! rb-tail) { /* 缓冲区未满 */ rb-buffer[rb-head] data; rb-head next; } } uint8_t ring_buffer_read(ring_buffer_t *rb, uint8_t *data) { if (rb-head rb-tail) { return 0; /* 缓冲区空 */ } *data rb-buffer[rb-tail]; rb-tail (rb-tail 1) % RX_BUFFER_SIZE; return 1; }这里的head和tail必须用volatile修饰因为它们在中断和主循环两个上下文里都会读写。不加volatile的话编译器可能把变量值优化进寄存器导致读到过期数据。环形缓冲区在接收中断方案里配合得很好中断里只管往里写主循环里按需取出来解析二者互不阻塞。还有一种做法是根据接收到的数据量动态处理如果接收的数据比较长可以先收进缓冲区再一次性处理如果数据比较短可以立即处理。8. 完整示例代码一个开箱即用的串口收发框架说了这么多最终落地还是要看完整代码。下面这套代码在GD32F303上验证过实现了接收中断空闲中断DMA的接收以及发送DMA的发送可以直接作为项目模板使用。#include gd32f30x.h #include string.h #define RX_BUFFER_SIZE 256 #define TX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; uint8_t tx_buffer[TX_BUFFER_SIZE]; volatile uint16_t rx_len 0; volatile uint8_t tx_busy 0; /* 串口初始化GPIO USART 中断 DMA */ void usart_init(void) { /* GPIO时钟 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART0); rcu_periph_clock_enable(RCU_DMA0); /* GPIO复用配置 PA9TX, PA10RX */ 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_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); /* USART参数 */ 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); /* DMA接收配置USART0_RX - DMA0_CH4 */ dma_deinit(DMA0, DMA_CH4); dma_parameter_struct dma_rx_init; dma_struct_para_init(dma_rx_init); dma_rx_init.direction DMA_PERIPHERAL_TO_MEMORY; dma_rx_init.memory_addr (uint32_t)rx_buffer; dma_rx_init.memory_inc DMA_MEMORY_INCREASE_ENABLE; dma_rx_init.memory_width DMA_MEMORY_WIDTH_8BIT; dma_rx_init.number RX_BUFFER_SIZE; dma_rx_init.periph_addr (uint32_t)USART_DATA(USART0); dma_rx_init.periph_inc DMA_PERIPH_INCREASE_DISABLE; dma_rx_init.periph_width DMA_PERIPHERAL_WIDTH_8BIT; dma_rx_init.priority DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH4, dma_rx_init); dma_channel_enable(DMA0, DMA_CH4); /* DMA发送配置DMA0_CH5 - USART0_TX */ dma_deinit(DMA0, DMA_CH5); dma_parameter_struct dma_tx_init; dma_struct_para_init(dma_tx_init); dma_tx_init.direction DMA_MEMORY_TO_PERIPHERAL; dma_tx_init.memory_addr (uint32_t)tx_buffer; dma_tx_init.memory_inc DMA_MEMORY_INCREASE_ENABLE; dma_tx_init.memory_width DMA_MEMORY_WIDTH_8BIT; dma_tx_init.number 0; dma_tx_init.periph_addr (uint32_t)USART_DATA(USART0); dma_tx_init.periph_inc DMA_PERIPH_INCREASE_DISABLE; dma_tx_init.periph_width DMA_PERIPHERAL_WIDTH_8BIT; dma_tx_init.priority DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH5, dma_tx_init); /* DMA发送完成中断 */ dma_interrupt_enable(DMA0, DMA_CH5, DMA_INT_FTF); nvic_irq_enable(DMA0_Channel5_IRQn, 0, 1); /* USART DMA请求使能 */ usart_dma_receive_config(USART0, USART_DENR_ENABLE); usart_dma_transmit_config(USART0, USART_DENT_ENABLE); /* USART使能 */ usart_enable(USART0); /* 空闲中断使能 */ usart_interrupt_enable(USART0, USART_INT_IDLE); nvic_irq_enable(USART0_IRQn, 0, 0); } /* 串口接收中断 */ void USART0_IRQHandler(void) { /* 空闲中断一帧数据接收完成 */ if (RESET ! usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE)) { /* 清除IDLE标志 */ usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE); /* 计算收到多少数据 */ uint32_t remain dma_transfer_number_get(DMA0, DMA_CH4); rx_len RX_BUFFER_SIZE - remain; if (rx_len 0) { /* 这里放你的协议解析函数或者直接处理数据 */ protocol_process(rx_buffer, rx_len); } /* 重新配置并重启DMA */ dma_transfer_number_config(DMA0, DMA_CH4, RX_BUFFER_SIZE); dma_channel_enable(DMA0, DMA_CH4); } } /* DMA发送完成中断 */ void DMA0_Channel5_IRQHandler(void) { if (RESET ! dma_interrupt_flag_get(DMA0, DMA_CH5, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH5, DMA_INT_FLAG_FTF); dma_channel_disable(DMA0, DMA_CH5); /* 等待最后一个字节真正发完 */ while (RESET usart_flag_get(USART0, USART_FLAG_TC)); usart_flag_clear(USART0, USART_FLAG_TC); tx_busy 0; } } /* 发送接口 */ void usart_send_dma(uint8_t *data, uint16_t len) { while (tx_busy); memcpy(tx_buffer, data, len); dma_transfer_number_config(DMA0, DMA_CH5, len); dma_channel_enable(DMA0, DMA_CH5); tx_busy 1; } int main(void) { /* 系统时钟初始化 */ system_clock_config(); /* 串口初始化 */ usart_init(); while (1) { /* 主循环处理接收数据、执行其他任务 */ } }这套框架在实际项目里用了挺久稳定性还是可以的。但有几个使用前提要注意rx_len在中断里赋值、在主循环里读取需要注意访问时序必要时加临界区保护如果协议解析耗时较长不要在中断里做把数据拷贝到另一个缓冲区后置标志主循环再处理DMA缓冲区的大小要根据最大帧长度来定宁可大一点也别让一帧数据超过缓冲区长度。9. 经验总结与后续计划GD32F303的USART外设整体设计思路和STM32非常接近但库函数命名、寄存器位定义、DMA通道映射这些细节差异足够让一个粗心的开发者折腾好几个晚上。我从这次调试中提炼出的核心经验有三条第一先确认时钟和引脚复用再谈其他。串口不工作八成是时钟没开全或者AF配置不对这两步排查干净了后面就顺畅了。第二空闲中断DMA是接收不定长数据的正解。它把帧边界检测和搬运工作都交给了硬件CPU只需要在一帧结束时介入一次省下的精力可以放到业务逻辑上。第三DMA完成中断不代表数据已经发完。最后一个字节可能还在移位寄存器里如果需要紧接着做掉电、跳转、关串口等操作务必先等待TC标志。这篇是第一篇小记接下来我计划把GD32F303的定时器、ADC、DAC和CAN总线也逐步整理出来尤其是定时器PWM输出和编码器接口在电机控制里的实际使用经验踩过的坑一样不会少写。调试这条路跌跌撞撞走多了也就走成了一条直路。