STM32双机串口通信实战:从硬件连接到自定义协议设计
1. 项目缘起:从单打独斗到协同作战
在嵌入式开发中,我们常常会遇到一个场景:一个STM32板子不够用。可能是功能模块太多,一个MCU的引脚资源或计算能力捉襟见肘;也可能是为了模块化设计,将传感器采集、核心逻辑、人机交互等功能拆分到不同的板卡上。这时候,如何让这些独立的“大脑”顺畅地对话,就成了项目成败的关键。串口通信,作为嵌入式领域最古老、最经典、也最可靠的通信方式之一,自然成为了连接两个STM32板子的首选方案。它不像SPI或I2C那样有严格的主从限制,也不像CAN总线那样需要复杂的协议栈,两根线(TX和RX)一交叉,理论上就能让数据在两个设备间流动起来。
但“理论上”和“实际上”往往隔着一条鸿沟。我见过不少初学者,照着教程把两个板子的TX和RX交叉连接,烧录了最简单的收发程序,却发现数据要么石沉大海,要么乱码丛生。这背后的原因,远不止连线那么简单。从最基础的波特率匹配,到数据帧格式的约定,再到通信协议的制定和错误处理机制,每一步都藏着细节。这个项目,就是要把这些细节掰开揉碎,带你从零开始,搭建一个稳定、可靠的双STM32串口通信系统。无论你是想实现双机备份、功能扩展,还是学习分布式嵌入式系统的入门,这篇文章都能给你一套可以直接“抄作业”的完整方案。
2. 通信基石:深入理解UART的物理与逻辑层
在动手连接杜邦线之前,我们必须先统一思想,也就是统一通信的“语言规则”。STM32的串口,通常指的是其内置的通用异步收发传输器(UART)或通用同步异步收发传输器(USART)。我们这里主要使用其异步模式(UART)。
2.1 核心参数:波特率、数据位、停止位与校验位
这是串口通信的“宪法”,双方必须完全一致,否则通信必然失败。
波特率 (Baud Rate):这是最容易出问题的地方。它表示每秒传输的符号数,对于常见的8-N-1格式,一个符号就是一个比特(bit),所以9600波特率大致意味着每秒传输9600比特数据。关键点在于精度。STM32的UART波特率由系统时钟分频而来,如果计算出的分频系数不是整数,就会产生误差。误差累积会导致采样点偏移,最终产生误码。通常要求误差小于2.5%(对于某些UART甚至要求更严)。例如,当系统时钟为72MHz,想要配置115200的波特率时,需要计算分频系数:72000000 / 115200 = 625。这是一个整数,因此可以精确配置。但如果想要配置9600,系数是7500,也是整数。在配置时,务必使用STM32CubeMX或手动计算确认分频系数是整数或误差在可接受范围内。
数据位 (Data Bits):表示一个帧中有效数据的长度,通常是8位。这也是最常用的配置,因为一个字节(Byte)正好是8位,便于处理。
停止位 (Stop Bits):用于标识一个数据帧的结束,可以是1、1.5或2位。99%的情况下,使用1位停止位就够了。增加停止位可以给接收方更多的时间来处理帧结束,在通信环境较差时可能有点用,但会降低有效数据吞吐率。
校验位 (Parity Bit):用于简单的错误检测。可以是奇校验、偶校验或无校验。奇偶校验只能检测奇数个位错误,对于偶数个位错误无能为力。在稳定的电路板上,两个板子距离很近时,干扰较小,通常选择“无校验”(None)。如果为了更高的可靠性,建议在应用层使用更强大的校验算法(如CRC),而不是依赖硬件奇偶校验。
一个最常用、最稳妥的配置是:115200波特率,8位数据位,1位停止位,无校验位。在项目初期,强烈建议使用这个配置以减少变量。
2.2 硬件连接:不仅仅是TX接RX
物理连接看似简单,却有几个极易忽略的坑。
- 交叉连接是铁律:板子A的TX(发送)引脚必须连接板子B的RX(接收)引脚,反之亦然。自己发给自己收是不会有数据的。
- 共地是关键:两个板子的GND(地)必须连接在一起。这是所有电平信号的参考基准。没有共地,电压高低就没有统一的判断标准,通信必然混乱。这是新手最容易忘记的一步!
- 电平匹配需注意:STM32的GPIO引脚通常是3.3V电平。只要两个STM32板子的工作电压都是3.3V,就可以直接连接。如果一个板子是5V电平(比如某些Arduino),则不能直接连接,需要用电平转换模块(如TXB0104等)进行转换,否则可能损坏3.3V的STM32芯片。
- 避免引脚冲突:确保你选择的UART引脚(如USART1的PA9/PA10)没有用于其他功能(如调试接口SWD)。如果使用了ST-Link的虚拟串口功能(通常也是通过某个USART实现),也要注意避开。
注意:连接时最好先断电操作。热插拔杜邦线有时会导致瞬间短路或浪涌,虽然不一定会损坏芯片,但不是一个好习惯。
3. 软件驱动:配置与收发实战
硬件准备就绪后,我们来让软件跑起来。这里以STM32CubeIDE/HAL库为例,因为它配置起来最直观。我们假设要实现板子A发送一个字符串,板子B接收并回传。
3.1 发送端(板子A)配置与代码
首先,在STM32CubeMX中为板子A配置USART。
- 选择USART外设(如USART1)。
- 模式选择“Asynchronous”(异步)。
- 参数设置:波特率115200,数据位8,停止位1,校验位None,硬件流控制None。
- 使能USART全局中断(NVIC Settings中勾选)。这对于接收数据至关重要。
- 生成代码。
在生成的工程中,我们编写发送代码。HAL库提供了阻塞式、中断式和DMA式三种发送函数。对于初学者,我们先从简单的阻塞式开始,但要知道它的缺点。
// 板子A - main.c 中的一段示例 char txBuffer[] = "Hello from Board A!\r\n"; // \r\n是换行,方便串口助手查看 while (1) { // 阻塞式发送,函数会一直等待直到发送完成 HAL_UART_Transmit(&huart1, (uint8_t*)txBuffer, strlen(txBuffer), 1000); HAL_Delay(1000); // 每秒发送一次 }HAL_UART_Transmit的最后一个参数是超时时间(毫秒)。如果超过这个时间还没发送完,函数会返回错误HAL_TIMEOUT。阻塞式发送的缺点是,在发送数据期间,CPU被“挂起”,不能做其他事情。对于发送短字符串问题不大,但如果要发送大量数据,就会严重影响系统实时性。
3.2 接收端(板子B)配置与代码
板子B的USART配置与板子A必须完全一致。关键在于如何接收数据。我们使用中断方式,这是最常用、最高效的方法。
启动接收:在main函数初始化后,启动串口中断接收。
// 板子B - main.c 初始化部分 uint8_t rxBuffer[64]; // 定义一个接收缓冲区 HAL_UART_Receive_IT(&huart1, rxBuffer, 1); // 启动中断接收,每次接收1个字节这里我们设置每次接收1个字节。一旦有数据到来,USART接收完一个字节后就会触发中断。
编写中断回调函数:当收到一个字节后,HAL库会调用
HAL_UART_RxCpltCallback回调函数。我们需要重写这个函数。// 板子B - 在main.c 用户代码区添加 uint8_t rxData; // 用于存放单个字节 uint8_t rxBuffer[64]; int rxIndex = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) // 判断是哪个串口触发的中断 { rxBuffer[rxIndex++] = rxData; // 将收到的字节存入缓冲区 // 简单判断:如果收到回车符(\r或\n),则认为一条消息结束 if (rxData == '\r' || rxData == '\n' || rxIndex >= sizeof(rxBuffer)-1) { rxBuffer[rxIndex] = '\0'; // 添加字符串结束符 printf("Received: %s\r\n", rxBuffer); // 通过串口打印出来(需重定向printf) // 这里可以添加回传代码,例如:HAL_UART_Transmit(&huart1, rxBuffer, rxIndex, 100); rxIndex = 0; // 重置索引,准备接收下一条消息 } // 重新启动中断接收,等待下一个字节 HAL_UART_Receive_IT(&huart1, &rxData, 1); } }注意:在回调函数中,我们只是简单地缓存数据并判断帧结束。在复杂的应用中,这里应该是一个状态机,用于解析协议。同时,回调函数中不宜执行耗时操作。
重定向printf(可选但建议):为了方便调试,我们通常将
printf函数重定向到串口。在usart.c文件中添加以下代码:#include <stdio.h> int _write(int file, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t*)ptr, len, HAL_MAX_DELAY); return len; }这样,在板子B上就可以用
printf来打印信息了。
3.3 进阶:使用DMA解放CPU
当需要高速、大数据量通信时,中断方式每个字节都进一次中断,开销依然很大。此时,直接存储器访问(DMA)是终极解决方案。DMA可以在不占用CPU的情况下,在外设(UART)和内存(缓冲区)之间搬运数据。
发送端使用DMA:
char dmaTxBuffer[] = "Large data block..."; HAL_UART_Transmit_DMA(&huart1, (uint8_t*)dmaTxBuffer, sizeof(dmaTxBuffer)); // 调用后立即返回,CPU可以去执行其他任务 // 可以通过 HAL_UART_TxCpltCallback 回调函数得知DMA发送完成接收端使用DMA(更常用):
uint8_t dmaRxBuffer[256]; HAL_UART_Receive_DMA(&huart1, dmaRxBuffer, sizeof(dmaRxBuffer)); // DMA会在后台持续接收数据,并自动写入dmaRxBuffer // 我们需要通过“空闲中断”(Idle Interrupt)来检测一帧数据接收完成空闲中断是USART在检测到总线上一段时间(一个字节传输时间)没有新数据时产生的中断。结合DMA,我们可以实现“不定长数据帧”的高效接收:使能空闲中断,启动DMA接收。数据源源不断存入缓冲区,当一帧数据发送完毕,总线空闲,触发空闲中断。在中断服务函数中,我们可以根据DMA当前搬运的地址,计算出这一帧数据的长度,然后进行处理。这是工业级串口通信的标配方案,能极大提升效率和处理能力。配置空闲中断需要在CubeMX中额外勾选“USART全局中断”,并在代码中手动使能空闲中断:__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);,并编写对应的中断处理逻辑。
4. 从字节流到信息:自定义通信协议
原始字节流只是通信的基础。两个板子要真正理解对方,需要一套共同的“语言”,也就是通信协议。没有协议,接收方无法判断一包数据从哪里开始、到哪里结束、数据是什么含义。
4.1 设计一个简单的帧结构
一个健壮的协议帧至少包含帧头、数据长度、数据内容、校验和和帧尾。
| 字段 | 长度(字节) | 说明 | 示例值(十六进制) |
|---|---|---|---|
| 帧头 | 2 | 固定值,用于标识帧的开始 | 0xAA, 0x55 |
| 命令字 | 1 | 标识这条指令是做什么的(如:0x01读传感器,0x02控制LED) | 0x01 |
| 数据长度 | 1 | 指示后面“数据域”的长度(0-255) | 0x05 |
| 数据域 | N | 实际的有效数据,长度由“数据长度”字段指定 | ... |
| 校验和 | 1 | 对前面所有字节进行累加和(或CRC8),用于验证数据完整性 | (计算得出) |
| 帧尾 | 2 | 固定值,用于标识帧的结束 | 0x0D, 0x0A |
4.2 发送端封包代码示例
typedef struct { uint8_t header[2]; uint8_t cmd; uint8_t len; uint8_t data[256]; uint8_t checksum; uint8_t footer[2]; } UART_Frame_t; void send_packet(UART_HandleTypeDef *huart, uint8_t cmd, uint8_t *data, uint8_t len) { UART_Frame_t frame; frame.header[0] = 0xAA; frame.header[1] = 0x55; frame.cmd = cmd; frame.len = len; memcpy(frame.data, data, len); // 计算校验和(简单累加和) uint8_t sum = 0; sum += frame.cmd; sum += frame.len; for(int i=0; i<len; i++) { sum += frame.data[i]; } frame.checksum = sum; frame.footer[0] = 0x0D; frame.footer[1] = 0x0A; // 发送整个结构体(注意长度计算) uint16_t total_len = 2 + 1 + 1 + len + 1 + 2; HAL_UART_Transmit(huart, (uint8_t*)&frame, total_len, 1000); }4.3 接收端解包状态机
接收端不能再像之前那样简单地判断回车符了,需要实现一个状态机来解析这个协议帧。
typedef enum { STATE_WAIT_HEADER1, STATE_WAIT_HEADER2, STATE_WAIT_CMD, STATE_WAIT_LEN, STATE_WAIT_DATA, STATE_WAIT_CHECKSUM, STATE_WAIT_FOOTER1, STATE_WAIT_FOOTER2 } ParserState_t; ParserState_t state = STATE_WAIT_HEADER1; UART_Frame_t rxFrame; uint8_t dataIndex = 0; uint8_t calcChecksum = 0; void parse_byte(uint8_t byte) { switch(state) { case STATE_WAIT_HEADER1: if(byte == 0xAA) state = STATE_WAIT_HEADER2; break; case STATE_WAIT_HEADER2: if(byte == 0x55) { state = STATE_WAIT_CMD; calcChecksum = 0; // 开始计算校验和 } else { state = STATE_WAIT_HEADER1; // 同步失败,重新开始 } break; case STATE_WAIT_CMD: rxFrame.cmd = byte; calcChecksum += byte; state = STATE_WAIT_LEN; break; case STATE_WAIT_LEN: rxFrame.len = byte; calcChecksum += byte; dataIndex = 0; if(rxFrame.len > 0) { state = STATE_WAIT_DATA; } else { state = STATE_WAIT_CHECKSUM; // 数据长度为0,跳过数据域 } break; case STATE_WAIT_DATA: rxFrame.data[dataIndex++] = byte; calcChecksum += byte; if(dataIndex >= rxFrame.len) { state = STATE_WAIT_CHECKSUM; } break; case STATE_WAIT_CHECKSUM: if(byte == calcChecksum) { state = STATE_WAIT_FOOTER1; } else { // 校验失败,丢弃本帧,回到初始状态 state = STATE_WAIT_HEADER1; } break; case STATE_WAIT_FOOTER1: if(byte == 0x0D) state = STATE_WAIT_FOOTER2; else state = STATE_WAIT_HEADER1; break; case STATE_WAIT_FOOTER2: if(byte == 0x0A) { // 一帧完整的数据接收成功! process_frame(&rxFrame); // 调用业务处理函数 } // 无论帧尾是否正确,都回到初始状态寻找下一帧 state = STATE_WAIT_HEADER1; break; } }在串口接收中断回调函数中,不再直接处理数据,而是调用parse_byte(rxData),将每个字节喂给这个状态机。状态机会自动完成帧的同步、校验和解析。这种方式抗干扰能力极强,即使中间有数据丢失或错位,也能在下一帧重新同步。
5. 实战调试与排坑指南
理论完备,代码写好,但上电后没反应?这是最考验人的阶段。下面是我总结的一套调试流程和常见问题排查清单。
5.1 调试流程四步法
第一步:硬件检查(肉眼可见)
- 连线:TX-RX是否交叉连接?GND是否相连?用万用表通断档检查最可靠。
- 电源:两个板子是否都已正常供电?电源指示灯是否亮起?
- 引脚:确认代码中使用的UART引脚与实际连接的物理引脚一致(查原理图)。
第二步:单板自发自收测试(隔离问题)这是确定一个板子的串口硬件和基础发送功能是否正常的最佳方法。将板子A的TX和RX用杜邦线短接,然后运行一个发送特定字符串(如“TEST”)的程序。如果程序能通过串口接收中断收到自己发出的“TEST”,说明这个板子的UART发送和接收通路硬件、驱动配置基本正常。对板子B也做同样的测试。这个步骤能有效区分是发送方问题还是接收方问题,或者是连接问题。
第三步:逻辑分析仪/示波器抓取波形(终极武器)如果自发自收正常,但双机通信失败,逻辑分析仪是神器。将通道连接到TX、RX线上。
- 看TX线:发送方发送时,是否有波形?波形的波特率是否正确(测量一个位的时间,倒数即为实际波特率)?数据内容是否和代码发送的一致?
- 看RX线:接收方应该收到数据的时刻,线上是否有波形?波形是否干净(有无毛刺)?电平幅度是否足够(3.3V)? 通过波形,可以直观看到数据是否真的从A发到了B的RX脚,以及数据本身是否正确。我曾用这个方法发现过一个板子的TX引脚内部损坏,始终输出高电平,导致通信失败。
第四步:软件调试与打印
- 检查初始化顺序:确保所有外设(时钟、GPIO、UART)初始化完成后再进行发送/接收操作。
- 检查中断优先级:如果系统中有其他中断(如SysTick定时器中断),且其优先级高于UART中断,可能导致UART中断被延迟响应,造成数据丢失。确保UART接收中断有足够高的优先级(数值小的优先级高)。
- 使用调试器单步跟踪:在接收中断回调函数和发送函数处设置断点,看程序是否按预期进入。
5.2 十大常见问题与解决方案
- 问题:完全没数据
- 排查:检查电源和地线;检查TX/RX是否交叉连接;检查CubeMX中是否使能了USART外设;检查程序是否卡在某个硬件初始化错误(如晶振失败)上。
- 问题:收到乱码
- 排查:99%是波特率不匹配。用逻辑分析仪测量实际波特率,与代码配置对比。检查双方系统时钟(HCLK)配置是否正确,特别是如果使用了外部晶振(HSE),是否成功起振并配置为系统时钟源。
- 问题:只能收到第一个字节或前几个字节
- 排查:这是中断未重装的典型症状。在
HAL_UART_RxCpltCallback回调函数末尾,必须再次调用HAL_UART_Receive_IT来重新启动接收中断,否则只会触发一次。
- 排查:这是中断未重装的典型症状。在
- 问题:数据丢失,不完整
- 排查:接收缓冲区溢出。中断处理函数(或回调函数)执行时间过长,导致在新的字节到来时,上一个字节还没被取出,从而被覆盖。优化中断服务函数,只做最必要的操作(如存入缓冲区、设置标志位),将复杂处理(如协议解析)放到主循环中。考虑使用DMA+空闲中断方案。
- 问题:通信一段时间后死机
- 排查:堆栈溢出或内存泄漏。在中断中调用了不可重入函数或进行了动态内存分配。检查中断服务函数是否过于复杂。使用DMA时,缓冲区是否定义得足够大且未越界访问。
- 问题:使用
printf重定向后程序卡死- 排查:未启用“Use MicroLIB”或“Newlib-nano”等微库。在IDE的工程属性中,需要勾选使用微库以减小代码体积。另外,确保
_write函数实现正确,且使用的huart句柄已正确初始化。
- 排查:未启用“Use MicroLIB”或“Newlib-nano”等微库。在IDE的工程属性中,需要勾选使用微库以减小代码体积。另外,确保
- 问题:两个板子程序一样,但一个发一个收不行
- 排查:检查两个板子的型号是否完全一致?不同型号STM32的引脚复用功能可能不同。检查
stm32xxxx_hal_conf.h文件中是否已取消对应UART模块的注释#define HAL_UART_MODULE_ENABLED。
- 排查:检查两个板子的型号是否完全一致?不同型号STM32的引脚复用功能可能不同。检查
- 问题:加了协议后解析不对
- 排查:结构体字节对齐问题。编译器可能会在结构体成员之间插入填充字节以保证对齐,这会导致
sizeof(UART_Frame_t)比你计算的大,发送和接收时对不齐。在结构体定义前加#pragma pack(1)指令强制1字节对齐,或者在发送时避免直接发送整个结构体,而是按字段逐个发送。
- 排查:结构体字节对齐问题。编译器可能会在结构体成员之间插入填充字节以保证对齐,这会导致
- 问题:通信受其他外设(如电机、继电器)干扰
- 排查:电源噪声或空间电磁干扰。为MCU的电源引脚增加去耦电容(如100nF陶瓷电容紧贴引脚)。如果线路较长,考虑使用屏蔽线或双绞线。在软件上,可以增加协议的校验强度(如使用CRC16代替累加和),并加入超时重发机制。
- 问题:想提高通信速度,但提高波特率后出错
- 排查:首先确认芯片支持更高的波特率(查看数据手册)。其次,高波特率对时钟精度和PCB布线要求更高。检查系统时钟是否稳定,TX/RX走线是否过近或平行于其他高速信号线,导致串扰。可以尝试降低波特率,或在软件上优化协议,减少冗余数据,而不是一味提高波特率。
6. 性能优化与进阶思路
当基础通信稳定后,我们可以从以下几个方向进行优化和扩展,让这个双机系统更强大、更可靠。
6.1 双缓冲与环形队列管理
在中断服务函数中,最忌讳的就是长时间操作。对于接收,一个经典优化是使用双缓冲或环形队列。
- 双缓冲:准备两个缓冲区A和B。中断回调函数将数据填入缓冲区A,当A满或收到帧结束符时,切换至缓冲区B进行填充,同时通知主循环处理缓冲区A的数据。这避免了处理数据时丢失新数据。
- 环形队列:在中断中只将数据写入一个环形队列(FIFO),主循环不断从队列中读取并解析。HAL库本身不提供队列,需要自己实现或使用RTOS的消息队列。这是更通用、更优雅的解决方案。
6.2 超时、重发与应答机制(可靠传输)
我们的简单协议没有确认机制。在实际工业应用中,需要实现可靠传输。
- 应答(ACK/NACK):接收方成功解析一帧数据后,应发送一个应答帧(ACK)给发送方。如果校验失败,则发送否定应答(NACK)。
- 超时重发:发送方发出数据后,启动一个定时器。如果在规定时间内没有收到ACK,则认为数据丢失,重新发送该帧数据。需要为每帧数据设置一个唯一的序列号,以便接收方去重。
- 滑动窗口:为了提高效率,可以一次发送多帧数据(一个窗口),再批量确认。这类似于TCP协议,能充分利用链路带宽。
6.3 多机通信与RS-485总线
目前我们是一对一通信。如果需要连接多个STM32板子,UART的点对点特性就不够了。这时可以引入RS-485标准。
- 硬件改动:每个STM32板子需要连接一个RS-485收发器芯片(如MAX485)。所有设备的A、B线并联在一起,形成一条总线。
- 软件改动:需要增加一个控制收发器方向(发送/接收)的引脚(DE/RE)。发送数据前,拉高该引脚使能发送;发送完成后,拉低该引脚切换回接收模式。这被称为“半双工”通信。
- 协议升级:必须在数据帧中加入“地址”字段。每个设备有唯一地址。主机发送带目标地址的数据帧,总线上的所有设备都收到,但只有地址匹配的设备才会处理并回复,其他设备忽略。这就实现了在一条总线上与多个设备通信。
6.4 与上位机(PC)通信
两个STM32之间通信稳定后,很自然的一个扩展就是让其中一个STM32作为“网关”,通过USB转串口模块(如CH340)与PC通信。这样,PC上的串口助手或自定义的上位机软件就可以监控或控制整个嵌入式网络。
- 电平转换:STM32是3.3V TTL电平,PC的串口(如果还有的话)是RS-232电平(±12V),不能直接连接。必须使用USB转TTL串口模块(如CH340、CP2102、FT232等),它们输出的是3.3V或5V TTL电平,与STM32兼容。
- 协议统一:PC上位机和STM32之间也需要约定相同的通信协议。通常,可以让STM32“网关”负责协议转换,例如将来自总线的自定义协议数据包,转换成便于PC解析的JSON格式字符串发送给上位机,反之亦然。
从最简单的交叉连线,到稳定可靠的协议通信,再到面向多机与网络的扩展,两个STM32板子通过串口的对话,是一个典型的嵌入式通信项目,它几乎涵盖了从硬件到软件、从底层驱动到应用协议的所有核心知识点。把这个项目吃透,再面对SPI、I2C、CAN甚至以太网通信时,你会发现很多思想是相通的。调试过程虽然可能充满挫折,但每一次解决问题的过程,都是对系统理解的一次深化。最后,我个人的一个习惯是,在项目初期,一定会先花时间把“自发自收”测试和逻辑分析仪抓波形这两步做扎实,这能帮你排除掉至少80%的硬件和底层驱动问题,把精力集中在更有价值的应用逻辑调试上。