STM32串口通信实战:双机UART连接、协议设计与DMA优化
1. 项目概述:为什么需要连接两块STM32板子?
在嵌入式开发的实际项目中,我们常常会遇到一个核心需求:让两块或多块单片机协同工作。比如,一块板子负责采集传感器数据(温度、湿度、姿态),另一块板子负责复杂算法处理或作为人机交互界面;又或者,在分布式系统中,主控板需要向多个执行单元发送指令。这时,最简单、最经典、成本最低的通信方式,就是串口(UART)。它只需要两根线(TX和RX)就能实现全双工数据交换,是MCU之间“对话”的首选协议。
我手头这个项目,就是基于两块STM32F103C8T6(俗称“蓝桥杯”或“最小系统板”)来实现一个简单的数据透传和指令控制系统。一块板子作为“主机”,模拟采集数据并发送控制命令;另一块作为“从机”,接收命令并执行相应动作(比如点亮不同的LED),同时将自身状态回传。这个场景虽然基础,但涵盖了串口通信从硬件连接到软件协议设计的完整流程,是理解更复杂通信协议(如CAN、SPI、I2C)的绝佳起点。无论你是刚接触STM32的新手,还是想巩固通信基础的老鸟,跟着走一遍,都能把串口那点事儿彻底搞明白。
2. 硬件连接与电平匹配:别让第一步就“翻车”
串口通信听起来简单,但硬件连接上的坑最多。很多初学者烧了芯片或者通信乱码,问题都出在这里。
2.1 核心连接原则:交叉互联
串口通信的核心是交叉互联:A设备的发送端(TX)连接B设备的接收端(RX),A设备的接收端(RX)连接B设备的发送端(TX)。对于两块STM32板子,假设我们使用USART1,那么连接方式如下:
- 板A的
PA9(USART1_TX) ---> 板B的PA10(USART1_RX) - 板A的
PA10(USART1_RX) ---> 板B的PA9(USART1_TX) - 板A的
GND---> 板B的GND(至关重要!共地是信号参考的基础)
注意:务必使用杜邦线牢固连接,并确保GND已连接。浮空的GND会导致电平参考混乱,通信必然失败。
2.2 电平匹配:3.3V的STM32世界
STM32的IO口电平是3.3V。幸运的是,STM32的USART端口是兼容3.3V TTL电平的。因此,两块同为3.3V供电的STM32板子,其串口可以直接相连,无需任何电平转换电路。这是STM32生态带来的便利。
但是,如果你需要连接一个5V TTL电平的设备(比如某些老款的Arduino),直接连接就可能损坏STM32的IO口(虽然很多STM32的IO口容忍5V,但并非全部,且非长久之计)。这时就需要一个电平转换电路,例如使用TXS0108E这类双向电平转换芯片,或者简单的电阻分压电路(将5V TX端的信号通过两个电阻分压至约3.3V后再接入STM32的RX)。
实操心得:在连接任何设备前,先用万用表测量一下对方串口的空闲电平。对于TTL串口,空闲时为高电平。如果测出是5V,就要警惕了。对于STM32之间的通信,确认双方都是3.3V供电,就可以放心直连。
2.3 是否需要外部上拉?
STM32的USART TX引脚在配置为推挽输出后,驱动能力足够,一般不需要上拉。RX引脚配置为浮空输入即可。但在一些干扰较强的环境中,或者线路较长时,可以在RX引脚上添加一个4.7kΩ~10kΩ的上拉电阻到3.3V,以提高抗干扰能力,避免因干扰产生误起始位。对于板间短距离通信(<30cm),通常可以省略。
3. 软件配置详解:从CubeMX到代码生成
硬件连好后,我们进入软件层面。我将使用STM32CubeMX进行初始化配置,这是目前最高效、最不容易出错的方式。
3.1 CubeMX工程基础配置
- 选择芯片:为两个工程分别选择正确的STM32型号(例如STM32F103C8Tx)。
- 系统核心(SYS):将
Debug改为Serial Wire,这样既可以用ST-Link调试,又不占用用于串口的PA13/PA14引脚。 - 时钟(RCC):
High Speed Clock (HSE)选择Crystal/Ceramic Resonator。外部8MHz晶振是保证串口波特率精确的基础。- 在
Clock Configuration标签页,配置系统时钟。对于F103,通常通过PLL将HSE 8MHz倍频到72MHz作为系统时钟(SYSCLK)。
- USART1配置:
- 模式:选择
Asynchronous(异步模式)。 - 基本参数:这是关键,两块板子的这些参数必须完全一致!
Baud Rate(波特率): 115200 Bits/s(常用值,兼顾速度和稳定性)。Word Length(字长): 8 Bits(最常用)。Parity(奇偶校验): None(无校验)。Stop Bits(停止位): 1。Over Sampling(过采样): 16 Samples(默认,抗噪性好)。
- 引脚:默认PA9为
USART1_TX, PA10为USART1_RX,通常无需改动。 - NVIC设置:勾选
USART1 global interrupt使能全局中断。这是我们采用“中断接收+轮询发送”架构的基础。
- 模式:选择
3.2 生成代码与项目结构
配置完成后,点击GENERATE CODE生成Keil MDK(或你使用的IDE)工程。CubeMX会生成所有外设的初始化代码MX_USART1_UART_Init(),并自动将HAL库文件加入工程。
生成的代码关键点检查:
- 在
main.c中,SystemClock_Config()函数是否正确配置了72MHz时钟。 MX_USART1_UART_Init()函数中,波特率等参数是否与设置一致。- 在
stm32f1xx_it.c中,已经生成了USART1_IRQHandler()中断服务函数框架,它会调用HAL库的中断处理函数HAL_UART_IRQHandler(&huart1)。
4. 通信程序架构设计:中断、DMA与协议
直接在主循环里用HAL_UART_Receive()这种阻塞式接收是极不推荐的,它会卡死整个程序。我们需要更高效的架构。
4.1 推荐架构:中断接收 + 环形缓冲区 + 协议解析
这是最经典且稳定的方案。
- 中断接收:使能串口接收中断。每当收到一个字节,硬件就会触发中断,在中断服务程序(ISR)中,我们迅速将这个字节存入一个预先定义好的数组(环形缓冲区),然后立即退出中断。这个过程非常快,不影响主程序运行。
- 环形缓冲区:一个头指针(
RxBuf_Head)和一个尾指针(RxBuf_Tail)管理的数组。中断程序向Head位置写数据,主程序从Tail位置读数据。当Head追上Tail,表示缓冲区满;当Tail追上Head,表示缓冲区空。这解决了数据覆盖和丢失的问题。 - 主程序轮询解析:在主循环中,不断检查环形缓冲区是否有新数据。一旦发现完整的一帧数据(根据自定义协议判断,例如判断帧头、帧尾和长度),就将其从缓冲区中取出进行解析和处理。
4.2 代码实现:中断接收与缓冲区管理
首先,在main.c的私有变量定义区,定义环形缓冲区及相关变量:
/* Private variables ---------------------------------------------------------*/ #define RX_BUF_SIZE 256 uint8_t Uart1_RxBuf[RX_BUF_SIZE]; // 环形缓冲区 volatile uint16_t Uart1_RxHead = 0; // 写指针,中断修改,必须加volatile volatile uint16_t Uart1_RxTail = 0; // 读指针,主程序修改 uint8_t Uart1_RxByte = 0; // 临时存放接收到的字节在main()函数的初始化部分,在MX_USART1_UART_Init()之后,启动串口接收中断:
/* 启动串口空闲中断接收一个字节 */ /* 注意:这里先接收一个字节,后续在中断中连续接收 */ if (HAL_UART_Receive_IT(&huart1, &Uart1_RxByte, 1) != HAL_OK) { Error_Handler(); }然后,我们需要重写(Override)HAL库的串口接收完成回调函数。这个函数会在每次成功接收一个字节后,被HAL_UART_IRQHandler调用。在main.c的/* USER CODE BEGIN 4 */区域添加:
/* 重写HAL库的UART接收完成回调函数 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) // 判断是哪个串口 { /* 将收到的字节存入环形缓冲区头部 */ Uart1_RxBuf[Uart1_RxHead] = Uart1_RxByte; Uart1_RxHead = (Uart1_RxHead + 1) % RX_BUF_SIZE; // 头指针循环 /* 如果缓冲区满了,丢弃最旧的数据(尾指针前移) */ if (Uart1_RxHead == Uart1_RxTail) { Uart1_RxTail = (Uart1_RxTail + 1) % RX_BUF_SIZE; // 这里可以设置一个错误标志,通知主程序发生了溢出 } /* 重新启动中断接收,等待下一个字节 */ HAL_UART_Receive_IT(&huart1, &Uart1_RxByte, 1); } }4.3 自定义通信协议设计
原始字节流没有意义,我们需要定义一种帧格式,让双方知道一段数据的开始、结束和含义。一个简单实用的协议如下:
| 字节位置 | 字段名 | 长度 | 说明 | 示例值(十六进制) |
|---|---|---|---|---|
| 0 | 帧头 | 1 | 固定值,标识帧开始 | 0xAA |
| 1 | 命令字 | 1 | 指示本帧数据的类型或意图 | 0x01 (查询) |
| 2 | 数据长度 N | 1 | 后续有效数据的字节数 | 0x04 |
| 3 ~ 3+N-1 | 数据域 | N | 实际要传输的数据 | ... |
| 3+N | 校验和 | 1 | 前面所有字节的累加和(取低8位) | SUM |
| 3+N+1 | 帧尾 | 1 | 固定值,标识帧结束 | 0x55 |
协议解析流程(在主循环中):
- 从环形缓冲区读取一个字节。
- 如果读到
0xAA,进入“寻找帧”状态。 - 继续读取,拿到命令字、数据长度N。
- 再连续读取N个字节的数据。
- 读取校验和字节,计算前面所有字节(帧头到数据域最后一个字节)的累加和,与收到的校验和比对。不一致则丢弃本帧。
- 最后读取帧尾
0x55,确认帧格式完整。 - 校验通过后,根据“命令字”执行相应操作。
发送一帧数据的函数示例:
void Uart1_Send_Frame(uint8_t cmd, uint8_t *data, uint8_t len) { uint8_t frame[256]; // 临时缓冲区,足够大 uint8_t frame_len = 0; uint8_t checksum = 0; // 帧头 frame[frame_len++] = 0xAA; checksum += 0xAA; // 命令字 frame[frame_len++] = cmd; checksum += cmd; // 数据长度 frame[frame_len++] = len; checksum += len; // 数据域 for (int i = 0; i < len; i++) { frame[frame_len++] = data[i]; checksum += data[i]; } // 校验和 frame[frame_len++] = checksum; // 帧尾 frame[frame_len++] = 0x55; // 使用HAL库阻塞式发送(简单演示,实际可用DMA) HAL_UART_Transmit(&huart1, frame, frame_len, 1000); }5. 高级话题:使用DMA提升效率与稳定性
当数据量较大或频率较高时,频繁的中断仍然会消耗不少CPU资源。此时,DMA(直接存储器访问)是更好的选择。DMA可以在外设(如UART)和内存(如数组)之间直接搬运数据,无需CPU干预。
5.1 串口接收DMA配置(CubeMX)
- 在CubeMX的USART1配置中,将
DMA Settings选项卡下的Add,为USART1_RX添加一个DMA流(例如DMA1_Channel5)。 - 模式选择
Circular(循环模式)。这样DMA会持续不断地将串口接收到的数据搬运到我们指定的大数组中,当数组填满后,会自动从头开始覆盖,形成一个硬件实现的环形缓冲区。 - 数据宽度都选择
Byte。
5.2 代码实现:DMA接收+空闲中断
仅配置DMA循环接收还不够,我们不知道何时收到了一帧完整的数据。这里需要结合串口空闲中断(IDLE Interrupt)。当串口总线上一段时间没有新数据(即空闲时间超过一个字符传输时间),就会产生空闲中断。
配置步骤:
- 在CubeMX中使能USART1的全局中断(NVIC)。
- 在代码中,需要手动使能串口的空闲中断:
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); - 在
USART1_IRQHandler函数中,除了调用HAL_UART_IRQHandler,还需要添加对空闲中断的判断和处理。
空闲中断处理逻辑:
void USART1_IRQHandler(void) { /* USER CODE BEGIN USART1_IRQn 0 */ // 判断是否是空闲中断 if((__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 清除空闲中断标志 // 计算DMA已经接收了多少数据 uint16_t dma_received_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 获取本次数据的起始位置(需要根据上次处理的位置计算) // 将 dma_received_len 长度的新数据交给协议解析函数处理 // 然后更新“上次处理位置”指针 } /* USER CODE END USART1_IRQn 0 */ HAL_UART_IRQHandler(&huart1); /* USER CODE BEGIN USART1_IRQn 1 */ /* USER CODE END USART1_IRQn 1 */ }这种方式效率极高,CPU只在收到一整包数据后才被中断唤醒进行处理,非常适合高速、大数据量的串口通信。
6. 调试技巧与常见问题排查
即使按照步骤操作,第一次调通串口也常会遇到问题。下面是我总结的排查清单。
6.1 通信完全无反应(收不到任何数据)
- 检查硬件连接:这是第一位的!用万用表通断档检查TX-RX是否交叉连接,GND是否连通。检查杜邦线是否完好。
- 检查引脚复用:确认使用的PA9/PA10没有被其他功能占用(比如JTAG)。在CubeMX中检查引脚颜色,确保没有冲突警告。
- 检查时钟配置:串口波特率依赖于系统时钟。如果系统时钟配置错误(比如还是用的内部HSI 8MHz),实际波特率会严重偏离。确保
SystemClock_Config()正确,并且HAL_RCC_GetSysClockFreq()返回的值是预期的(如72MHz)。 - 检查波特率等参数:确保两块板子的波特率、数据位、停止位、校验位完全一致!一个标点符号都不能错。
- 检查中断优先级:如果程序中还有其他高优先级中断长时间阻塞,可能导致串口中断无法响应。检查NVIC配置。
- 使用逻辑分析仪或示波器:这是终极武器。探头连接到TX引脚,看看是否有波形发出。测量一个位的时间(例如115200波特率,一位约8.68us),看是否与设置相符。
6.2 能收到数据但全是乱码
- 波特率不匹配:这是最常见原因。即使两边都设为115200,如果时钟源不准,实际波特率也会有细微差异,积累误差会导致错位。尝试降低波特率(如9600)测试。最好使用外部晶振。
- 电平问题:虽然都是3.3V,但如果一方电源不稳,高电平电压不足,也可能导致误判。测量TX引脚在发送时的实际高电平电压。
- 数据位/停止位/校验位不匹配:仔细核对CubeMX和对方设备(如串口调试助手)的设置。
- 缓冲区溢出:如果发送方发送过快,接收方处理不及时,又没用环形缓冲区或DMA,数据会被覆盖。检查接收代码是否有溢出保护。
6.3 数据丢包或断断续续
- 中断被屏蔽时间过长:如果在主循环或高优先级中断中有长时间关中断(
__disable_irq())或耗时操作,会导致串口中断无法及时响应,丢失数据。优化代码,减少关中断时间。 - 环形缓冲区大小不足:如果数据突发量大,缓冲区太小会被快速填满。增加
RX_BUF_SIZE。 - 发送函数阻塞时间过长:
HAL_UART_Transmit是阻塞的,如果发送一长串数据,期间无法处理接收。对于主机,可以考虑使用HAL_UART_Transmit_IT(中断发送)或HAL_UART_Transmit_DMA(DMA发送)。 - 物理连接不可靠:杜邦线接触不良,在振动或移动时导致断续。尝试焊接或使用更可靠的连接器。
6.4 使用串口调试助手辅助调试
在开发初期,可以先用USB转TTL模块将一块STM32的串口连接到电脑,用串口调试助手(如XCOM、SSCOM)代替另一块板子。这样可以:
- 验证发送:在代码中让STM32定时发送固定数据包,在电脑端查看接收是否正确。
- 验证接收:从电脑端发送数据,观察STM32能否正确接收并响应(比如让LED闪烁)。
- 抓取数据:当两块STM32通信异常时,可以用调试助手作为“监听者”,并联到通信线路上,查看实际传输的数据流,判断问题出在发送方还是接收方。
一个关键的调试技巧:在串口初始化后,先让板子发送一段固定的字符串(如"Hello from Board A!\r\n")。如果能在调试助手上看到这行字,至少证明发送端、硬件连接、波特率设置这一半的路径是通的。这能极大缩小问题范围。
