STM32双机串口通信实战:从硬件连接到自定义协议设计

发布时间:2026/7/29 10:37:37

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和内存缓冲区之间搬运数据。发送端使用DMAchar 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控制LED0x01数据长度1指示后面“数据域”的长度0-2550x05数据域N实际的有效数据长度由“数据长度”字段指定...校验和1对前面所有字节进行累加和或CRC8用于验证数据完整性(计算得出)帧尾2固定值用于标识帧的结束0x0D, 0x0A4.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 i0; ilen; 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句柄已正确初始化。问题两个板子程序一样但一个发一个收不行排查检查两个板子的型号是否完全一致不同型号STM32的引脚复用功能可能不同。检查stm32xxxx_hal_conf.h文件中是否已取消对应UART模块的注释#define HAL_UART_MODULE_ENABLED。问题加了协议后解析不对排查结构体字节对齐问题。编译器可能会在结构体成员之间插入填充字节以保证对齐这会导致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%的硬件和底层驱动问题把精力集中在更有价值的应用逻辑调试上。

相关新闻