
简介STM32F091多串口程序是一份基于ARM Cortex-M0内核微控制器的UART驱动与中断接收工程适合嵌入式开发者、物联网及工业控制领域技术人员参考。程序围绕主串口中断接收展开实现一个主串口向五个乃至七个从串口的数据转发可通过中断优先级管理、独立DMA通道或轮询标识灵活扩展涵盖时钟初始化、GPIO复用、波特率数据位配置、接收中断及可选DMA传输等完整步骤。压缩包内含881个文件以C源文件、头文件、汇编文件、编译生成的目标链接文件、Keil工程配置及芯片文档为主辅以网页与PDF说明总体积约7.58MB目录结构清晰便于对照学习。已有767人学习下载。通过分析这些代码可深入理解STM32F091多串口的驱动写法、中断处理与转发逻辑并借鉴防止数据丢失、保障实时性的工程经验对实现多路串口桥接或数据采集系统有直接价值。1. STM32F091 多串口程序难点从来不在串口多而在它们同时工作做 STM32F091 多串口程序最有意思的一点是单看任何一个串口配置都简单到能闭眼写完但当真把 8 个 UART 全部挂上外设、跑起数据时问题就全来了——中断互相抢占、DMA 通道不够分、波特率误差在某个串口上突然放大、一帧数据被空闲中断硬生生拆成两半。我见过不少开发者被这些现象折磨到怀疑芯片体质最后只能靠玄学调参。这篇文章想直接把 STM32F091 多串口程序的完整落地路径讲透先盘资源、再分配通道、给出可抄的初始化和收发代码最后把最容易翻车的几个场景以现象到原因到解决的方式列清楚。适合正在做多传感器采集、一主多从协议转换、或把多个串口模块同时挂到一颗低成本 M0 芯片上的从业者。2. 多串口方案的资源底牌8 个 UART 与中断/DMA 怎么分才不打架2.1 STM32F091 的串口资源盘点哪些口能复用哪些口要省着用STM32F091 这颗片子最吸引人的一点是它在一颗 M0 内核上放了一堆串口资源片上最多提供 USART1/USART2 和 UART3 到 UART8加起来 8 个串口。但“有”和“能用”之间隔着几道坎。第一道坎是引脚。每个串口的 TX/RX 并不是固定在某一对引脚上的而是有多组复用选择具体映射关系要去数据手册的 alternate function 表里查。开始画原理图之前我会先把目标封装的所有引脚和 AF 映射列出来而不是等板子回来了再拿杜邦线飞。有些引脚同时挂在了多个串口候选上选了 A 串口B 串口就只能换引脚这一步在布局阶段就要定死。第二道坎是 M0 内核的资源预算。F091 的主频上限是 48MHzFlash 和 RAM 都谈不上宽裕8 个串口如果全部走中断收发一个大数据帧进来CPU 时间就会被反复打断主循环里的业务逻辑几乎跑不动。所以做多串口程序前第一件事不是写代码而是给每个串口定工作模式和优先级。我一般会按“数据量 × 实时性”把串口分成三类第一类是高速持续数据流比如 4G 模块、Wi-Fi 透传走 DMA 收发第二类是中等数据量、需要及时响应的设备比如称重仪表、条码枪走中断 空闲中断判断第三类是低频配置口比如按键面板、调试日志轮询就够用。注意8 个串口不要全上 DMAF091 的 DMA 资源非常紧张。它在手册里的 DMA 请求映射表串口和 DMA 通道的分配是有讲究的有的通道天然只能服务特定几个外设串口一多先到先得后配的串口就可能找不到空闲通道。2.2 中断与 DMA 通道分配先分优先级再写代码动手写时钟和串口初始化代码之前先画一张分配表。比如一个典型应用串口 1 接上位机走 DMA 收发抢占优先级最高串口 2 接定位模块走 DMA 接收 空闲中断串口 3 接传感器走中断接收串口 4 到串口 8 做配置口或日志口其中一部分甚至只在开机时读写一轮。串口典型用途收发方式波特率中断/优先级策略USART1上位机通信RX: DMA 空闲中断TX: DMA115200抢占优先级最高USART2定位模块RX: DMA 空闲中断TX: 轮询9600抢占优先级次高UART3传感器轮询RX: 中断逐字节TX: 轮询115200普通优先级UART4-UART8配置/日志/低速设备RX: 查询或低优先级中断9600低优先级或关闭中断这张表解决的是一个非常重要的问题中断嵌套顺序。F091 的 NVIC 支持抢占优先级多串口同时收发时只有优先级最高的串口能立刻打断主流程其他串口的数据会排队。很多翻车场景的根源就是把所有串口的中断优先级都设成了最好的一档结果任意一个串口来数据都能打断一切主循环直接饿死。具体到实现层面先定哪几个串口必须高优先级哪几个串口能容忍延迟再用这张表指导 CubeMX 里的配置和代码里的优先级设置效率会高很多。另外F091 是 M0 内核没有位带操作。需要保护多串口共享的缓冲区索引时不要想着用位带原子改标志老老实实用临界区或者关中断。这是以后写收发缓冲时必须记住的前提。3. 初始化配置时钟、波特率误差与 CubeMX 批量生成3.1 从 8MHz 换到 48MHz 系统时钟算清 BRR 误差再动手串口初始化前先把系统时钟拉起来。F091 外部晶振常见是 8MHz内部 PLL 可以倍频到 48MHz把系统时钟跑到最高频串口波特率生成器才有足够的整数分频粒度。这里注意一个坑从 8MHz 切到 48MHz 时Flash 等待周期要跟着设置好否则程序可能直接跑飞。CubeMX 生成的代码一般会自动处理这些但如果你习惯手写寄存器初始化最容易漏的就是这一步。F091 的串口时钟来自 APB 总线。系统时钟 48MHz 时只要 APB 分频器设为 /1串口外设时钟就是 48MHz。波特率分频的计算公式是 BRR 串口时钟 / 波特率但实际写入 BRR 寄存器的值和这个商并不完全相等因为 BRR 只有有限位数取整后必然产生误差。计算一个实际例子时钟 48MHz、波特率 115200 时48,000,000 / 115,200 416.666取整后实际波特率约为 115,107误差约 0.08%完全可用。但如果外部晶振是 12MHzPLL 配置不当导致串口时钟变成 44MHz44,000,000 / 115,200 381.94取整误差到了 0.2% 附近接某些对时序敏感的对端设备就可能出现概率性乱码。这就是多串口程序的第一个核心逻辑波特率误差不是“有或没有”而是“能不能压在接收端的容忍范围里”。常见 UART 异步通信在 8-N-1 格式下接收端能容忍约 ±2% 的频率偏差超过这个边界串口助手打出来的字可能正常但接真实模块就开始随机翻车。所以在配置串口时我建议优先保证所有串口共用同一个串口时钟源不要一个串口用一个时钟。CubeMX 的 Clock Configuration 页面可以直接看到系统时钟、APB 分频和串口时钟的关系把 APB 分频设置成 /1让所有 UART 共享 48MHz 时钟是最省心的做法。3.2 CubeMX 多串口初始化与表驱动配置用 CubeMX 配多串口时我喜欢把重复劳动压缩到最小。在 Pinout 页面把用到的串口引脚逐个点亮每个 UART 的 Mode 选 AsynchronousParameter Settings 里配置波特率、字长 8、停止位 1、无校验。这里有个经验值同一项目的多个串口波特率尽量用整数分频能整除的值比如 48MHz 时钟下 115200、9600、38400 都相对友好而 57600 在某些分频组合下误差偏大。CubeMX 生成工程后HAL 库会给每个串口生成一个UART_HandleTypeDef实例和对应的MX_USARTx_UART_Init函数。串口多了之后逐个手写初始化函数非常容易漏配我会再加一层表驱动封装。用一张配置表把串口实例、波特率、优先级、中断回调入口统一起来主循环里通过索引访问后面新增串口只需要改表不用改逻辑。typedef struct { UART_HandleTypeDef *huart; uint32_t baudrate; IRQn_Type irq; uint8_t preempt_prio; } uart_cfg_t; const uart_cfg_t uart_configs[] { { huart1, 115200, USART1_IRQn, 1 }, { huart2, 9600, USART2_IRQn, 2 }, { huart3, 115200, USART3_IRQn, 8 }, }; void uart_common_init(const uart_cfg_t *cfg) { HAL_NVIC_SetPriority(cfg-irq, cfg-preempt_prio, 0); HAL_NVIC_EnableIRQ(cfg-irq); }这段代码的作用是把中断优先级和中断使能从每个串口的初始化里抽出来统一由配置表驱动。preempt_prio字段写成 1、2、8代表不同的抢占优先级数值越小优先级越高。多串口场景下控制链路和高速链路用低数值日志和低速配置口用高数值这样即使日志串口疯狂刷屏也不会堵死核心通信链路。注意这个例子里我把优先级抽出来了但每个串口的波特率、引脚复用、DMA 通道仍然看 CubeMX 里那一段配置不在表驱动范围内因为它们和具体外设强绑定。初始化时还有一个容易忽略的参数TX/RX 的 GPIO 速度尤其是波特率拉到 460800 或者 1M 时GPIO 输出速度必须足够快否则信号边沿变形短距离通信也可能丢字节。CubeMX 默认的 GPIO 设置通常偏低我在高速串口上会手动把对应引脚的 GPIO Speed 调到 Very High。4. 收发实现空闲中断DMA 怎么搭才稳4.1 接收不定长数据的两种姿势空闲中断与 DMA 半满中断串口接收有两种典型的实现方式多串口程序里需要根据每个口的数据特点去选。第一种是“DMA 半满/全满中断”。先给 DMA 配置一个固定大小的接收缓冲区DMA 每接收一半或收满就触发一次中断应用层去缓冲区取数据。这种方式的优点是中断频率固定、实时性可控缺点是无法直接知道一帧数据什么时候结束应用层要自己判断数据边界对不定长包处理很不友好。第二种是“空闲中断 DMA”。F091 的 UART 模块在检测到串口总线空闲时会置上 IDLE 标志产生一次中断。这是处理不定长帧最顺手的组合DMA 一直在接收等到总线空闲了空闲中断告诉你“这一批数据到了”应用层再从 DMA 缓冲区按实际收到的字节数取出。相比半满中断空闲中断天然对应一帧结束非常适合多个串口分别接不同协议的从设备。我在项目里的通用姿势是所有数据量稍大的串口都走空闲中断 DMA 接收DMA 缓冲区设成该串口可能出现的最大帧长度。缓冲区长度要比理论最大帧长略大一点防止一个异常长包直接写穿缓冲。初始化时调用HAL_UARTEx_ReceiveToIdle_DMA()这个 API 会同时开启 DMA 接收和空闲中断一帧数据结束后进入HAL_UARTEx_RxEventCallback()回调回调参数里的Size就是实际收到的字节数。用回调而不是在中断里直接处理业务可以把串口逻辑和协议解析解耦。#define UART1_RX_BUF_SIZE 128 uint8_t uart1_rx_buf[UART1_RX_BUF_SIZE]; void start_uart1_rx(void) { // 这个调用同时打开 DMA 和空闲中断收到一帧后回调 HAL_UARTEx_ReceiveToIdle_DMA(huart1, uart1_rx_buf, UART1_RX_BUF_SIZE); __HAL_DMA_DISABLE_IT(hdma_usart1_rx, DMA_IT_HT); // 关闭半传输中断只关心满帧和空闲 }说明一下这几行的用意HAL_UARTEx_ReceiveToIdle_DMA()是启动接收的关键一次性完成 DMA 通道、接收缓冲和空闲中断的绑定关闭半传输中断是为了减少无谓的上下文切换因为多串口场景下 CPU 时间很宝贵。如果你需要处理超长数据流可以保留半中断在两个缓冲区之间交替但大部分设备通信场景都用不上。4.2 环形缓冲区与最小协议解析代码空闲中断虽然能分辨一帧结束但如果主循环来不及处理回调里积累的数据会丢失。所以多串口程序里我坚持引入环形缓冲区中断回调只管把数据写进环形缓冲主循环按自己的节奏读出来做协议解析。这样即使某个串口突然涌进大量数据也不会立即丢帧。环形缓冲区有几个细节必须注意缓冲区大小必须是 2 的 N 次方这样取模运算可以优化成位与省掉除法读写索引要定义成volatile避免中断和主循环之间的缓存一致性问题只允许一个生产者一个消费者时不用加锁但生产者和消费者各自只修改自己的索引。#define RBUF_SIZE 256 typedef struct { uint8_t buf[RBUF_SIZE]; volatile uint16_t head; // 写索引中断回调里更新 volatile uint16_t tail; // 读索引主循环里更新 } ring_buf_t; uint16_t rbuf_used(ring_buf_t *rb) { return (uint16_t)(rb-head - rb-tail); } uint8_t rbuf_pop(ring_buf_t *rb, uint8_t *out) { if (rb-tail rb-head) { return 0; } *out rb-buf[rb-tail]; rb-tail (uint16_t)((rb-tail 1) (RBUF_SIZE - 1)); return 1; }这个实现里head和tail都用uint16_t并且让它们自由计数、不归零。读取后索引只做递增用 (RBUF_SIZE - 1)完成折返。判断缓冲区空不空只需要比较两个索引是否相等。这种写法避免了“读写索引在同一时刻被两边修改”带来的竞争是嵌入式里最常见的无锁单生产者消费者模型。有了缓冲协议解析就可以完全搬到主循环。拿最常见的“帧头 长度 数据 校验”格式举例解析代码是一个简单的状态机typedef enum { PARSE_WAIT_HEAD, PARSE_WAIT_LEN, PARSE_WAIT_DATA, PARSE_WAIT_CRC, } parse_state_t; parse_state_t parse_state PARSE_WAIT_HEAD; uint8_t frame_data[64]; uint16_t frame_index 0; uint16_t frame_len 0; void parse_byte(uint8_t byte) { switch (parse_state) { case PARSE_WAIT_HEAD: if (byte 0xAA) { parse_state PARSE_WAIT_LEN; } break; case PARSE_WAIT_LEN: frame_len byte; frame_index 0; parse_state PARSE_WAIT_DATA; break; case PARSE_WAIT_DATA: frame_data[frame_index] byte; if (frame_index frame_len) { parse_state PARSE_WAIT_CRC; } break; case PARSE_WAIT_CRC: // 校验字节比对在这里做匹配成功则上传完整帧给业务层 parse_state PARSE_WAIT_HEAD; break; default: parse_state PARSE_WAIT_HEAD; break; } }状态机的价值在于每个串口的协议解析互不干扰每个串口维护自己的parse_state主循环可以轮流喂给 8 个解析器。多串口项目里不要写“一个中断里把协议全解完”的结构那会让优先级高的串口抢占大量 CPU 时间其它串口的缓冲很快溢出。5. 多串口程序避坑清单4 个让开发者翻车的真实场景5.1 波特率误差为什么自己打印正常对端全是乱码现象板子通过串口连电脑串口助手自发自收完全正常连到某个定位模块或工业仪表上数据就开始出现概率性乱码有时候重启一下又好一阵子。原因自发自收是同一个芯片的同一个串口时钟误差再大两边也是“同源偏差”互相能容忍而对端设备用的是自己的晶振时钟两边频率误差叠加后超过了 UART 接收端容忍的阈值。最典型的诱因是外部晶振频率不是按串口时钟最优值选的或者 APB 分频器设置让串口时钟变成了一个尴尬的值。解决先查时钟树。用 CubeMX 的 Clock Configuration把串口时钟统一到 48MHz确认 APB 分频是 /1。然后按“串口时钟 / 波特率”逐口手算 BRR 取整误差误差超过 0.5% 的串口优先换波特率或换时钟源而不是调接受端的采样点。如果你用的是无源晶振而不是有源晶振还要考虑晶振负载电容是否匹配频率偏了 30ppm 不算事偏到 0.1% 就足以在长帧场景下产生误码。这一条是典型“看着代码没问题、实际玄学”的坑先怀疑时钟再怀疑线材。5.2 DMA 通道被占新串口一开老串口就不动了现象串口 1 的 DMA 收发一直跑得很好后来在 CubeMX 里新增了串口 4 并给它配了 DMA 接收编译烧录后串口 1 的接收数据全部停止DMA 好像失效了。原因F091 的 DMA 请求映射并不是所有串口都能独占通道两个串口的 RX 请求可能映射到同一个 DMA 通道或者新增串口使能时把已有通道的配置覆盖掉了。CubeMX 在图形界面里不会主动告诉你“该通道已被占用”只有查参考手册的 DMA 请求映射表才能发现冲突。解决打开参考手册的 DMA 请求映射表把已使用的每个串口的 DMA 请求和通道号列成一张核对表确保通道不重叠。通道冲突时优先让高速串口保留 DMA 通道低速串口改用中断接收。如果你发现某个串口必须 DMA、又实在没有空闲通道只能调整引脚复用或者换用其它具备 DMA 能力的外设组合而不是硬塞。每次在 CubeMX 里改完配置后重新生成代码时我会顺手看一眼生成的dma.c和stm32f0xx_hal_msp.c确认新通道配置没有覆盖旧通道。5.3 中断优先级全拉满主循环卡死与系统响应失灵现象多个串口同时以较高频率收发数据时主循环里的轮询任务明显变慢再极端一些喂狗不及时导致复位或者按键响应延迟好几秒。调试一看中断一直在进来主循环根本没机会跑。原因把 8 个串口的中断优先级全部设成最高任何一个字节到达都会立即抢占主流程。M0 的中断响应虽然快但频繁切换上下文累积起来就是巨大的 CPU 开销。数据量大的串口冲击下主循环的优先级被彻底边缘化。解决回归到第 2 章的分配表给每个串口按重要性设置抢占优先级。控制链路用最高优先级数据采集用中等优先级日志串口必须给最低优先级。NVIC 里抢占优先级数值越小优先级越高日志口设成一个较大的数值比如 10 以上再配合日志开关宏发布版本直接关闭日志串口中断。这里还有一个容易忽略的点DMA 接收完成中断和空闲中断不要全挤在同一档位给 DMA 完成留一个稍高的优先级否则大帧接收过程中如果被更高优先级串口反复打断DMA 配置可能被卸载重载。5.4 空闲中断误触发一帧数据被硬生生拆成两段现象某个串口接收不定长帧数据量一多协议层偶尔报“帧头丢失”或者“长度不对”。抓日志发现一个完整的 64 字节帧有时被拆成 30 字节和 34 字节两段分别触发了两次空闲中断。原因空闲中断的判定标准是串口线路上超过一个字节时间没有电平翻转。如果发送方在两段数据之间插入了一个稍长的间隔比如对端设备分两次写入串口中间间隔了几毫秒接收端就会认为上一帧结束置位 IDLE。解决不要在空闲中断回调里立刻按“一整帧”处理而是把数据先送进环形缓冲区由协议状态机负责拼帧。状态机只认帧头、帧长和校验不依赖空闲中断来切帧边界同时加一个超时计数器比如累计 50ms 没有新数据到达才认为当前帧结束。这样即使 IDLE 误触发数据也不会丢只是多一次回调拼帧结果仍正确。另外排查一下对端设备的发送逻辑是否会在帧中间做延迟很多第三方模块的固件会在指令和应答之间人为穿插延迟。6. 验证稳定性与进阶用回环压测和多级日志守住最后一公里6.1 串口回环压力测试让每个口都自发自收跑一个晚上多串口程序最怕的不是功能写不完而是功能写完了却不敢保证稳定性。我的验证习惯是一块测试板、一根短接线把每个串口的 TX 和 RX 外部短接然后让每个串口自发自收跑一整晚统计误码和丢帧。void uart_loopback_test(UART_HandleTypeDef *huart, uint32_t count) { uint8_t tx 0x00; uint8_t rx 0x00; uint32_t error_cnt 0; for (uint32_t i 0; i count; i) { tx (uint8_t)(i 0xFF); HAL_UART_Transmit(huart, tx, 1, 100); HAL_UART_Receive(huart, rx, 1, 100); if (tx ! rx) { error_cnt; } } // error_cnt 大于 0 时把结果和串口编号上报到日志口 }这段回环逻辑的意义不在于多复杂而在于能用最小的代码量持续压测每个串口的物理链路和驱动配置。测试时注意几件事所有串口同时跑不要一个个测否则并发下的中断冲突和 DMA 竞争测不出来测试期间日志串口保持打开方便观察哪个口的 error_cnt 在增长连续跑 8 小时以上才有说服力偶发的误码往往出现在温度变化或电压波动的时候。6.2 从多串口裸机到可维护系统协议状态机与低功耗唤醒回环压测通过后多串口程序才算真正落地。接下来我一般会再做两件提升可维护性的事第一把每个串口的协议解析统一成第 4 章那类状态机避免不同串口用不同风格的处理逻辑第二预留一个固定日志串口用分级日志输出框架运行时能单独打开某个串口的调试信息。F091 的资源注定它跑不了复杂的调度系统但一套清晰的驱动分层能让后续维护的人少流很多汗。特别提醒一下低功耗场景如果项目要求休眠时靠串口唤醒F091 的串口唤醒能力需要把对应引脚配置成中断唤醒源同时注意 DMA 在低功耗模式下是否关闭。这类需求通常只保留一个监听串口把其它串口在休眠前全部去使能才能把功耗压下去。我自己吃了不少多串口程序的亏后来养成了一个习惯无论芯片资源多紧张都要先做一张串口分配表再动代码。表里标清楚 8 个口的用途、工作模式、优先级和波特率后面所有配置、代码、排错都以这张表为准。这个习惯帮我避开了绝大多数“功能全写完才发现通道冲突”的血泪场景。希望帮到你。本文还有配套的精品资源点击获取