☰
STM32双机串口通信实战:从硬件连接到可靠帧协议
2026/10/26 4:34:27 网站建设 项目流程

简介:这份资源聚焦STM32双板之间的串口通信实践,面向已掌握GPIO与基础外设、希望打通板间数据收发链路的嵌入式学习者。方案基于正点原子mini板与精英板搭建:mini板经USART3(PB10、PB11)发送数据,精英板用同组串口接收,并通过USART1(CH340下载口)回传至串口调试助手查看,同时可将接收内容显示在LCD屏上;接收成功时LED10翻转、发送成功时LED1翻转,便于直观判断通信状态。资源包共321个文件、约7.35MB,以c与h源码为主体,辅以Keil工程文件、编译中间产物及hex、map等输出文件,工程结构完整,可直接编译调试。若无mini板可用串口助手模拟发送端,无LCD屏可注释相应初始化代码,适配不同硬件条件。目前已有4418人学习,适合作为板间串口联调的参考范例,帮助读者理解串口初始化、中断接收与状态指示的配合方式。

1. 两块 STM32 之间怎么把数据可靠地传起来

手头有两块 STM32 开发板,想让它们互相说话,最省事的方案就是串口。不用额外接线复杂的总线,两根线交叉一接,地线共上,代码里把波特率对齐,数据就能跑起来。这件事听起来简单,但真到落地的时候,坑集中在几个地方:引脚复用有没有配对、中断和 DMA 谁先谁后、帧头帧尾怎么定、出错之后怎么恢复。我见过不少人在实验室里调了一下午,最后发现是 TX 和 RX 没交叉,或者两边波特率差了半个档位。

这篇笔记围绕「STM32 之间的串口通信」这个资源展开,把两块板子对传数据的完整链路拆开:从硬件连线、USART 初始化、中断收发,到 DMA 搬运和协议帧设计,再到实际调试时最容易翻车的几个点。适合已经能点灯、会建工程,但还没把双机通信跑稳的开发者;也适合想把现有单机串口代码升级成可靠帧协议的人。下面按「先跑通、再跑稳、最后跑快」的顺序往下走。

2. 双机串口链路的硬件与初始化:先把物理层对齐

两块 STM32 通信,第一步不是写代码,是把线接对。串口是异步全双工,A 板的 TX 要接 B 板的 RX,A 板的 RX 要接 B 板的 TX,两边 GND 必须共地。很多人第一次接线时把 TX 对 TX、RX 对 RX,结果两边都在发、都在等,谁也没收到,示波器一量发现线上有波形但对方引脚没反应,这就是典型的交叉没做对。

2.1 引脚选择与复用配置

以常见的 STM32F103 系列为例,USART1 默认引脚是 PA9(TX)和 PA10(RX),USART2 是 PA2(TX)和 PA3(RX)。选哪个外设取决于你板子上哪组引脚没被占用。配置时要注意两点:一是 GPIO 模式,TX 配成复用推挽输出(GPIO_Mode_AF_PP),RX 配成浮空输入或上拉输入(GPIO_Mode_IN_FLOATING / GPIO_Mode_IPU);二是时钟,GPIO 所在总线和 USART 外设总线的时钟都要开,漏开一个就是死配置,编译能过、运行没波形。

/* 以 USART1 为例,PA9=TX, PA10=RX,标准库写法 */ void USART1_Config(uint32_t baud) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; /* 开时钟:GPIOA 在 APB2,USART1 也在 APB2 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); /* TX: 复用推挽,50MHz */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* RX: 浮空输入,外部或内部上拉均可 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = baud; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); }

这段代码里几个参数值得说清楚。USART_WordLength_8b配USART_Parity_No是最常见的 8N1 组合,两边必须完全一致,差一位校验就会整帧错位。USART_BaudRate传的是目标波特率,标准库内部会根据 APB 时钟算分频,所以系统时钟配置必须和实际晶振匹配,否则算出来的波特率有偏差。USART_Mode要同时开 Rx 和 Tx,只开一个的话另一半功能是哑的。硬件流控这里关掉,双机直连一般不需要 RTS/CTS,除非线很长或者速率很高。

2.2 两边参数必须逐项对齐

双机通信和单机调试最大的区别是:单机你只关心自己,双机任何一边参数不对,整条链路都不通。下面这张表是我一般会逐项核对的清单,两边填一样的值。

参数项常用取值不一致的后果
波特率9600 / 115200收到乱码或全 0xFF
数据位8 位帧边界错位
停止位1 位偶发丢字节
校验位无整帧解析失败
电平标准TTL 3.3V需电平转换才能互连
共地必须连接波形漂移、误码率高

