ESP32 与 STM32F407 的串口双机通信实战指南
你有没有遇到过这样的场景:一个项目里,既要处理复杂的实时控制任务——比如电机驱动、高速 ADC 采集,又要实现 Wi-Fi 联网上传数据到云平台?这时候,单靠一块 MCU 往往捉襟见肘。CPU 资源被中断抢占得七零八落,Wi-Fi 协议栈一跑起来,实时性直接崩盘。
那怎么办?
聪明的工程师早就给出了答案: 分工协作 。
把“干活”的活儿交给擅长控制的 STM32F407,把“说话”的事儿甩给精通联网的 ESP32 —— 它们之间怎么“对话”?最简单、最可靠的方式之一就是: UART 串口通信 。
别看它老,但真香。没有复杂的协议栈,不需要额外芯片,两根线加一个共地,就能打通两个世界的任督二脉。今天我们就来深挖这个看似基础、实则关键的技术点:如何让 ESP32 和 STM32F407 通过串口稳定“对线”。
为什么是 UART?而不是 SPI 或 I²C?
你说,SPI 不是更快吗?I²C 不是能连多个设备吗?确实。但在跨平台互联这件事上,UART 反而成了最优解。
先说 SPI :虽然速率高,但它本质上是个主从结构。谁当主?谁当从?一旦角色固定,灵活性就差了。而且 ESP32 和 STM32 的 SPI 时序细节略有不同,稍不注意就会出现采样错位、极性相位不匹配的问题。更麻烦的是,SPI 引脚通常不能随意重映射,布线受限。
再看 I²C :支持多设备没错,但它对电平匹配和上拉电阻要求严格。ESP32 和 STM32 都是 3.3V 器件,理论上兼容,但实际使用中经常因为总线负载过大或干扰导致通信失败。再加上 I²C 是开漏输出,速率越高越容易出问题,远距离传输基本劝退。
而 UART 呢?
- 点对点通信,天生适合双机交互;
- 全双工,各自 TX/RX 互不干扰;
- 协议极其简单,起始位 + 数据位 + 停止位,硬件自动搞定;
- 几乎所有 MCU 都带至少两个 UART 接口;
- 波特率标准化(9600、115200…),配置统一;
- 调试方便,拿个 USB-TTL 模块一接,串口助手就能看到原始数据流。
所以,在“STM32 干活 + ESP32 上网”这种经典架构里,UART 成为了事实上的首选通信方式。
🛠️ 小贴士:如果你需要三台以上设备组网,可以考虑用 RS485 + Modbus 协议;但如果只是两台机器“私聊”,UART 绝对是最轻量、最高效的方案。
物理连接:别小看这两根线
我们先来看最基本的硬件连接:
STM32F407 ↔ ESP32
TX -------------------> RX
RX <------------------- TX
GND ==================== GND
看起来很简单吧?但很多初学者在这里翻车了。
常见坑点一:TX 接错了 RX!
这是新手最容易犯的错误。记住一句话: 你的嘴(TX)要对着别人的耳朵(RX) 。
- STM32 的发送引脚(TX) → 接 ESP32 的接收引脚(RX)
- STM32 的接收引脚(RX) ← 接 ESP32 的发送引脚(TX)
交叉连接!不是同名对接!
常见坑点二:忘了共地
你以为信号线接好了就行?NO!如果没有共享的地线(GND),电压参考系不一样,电平识别就会出错。哪怕两个板子都插在同一个 USB 插排上,也不能保证地是连通的。
✅ 正确做法:用一根足够粗的导线将两块开发板的 GND 引脚牢牢焊在一起,越短越好,避免形成地环路引入噪声。
常见坑点三:电平兼容性真的 OK 吗?
ESP32 和 STM32F407 都是 3.3V 逻辑器件,按理说可以直接互联。但要注意一点:某些 ESP32 模块的 IO 可能允许 5V 输入(如 NodeMCU 板载电平转换),而 STM32F407 的 IO 是 5V tolerant 吗?
查手册!
STM32F407 的多数 IO 支持 5V tolerant(具体看封装和引脚定义),但一旦启用内部上拉/下拉,可能会影响耐压能力。为保险起见,建议全程工作在 3.3V 系统,不要混入 5V 电源。
如果必须隔离(比如工业现场有强干扰),可以用光耦或者专用隔离模块(如 ADM2682E),但这会增加成本和复杂度。一般情况下,做好共地+短线连接即可。
软件实现:两边怎么“说话”
接下来进入代码环节。我们要确保两边使用相同的“语言规则”——也就是通信参数一致。
关键参数必须同步
| 参数 | 必须一致? | 推荐值 |
|---|---|---|
| 波特率 | ✅ | 115200 (常用) |
| 数据位 | ✅ | 8 |
| 停止位 | ✅ | 1 |
| 校验位 | ❌(可选) | 无(N) |
| 流控 | ❌ | 无 |
其中最重要的是 波特率 。假设 STM32 设置为 115200,而 ESP32 设成 9600,那收到的数据全是乱码,就像两个人用不同语速讲话,谁都听不懂谁。
为什么推荐 115200?
- 够快:每秒传 11.5KB 数据,对于传感器上报、命令下发完全够用;
- 兼容性好:几乎所有设备都支持;
- 实测稳定:在普通杜邦线长度下(<30cm)误码率极低。
当然,如果你的线路很长或环境嘈杂,可以降到 9600 或 19200 提高容错率。
ESP32 端:Arduino 环境下的实现
ESP32 在 Arduino IDE 下编程非常友好,尤其对 UART 支持完善。
#include <HardwareSerial.h>
// 使用 UART2,避免占用默认串口(UART0)
HardwareSerial Serial2(2);
void setup() {
// 初始化调试串口(用于打印日志)
Serial.begin(115200);
delay(1000);
Serial.println("\n[ESP32] 启动中...");
// 配置 UART2: RX=16, TX=17
Serial2.begin(115200, SERIAL_8N1, 16, 17);
Serial.println("[ESP32] UART2 已启动,等待 STM32 连接...");
}
void loop() {
// 检查是否有来自 STM32 的数据
if (Serial2.available()) {
String msg = Serial2.readStringUntil('\n'); // 以换行为结束符
Serial.println("📩 收到 STM32 数据: " + msg);
// 解析并处理命令(示例)
if (msg.startsWith("CMD:")) {
handleCommand(msg);
} else {
// 默认回复 ACK
Serial2.println("ACK:" + msg);
}
}
delay(10); // 小延时防 CPU 占满
}
void handleCommand(const String& cmd) {
if (cmd == "CMD:START_MOTOR") {
Serial.println("✅ 执行指令: 启动电机");
Serial2.println("RESP:OK");
} else if (cmd == "CMD:STOP_MOTOR") {
Serial.println("🛑 执行指令: 停止电机");
Serial2.println("RESP:OK");
} else {
Serial2.println("RESP:UNKNOWN_CMD");
}
}
🔍 重点说明 :
-
我们用了
HardwareSerial类创建了一个独立的串口实例Serial2,绑定到 GPIO16(RX)和 GPIO17(TX)。这样就不会影响 UART0 的下载和调试功能。 -
使用
readStringUntil('\n')是为了防止“粘包”。只要 STM32 每次发送完加个\n,ESP32 就能完整读取一帧。 - 加了简单的命令解析机制,模拟真实应用场景。
💡
经验提示
:不要在
loop()
中频繁调用
delay()
,尤其是大数值。否则会影响实时响应。这里只用了 10ms,是为了防止
available()
查询太密集占用 CPU。
STM32F407 端:基于 HAL 库的配置与通信
相比 ESP32 的高级封装,STM32 更接近底层,但也更可控。
我们使用 STM32CubeMX 配合 HAL 库进行配置,这是目前主流开发方式。
Step 1:CubeMX 配置 USART2
- 选择 USART2,模式设为 Asynchronous(异步串行)
- 波特率:115200
- 字长:8
- 停止位:1
- 校验:None
- 硬件流控:Disable
- 引脚分配:PA2 → TX,PA3 → RX(默认复用)
生成代码后,会在
main.c
中自动生成
MX_USART2_UART_Init()
函数。
Step 2:编写发送与接收逻辑
#include "main.h"
#include "usart.h"
#include "string.h"
UART_HandleTypeDef huart2;
// 接收缓冲区(全局变量)
uint8_t rx_byte = 0; // 单字节接收缓存
char rx_buffer[64]; // 存储完整消息
int buffer_index = 0;
// 发送封装函数
void send_to_esp32(const char* str) {
uint8_t len = strlen(str);
uint8_t packet[128];
sprintf((char*)packet, "%s\n", str); // 添加换行符作为帧边界
HAL_UART_Transmit(&huart2, packet, strlen((char*)packet), 100);
}
// 接收回调函数(中断触发)
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
if (huart == &huart2) {
// 收到一个字节
if (rx_byte == '\n' || rx_byte == '\r') {
// 结束符到达,处理整条消息
rx_buffer[buffer_index] = '\0'; // 补字符串结尾
buffer_index = 0; // 重置索引
// 回显测试
send_to_esp32("ECHO:");
send_to_esp32(rx_buffer);
// 可在此处添加命令解析逻辑
} else {
// 普通字符存入缓冲区
if (buffer_index < 63) {
rx_buffer[buffer_index++] = rx_byte;
}
}
// 重新开启下一次中断接收
HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
}
}
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART2_UART_Init();
// 开启串口中断接收(关键!)
HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
// 发送启动问候
send_to_esp32("Hello ESP32! This is STM32F407");
while (1) {
// 主循环定期上报状态
send_to_esp32("STATUS:RUNNING,TEMP=25.3,HUM=60");
HAL_Delay(2000); // 每 2 秒发一次
}
}
🎯 核心要点解析 :
-
HAL_UART_Receive_IT()启用中断接收模式,每收到一个字节就触发一次中断,极大降低 CPU 占用。 -
在回调函数
HAL_UART_RxCpltCallback中判断是否收到\n,以此界定一帧数据的结束。 - 使用环形缓冲思想暂存字符,直到完整帧接收完毕再处理。
-
每次处理完都要重新调用
HAL_UART_Receive_IT(),否则后续无法继续接收!
⚠️ 注意陷阱:有些开发者习惯用轮询方式写
while(HAL_UART_Receive() != HAL_OK);
,这会导致程序卡死。一定要用中断或 DMA 方式才能保证系统响应性。
通信协议设计:让数据更有意义
光通上了还不够,还得“说得清楚”。
想象一下,如果 STM32 直接扔一堆原始字节过去,ESP32 怎么知道哪些是温度、哪些是指令?所以必须制定一套 通信协议 。
方案一:简单文本协议(推荐初学者)
优点:人类可读,调试方便。
格式示例:
DATA:{"temp":25.3,"hum":60}\n
CMD:START_MOTOR\n
RESP:OK\n
解析方式:字符串匹配 + JSON 解析(可用 ArduinoJson 库)。
适用场景:低频数据上报、远程控制类应用。
方案二:二进制协议(高效紧凑)
优点:体积小、解析快、抗干扰强。
定义结构体:
typedef struct {
uint8_t header; // 起始符,如 0xAA
uint8_t cmd_id; // 命令类型
float temp;
float hum;
uint16_t crc; // 校验和
} __attribute__((packed)) sensor_data_t;
然后整体发送:
sensor_data_t data = {0xAA, 0x01, 25.3f, 60.0f, 0};
data.crc = calculate_crc((uint8_t*)&data, sizeof(data)-2);
HAL_UART_Transmit(&huart2, (uint8_t*)&data, sizeof(data), 100);
ESP32 端同样用
struct
对应解析即可。
⚠️ 注意内存对齐问题!加上
__attribute__((packed))
防止编译器插入填充字节。
加点健壮性:加入 CRC 校验
即使波特率匹配、线路良好,电磁干扰仍可能导致个别比特翻转。加个 CRC 校验能大幅提升可靠性。
推荐使用 CRC-16/CCITT-FALSE:
uint16_t crc16(uint8_t *data, int len) {
uint16_t crc = 0xFFFF;
for (int i = 0; i < len; ++i) {
crc ^= data[i] << 8;
for (int j = 0; j < 8; ++j) {
if (crc & 0x8000)
crc = (crc << 1) ^ 0x1021;
else
crc <<= 1;
}
}
return crc;
}
接收方计算 CRC 并比对,不一致则丢弃该帧,并可要求重传。
实战中常见的那些“灵异事件”
你以为写完代码烧进去就万事大吉?Too young.
下面这些“玄学问题”,每一个我都亲手 debug 过……
🚨 问题 1:串口助手能看到数据,但另一方收不到?
检查是否开启了中断接收!
常见于 STM32 端忘记调用
HAL_UART_Receive_IT()
,导致只收第一个字节,后面全丢。
解决方法:确保在初始化后立即开启中断接收,并在每次回调后重新注册。
🚨 问题 2:偶尔收到乱码或字符缺失?
可能是波特率误差太大。
STM32 的 USART 波特率由 PCLK 分频而来。F407 的 APB1 外设时钟是 42MHz(PCLK1),而 USART2 属于 APB1 总线。
计算公式:
Baudrate = PCLK / (16 * USARTDIV)
查表可知,42MHz 下 115200 波特率的实际分频值为 22.96875,存在一定误差(约 0.8%),仍在可接受范围内(<2%)。
但如果主频配置错误(比如误设为 72MHz),误差可能飙升至 10% 以上,必然出错。
✅ 建议:使用 STM32CubeMX 自动生成时钟树,避免手动算错。
🚨 问题 3:数据“粘在一起”分不清?
这就是典型的 粘包问题 。
例如 STM32 连续发送两条消息:
STATUS:OK
STATUS:OK
ESP32 可能在一次
available()
中读到
"STATUS:OKSTATUS:OK"
,无法分割。
解决方案:
- 发送端每条消息以
\n
结尾;
- 接收端使用
readStringUntil('\n')
;
- 或者改用固定长度包头(如前两位表示长度)。
🚨 问题 4:长时间运行后通信中断?
很可能是 缓冲区溢出 或 中断优先级冲突 。
比如 STM32 正在执行高优先级中断(如 PWM 更新),导致 UART 接收中断迟迟得不到响应,FIFO 溢出,触发
ORE
(Overrun Error)。
查看状态寄存器:
if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_ORE)) {
__HAL_UART_CLEAR_OREFLAG(&huart2);
// 记录错误次数
}
优化策略:
- 提升 UART 中断优先级;
- 使用 DMA 替代中断接收(大数据量推荐);
- 增加超时检测机制,发现长期无通信则重启串口。
进阶玩法:DMA + 环形缓冲提升性能
当你需要传输大量数据(比如波形采样、图像片段),中断方式就不够用了。
这时该请出 DMA(Direct Memory Access) 登场。
STM32 上如何用 DMA 接收 UART 数据?
#define RX_BUFFER_SIZE 256
uint8_t dma_rx_buffer[RX_BUFFER_SIZE];
// 初始化时启用 DMA 接收
HAL_UART_Receive_DMA(&huart2, dma_rx_buffer, RX_BUFFER_SIZE);
// 当半数缓冲区填满时触发回调
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart) {
if (huart == &huart2) {
// 处理前半部分数据 [0 ~ 127]
process_uart_data(&dma_rx_buffer[0], 128);
}
}
// 当整个缓冲区填满时触发
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
if (huart == &huart2) {
// 处理后半部分数据 [128 ~ 255]
process_uart_data(&dma_rx_buffer[128], 128);
}
}
这种方式实现了“零 CPU 干预”的数据接收,特别适合高速连续数据流。
配合双缓冲机制,几乎不会丢失任何字节。
ESP32 端也有类似的 Ring Buffer 机制(在 ESP-IDF 中),可以实现对等高性能通信。
如何调试?别只会 printf
当通信出问题时,你怎么排查?
方法 1:串口助手监听(最直观)
将 USB-TTL 模块接到 TX 线上(注意:只能接一根!不能同时接两根造成短路),打开串口助手观察原始数据流。
看看有没有预期的消息发出,格式是否正确,是否有乱码。
⚠️ 切记:监听时不要让两个 MCU 同时向同一根线发送数据,否则会冲突!
方法 2:逻辑分析仪抓波形(专业级)
推荐 Saleae 或国产低成本型号。
直接夹上 TX 和 GND,设置对应波特率,就能看到真实的电平变化。
你可以验证:
- 起始位是否正常?
- 数据位是否符合 LSB 顺序?
- 停止位宽度是否足够?
- 是否存在毛刺或抖动?
这是我解决“偶发丢帧”问题的终极武器。
方法 3:LED 打拍子(土法炼钢)
在关键节点加 LED 指示灯:
// 每次收到数据闪烁一次
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
HAL_Delay(50);
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
虽然原始,但在没有调试工具的情况下,能快速判断程序是否卡住、中断是否触发。
最后的思考:UART 过时了吗?
有人可能会说:“现在都 2025 年了,还用 UART?应该上 CAN、Ethernet、甚至 PCIe!”
话虽如此,但工程的本质是 解决问题的成本与效率平衡 。
UART 没有被淘汰,恰恰因为它够简单、够稳定、够通用。
在一个产品从原型到量产的过程中,UART 往往承担着至关重要的角色:
- 调试通道(打印日志)
- 升级接口(ISP 下载)
- 外设通信(GPS、指纹模块、显示屏)
- 双 MCU 协作中枢
它是嵌入式系统的“普通话”,不管你用什么方言(RTOS、裸机、Linux),都能靠它交流。
所以,别轻视 UART。掌握它,等于掌握了一种底层思维: 化繁为简,直击本质 。
现在你手里的杜邦线,已经不只是两根导线了。它是两个世界的桥梁,是控制与连接的交汇点。
下次当你把 STM32 和 ESP32 用串口连起来的时候,不妨想想:这两个芯片正在悄悄地说些什么?
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
519

被折叠的 条评论
为什么被折叠?



