3、ESP32串口通信:UART基础、Serial库使用、与PC串口调试助手通信、自定义通信协议
串口通信,在嵌入式开发里就像人的嘴巴和耳朵。你想想看,设备要跟外界交流,总得有个通道吧?UART就是最经典、最常用的那个通道。我个人做IoT项目这么多年,几乎每个产品都离不开它——调试打印、传感器数据读取、与4G模块握手,全得靠它。
这一章,我们就来彻底搞懂ESP32的串口通信。从硬件原理到Serial库调用,再到跟PC上的串口调试助手“聊天”,最后自己定义一套靠谱的通信协议。嗯,内容有点多,但都是实战干货。
3.1 UART基础:串口通信的“物理层”
UART全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。说白了,它就是用两根线(TX和RX)实现设备间的数据交换。一根发,一根收,地线共地,就这么简单。
为什么叫“异步”?因为没有独立的时钟线。收发双方得事先约定好相同的波特率(Baud Rate),比如9600、115200。我刚开始学的时候,总搞不清波特率是什么。后来在项目里吃过亏——两块板子波特率没对上,数据全是乱码。从那以后,我每次写代码第一件事就是检查波特率配置。
UART的数据帧结构是这样的:
| 起始位 | 数据位(5~8位) | 校验位(可选) | 停止位(1或2位) |
|---|---|---|---|
| 1位(低电平) | LSB先发 | 奇/偶校验 | 高电平 |
举个例子,发送一个字符'A'(0x41),在115200波特率、8N1(8数据位、无校验、1停止位)模式下,实际线路上会这样走:先拉低1位(起始),然后依次发送01000010(LSB优先),最后拉高1位(停止)。
3.2 ESP32的Serial库:三组硬件串口
ESP32比较良心,内置了3个硬件UART控制器:UART0、UART1、UART2。每个都可以独立配置引脚、波特率、数据格式。这在做复杂项目时特别有用——你可以用一个串口跟PC通信,另一个串口接传感器,第三个串口接4G模块。
默认情况下:
- UART0:TX=GPIO1,RX=GPIO3。这个串口默认连接到了USB转串口芯片(CP2102或CH340),所以你在Arduino IDE里用Serial.print()打印,实际走的就是UART0。
- UART1:TX=GPIO10,RX=GPIO9。注意!这两个引脚在大多数ESP32开发板上被用于Flash通信,不建议直接使用。我踩过这个坑,焊好板子发现UART1死活不通,查了半天才知道是引脚冲突。
- UART2:TX=GPIO17,RX=GPIO16。这个最自由,随便用。
初始化一个串口,代码非常简单:
void setup() {
// 初始化UART0,波特率115200
Serial.begin(115200);
// 初始化UART2,自定义引脚
Serial2.begin(115200, SERIAL_8N1, 16, 17);
// 参数:波特率,数据格式,RX引脚,TX引脚
}
你看,Serial2.begin()可以指定引脚,这就很灵活了。我一般把UART0留给调试日志,UART2接实际设备。
3.3 与PC串口调试助手通信:实战演练
光说不练假把式。我们来写一个最简单的程序:ESP32收到什么,就原样返回什么。这就是经典的“回环测试”。
void setup() {
Serial.begin(115200);
Serial.println("ESP32 UART Test Ready!");
}
void loop() {
if (Serial.available() > 0) {
// 读取一个字节
char c = Serial.read();
// 原样返回
Serial.write(c);
}
}
把这段代码烧录到ESP32,打开PC上的串口调试助手(比如Putty、SSCOM、Arduino自带的串口监视器)。设置波特率115200,你会看到“ESP32 UART Test Ready!”打印出来。然后你在发送框里输入“Hello”,ESP32会立刻返回“Hello”。
更稳妥的做法是使用字符串方式:
void loop() {
if (Serial.available()) {
String data = Serial.readString();
Serial.print("Received: ");
Serial.println(data);
}
}
Serial.readString()会一直读取直到超时(默认1秒)。这样就不会丢数据了,但实时性会差一点。看你的应用场景取舍。
3.4 自定义通信协议:让数据“说人话”
直接发字符串虽然方便,但在实际项目中远远不够。你想想看,如果传感器每秒发100个数据,每个数据都是“23.5, 67.8, 12.3...”这样的字符串,接收方怎么知道哪个数据对应哪个参数?万一中间有一个字节错了,后面的数据全乱套。
所以,我们需要自定义通信协议。说白了,就是收发双方约定好:数据怎么打包、怎么解析、怎么校验。
我常用的一个简单协议格式如下:
| 帧头 | 数据长度 | 命令字 | 数据区 | 校验和 | 帧尾 |
|---|---|---|---|---|---|
| 0xAA 0x55 | 1字节 | 1字节 | N字节 | 1字节 | 0x0D 0x0A |
帧头用0xAA 0x55,这两个字节不容易在正常数据中出现。帧尾用\r\n(0x0D 0x0A),跟串口调试助手的换行符一致。校验和是数据区所有字节的累加和取低8位。
举个例子,我要发送温度25.3℃和湿度60.5%:
// 假设温度放大10倍为253,湿度放大10倍为605
// 数据区:0xFD (253) 0x02 (605的高字节) 0x5D (605的低字节)
// 校验和 = 0xFD + 0x02 + 0x5D = 0x15C,取低8位为0x5C
// 完整数据包:
// AA 55 03 01 FD 02 5D 5C 0D 0A
// 帧头 长度 命令 数据区 校验 帧尾
在ESP32端解析这个协议:
#define FRAME_HEADER1 0xAA
#define FRAME_HEADER2 0x55
#define FRAME_TAIL1 0x0D
#define FRAME_TAIL2 0x0A
uint8_t rx_buffer[256];
uint8_t rx_index = 0;
bool frame_started = false;
void loop() {
while (Serial2.available()) {
uint8_t byte = Serial2.read();
// 状态机解析
if (!frame_started) {
if (byte == FRAME_HEADER1) {
rx_buffer[0] = byte;
rx_index = 1;
} else if (rx_index == 1 && byte == FRAME_HEADER2) {
rx_buffer[1] = byte;
rx_index = 2;
frame_started = true;
} else {
rx_index = 0; // 重新开始
}
} else {
rx_buffer[rx_index++] = byte;
// 检查帧尾
if (rx_index >= 4 &&
rx_buffer[rx_index-2] == FRAME_TAIL1 &&
rx_buffer[rx_index-1] == FRAME_TAIL2) {
// 解析完成,提取数据
uint8_t len = rx_buffer[2];
uint8_t cmd = rx_buffer[3];
uint8_t checksum = 0;
// 计算校验和
for (int i = 0; i < len; i++) {
checksum += rx_buffer[4 + i];
}
if (checksum == rx_buffer[4 + len]) {
// 校验通过,处理数据
int16_t temp = (rx_buffer[4] << 8) | rx_buffer[5];
float temperature = temp / 10.0;
Serial.print("Temperature: ");
Serial.println(temperature);
} else {
Serial.println("Checksum error!");
}
// 重置状态机
frame_started = false;
rx_index = 0;
}
// 防止缓冲区溢出
if (rx_index >= 256) {
frame_started = false;
rx_index = 0;
}
}
}
}
3.5 实战建议:从简单到复杂
如果你刚开始接触串口协议,我建议你分三步走:
- 第一步:先用Serial.print()和Serial.readString()实现字符串通信。别管效率,先跑通。
- 第二步:引入帧头和帧尾,用状态机解析。这时候你已经有了“协议”的概念。
- 第三步:加上校验和,处理异常情况(比如超时、数据错位)。这才是工业级代码。
我个人习惯在调试阶段用第二步,量产阶段必须上第三步。你想想看,如果产品在客户现场因为一个干扰字节就死机了,那得多尴尬?
好了,这一章的内容就到这里。串口通信是嵌入式开发的“基本功”,练好了后面做MQTT、HTTP、蓝牙都会轻松很多。下一章我们开始玩WiFi,让ESP32真正联网。