这里有个容易被忽略的点:两块板子如果供电不同源,GND 一定要连。我遇到过一块板用 USB 供电、另一块用外部电源,地没共,串口能收到数据但每隔几十字节就错一个,查了半天以为是波特率误差,最后补一根地线就好了。另外,如果两块板子距离超过几十厘米,TTL 直连的抗干扰会变差,常见做法是降到 9600 或者加一级电平转换/隔离。

初始化跑通之后,先别急着上协议。写一个最简单的回环测试:A 板定时发一个字节,B 板收到后原样发回,A 板用调试串口打印出来。这一步能确认物理层和基本收发没问题,后面再叠中断、DMA 和帧协议,出问题时才能快速定位是哪一层的事。

3. 中断收发与环形缓冲:别在中断里做重活

物理层通了,接下来要解决「怎么收、怎么发」。最直接的做法是轮询,主循环里不停查 RXNE 标志,但这样 CPU 被绑死,稍微加点别的任务就顾不过来。实际项目里更常见的是中断收发,配合环形缓冲区把「收」和「处理」解耦。这一章把中断方式拆开讲,包括接收中断的写法、环形缓冲的实现,以及发送时什么时候该用中断、什么时候该阻塞。

3.1 接收中断加环形缓冲的标准写法

接收中断的核心逻辑很短:进中断、读数据寄存器、塞进缓冲区、清标志。真正容易出问题的是缓冲区管理和主循环的配合。下面是一个可复用的环形缓冲实现,接收中断只负责写,主循环只负责读,两边通过头尾指针协调。

#define RB_SIZE 256 /* 必须是 2 的幂,方便用掩码取模 */ typedef struct { volatile uint16_t head; /* 中断里写 */ volatile uint16_t tail; /* 主循环里写 */ uint8_t buf[RB_SIZE]; } RingBuf; static RingBuf rb; /* 中断里调用:写入一个字节,满了就丢弃并计数 */ void RB_Put(RingBuf *r, uint8_t data) { uint16_t next = (r->head + 1) & (RB_SIZE - 1); if (next == r->tail) { return; /* 缓冲区满,丢弃,实际项目可加溢出计数 */ } r->buf[r->head] = data; r->head = next; } /* 主循环里调用:取出一个字节,返回 0 表示空 */ uint8_t RB_Get(RingBuf *r, uint8_t *data) { if (r->head == r->tail) { return 0; } *data = r->buf[r->tail]; r->tail = (r->tail + 1) & (RB_SIZE - 1); return 1; } /* USART1 接收中断服务函数 */ void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART1); RB_Put(&rb, ch); USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }

head和tail都加了volatile,因为它们在中断和主循环两个上下文里被修改,编译器不能把它们优化进寄存器。缓冲区大小取 2 的幂,是为了用& (RB_SIZE - 1)代替取模运算,中断里少几个周期。缓冲区满时直接丢弃是简化处理,实际项目里我一般会加一个溢出计数,调试时打印出来,能判断是主循环处理太慢还是对方发得太猛。中断里只做搬运、不做解析,这是血泪经验:曾经有人在中断里直接拼帧、算校验,结果主循环被拖慢,别的中断响应也受影响,最后改成中断只入缓冲、主循环解析才稳下来。

3.2 发送:阻塞、中断、DMA 怎么选

发送比接收简单,但方式选错也会难受。小数据量、低频发送,直接阻塞等 TXE 就行,代码最短;数据量中等、不想占 CPU,用发送中断,把待发数据放进发送缓冲,TXE 中断里逐个吐;数据量大或者要连续发(比如传一帧图像数据),上 DMA 最省心。三种方式的取舍可以看下面这张表。

方式CPU 占用适用场景注意点
阻塞发送高调试打印、偶发短帧发送期间主循环停摆
中断发送中中等长度、频率不高要维护发送缓冲和状态
DMA 发送低长帧、连续数据发送完成中断里再切下一块

阻塞发送的写法就是循环等USART_FLAG_TXE,再写数据寄存器,最后等USART_FLAG_TC确保最后一位移完。中断发送要自己维护一个发送环形缓冲,TXE 中断里取一个字节写 DR,缓冲空了就关 TXE 中断。DMA 发送则是把缓冲区首地址和长度交给 DMA 通道,配置成存储器到外设,传输完成中断里做后续处理。我一般的原则是:调试信息用阻塞,业务帧用中断,批量数据用 DMA,别一上来就全上 DMA,配置复杂度上去了,出问题反而不好查。

4. 帧协议设计:让「一串字节」变成「一条消息」

中断和缓冲解决的是字节级收发,但两块板子之间传的往往不是单个字节,而是一条有结构的消息:命令、参数、状态。串口本身是字节流,没有消息边界,所以必须自己定一套帧格式,否则对方收到一串字节根本不知道从哪切。这一章讲帧头帧尾、长度字段、校验怎么设计,以及解析状态机怎么写。

