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位(停止)。

我的习惯:调试阶段一律用115200波特率。9600太慢,打印日志时明显卡顿。量产时如果对稳定性要求极高,可以降到9600,抗干扰能力更强。

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”。

避坑指南:我曾经在串口调试助手发送“Hello”时,发现只收到了“Hell”,最后一个'o'丢了。为什么?因为Serial.write()是逐字节发送,如果PC端接收缓冲区处理不及时,就会丢数据。解决办法:用Serial.print()代替Serial.write(),或者加一个短暂延时。

更稳妥的做法是使用字符串方式:

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;
      }
    }
  }
}
注意:这个状态机看起来有点复杂,但它是串口通信的“标准写法”。千万不要用delay()或者阻塞式等待,否则一旦数据流中断,整个程序就卡死了。我见过太多新手在这里翻车。

3.5 实战建议:从简单到复杂

如果你刚开始接触串口协议,我建议你分三步走:

  1. 第一步:先用Serial.print()和Serial.readString()实现字符串通信。别管效率,先跑通。
  2. 第二步:引入帧头和帧尾,用状态机解析。这时候你已经有了“协议”的概念。
  3. 第三步:加上校验和,处理异常情况(比如超时、数据错位)。这才是工业级代码。

我个人习惯在调试阶段用第二步,量产阶段必须上第三步。你想想看,如果产品在客户现场因为一个干扰字节就死机了,那得多尴尬?

好了,这一章的内容就到这里。串口通信是嵌入式开发的“基本功”,练好了后面做MQTT、HTTP、蓝牙都会轻松很多。下一章我们开始玩WiFi,让ESP32真正联网。