简介:这份资源面向嵌入式初学者与跨平台串口调试开发者,聚焦STM32F103ZET6与Arduino之间USART通信的完整实现,解决两者供电电压不同(3.3V与5V)时的电平匹配与数据收发问题。压缩包共77个文件,约278KB,以35个.h头文件与34个.c源文件为主体,涵盖USART初始化、中断收发、SysTick延时及命令解析等模块,另含uvproj、uvopt工程文件与map、lst编译产物,便于直接导入Keil查看工程结构。内容围绕波特率、数据位、停止位、奇偶校验与硬件握手等关键参数展开,并涉及SoftwareSerial库使用与电平转换思路,可帮助读者理解跨平台串口通信的配置要点与常见乱码、丢包问题的排查方向。目前已有2261人学习下载,适合作为STM32与Arduino联合调试的实践参考。
1. stm32_arduino_usart通信:两块板子怎么把一句话说清楚
手头同时有 STM32 和 Arduino 的人,早晚会撞上同一个需求:让这两块板子互相说话。STM32 跑实时控制、采样、算力活,Arduino 接传感器、驱动舵机、做简单人机交互,两边各干各的,中间用一根串口线连起来。听起来简单,真上手就会发现电平、波特率、时钟、收发时序,随便哪个环节没对齐,接收端就是一堆乱码或者干脆没反应。stm32_arduino_usart通信这件事,本质不是「接线通电就能通」,而是把两套完全不同的开发体系在 USART 这一层对齐。
它适合谁?适合已经能单独点亮 STM32 和 Arduino、但没让两者协同工作过的嵌入式开发者;也适合做多板系统、想把任务拆到不同 MCU 上的人。核心要解决的问题就三个:电平能不能对上、时钟能不能算准、收发逻辑会不会互相踩。把这三件事讲透,剩下的就是照着参数填、照着代码改。下面按「先立住原理、再动手复现、最后排坑」的顺序走,每一步都给到能直接抄的参数和代码。
2. USART 底层对齐:电平、时钟与帧格式三件事
2.1 先分清 USART 和 UART,别在名字上翻车
很多人把 USART 和 UART 混着叫,接线时才发现同步时钟脚没接。UART 是异步收发,只有 TX、RX 两根信号线,靠双方约定的波特率对齐每一位;USART 是通用同步异步收发器,多了一根时钟线,可以工作在同步模式。STM32 的片上外设叫 USART,Arduino 的硬件串口本质是 UART。做 stm32_arduino_usart通信时,绝大多数场景用的是异步模式,也就是只接 TX、RX、GND 三根线,同步时钟脚悬空不用。名字叫 USART,实际跑的是异步,这一点先记住,能省掉一堆「为什么时钟脚没接也能通」的困惑。
异步模式下,双方没有共享时钟,全靠各自的波特率发生器在约定时刻采样。发送方按波特率把每一位依次推出去,接收方在起始位下降沿之后,按同样的波特率在每个位周期的中间采样。两边波特率只要偏差超过大约 2% 到 3%,采样点就会漂到相邻位,收到的东西自然错乱。所以第一件事不是写代码,是把两边的波特率算准、设成一样。
2.2 电平匹配:3.3V 和 5V 之间那道坎
STM32 的 IO 一般是 3.3V 电平,Arduino Uno 这类板子是 5V 电平。直接把 STM32 的 TX 接到 Arduino 的 RX,STM32 输出 3.3V 高电平,Arduino 的 5V 输入阈值通常在 0.6×VCC 即 3.0V 左右,3.3V 勉强能识别为高,短距离、低波特率下常常能通,但这是踩在边缘上,温度一变、线一长就可能翻车。反过来 Arduino 的 TX 输出 5V 进 STM32 的 RX,如果 STM32 引脚不是 5V 容忍的,长期灌 5V 会伤 IO。
稳妥做法是加电平转换。常见方案有两种:一是用双向电平转换模块,基于 MOS 管加两只上拉电阻,成本低、速率够用;二是用电阻分压,Arduino TX 到 STM32 RX 之间串 1kΩ、再对地接 2kΩ,把 5V 分到约 3.3V,但分压只适合单向、低速,且会拖慢上升沿。我一般会先查 STM32 那款芯片的引脚是否标了 FT(5V 容忍),是 FT 就可以直连 Arduino TX,不是 FT 就必须转换。这一步查数据手册的引脚定义表,别凭感觉。
2.3 帧格式:数据位、停止位、校验位必须逐项对齐
异步串口一帧的组成是:1 个起始位(低电平)、5 到 9 个数据位、可选的校验位、0.5 到 2 个停止位。STM32 和 Arduino 默认都是 8 数据位、无校验、1 停止位,也就是常说的 8N1。只要两边都保持默认,这一项通常不用动。但如果你在 STM32 侧改过校验位或者停止位,Arduino 侧没跟着改,收到的就是持续错位的数据。
下面这张表把两边常见配置项列出来,方便逐项核对:
| 配置项 | STM32 侧常见取值 | Arduino 侧常见取值 | 是否必须一致 |
|---|---|---|---|
| 波特率 | 9600 / 115200 | 9600 / 115200 | 必须 |
| 数据位 | 8 | 8 | 必须 |
| 校验位 | None | None | 必须 |
| 停止位 | 1 | 1 | 必须 |
| 流控 | None | None | 必须 |
| 电平 | 3.3V | 5V | 需转换或确认容忍 |
2.4 波特率误差从哪来:时钟树和分频系数
STM32 的 USART 波特率来自外设时钟,经过一个分频寄存器得到。以常见的 72MHz 外设时钟、目标 115200 为例,分频系数是 72e6/(16×115200)=39.0625,取整数部分 39,小数部分 0.0625 写进小数寄存器,实际波特率是 72e6/(16×39.0625)=115200,误差接近零。但如果外设时钟是 36MHz,分频系数变成 19.53125,取整后实际波特率会有偏差,偏差大到一定程度就收不对。
Arduino 侧用 16MHz 晶振,115200 的分频是 16e6/(16×115200)=8.68,取整 8 后实际波特率约 125000,误差约 8.5%,这已经超过安全范围。所以 Arduino 在 16MHz 下跑 115200 本身就偏,短距离能通,长线或干扰环境下容易出错。更稳的选择是 9600 或 19200,误差小得多。这也是为什么很多教程默认用 9600——不是它慢,是它稳。
提示:如果必须用 115200,优先确认 STM32 外设时钟能否整除出低误差分频,Arduino 侧可考虑换用带 16MHz 晶振但支持更好分频的库,或者干脆降到 57600 试。
3. 动手接线与最小收发代码
3.1 接线:TX 对 RX,GND 必须共地
先把线接对,再谈代码。异步串口是交叉连接:STM32 的 TX 接 Arduino 的 RX,STM32 的 RX 接 Arduino 的 TX,两边的 GND 必须连在一起。GND 不共地,两边对「高」和「低」的参考点不同,收到的全是噪声。如果用了电平转换模块,模块的 HV 接 5V 侧、LV 接 3.3V 侧,信号线对应穿过模块。
接线顺序建议:先断电,接 GND,再接信号线,最后上电。带电插拔信号线容易在瞬间产生尖峰,打坏 IO。接好后先别急着跑代码,用万用表量一下两边 GND 是否真的导通,这一步花十秒,能省掉后面半小时的排查。
3.2 STM32 侧:用 HAL 库初始化 USART 并发送
下面这段是 STM32 用 HAL 库初始化 USART1、以 9600 波特率发送一个字符串的最小代码。假设外设时钟 72MHz,引脚 PA9 为 TX、PA10 为 RX。
// stm32_usart_tx.c // 假设已由 CubeMX 生成 GPIO 和 USART1 时钟使能 UART_HandleTypeDef huart1; void USART1_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 9600; // 与 Arduino 侧保持一致 huart1.Init.WordLength = UART_WORDLENGTH_8B; // 8 数据位 huart1.Init.StopBits = UART_STOPBITS_1; // 1 停止位 huart1.Init.Parity = UART_PARITY_NONE; // 无校验 huart1.Init.Mode = UART_MODE_TX_RX; // 收发都开 huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; // 无硬件流控 huart1.Init.OverSampling = UART_OVERSAMPLING_16; // 16 倍过采样 if (HAL_UART_Init(&huart1) != HAL_OK) { // 初始化失败,检查时钟和引脚配置 Error_Handler(); } } void USART1_SendString(uint8_t *str, uint16_t len) { // 阻塞发送,超时 100ms,适合调试阶段 HAL_UART_Transmit(&huart1, str, len, 100); }这段代码的关键在huart1.Init这几个字段:BaudRate必须和 Arduino 完全一致;WordLength、StopBits、Parity三项决定帧格式,默认 8N1 就别乱改;OverSampling选 16 倍是常规做法,采样更稳。HAL_UART_Transmit是阻塞发送,调一次发一批,调试阶段够用,正式产品里如果发送频繁,建议换成中断或 DMA,避免占着 CPU 空等。
3.3 Arduino 侧:Serial 初始化与接收解析
Arduino 侧用内置Serial对象,初始化时把波特率设成和 STM32 一样,然后在loop里读数据。
// arduino_usart_rx.ino void setup() { Serial.begin(9600); // 必须与 STM32 侧一致 while (!Serial) { } // 等待串口就绪,部分板子需要 } void loop() { if (Serial.available() > 0) { // 读到换行符为止,组成一条完整消息 String msg = Serial.readStringUntil('\n'); msg.trim(); // 去掉首尾空白和回车 if (msg.length() > 0) { // 这里处理收到的内容,比如点亮 LED 或回发确认 Serial.print("ACK:"); Serial.println(msg); } } }Serial.begin(9600)里的 9600 是两边约定的速率,改一边就必须改另一边。Serial.available()返回接收缓冲区里还有多少字节没读,大于 0 说明有数据。readStringUntil('\n')会一直读到换行符,适合按行发送的文本协议。trim()去掉末尾的\r和空白,避免解析时多出看不见的字符。回发ACK:是为了让 STM32 侧能确认对方收到了,调试时非常有用。
3.4 收发联调:先单向再双向,先慢后快
第一次联调,建议先只做单向:STM32 每隔一秒发一句hello\n,Arduino 收到后原样回发。STM32 侧用HAL_UART_Receive阻塞接收,超时设 200ms,收到就打印。确认单向通了,再改成双向轮流发。波特率先用 9600,通了再试 19200、38400,最后才碰 115200。每换一次波特率,两边同时改,改完重新上电,不要热改。
联调时如果 Arduino 串口监视器能看到 STM32 发来的内容,但 STM32 收不到 Arduino 的回发,先查 Arduino 的 TX 有没有真的接到 STM32 的 RX,再查 STM32 的接收中断或轮询有没有开。很多时候问题不在代码,在那一根线插错了排针。
4. 避坑与排查:五个让通信翻车的典型场景
4.1 收到稳定乱码:波特率或时钟对不上
现象:Arduino 串口监视器里全是ÿ、?之类的乱码,换行也不对。原因:两边波特率不一致,或者 STM32 外设时钟配置和预期不符,导致实际波特率偏离。解决:先用示波器或逻辑分析仪量 STM32 TX 上一位的宽度,算出实际波特率,和设定值对比。没有仪器就先把两边都降到 9600,确认能通再往上加。STM32 侧重点检查SystemCoreClock和 USART 时钟源,CubeMX 里时钟树配错是高频翻车点。
4.2 完全没反应:GND 没共地或 TX/RX 接反
现象:两边都上电了,发什么都没人收。原因:最常见的是 GND 没连,其次是 TX 接了 TX、RX 接了 RX。解决:万用表量两边 GND 是否导通;把信号线对调试一次。如果用了电平转换模块,确认 HV 和 LV 没接反,模块本身也要共地。
4.3 偶尔丢字节:阻塞发送和接收缓冲区溢出
现象:大部分数据对,偶尔少一两个字节或整条丢失。原因:STM32 用阻塞发送时如果同时有接收任务,CPU 被占住,接收寄存器溢出;或者 Arduino 侧loop里处理太慢,接收缓冲区(默认 64 字节)被填满后新数据覆盖旧数据。解决:STM32 侧改用中断或 DMA 收发;Arduino 侧缩短单次处理时间,或者把Serial.setTimeout调小、及时读走数据。发送长数据时加帧头帧尾和校验,接收端按帧解析,别指望一次read拿全。
4.4 上电瞬间收到一个错误字节
现象:每次上电,接收端都会先收到一个 0x00 或 0xFF。原因:上电瞬间引脚电平未稳定,TX 线被拉低一下,接收端把它当成起始位,采到一个错误字节。解决:初始化时先把 TX 引脚设为高电平或复用推挽输出并置高,再切到 USART 功能;接收端在正式解析前先丢弃第一帧,或者用帧头同步,收到合法帧头才开始组包。
4.5 长线通信不稳定:地电位差和干扰
现象:短线一切正常,线一拉长到一两米就时好时坏。原因:两地电位不完全相等,加上长线引入的干扰,采样点漂移。解决:缩短线长,用双绞线并把 GND 和信号线绞在一起;降低波特率;在接收端加适当的上拉或下拉;条件允许时改用差分信号方案。普通杜邦线超过半米就要警惕,别用「刚才还好好的」来判断稳定性。
5. 进阶:把 USART 通信做成可靠协议
5.1 从裸字节到带帧头帧尾的协议
裸发字符串只适合调试。真正要稳定,得定义帧结构。我一般用这样的格式:帧头0xAA 0x55、长度字节、命令字、数据区、校验和。接收端状态机逐字节扫描,遇到帧头才开始收,收到长度后按长度取数据,最后校验。这样即使中间丢了字节,也能在下一个帧头重新同步,不会一直错位。
// 简易接收状态机(STM32 侧,放在 USART 中断里逐字节喂入) typedef enum { WAIT_H1, WAIT_H2, WAIT_LEN, WAIT_CMD, WAIT_DATA, WAIT_SUM } RxState; RxState state = WAIT_H1; uint8_t buf[64]; uint8_t len, idx, cmd, sum; void RxByte(uint8_t b) { switch (state) { case WAIT_H1: if (b == 0xAA) state = WAIT_H2; break; case WAIT_H2: state = (b == 0x55) ? WAIT_LEN : WAIT_H1; // 第二个头不对就重来 break; case WAIT_LEN: len = b; idx = 0; sum = b; state = WAIT_CMD; break; case WAIT_CMD: cmd = b; sum += b; state = WAIT_DATA; break; case WAIT_DATA: if (idx < len) { buf[idx++] = b; sum += b; } if (idx >= len) state = WAIT_SUM; break; case WAIT_SUM: if (b == sum) { /* 校验通过,处理 cmd 和 buf */ } state = WAIT_H1; // 无论对错都回到找帧头 break; } }状态机的核心是「任何异常都回到找帧头」,这样一次错位不会拖垮后续所有帧。sum用累加和是最简单的校验,要求高可以换 CRC8 或 CRC16。len要限制上限,防止越界写buf。
5.2 用超时和重发兜住偶发丢包
再稳的物理层也会偶发丢字节。协议层加一个确认和重发机制:发送方发完一帧启动定时器,规定时间内没收到 ACK 就重发,重发超过三次报错。接收方校验通过后回一帧 ACK,校验失败就丢弃、不回。这样上层业务不用关心底层丢没丢,只管调发送接口。
超时时间怎么定?按一帧的传输时间乘 2 到 3 倍。9600 波特率下,一个字节约 1ms,20 字节的帧约 20ms,超时设 60ms 比较合适。设太短会误重发,设太长丢包后恢复慢。这个值要按实际帧长和波特率算,别照抄。
5.3 验证方法:用逻辑分析仪看时序,用压力测试看稳定性
调通之后别急着收工。拿逻辑分析仪抓一段 TX 和 RX 的波形,确认起始位、数据位、停止位的宽度和预期一致,确认没有毛刺。然后做压力测试:连续发一万帧,统计丢帧率和误码率。我一般会写个小脚本让 STM32 循环发递增序号,Arduino 侧记录收到的序号,最后统计缺了哪些。丢帧率在千分之一以内算可用,超过就要回去查线、查波特率、查缓冲区。
注意:压力测试时把串口监视器关掉,它本身会拖慢 Arduino 的接收速度,导致误判。
5.4 一个我常犯的错:忽略接收缓冲区的实际大小
早期做 stm32_arduino_usart通信时,我总以为「发了就一定能收到」,直到有一次 Arduino 侧在处理一个耗时任务,接收缓冲区 64 字节被填满,后面的数据全丢了,而 STM32 侧还在傻发。后来养成习惯:发送前先确认对方有空,或者协议里加流控字段,接收方忙就回一个「稍等」,发送方降速。这个习惯比任何调试技巧都管用。缓冲区大小、处理耗时、发送速率这三者要一起算,别只盯着波特率。希望帮到你。
本文还有配套的精品资源,点击获取