4.1 帧格式的四个要素

一个能用的帧格式,至少要有帧头、长度、载荷、校验四部分。帧头用来做同步,长度告诉解析器这条消息有多长,校验用来判断传输有没有出错。下面是我常用的一种紧凑格式。

字段长度说明
帧头2 字节固定 0xAA 0x55,用于同步
长度1 字节载荷字节数,不含帧头帧尾
命令1 字节消息类型
载荷N 字节具体数据,N 由长度字段给出
校验1 字节从长度到载荷的累加和或异或

帧头用两个字节而不是一个,是为了降低误同步概率。单字节帧头在数据里出现的概率不低,一旦载荷里恰好出现同样的字节,解析器就可能从错误位置开始切帧。长度字段放在命令前面,解析器读到长度就知道后面还要收多少字节,配合状态机可以做到边收边解析,不用等整帧到齐。校验用累加和实现最简单,异或也行,要求高一点可以上 CRC8,代价是多一张表或者多几行移位代码。

4.2 解析状态机:逐字节喂、逐状态走

解析器我一般写成状态机,每来一个字节推进一步,不依赖整帧缓冲。这样内存占用小,也不怕半帧卡住。核心状态有四个:等帧头 1、等帧头 2、收长度和命令、收载荷和校验。

typedef enum { ST_HEAD1 = 0, ST_HEAD2, ST_LEN, ST_CMD, ST_PAYLOAD, ST_CHECK } ParseState; typedef struct { ParseState state; uint8_t len; uint8_t cmd; uint8_t payload[64]; uint8_t idx; uint8_t sum; } Parser; /* 每收到一个字节调用一次,返回 1 表示一帧解析完成 */ uint8_t Parse_Byte(Parser *p, uint8_t byte) { switch (p->state) { case ST_HEAD1: if (byte == 0xAA) p->state = ST_HEAD2; break; case ST_HEAD2: p->state = (byte == 0x55) ? ST_LEN : ST_HEAD1; break; case ST_LEN: p->len = byte; p->sum = byte; /* 校验从长度开始累加 */ p->state = ST_CMD; break; case ST_CMD: p->cmd = byte; p->sum += byte; p->idx = 0; p->state = (p->len > 0) ? ST_PAYLOAD : ST_CHECK; break; case ST_PAYLOAD: if (p->idx < sizeof(p->payload)) { p->payload[p->idx++] = byte; } p->sum += byte; if (p->idx >= p->len) p->state = ST_CHECK; break; case ST_CHECK: p->state = ST_HEAD1; return (p->sum == byte) ? 1 : 0; /* 校验通过返回 1 */ } return 0; }

状态机的关键点是:任何一步不匹配都回到ST_HEAD1重新找同步,而不是停在原地等。比如ST_HEAD2收到不是 0x55 的字节,直接回ST_HEAD1,这样即使中途错位,下一个 0xAA 0x55 也能重新对上。载荷长度要做上限保护,p->idx < sizeof(p->payload)这一句防止长度字段被干扰成一个大数导致越界写。校验范围从长度字段开始,帧头不参与,因为帧头是同步用的,不承载信息。主循环里从环形缓冲取字节,喂给Parse_Byte,返回 1 就说明拿到一条完整消息,再按cmd分发处理。

这套帧格式和状态机不依赖具体外设,中断收、DMA 收都能接,换一块板子、换一个串口,改的只是底层取字节的地方。实际项目里我还会加一个超时机制:如果一帧收到一半,超过比如 50ms 没有新字节,就把状态机复位,避免半帧一直挂着占住解析器。

5. 联调避坑:那些让链路时通时断的细节

代码写完、两边烧录,真正的考验才开始。双机串口的问题往往不是「完全不通」,而是「时通时断」「跑一会儿就错」,这类问题最难查,因为单看代码每行都对。这一章列几个我实际踩过的坑,按现象、原因、解决三段写,遇到类似症状可以对照排查。

5.1 常见问题排查清单

现象一:收到数据但全是乱码。原因基本是两边波特率不一致,或者系统时钟配置和实际晶振对不上导致分频算错。解决方法是先用示波器或逻辑分析仪量一位的时间,反推实际波特率,再核对两边SystemInit里的时钟源和倍频系数。还有一种隐蔽情况:一边用内部 RC 振荡器(HSI),一边用外部晶振(HSE),HSI 精度差,高波特率下误差累积就出乱码,双机通信建议两边都用外部晶振。

现象二:短帧正常,长帧跑到一半就错。常见原因是接收缓冲溢出。主循环处理慢,中断还在往里塞,缓冲区满了就开始丢字节,丢的位置恰好在中途,帧就断了。解决方法是加大环形缓冲、提高主循环取数频率,或者在协议层加序号和重传。我一般会在溢出时置一个标志,调试阶段打印出来,能直接看出是不是这个原因。

