GD32F470六路串口程序拆解:引脚复用、波特率误差与中断调度
简介这是一份基于GD32F470微控制器的六路串口通信工程面向嵌入式开发及GD32进阶学习者。程序以串口中断方式实现多路数据收发覆盖初始化配置、中断优先级分配、环形缓冲区设计、硬件或软件流控制等关键环节代码结构清晰便于直接复用或裁剪扩展。压缩包共184个文件以C源码与头文件为核心配合uvprojx/uvoptx工程配置、axf/hex可执行镜像、map映射表并包含SDRAM、CAN、Flash等外设参考代码包体约9.68MB。工程还保留了uart1/uart6等关键头文件的bak备份便于对照修改与恢复。目前已有403人学习该工程适合需要快速搭建多串口应用、研究中断服务程序写法或排查UART通信问题的软硬件工程师参考。1. 六路串口不是六份复制先看清这份 GD32F470-6路串口程序的组织方式拿到 GD32F470-6路串口程序 这份工程时我第一反应是去数它有没有把初始化代码偷懒复制六遍。看完整套代码之后有点意外USART0 到 UART5 的时钟、引脚、中断和收发缓冲全部收在了一张表驱动的框架里改某一路的波特率或者换引脚只需要动配置表中断函数和收发逻辑完全不用碰这个组织方式比我预想的干净。这份程序解决的是 GD32F470 这类大封装 MCU 上最常见的“外设多、引脚挤”问题。六路串口要同时挂 485 仪表、蓝牙模块、GPS、调试口和两块串口屏引脚怎么分配、中断怎么排队、收发缓冲区怎么不互相挤占才是真正让人头疼的地方。适合正在画板子选引脚、或者已经画完板子但串口调不通的开发者。下面我按规划、初始化和排错的顺序把这份程序的实现思路和踩过的坑一次讲透。2. 串口资源规划引脚复用表与时钟树里的波特率误差2.1 USART 与 UART 的差异先弄清六路里哪些带同步时钟GD32F470 的串口外设分两类一类叫 USART支持同步模式和硬件流控另一类叫 UART只有异步模式。这份程序的六路里USART0 到 USART3 属于前者UART4、UART5 属于后者。如果外设里有一路是 SPI 转串口芯片或者需要接 485 方向控制脚优先把 USART 让给它因为同步模式和 RTS/CTS 流控只有 USART 才有UART 想用硬件流控也没门。把串口按需求分成“要流控的”“要同步的”“只收不发的”“纯调试的”四类再对应到具体外设上比拿到工程就改波特率要靠谱得多。引脚复用是规划阶段最先翻车的地方。GD32 的串口引脚复用每路串口一般有多组引脚可选比如 PA、PB、PC、PD 上都有同名串口的映射但不是随便挑两个引脚就能组一对TX 和 RX 必须落在同一组里。我一般先把数据手册的复用表打开按外设把所有可用组列出来划掉跟板上已有器件冲突的引脚再决定每组怎么选。规划时我会按下面的顺序做判断先固定不能动的接了传感器、显示屏等已经画死在板子上的串口它们的引脚组就是确定的。再给高实时性的外设挑引脚比如 GPS 每秒来一次数据中断优先级要求高给它选一个跟其他外设没有复用冲突的引脚组。最后安排调试串口调试口最灵活放到哪组剩下就给哪组而且最好选一个能引到板边的引脚方便接 USB 转串口模块。这里有一个细节坑同一路串口的多个引脚组AF 复用编号可能不一样。比如某路串口的 PA 组 AF 号是 AF7换到 PD 组变成 AF8。初始化时如果照着 PA 组的 AF 号填到 PD 组上引脚功能就错了表现是发送没波形、接收永远收不到。这份程序在配置表里专门留了gpio_af字段每个引脚组单独填一个 AF 号而不是整个串口共用一个原因就在这里。2.2 APB1 与 APB2 分频同一份波特率代码误差差出一倍串口时钟频率不是直接来自主频而是来自 APB 总线。GD32F470 的 USART0 挂在 APB2 上其余五路挂在 APB1 上具体归属受芯片型号和系统时钟配置影响打开工程里的系统时钟文件确认一下最稳。两条 APB 总线的预分频系数经常不同导致各路串口的外设时钟不一样同样写一句usart_baudrate_set(USARTx, 115200)实际算出来的波特率误差可能差一个数量级。usart_baudrate_set内部做的事情是用外设时钟除以16 × 目标波特率得到一个分频值再取整写入寄存器。取整的那一下就会产生误差。我在一个把 APB1 配到 60MHz、APB2 配到 120MHz 的工程里算过一笔账直接决定了我后来选串口的思路外设时钟分频值计算取整后实际波特率误差120 MHz120000000 / (16 × 115200) 65.10120000000 / (16 × 65) 1153840.16%60 MHz60000000 / (16 × 115200) 32.5560000000 / (16 × 33) 1136361.36%0.16% 的误差基本可以无视绝大多数对端芯片能轻松容差。但 1.36% 这个数已经逼近很多串口芯片的容差上限表现就是平时收发好好的温度一变或者帧长了偶尔错一两个字节查又查不出规律非常像“玄学”。实际上就是波特率寄存器取整取偏了。所以规划时不要看代码里写的波特率要看这个串口实际挂在 APB1 还是 APB2把误差算一遍再定。如果某路必须用 115200 但误差又压不下去常见做法有三个方向一是把 APB1 的分频系数调一下让外设时钟更有利于整数分频二是换一路挂在 APB2 上的串口三是用 oversampling by 8 模式把分母从 16 降到 8取整粒度更细误差通常能压到 0.2% 以内。具体支持情况要看固件库版本不是所有型号都开放了过采样配置接口。3. 初始化与中断接收把六路串口收进一套驱动框架3.1 统一的串口实例表六路配置只改一处这份程序最值得抄的部分是配置表。它没有给每路串口单独写一套初始化函数而是定义了一个配置结构体把外设基地址、时钟、GPIO、AF 号、波特率、中断优先级全部装进一张表。初始化就变成遍历这张表。这样做的好处是新增一路串口只需要在表里加一行改某一路的引脚或波特率也只需要改一行不用动中断函数。typedef struct { uint32_t usart_periph; /* USART0/UART4 等外设基地址 */ rcu_periph_enum periph_clk; /* 外设时钟使能位如 RCU_USART0 */ rcu_periph_enum gpio_clk; /* GPIO 时钟使能位如 RCU_GPIOA */ uint32_t gpio_port; /* TX/RX 所在端口 */ uint32_t tx_pin; /* TX 引脚 */ uint32_t rx_pin; /* RX 引脚 */ uint32_t gpio_af; /* 该引脚组的 AF 号按手册查表 */ uint32_t baudrate; /* 目标波特率 */ uint8_t preempt_prio; /* 抢占优先级 */ uint8_t sub_prio; /* 子优先级 */ } uart_cfg_t;每个字段的含义和填法都有讲究。usart_periph填 USART0、UART4 这种固件库定义的外设句柄periph_clk填对应的时钟使能位它和usart_periph是一一对应的USART0 配 RCU_USART0UART4 配 RCU_UART4名字上就差一个字母复制粘贴时最容易错成相邻的串口。gpio_af不是让整个串口共用一个 AF 号而是按当前选的这一组引脚查手册填。preempt_prio和sub_prio是 NVIC 的中断优先级后面的中断调度章节会展开。有了结构体再定义六路串口的配置表static const uart_cfg_t uart_cfgs[] { {USART0, RCU_USART0, RCU_GPIOA, GPIOA, GPIO_PIN_9, GPIO_PIN_10, AF_UART0_PA, 115200, 1, 0}, {USART1, RCU_USART1, RCU_GPIOA, GPIOA, GPIO_PIN_2, GPIO_PIN_3, AF_UART1_PA, 9600, 2, 0}, {USART2, RCU_USART2, RCU_GPIOB, GPIOB, GPIO_PIN_10, GPIO_PIN_11, AF_UART2_PB, 115200, 2, 1}, {USART3, RCU_USART3, RCU_GPIOC, GPIOC, GPIO_PIN_10, GPIO_PIN_11, AF_UART3_PC, 19200, 3, 0}, {UART4, RCU_UART4, RCU_GPIOA, GPIOA, GPIO_PIN_0, GPIO_PIN_1, AF_UART4_PA, 9600, 3, 1}, {UART5, RCU_UART5, RCU_GPIOC, GPIOC, GPIO_PIN_12, GPIO_PIN_11, AF_UART5_PC, 115200, 3, 2}, };AF_UART0_PA这类宏是工程里自定义的不是固件库标准定义因为我吃过“照抄引脚组的 AF 号”的亏所以每个引脚组单独起名、单独查表。配置表是static const放在只读区运行时不修改也避免被误写。初始化函数就是一个 for 循环遍历这张表void uart_all_init(void) { uint8_t i; for (i 0; i sizeof(uart_cfgs) / sizeof(uart_cfgs[0]); i) { uart_periph_init(uart_cfgs[i]); } }uart_periph_init负责具体的寄存器操作开时钟、配 GPIO 复用、配串口参数、使能中断。这段逻辑被六路串口共用新增一路只在表里加一行的价值就在这里。3.2 中断接收与环形缓冲区不掉字节的收数方式六路串口同时来数据用主循环查询的方式基本扛不住。查询循环要一路一路地轮询每一路来一个字节就要在主循环里处理一次哪路的数据密一点其他路就容易漏。这份程序用的是中断接收加环形缓冲区的组合每个串口一个中断服务函数只负责把收到的字节写进缓冲区真正的协议解析放在主循环里做。环形缓冲区我用的是 head/tail 双指针模型#define UART_RX_BUF_SIZE 256 typedef struct { uint8_t buf[UART_RX_BUF_SIZE]; volatile uint16_t head; /* 写入位置 */ volatile uint16_t tail; /* 读取位置 */ volatile uint16_t overflow_cnt; /* 溢出计数 */ } uart_rb_t; uint16_t rb_write(uart_rb_t *rb, uint8_t data) { uint16_t next (rb-head 1) % UART_RX_BUF_SIZE; if (next rb-tail) { rb-overflow_cnt; /* 缓冲区满丢弃数据并计数 */ return 1; } rb-buf[rb-head] data; rb-head next; return 0; }head 指向下一个要写入的位置tail 指向下一个要读出的位置。缓冲区为空时 head 和 tail 相等判断满的条件是“head 再往前一格就碰到 tail”也就是故意留一个空位。这样空和满两种状态不会混淆是环形缓冲区最简单的实现方式也最不容易写错。volatile必须加在 head 和 tail 上因为它们在中断和主循环两侧都会被读写不加 volatile 的话编译器可能把变量优化到寄存器里导致主循环读到的不是最新值。缓冲区大小取 256 而不是 128 或 512是折中的结果。GD32F470 的 RAM 够大六路每路 256 字节一共才 1.5KB相当宽裕。取的太小一帧稍长的数据就可能溢出取到 512 也不会带来明显好处因为按 115200 波特率算256 字节能缓冲约 22 毫秒的连续数据足够主循环调度一个来回了。3.3 发送侧的选择查询发送还是 DMA 发送接收用了中断发送这边要单独想。常见做法是低速串口用查询发送高速大包串口用 DMA 发送。查询发送的好处是实现简单、确定性强坏处是每发一个字节都要等硬件发送完成一帧几十字节就要占住 CPU 几十个字节的时间。给调试串口用完全没问题给串口屏刷数据就有点吃力了。void uart_send_byte(uint32_t usart_periph, uint8_t data) { while (usart_flag_get(usart_periph, USART_FLAG_TC) RESET) { /* 等待上一字节发送完成 */ } usart_data_transmit(usart_periph, data); }这里等的是 TC发送完成标志不是 TBE发送缓冲区空。TC 表示上一字节已经从移位寄存器完全送出去了而 TBE 只表示数据进了发送缓冲区移位寄存器可能还在工作。如果只等 TBE 就写下一个字节连续发送时最后一个字节容易丢。代价是要多等一小段时间但对低速串口来说无所谓。DMA 发送适合波特率 460800 以上、或者一帧几百字节的场景。开启 DMA 发送后CPU 只需要配置一次 DMA 描述符后续整帧数据由 DMA 搬运发送完成中断再通知 CPU 释放下一次发送。GD32F470 的 DMA 请求是固定映射的六路串口各自的 TX DMA 请求号都不一样配置时容易搞混建议用固件库头文件里的DMAx_CHx_Request宏定义而不是自己记数字。4. 中断优先级与缓冲区水位六路并发收发时的调度细节4.1 NVIC 优先级分组六路中断不能都挤同一级六路串口的中断优先级分配是并发调度的核心。GD32F470 的 NVIC 支持抢占优先级和子优先级两级。抢占优先级决定一个中断能不能打断另一个正在执行的中断子优先级只在抢占优先级相同的时候用来排队。六路串口如果都设成同一个抢占优先级高实时性那路来了数据也没法打断正在执行的低优先级中断服务函数实时性等于零。我在这份工程上用的分配思路是处理内容越短、越不能丢数据的外设优先级越高处理量大、实时性要求低的外设优先级放低让缓冲区吸收波动。上面配置表里的优先级字段对应下来是这样一组分配串口抢占优先级子优先级挂的外设分配理由USART010GPS数据按秒到短帧不能丢USART120传感器上报频繁帧短USART221蓝牙模块数据量中等可容忍少量延迟USART330串口屏数据包大靠缓冲区吸收UART431485仪表低速查询为主UART532调试口最低优先级丢了就丢了这个分配把抢占优先级分成了 1、2、3 三档同档内再用子优先级排序。GPS 那路抢占优先级最高因为它一旦被别的中断堵住下一次数据又来了缓冲区很快就溢出。调试串口放到最低档反正调试信息丢几条不影响功能。4.2 中断服务函数里只做搬数据让解析留在主循环中断服务函数写得长不长直接决定六路并发时会不会丢数据。最稳妥的写法是中断里只做两件事读数据寄存器、把数据写进环形缓冲区。协议解析、指令分发、状态机切换全部放到主循环里做。下面是一个 USART0 的中断服务函数void USART0_IRQHandler(void) { uint8_t data; if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_RBNE) ! RESET) { data (uint8_t)usart_data_receive(USART0); rb_write(uart_rbs[0], data); } }GD32F470 的接收中断标志叫 RBNE全称是 Read Buffer Not Empty即读缓冲区非空。注意这和 STM32 上习惯的 RXNE 不是一个名字从 STM32 工程迁过来的人很容易在这里看走眼固件库函数名都变了标志名也是重新命名的。usart_data_receive读一次数据寄存器RBNE 标志会被硬件自动清除不需要手动写清除语句。如果读完了还手动去清标志反而可能把下一个字节的中断请求误清掉造成丢数据。中断服务函数里不要调用printf不要做字符串拼接更不要在中断里等待某个外设响应。六路串口并发时抢占优先级高的中断会打断低优先级的中断服务函数如果在低优先级中断里做耗时操作高优先级串口的缓冲区可能在这段时间内被写满溢出计数就会涨。把解析留到主循环中断函数保持几十个周期内完成是这条驱动框架最根本的性能保证。4.3 缓冲区水位监控溢出之前先留下证据缓冲区溢出不一定是设计错误也可能是外设数据量波动。最怕的是性能问题没暴露前程序已经跑飞了。我在这个工程里给每个环形缓冲区加了一个水位监控记录历史最大占用深度方便复现问题和调参。uint16_t rb_get_watermark(uart_rb_t *rb) { uint16_t used; if (rb-head rb-tail) { used rb-head - rb-tail; } else { used UART_RX_BUF_SIZE - (rb-tail - rb-head); } if (used rb-watermark) { rb-watermark used; } return used; }watermark 和 overflow_cnt 配合起来用overflow_cnt 告诉你“丢过多少字节”watermark 告诉你“缓冲区最多被用到多深”。调试时在系统跑一段时间后把这两个值打出来就能判断某一路的缓冲区大小是否合理。如果某路 watermark 长期接近 256 而 overflow_cnt 偶尔增长说明这路应该加大缓冲区或者提高中断优先级如果 watermark 一直很低但 overflow_cnt 也在涨那问题多半出在中断服务函数被高优先级抢占太久加缓冲区解决不了要去看中断优先级分配。这算是我调多串口程序的一个习惯先让系统把压力数据量化出来再决定改哪边。没有这两个计数六路串口并发的问题基本靠猜猜着猜着就变成某路串口的“玄学丢字节”了。5. 六路串口避坑乱码、丢字节与中断卡死的排查记录5.1 现象某路串口复位后前几个字节收到 0x00现象是系统上电后某路串口对接的设备回传的每帧数据帧头前总多出几个 0x00 字节过一会儿自己就好了。原因排查下来是两个叠加的。一是该路 RX 引脚在串口外设使能之前已经被 GPIO 初始化成了浮空输入引脚电平不确定串口一使能硬件误判成起始位和几个 0 数据位二是检查引脚冲突表时发现这路串口的 RX 引脚恰好和另一路串口的 TX 引脚复用在同一个引脚上后初始化的那路把引脚复用配置覆盖了前面那路导致线上信号时序完全错乱。解决分两步。第一步所有串口的 GPIO 初始化必须赶在串口外设使能之前完成并且 RX 引脚在 GPIO 配置时给一个上拉让空闲电平确定。第二步把六路串口的所有引脚组列成一张冲突表同一端口同一引脚只能归属一路发现冲突就换这一路的备用引脚组。从那以后我每个新工程的第一件事就是把引脚复用表画出来贴在调试桌上。5.2 现象对端芯片偶尔错一帧但不是稳定错现象是某路串口和一块串口屏通信正常的时候发几百帧都没事但每隔几分钟就错一帧错的字节没有任何规律。用示波器量波形单字节的位宽看起来是对的看不出明显异常。原因出在波特率寄存器分频取整误差上。那路串口挂在 APB1 上外设时钟只有 60MHz目标波特率 115200 的分频值 32.55 取整后误差到 1.36%对端串口屏的容差刚好在临界线上。平时通信环境好的时候误差被容忍一旦温度漂移或者对方芯片的振荡器本身有微小偏差误差叠加起来就错帧。解决方法是把那路串口换到误差小的引脚组上同时对 APB1 的分频做了调整让外设时钟变成 120MHz误差从 1.36% 降到 0.16%。改完以后跑了三天没有再现错帧。这个问题的教训是分配串口时不能只看引脚够不够用还要算波特率误差。低速外设比如 9600 波特率APB1 分频取整后一般也能压到 0.2% 以内但 115200 这个档位在低外设时钟下特别容易踩线。5.3 现象中断一次都不进但查询模式能收到数据现象是某路串口用查询方式读数据能读到对端发来的字节改成中断方式后中断服务函数始终不执行。原因有三层挨个排查后才定位到。第一层是 NVIC 没使能固件库初始化时跳过了nvic_irq_enable中断根本没有被送到 NVIC第二层是串口的中断使能位没开usart_interrupt_enable只开了串口本身RBNE 中断触发不了第三层最隐蔽中断服务函数名字拼错了。启动文件里定义的向量名是USART0_IRQHandler工程里写成了USART0_Handler编译能通过、链接也不报错但真正的中断向量地址没有被覆盖中断一进来直接跑进死循环。解决方法是按三层顺序检查先查nvic_irq_enable的中断通道号和优先级参数再查usart_interrupt_enable是否使能了USART_INT_RBNE最后打开工程的启动文件把中断服务函数名和向量表里的名字逐字对比。从那以后我每加一路串口都会在初始化完成后手动发一个字节验证中断通路不再默认代码是对的。5.4 现象六路同时收发时低优先级那路丢字节现象是系统里几路串口同时有数据收发时UART5 那路调试串口偶尔丢几个字节其他路都正常。原因在于 UART5 是六路里抢占优先级最低的。当其他几路串口的中断服务函数在高频触发时UART5 的中断被反复推迟。虽然它的环形缓冲区不小但调试口的数据是连续的一旦缓冲区的写入速度跟不上数据到达速度溢出计数就开始增长丢字节随之而来。解决方法是把调试串口的缓冲区加大同时把调试信息的打印节奏降下来避免一次性输出大段日志。如果调大缓冲区还压不住就需要调整中断优先级把 UART5 的子优先级提到同级串口里最高的。这个问题的关键不是盲目加缓冲区而是先看 overflow_cnt确认丢字节的原因确实是缓冲区满再决定是加缓冲还是提优先级。5.5 现象查询发送卡死在 while 循环里现象是某路串口在主循环里调用发送函数程序跑到等待 TC 标志的 while 那里就不走了看门狗超时复位。原因最终定位在 GPIO 复用配置错误。那路串口的 TX 引脚复用号填成了另一组引脚的 AF 号引脚功能没有正确映射到串口 TX 上导致串口的移位寄存器输出没有物理路径TC 标志一直不复位。另一个常见原因是误开了硬件流控RTS/CTS 使能后对端没有拉高 CTS 信号发送一直被硬件阻塞。解决方法是先查 TC 标志卡死时串口是否已经usart_enable再用逻辑分析仪确认 TX 引脚有没有波形最后检查usart_hardware_flow_rts_config和usart_hardware_flow_cts_config是不是被意外打开。硬件流控这个坑最坑的地方在于复用配置错误和流控阻塞的表现几乎一样都是 TC 不复位只能一个一个排除。6. 从六路扩展到八路把框架复制一份时的边界与验证技巧6.1 扩展前先查四个点这份程序的表驱动设计让扩展变得很简单加一行配置就够了。但扩展之前有两个方向都得想清楚一是芯片是否还有可用的串口外设和引脚组二是新串口会不会挤占已有串口的资源。我给自己定了一个核对表每加一路强制走一遍IRQ 通道名对不对、AF 号是不是该引脚组的、外设时钟使能位是不是对应的 RCU 位、NVIC 优先级有没有跟现有配置冲突。这四个点里任何一个错了表现都类似排查起来却完全不是一条路。新增一路串口的配置项一般是这样的{UART6, RCU_UART6, RCU_GPIOA, GPIOA, GPIO_PIN_4, GPIO_PIN_5, AF_UART6_PA, 9600, 3, 3}UART6 的时钟使能位是 RCU_UART6不是 RCU_UART5也不是 RCU_USART3。这两个名字在固件库里离得很近复制粘贴时最容易错。我吃过这个亏那次第七路串口初始化后第五路的时钟被多使能了一次两个外设串了数据。6.2 回环测试不用示波器也能确认收发路径扩展完第一件事不是接真实外设而是做回环测试。拿一根杜邦线把新串口的 TX 和 RX 短接然后让程序自发一段数据看能不能原样收回来。回环测试能一次性验证 GPIO 复用、串口收发通路、中断接收和环形缓冲区整条链路。如果回环不通先查 TX 有没有波形再查 RX 中断进不进问题范围能缩小一半。我习惯在回环通过后再做一个压力测试让新串口以最高波特率连续收发 1000 帧每帧 128 字节结束后打印 overflow_cnt 和 watermark。如果溢出计数是 0、watermark 不超过缓冲区的 80%这条链路才算真的稳了。六路变八路本质上就是把这一套验证流程多走两遍。以前扩展串口我总觉得把上一路的配置复制下来改个名字就完了结果每次都要在中断卡死、优先级冲突上消耗一两天。后来把核对表和回环测试设成了固定动作再没有因为加串口翻过车。希望这份 GD32F470-6路串口程序 的拆解和避坑记录能帮你在多串口调试上少走这些弯路。本文还有配套的精品资源点击获取