STM32与Arduino USART串口通信实战:HAL库配置、电平匹配与中断收发

发布时间:2026/10/14 20:16:16
STM32与Arduino USART串口通信实战:HAL库配置、电平匹配与中断收发
简介这份资源面向嵌入式初学者与跨平台串口调试开发者聚焦STM32F103ZET6与Arduino之间USART通信的完整实现解决两者供电电压不同3.3V与5V时的电平匹配与数据收发问题。压缩包共77个文件约278KB以35个.h头文件与34个.c源文件为主体涵盖STM32端USART初始化、中断配置、收发函数及Arduino端SoftwareSerial通信逻辑另含uvproj、uvopt工程文件与map、lst编译产物便于直接导入Keil查看与复现。资源中涉及波特率、数据位、停止位、奇偶校验及硬件握手等关键参数配置并给出电平转换与调试排错思路。目前已有2261人学习下载适合希望掌握STM32 HAL/LL库串口编程、Arduino软串口扩展及跨平台数据交换的读者参考实践。1. 从两块板子的串口对不上说起STM32 与 Arduino 的 USART 通信到底怎么落地手头同时有 STM32 和 Arduino 的人早晚会撞上同一个场景STM32 跑着主控逻辑Arduino 挂着某块只认 5V TTL 的传感器或执行器两边想把数据对上。这时候最直接的办法就是走 USART——异步串口两根线TX 接 RXRX 接 TX共地就能通。但真上手你会发现STM32 的 HAL 库配置项一堆Arduino 的Serial又封装得太好两边波特率、数据位、校验位只要有一项对不上收到的就是乱码或者干脆没反应。这篇笔记拆的就是这个组合STM32 侧用 HAL 库配 USARTArduino 侧用Serial或SoftwareSerial把双向通信跑通顺带把电平匹配、中断收发、环形缓冲这些容易翻车的地方讲清楚。适合手里有 STM32 开发板和一块 Arduino 板、想把两者接起来传数据的嵌入式从业者新手照着接线和代码能复现熟手可以直接看参数边界和排错部分。2. 硬件层先对齐电平、引脚与共地这三件事2.1 3.3V 与 5V 的 TX/RX 到底能不能直连STM32 的 GPIO 一般是 3.3V 电平Arduino Uno 这类板子是 5V 电平。很多人第一反应是3.3V 接 5V 会不会烧实际情况要分方向看。STM32 的 TX 输出 3.3V 高电平接到 Arduino 的 RXArduino 判定高电平的门限通常在 0.6×VCC 即 3.0V 左右3.3V 刚好压线能识别多数情况下能通但这是踩在门限上的线一长、干扰一大就容易丢位。反过来 Arduino 的 TX 输出 5V直接进 STM32 的 RXSTM32 的 IO 多数不是 5V 容忍的长期这么接是在赌命。常见做法是加一级电平转换。最省事的是双向电平转换模块TXS0108E 或 BSS138 方案都行两边各接对应电压TX/RX 各走一路。如果只是临时验证也可以在 Arduino 的 TX 到 STM32 的 RX 之间串一个 1kΩ 电阻再并一个 3.3V 稳压管做钳位但这是应急手段不推荐进正式板子。提示拿不准自己的 STM32 型号 IO 是否 5V 容忍时查对应数据手册的 I/O level 章节标了 FTFive-volt Tolerant的引脚才能直接吃 5V标 TT 的不行。2.2 接线表与共地串口通信只有三根线是必须的TX、RX、GND。很多人只接 TX/RX 忘了共地结果两边参考电平不一致收数据全是 0xFF 或者随机值。下面这张表是 STM32 与 Arduino 对接的标准接法电平转换模块串在中间。STM32 侧电平转换 A 侧电平转换 B 侧Arduino 侧PA9 (TX)A1B1D0 (RX)PA10 (RX)A2B2D1 (TX)GNDGNDGNDGND3.3VVA————VB5V如果不用 D0/D1这两个脚同时是下载口插着 USB 时会冲突Arduino 侧可以改用SoftwareSerial指定任意两个数字脚比如 D10/D11这样调试时 USB 串口还能留着看日志。2.3 波特率与数据格式的默认约定两边必须约定同一组参数波特率、数据位、停止位、校验位。最常用的是 115200-8-N-1即 115200 波特、8 数据位、无校验、1 停止位。STM32 的 HAL 里对应WordLength UART_WORDLENGTH_8B、StopBits UART_STOPBITS_1、Parity UART_PARITY_NONE。Arduino 的Serial.begin(115200)默认就是 8-N-1所以只要波特率对上格式一般不用动。波特率误差是另一个隐形坑。STM32 用外部晶振时波特率可以配得很准用内部 RC 振荡器HSI时误差可能到 2%~3%115200 下累计误差超过 3% 就开始丢帧。Arduino Uno 的 16MHz 晶振在 115200 下误差约 2.1%勉强能用但再高比如 230400 就悬了。稳妥起见长距离或高波特率场景降到 9600 或 57600。3. STM32 侧HAL 库 USART 初始化与中断收发3.1 CubeMX 配置与生成的初始化代码用 CubeMX 配 USART1 最省事选 Asynchronous 模式波特率 1152008 数据位无校验1 停止位NVIC 里勾上 USART1 全局中断。生成代码后MX_USART1_UART_Init()里就是下面这套参数。/* USART1 初始化115200-8-N-1异步模式 */ huart1.Instance USART1; huart1.Init.BaudRate 115200; // 波特率两边必须一致 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(); }OverSampling保持 16 就行改成 8 能提高高波特率下的容错但一般用不上。HwFlowCtl在只接三根线时必须设成 NONE否则 HAL 会等 CTS 信号永远发不出去。3.2 中断接收与环形缓冲轮询收发HAL_UART_Transmit/HAL_UART_Receive在数据量小的时候够用但一旦主循环里有其他耗时任务轮询就会丢数据。正规做法是开中断接收每收到一个字节进一次回调把数据塞进环形缓冲主循环再从缓冲里取。#define RX_BUF_SIZE 128 static uint8_t rx_buf[RX_BUF_SIZE]; static volatile uint16_t rx_head 0; // 写入位置中断里改 static volatile uint16_t rx_tail 0; // 读取位置主循环里改 static uint8_t rx_byte; // 单字节接收暂存 /* 启动一次中断接收每次收完一个字节要重新挂上 */ void uart_start_rx(void) { HAL_UART_Receive_IT(huart1, rx_byte, 1); } /* 接收完成回调HAL 库自动调用 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uint16_t next (rx_head 1) % RX_BUF_SIZE; if (next ! rx_tail) // 缓冲没满才写入满了丢弃 { rx_buf[rx_head] rx_byte; rx_head next; } HAL_UART_Receive_IT(huart1, rx_byte, 1); // 重新挂接收 } } /* 主循环里调用从缓冲取一个字节没有返回 -1 */ int uart_read_byte(void) { if (rx_head rx_tail) return -1; uint8_t data rx_buf[rx_tail]; rx_tail (rx_tail 1) % RX_BUF_SIZE; return data; }逻辑说明HAL_UART_Receive_IT每次只收一个字节收完触发HAL_UART_RxCpltCallback在回调里把字节存进rx_buf并重新挂接收。rx_head和rx_tail用volatile修饰因为一个在中断里改、一个在主循环里改编译器不能优化掉。缓冲满时直接丢弃新数据而不是覆盖旧数据避免主循环读到半截帧。参数上RX_BUF_SIZE取 128 或 256取决于单帧最大长度一般留两倍余量。发送侧如果数据不长直接HAL_UART_Transmit(huart1, buf, len, 100)阻塞发送即可超时给 100ms。要非阻塞就上 DMA但那是另一个话题先把中断收发跑通再说。4. Arduino 侧Serial 与 SoftwareSerial 的取舍4.1 硬件串口与软串口的区别Arduino Uno 只有一个硬件串口就是 D0/D1它同时连着 USB 转串口芯片。这意味着如果你用 D0/D1 和 STM32 通信就没法同时用 USB 看串口监视器了两边会打架。解决办法有两个一是用SoftwareSerial在任意数字脚上模拟一个串口把 D0/D1 留给 USB 调试二是用 Mega 这类有多个硬件串口的板子Serial1专门对接 STM32。SoftwareSerial的代价是它靠软件定时采样高波特率下不稳115200 基本跑不动建议降到 9600 或 19200。硬件串口则没有这个限制。#include SoftwareSerial.h // RX 接 D10TX 接 D11对应 STM32 的 TX/RX SoftwareSerial stm32Serial(10, 11); void setup() { Serial.begin(115200); // USB 调试口看日志用 stm32Serial.begin(9600); // 与 STM32 通信软串口降速保稳 } void loop() { // 收到 STM32 的数据就转发到 USB 串口 if (stm32Serial.available()) { char c stm32Serial.read(); Serial.print(c); } // USB 串口有输入就发给 STM32 if (Serial.available()) { char c Serial.read(); stm32Serial.write(c); } }SoftwareSerial构造函数第一个参数是 RX 脚、第二个是 TX 脚别接反。begin的波特率必须和 STM32 侧完全一致。这段代码做的是双向透传方便先用串口助手验证链路通不通。4.2 帧协议怎么让两边知道一帧从哪开始到哪结束裸传字节在调试阶段没问题但正式用的时候必须定帧格式否则接收方不知道一帧的边界。最简单的方案是固定长度帧比如每帧 8 字节接收方攒够 8 个就处理。更通用的是加帧头帧尾比如0xAA 0x55开头0x0D 0x0A结尾中间是 payload。#define FRAME_HEADER1 0xAA #define FRAME_HEADER2 0x55 #define MAX_PAYLOAD 32 uint8_t frameBuf[MAX_PAYLOAD]; uint8_t frameIdx 0; bool inFrame false; void parseByte(uint8_t b) { if (!inFrame) { // 等待帧头第一个字节 if (b FRAME_HEADER1) { inFrame true; frameIdx 0; } return; } if (frameIdx 0 b ! FRAME_HEADER2) { // 第二个字节不是帧头第二字节复位 inFrame false; return; } frameBuf[frameIdx] b; if (frameIdx MAX_PAYLOAD) inFrame false; // 防溢出 }这段是状态机解析的骨架实际还要加帧尾判断和长度字段。参数上MAX_PAYLOAD按最大帧长设别开太大浪费 RAMUno 只有 2KB SRAM。STM32 侧发送时按同样格式打包两边协议对齐了才能稳定通信。5. 避坑与排查串口不通时先看这五条5.1 收到全是乱码现象串口助手或另一端收到的字节全是0xFF、0xFE这类值或者显示成乱码字符。原因九成是波特率不一致或者两边数据位/停止位/校验位配置不同。解决先把两边都设成 9600-8-N-1 这种最保守的组合确认能通再往上调。如果波特率确认一致还乱码查 STM32 的时钟树看 USART 挂的 APB 时钟和 CubeMX 里显示的是否一致时钟配错会导致实际波特率偏离。5.2 一个字节都收不到现象发送方HAL_UART_Transmit返回 HAL_OK但接收方available()一直是 0。原因通常是 TX/RX 接反了或者忘了共地。解决拿万用表量发送方 TX 在空闲时应该是高电平3.3V 或 5V发送瞬间会拉低。如果 TX 一直是低说明引脚配错或者被其他外设占用。共地一定要量一下两边 GND 之间是不是 0Ω。5.3 收几个字节后卡死现象通信刚开始正常收了几十个字节后 STM32 不再进中断或者 Arduino 的available()不再增加。原因多半是 STM32 侧中断接收没有重新挂载HAL_UART_Receive_IT只生效一次回调里忘了再调一次就只收一个字节。另一个可能是环形缓冲满了之后没有正确处理rx_head追上rx_tail后新数据全丢。解决检查回调里有没有重新HAL_UART_Receive_IT缓冲满时要么扩大缓冲要么加快主循环消费速度。5.4 用 D0/D1 时下载失败现象Arduino 接了 STM32 到 D0/D1 后再想通过 USB 下载程序就报错。原因D0/D1 同时是硬件串口和下载口外部设备拉着这两根线会干扰下载时序。解决下载时拔掉 D0/D1 上的线或者改用SoftwareSerial把通信挪到其他脚。这是血泪经验调试阶段尽量别占用 D0/D1。5.5 长线通信误码率高现象短接线时通信正常线延长到几十厘米后开始随机丢字节。原因TTL 电平抗干扰能力弱线长了之后分布电容和外部干扰导致边沿变缓采样点采错。解决降低波特率到 9600用双绞线比如网线里抽一对TX/RX 各绞一对并共地。条件允许就换成 RS485 差分传输那是长距离场景的正解但需要额外的收发器芯片。6. 进阶用 DMA 加空闲中断做不定长接收中断逐字节接收在 115200 下每 87μs 进一次中断数据量大时 CPU 开销不小。更高效的做法是 DMA 接收加串口空闲中断IDLE一次性收完一帧再处理。STM32 的 HAL 库对 IDLE 中断支持得不错配置思路是DMA 循环模式挂到 USART_RX开 IDLE 中断空闲中断触发时读 DMA 剩余计数算出本帧长度。#define DMA_RX_SIZE 64 static uint8_t dma_rx_buf[DMA_RX_SIZE]; /* 启动 DMA 接收 空闲中断 */ void uart_dma_start(void) { __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); // 开空闲中断 HAL_UART_Receive_DMA(huart1, dma_rx_buf, DMA_RX_SIZE); } /* 在 USART1_IRQHandler 里调用处理空闲中断 */ void uart_idle_handler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t remain __HAL_DMA_GET_COUNTER(huart1.hdmarx); uint16_t recv_len DMA_RX_SIZE - remain; // 本帧实际长度 if (recv_len 0) { process_frame(dma_rx_buf, recv_len); // 交给业务处理 } HAL_UART_Receive_DMA(huart1, dma_rx_buf, DMA_RX_SIZE); // 重启 DMA } }逻辑说明DMA 在后台把收到的字节写进dma_rx_bufCPU 不用管。总线空闲时触发 IDLE 中断此时__HAL_DMA_GET_COUNTER返回 DMA 还剩多少没写总数减去剩余就是这一帧的长度。处理完重新挂 DMA 准备下一帧。参数上DMA_RX_SIZE要大于单帧最大长度留一倍余量。注意 IDLE 中断标志必须用__HAL_UART_CLEAR_IDLEFLAG清光清 NVIC 没用。验证方法很简单STM32 侧用 DMA 收Arduino 侧每隔 500ms 发一串递增数字STM32 收到后原样回发Arduino 打印回来的是否和发出去的一致。跑上半小时不丢帧、不乱序这套链路就算稳了。从那以后我每次配串口都先把两边降到 9600 用透传验证物理层确认通了再往上加协议和提速这个习惯帮我省了无数次对着乱码发呆的时间。希望帮到你。本文还有配套的精品资源点击获取