现象三:上电初期正常,运行几分钟后通信卡死。多半是错误标志没清。串口有溢出错误(ORE)、帧错误(FE)、噪声错误(NE),这些标志置位后如果不读 SR 再读 DR 来清除,某些情况下会让接收中断不再触发。解决方法是在中断里判断错误标志,读到错误就清掉并复位接收状态机。下面这段是错误处理的常见写法。

void USART1_IRQHandler(void) { /* 先处理错误标志,避免卡死 */ if (USART_GetFlagStatus(USART1, USART_FLAG_ORE) != RESET) { (void)USART_ReceiveData(USART1); /* 读 DR 清 ORE */ Parser_Reset(&parser); /* 帧可能已断,复位解析 */ } if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART1); RB_Put(&rb, ch); } }

现象四:两块板子各自单独调试都正常,连起来就不行。检查共地,检查 TX/RX 是否交叉,检查两边电平是否都是 3.3V。还有一种情况是其中一块板子的串口引脚被别的外设复用了,比如调试口或者某个传感器,初始化顺序不对导致后初始化的把引脚抢走。解决方法是核对引脚分配表,确保同一引脚只被一个外设使用。

现象五:DMA 发送偶尔丢最后几个字节。DMA 传输完成中断里如果立刻关闭串口或者切换缓冲区,最后几位可能还没移完。正确做法是等USART_FLAG_TC置位再动,或者在 DMA 完成中断里只置标志,真正收尾放到主循环。这个坑很隐蔽,因为大部分时候数据是对的,只在特定时序下丢。

提示:联调阶段建议先降速到 9600 跑通全流程,确认协议和状态机没问题,再逐步升到 115200。速率越高,时序和缓冲的问题暴露得越明显,低速先排除逻辑错误,能省很多时间。

6. 用逻辑分析仪和压力测试把链路验到底

功能跑通不等于链路可靠。两块板子对传,最怕的是「实验室里好好的,现场跑一天错几次」。要判断这套串口通信到底稳不稳,得靠两样东西:一是逻辑分析仪看波形和时序,二是压力测试看长时间运行的误码和丢帧。这一章讲怎么验、看什么指标,以及一个我常用的自检技巧。

逻辑分析仪接在 TX 线上,解码成串口协议,能直接看到每一帧的字节内容。重点看三个地方:帧间隔是否稳定、有没有毛刺导致的误触发、波特率实测值和设定值差多少。如果解码出来的字节和发送端打印的对不上,问题就在物理层或波特率;如果对得上但接收端解析错,问题就在协议层。我一般会把发送端的原始字节和接收端的解析结果同时打印,两边一对比,问题出在哪一层立刻清楚。

压力测试的做法是让 A 板以固定间隔连续发帧,B 板解析后统计三个数:总帧数、校验失败数、解析超时数。跑上几个小时,看失败率是否随时间上升。如果失败率稳定在很低的值,可能是偶发干扰;如果越跑越高,多半是缓冲区或状态机有累积性问题,比如某个错误分支没复位干净。下面是一个简单的统计结构,接收端每处理一帧更新一次,定时打印。

typedef struct { uint32_t total; /* 收到完整帧数 */ uint32_t crc_err; /* 校验失败数 */ uint32_t timeout; /* 半帧超时数 */ uint32_t overflow; /* 缓冲溢出次数 */ } Stats; static Stats stats; /* 解析完成回调里更新 */ void On_Frame_Done(uint8_t ok) { stats.total++; if (!ok) stats.crc_err++; } /* 主循环里每 5 秒打印一次,观察趋势 */ void Stats_Report(void) { printf("total=%lu crc_err=%lu timeout=%lu ovf=%lu\r\n", stats.total, stats.crc_err, stats.timeout, stats.overflow); }

看这几个数的趋势比看单次结果有意义。crc_err占比高,说明物理层或波特率有问题;timeout多,说明帧不完整,可能是发送端中途停了或者接收端丢字节;overflow非零,说明主循环处理跟不上,得优化解析或者加大缓冲。我一般要求连续跑两小时以上,crc_err和timeout都为零才算过。

还有一个自检技巧:在帧里加一个递增序号,接收端检查序号是否连续。序号跳变就说明中间丢了帧,比单纯看校验失败更能定位丢帧位置。序号字段占一个字节,回绕也没关系,接收端只判断「当前序号是否等于上一帧序号加一」,不等就记一次丢帧。这个字段在调试阶段特别有用,能区分「帧收到了但校验错」和「帧根本没收到」两种情况。

从那以后我每次做双机串口,都会先把序号和统计加上,再谈功能。链路稳不稳,数据说了算,凭感觉判断「应该没问题」是最容易翻车的地方。希望这套流程帮到你,少走几个我走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询