STM32双UART串口通信同步收发设计实战:从原理到代码的完整实现
在嵌入式开发中,我们常常遇到这样的场景:主控芯片需要同时与上位机通信、读取传感器数据、控制外围设备,甚至还要处理人机交互。如果只用一个串口,系统响应就会变得迟缓,任务之间相互阻塞——这正是多通道异步通信需求的真实来源。
STM32作为工业界最主流的ARM Cortex-M系列MCU之一,几乎每款型号都集成了多个USART/UART外设。合理利用这些资源,不仅能提升系统的实时性与稳定性,还能显著降低CPU负载。本文将带你一步步构建一个双UART同步收发系统,不仅讲清“怎么做”,更深入剖析“为什么这么设计”。
为什么选择双UART?真实项目中的通信瓶颈
想象这样一个典型工业控制系统:
一台STM32通过USART1连接PC上位机接收指令,同时通过USART2轮询多个Modbus RTU温湿度传感器。当用户点击“采集全部数据”时,MCU需立即向传感器网络发出请求,并在收到所有应答后打包上传。
若采用传统轮询方式(如while(!rx_flag)),一旦某路通信延迟或丢包,整个主线程就被卡住,无法响应其他事件——这是典型的单线程阻塞陷阱。
而如果我们启用两个独立的UART通道,并配合中断+缓冲区机制,就能让两路通信并行运行、互不干扰。这才是现代嵌入式系统应有的模样。
核心架构设计:硬件基础与软件模型
双UART通信的核心组件
STM32的每个USART模块本质上是一个功能完整的串行通信引擎,包含:
- 独立的发送/接收移位寄存器
- 波特率发生器(基于PCLK分频)
- 数据帧格式控制器(起始位、数据位、校验位、停止位)
- 中断逻辑单元(RXNE、TC、OE等标志触发)
以常见的STM32F103C8T6为例:
-USART1挂载在APB2总线,最高时钟72MHz
-USART2挂载在APB1总线,最高时钟36MHz
这意味着它们的波特率计算基准不同,在配置时必须分别处理。
软件架构选型对比
| 方式 | 实时性 | CPU占用 | 扩展性 | 适用场景 |
|---|---|---|---|---|
| 轮询 + 标志位 | 差 | 高 | 差 | 极简应用 |
| 单中断 + 全局变量 | 中 | 中 | 一般 | 小型系统 |
| 双中断 + 环形缓冲区 | 优 | 低 | 好 | 推荐方案 |
| DMA + 空闲中断 | 极优 | 极低 | 复杂 | 大数据量 |
显然,我们要走的是第三条路:中断驱动 + 环形缓冲区 + 主循环协议解析。
初始化配置详解:从GPIO到NVIC
先来看最关键的初始化函数,它决定了整个通信系统的起点是否稳固。
#define UART1_BAUDRATE 115200 #define UART2_BAUDRATE 9600 void UART_Init(void) { // 使能时钟:USART1和GPIOA属于APB2;USART2属于APB1 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); GPIO_InitTypeDef GPIO_InitStruct; USART_InitTypeDef USART_InitStruct; // === USART1: PA9(TX), PA10(RX) === GPIO_InitStruct.GPIO_Pin = GPIO_PIN_9; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽输出 GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.GPIO_Pin = GPIO_PIN_10; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, &GPIO_InitStruct); USART_InitStruct.USART_BaudRate = UART1_BAUDRATE; USART_InitStruct.USART_WordLength = USART_WordLength_8b; USART_InitStruct.USART_StopBits = USART_StopBits_1; USART_InitStruct.USART_Parity = USART_Parity_No; USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStruct.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStruct); USART_Cmd(USART1, ENABLE); // === USART2: PA2(TX), PA3(RX) === GPIO_InitStruct.GPIO_Pin = GPIO_PIN_2; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.GPIO_Pin = GPIO_PIN_3; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStruct); USART_InitStruct.USART_BaudRate = UART2_BAUDRATE; USART_Init(USART2, &USART_InitStruct); USART_Cmd(USART2, ENABLE); // === 使能接收中断 === USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); // === NVIC优先级配置 === NVIC_EnableIRQ(USART1_IRQn); NVIC_SetPriority(USART1_IRQn, 1); // 较高优先级(例如用于关键命令) NVIC_EnableIRQ(USART2_IRQn); NVIC_SetPriority(USART2_IRQn, 2); // 较低优先级(例如传感器数据) }关键点解读
时钟门控不可少
必须显式开启对应外设的时钟,否则寄存器操作无效。很多人烧录程序后串口没反应,问题就出在这里。GPIO模式要匹配功能
- TX引脚设为AF_PP(复用推挽):可输出高/低电平,驱动能力强;
- RX引脚设为IN_FLOATING:允许外部信号自由拉高拉低,适合接收端。波特率差异的工程意义
通常:
- PC通信使用115200bps(高速调试)
- Modbus设备常用9600或19200bps(兼顾距离与可靠性)
这种混合速率设计非常贴近实际工程需求。中断优先级设置讲究策略
若上位机下发的是紧急停机指令,自然要比读温度慢半拍更重要。因此给USART1更高优先级是合理的。
中断服务程序:快进快出才是硬道理
中断服务程序(ISR)的设计原则只有一个:越短越好。任何耗时操作都应移交主循环处理。
为此,我们引入环形缓冲区(Ring Buffer)来暂存接收到的数据字节。
#define BUFFER_SIZE 64 typedef struct { uint8_t buffer[BUFFER_SIZE]; volatile uint16_t head; // 写指针(ISR更新) volatile uint16_t tail; // 读指针(主循环更新) } RingBuffer; RingBuffer uart1_rx_buf = { .head = 0, .tail = 0 }; RingBuffer uart2_rx_buf = { .head = 0, .tail = 0 };注意:head和tail必须声明为volatile,防止编译器优化导致读写异常。
USART1中断处理
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); // 自动清除RXNE标志 uint16_t next_head = (uart1_rx_buf.head + 1) % BUFFER_SIZE; if (next_head != uart1_rx_buf.tail) { // 缓冲区未满 uart1_rx_buf.buffer[uart1_rx_buf.head] = data; uart1_rx_buf.head = next_head; } // 否则丢弃新数据(避免溢出崩溃) } }同理实现USART2_IRQHandler,仅替换缓冲区对象即可。
ISR设计哲学
- 只做一件事:读DR寄存器 → 存入缓冲区 → 更新头指针
- 不调用复杂函数:如printf、malloc、浮点运算等
- 避免死循环等待:比如while里检查某个条件
- 保护共享资源:虽然此处无竞争(单写多读),但仍建议保持警惕
主循环数据处理:协议解析与软同步
所有重活交给主循环来做。这里我们模拟一个简单的帧结构:
[Start=0xAA][Len][Data...][CRC]void Process_Uart_Data(void) { static uint32_t last_sync_time = 0; // --- 处理USART1数据 --- while (uart1_rx_buf.tail != uart1_rx_buf.head) { uint8_t byte = uart1_rx_buf.buffer[uart1_rx_buf.tail]; uart1_rx_buf.tail = (uart1_rx_buf.tail + 1) % BUFFER_SIZE; Parse_Protocol_Frame(1, byte); // 参数1表示来自USART1 } // --- 处理USART2数据 --- while (uart2_rx_buf.tail != uart2_rx_buf.head) { uint8_t byte = uart2_rx_buf.buffer[uart2_rx_buf.tail]; uart2_rx_buf.tail = (uart2_rx_buf.tail + 1) % BUFFER_SIZE; Parse_Protocol_Frame(2, byte); // 参数2表示来自USART2 } // --- 定时同步任务(每10ms执行一次)--- if (SysTick_GetTicks() - last_sync_time >= 10) { Sync_Application_Tasks(); // 整合双通道数据,触发上报或控制 last_sync_time = SysTick_GetTicks(); } }时间戳的作用:实现“软同步”
假设你正在记录一条控制命令下发的时间,以及对应的传感器反馈时间。没有统一时间基准,你就无法判断“这个温度值是在命令前还是命令后采集的”。
解决方案很简单:使用SysTick提供毫秒级时间戳。
uint32_t SysTick_GetTicks(void) { return millis_counter; // 在SysTick_Handler中递增 }然后在解析协议帧时打上时间标签:
typedef struct { uint8_t source; // 来源通道 uint8_t data[32]; uint8_t len; uint32_t timestamp; } DataPacket; DataPacket recent_packet;这样后续就可以做时间对齐分析,比如绘制“命令-响应”时序图,或者进行数据融合处理。
常见坑点与调试秘籍
❌ 坑1:中断优先级颠倒导致关键消息被延迟
现象:上位机发“急停”命令,但执行滞后。
原因:USART2传感器持续发送数据,频繁触发低优先级中断,而高优先级中断未能及时抢占。
对策:
- 明确划分任务等级:控制信道 > 数据信道
- 使用NVIC_SetPriority合理分配抢占优先级
- 必要时关闭非关键中断进行诊断
❌ 坑2:缓冲区溢出导致数据错乱
现象:偶尔出现乱码或协议解析失败。
原因:主循环处理速度跟不上中断输入速率,缓冲区写满后开始覆盖旧数据。
对策:
- 扩大BUFFER_SIZE(建议≥128)
- 增加状态监控:if ((head + 1)%N == tail) overflow_count++;
- 在调试阶段打印溢出次数,评估系统压力
❌ 坑3:波特率误差过大引发通信失败
公式回顾:
BRR = PCLK / (16 * baudrate)例如,APB1=36MHz,目标波特率9600,则理论BRR ≈ 234.375 → 实际写入234或235。
建议:
- 使用标准波特率(9600, 19200, 115200)
- 查阅数据手册确认最大容错范围(通常±2%~3%)
- 实在不行换晶振频率或改用分数波特率(高级系列支持)
可扩展性思考:不止于双UART
STM32F4/F7/H7等高端型号支持多达8个串口。本设计思想完全可复制:
- 为每个UART维护独立的缓冲区和状态机
- 统一使用
Parse_Protocol_Frame(port, byte)接口 - 结合RTOS创建多个任务分别处理各通道数据
- 引入DMA进一步解放CPU(尤其是音频、GPS等大数据流)
未来还可以接入FreeRTOS,把每个UART做成独立任务:
[UART1 Task] ← Queue ← ISR [UART2 Task] ← Queue ← ISR [System Sync Task] ← Timer真正实现事件驱动、松耦合、高并发的现代嵌入式架构。
写在最后:从“能通”到“可靠”的跨越
很多初学者觉得“串口只要能发出去就行”。但在工业现场,真正的挑战在于:
- 长时间运行不重启
- 抗电磁干扰
- 故障自恢复
- 数据有序可追溯
本文所展示的“双UART同步收发”方案,不仅仅是技术实现,更是一种工程思维的体现:
用中断解耦时序,用缓冲化解峰值,用时间戳建立因果关系。
当你不再依赖while(!(USART1->SR & USART_FLAG_RXNE));这种原始写法时,说明你已经迈入了专业嵌入式开发的大门。
如果你正在做一个需要多设备联动的项目,不妨试试这套架构。它足够轻量,又能扛住真实环境的压力。
欢迎在评论区分享你的应用场景,我们一起探讨优化方案!