输入输出与编程 / 编程实验发布 内容更新

ESP32 串口通信:把另一台设备的数据读完整

用 UART1 和固定帧界定处理分片、粘包、缺结束符与超长输入;附 Arduino 3.3.12 完整解析器、故障发送脚本及同源主机检查。

读完能做什么
接收 @payload[CR]LF 帧,正确还原分片/连续输入,并在超时或坏帧后恢复。
开始之前
能上传并读串口日志;理解 millis 差值计时和状态机。
所需器材
官方 ESP32-S3-DevKitC-1 v1.1 / ESP32-S3-WROOM-1-N8R8、Adafruit PID954 3.3 V 信号 USB-to-TTL 线(2016-12-21 后 CP2102 版)、第二个 USB 端口、数据线与绝缘材料
版本组合
开发板:DevKitC-1 v1.1 / WROOM-1-N8R8框架:Arduino-ESP32 3.3.12工具:Arduino CLI 1.5.1
适用范围

只支持本文 3.3 V UART、115200/8N1、1–32 字节可打印 ASCII 协议;不直连 RS-232 或 5 V 设备。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/programming-inputs.md#p07版本:Arduino-ESP32 3.3.12验证日期:
构建 / 配置已通过
docs/esp32-evidence/logs/esp32-uart-packet-reader.log对象:esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc版本:Arduino CLI 1.5.1 / core 3.3.12验证日期:
仿真已通过
docs/esp32-evidence/programming-input-host.json;直接运行固件共享头文件的主机 fixture,未模拟外设/电气/RTOS 调度验证日期:
本站实板待验证
本站未实板验证;器件行为、物理方向/范围及速率须按本文记录

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 编程实验

先约定一帧,再讨论怎样接收

假设另一台设备发送温度。@TEMP=23.4 加一个换行,看起来像一行文字;串口实际给程序的是先后到来的字节。一次 available() 可能只看到 @TE,下一次才看到 MP=23.4;另一种情况下,一次循环就拿到 @A LF @B LF 两帧。available() 只回答“当前有多少可取字节”,不替你定义消息边界。

本篇把协议写死,便于先验证接收器:@ 开始,1–32 个可打印 ASCII payload 字节,LF 结束;兼容紧邻 LF 的一个 CR。payload 不能含 @、换行或非 ASCII 字节。空格允许,空帧拒绝。接收器只打印完整 payload,不执行文本命令,也不尝试兼容任意设备的二进制协议。

做到这里,你应该看到
你能在字节序列里指出一帧的开始、结束与最大长度;一次读取结果不被当成整帧。

参考:[1] Arduino-ESP32 3.3.12 HardwareSerial.h:begin/rxPin/txPin、缓冲与逐字节读取

02 / 编程实验

把调试串口和数据串口分开

板卡使用官方 DevKitC-1 v1.1 N8R8,经板载 USB-to-UART Bridge 上传并输出 Serial 115200。电脑第二个 USB 端口接 Adafruit PID954,用于发送数据。2016-12-21 后该产品采用 CP2102;厂家说明 RX/TX 是 3.3 V,红线却是 USB 的 5 V 电源,不能因为信号标 3.3 V 就把红线接进 GPIO。

断开两端 USB 后接表中三根信号/地线,红线单独绝缘。GPIO17/18 在官方复用表中分别列 U1TXD/U1RXD;本文通过 begin 的 rxPin=17、txPin=18 显式指定方向,使用 GPIO matrix 路由,不能按默认外设复用名反接。本例不发送应用回复,白 RX 线可以不接。

PID954 线板端用途
绿 TX 输出GPIO17 / J1-10UART1 RX
白 RX 输入GPIO18 / J1-11UART1 TX,可选
黑 GNDGND共同信号参考
红 5 V不连接、末端绝缘板由自己的 USB 供电
做到这里,你应该看到
能分别辨认 YOUR_DEBUG_PORT 与 YOUR_DATA_PORT;数据不进入 GPIO43/44 调试通道。

参考:[1] Arduino-ESP32 3.3.12 HardwareSerial.h:begin/rxPin/txPin、缓冲与逐字节读取[2] DevKitC-1 v1.1 固定提交:J1-10/11、GPIO17/18[3] Adafruit PID954:3.3 V RX/TX、线色、5 V 电源与修订

03 / 编程实验

驱动缓冲与应用缓冲各负责一层

DataUart 是 HardwareSerial(1)。先调用 setRxBufferSize(256),检查返回值,再 begin(115200, SERIAL_8N1,17,18)。256 字节是驱动接收缓冲,应用解析器另有 33 字节数组存 32 字节 payload 和末尾 NUL。把驱动缓冲调大不能替代应用长度检查。

loop 每次最多读 64 字节,然后 delay(1) 让出执行机会。逐字节 feed,读到 LF 才提交一帧;不使用会等待超时的 readStringUntil,也不把串口变成阻塞等整行的入口。这个边界只限制一次循环的工作量;debug 打印仍可能占时间,因此不能由代码推断持续 115200 洪泛一定无损。

  • 每次空闲 loop 调 tick;feed 也先检查时间,所以新 @ 到达时能先处理过期半帧。
  • 时间是应用取出字节的时刻;驱动已经缓存的字节没有独立的线上到达时间戳。
  • 新的 @ 无条件开新帧;原半帧被丢弃,记录 RESYNC。

参考:[1] Arduino-ESP32 3.3.12 HardwareSerial.h:begin/rxPin/txPin、缓冲与逐字节读取

04 / 编程实验

完整解析器:长度、超时与重新同步是同一个状态机

WAIT 忽略开始符前的噪声。READ 累积合法 payload;空 LF、非法字节或第 33 个 payload 字节分别拒绝。非法/超长进入 DROP,避免把坏帧尾巴误当下一帧;LF 回 WAIT,新的 @ 立即重新同步。

READ 与 DROP 都受两种时限约束:字节间隔达到 250 ms,或从 @ 起达到 1000 ms,清状态并报 timeout。后者防止攻击性或故障发送端每隔 200 ms 发一字节,让半帧永久占住接收器。uint32_t 差值允许 millis 回绕;本例时限远短于半个计时周期。返回值是事件位,过期同时到新字符时可带多个标志。

代码 / 配置
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#include <stddef.h>

// Protocol: @ + 1..32 printable ASCII payload bytes (except @) + [CR] LF.
// Returned bits may combine TIMEOUT with an event for the new incoming byte.
class PacketParser {
 public:
  enum Event : uint8_t { NONE=0, FRAME=1, TIMEOUT=2, OVERLONG=4,
                        INVALID=8, EMPTY=16, RESYNC=32 };
  static constexpr size_t MAX_PAYLOAD=32;
  static constexpr uint32_t GAP_MS=250, FRAME_MS=1000;
  uint8_t tick(uint32_t now) {
    if (state_ != WAIT && (uint32_t(now-last_) >= GAP_MS ||
                           uint32_t(now-start_) >= FRAME_MS)) {
      reset(); return TIMEOUT;
    }
    return NONE;
  }
  uint8_t feed(uint8_t b, uint32_t now) {
    uint8_t events=tick(now);
    if (b=='@') {
      if (state_ != WAIT) events |= RESYNC;
      state_=READ; size_=0; cr_=false; start_=last_=now;
      return events;
    }
    if (state_==WAIT) return events;
    last_=now;
    if (state_==DROP) {
      if (b=='\n') reset();
      return events;
    }
    if (b=='\n') {
      if (size_==0) { reset(); return events | EMPTY; }
      payload_[size_]='\0'; state_=WAIT; cr_=false;
      return events | FRAME;
    }
    if (b=='\r' && !cr_) { cr_=true; return events; }
    if (cr_ || b<32 || b>126) {
      state_=DROP; return events | INVALID;
    }
    if (size_==MAX_PAYLOAD) {
      state_=DROP; return events | OVERLONG;
    }
    payload_[size_++]=char(b);
    return events;
  }
  const char* payload() const { return payload_; }
 private:
  enum State : uint8_t { WAIT, READ, DROP } state_=WAIT;
  char payload_[MAX_PAYLOAD+1]={};
  size_t size_=0;
  bool cr_=false;
  uint32_t start_=0,last_=0;
  void reset() { state_=WAIT; size_=0; cr_=false; }
};

参考:[1] Arduino-ESP32 3.3.12 HardwareSerial.h:begin/rxPin/txPin、缓冲与逐字节读取

05 / 编程实验

完整 Arduino 入口:每个 FRAME 立即消费

保持 .ino 与 PacketParser.h 在同名工程目录,不能只复制下方入口。FRAME 事件发生后立即打印 payload,因为下一帧会复用同一个数组。这里不向 UART1 回 ACK,避免把收发协议扩张成未解释的双向系统。

代码 / 配置
// SPDX-License-Identifier: MIT
#include <Arduino.h>
#include "PacketParser.h"

constexpr int DATA_RX=17, DATA_TX=18;
HardwareSerial DataUart(1);
PacketParser parser;
uint32_t frames=0;
bool ready=false;

void report(uint8_t e) {
  if (!e) return;
  if (e & PacketParser::TIMEOUT) Serial.println("REJECT timeout");
  if (e & PacketParser::OVERLONG) Serial.println("REJECT overlong");
  if (e & PacketParser::INVALID) Serial.println("REJECT invalid");
  if (e & PacketParser::EMPTY) Serial.println("REJECT empty");
  if (e & PacketParser::RESYNC) Serial.println("RESYNC new @");
  if (e & PacketParser::FRAME) {
    Serial.printf("FRAME %lu [%s]\n", (unsigned long)++frames, parser.payload());
  }
}
void setup() {
  Serial.begin(115200);  // DevKitC-1 USB-to-UART bridge, not the data cable.
  if (DataUart.setRxBufferSize(256) != 256) {
    Serial.println("STOP rx buffer allocation failed"); return;
  }
  DataUart.begin(115200, SERIAL_8N1, DATA_RX, DATA_TX);
  if (!DataUart) { Serial.println("STOP UART init failed"); return; }
  ready=true;
  Serial.println("READY UART1 RX=17 TX=18 115200 8N1; @payload[CR]LF");
}
void loop() {
  if (ready) {
    report(parser.tick(millis()));
    // Bound each drain: other loop work gets a chance even with continuous input.
    for (unsigned n=0; n<64 && DataUart.available()>0; ++n) {
      int b=DataUart.read();
      if (b>=0) report(parser.feed(uint8_t(b), millis()));
    }
  }
  delay(1);
}
做到这里,你应该看到
下载包中包含头文件、入口、sketch.yaml、README、fixture、记录模板与 LICENSE。

参考:[1] Arduino-ESP32 3.3.12 HardwareSerial.h:begin/rxPin/txPin、缓冲与逐字节读取

06 / 编程实验

先构建,再运行同一份头文件的主机检查

确认 Arduino CLI 1.5.1 与全局 esp32 core 3.3.12。IDE 对应 ESP32S3 Dev Module、8 MB、OPI PSRAM、QIO、8M with spiffs、CDC On Boot Disabled、Hardware CDC and JTAG。profiles 不设 default_profile;使用下列完整 FQBN 日常构建,显式 --profile 才启用独立安装。

指定组合实际编译通过,源码 SHA 与命令保存在 programming-input-builds.json。主机 fixture 直接 include 固件 PacketParser.h,8 组检查覆盖分片、粘包/CRLF、缺 LF 超时恢复、32/33 边界、非法与空帧、新 @ 重开、整帧时限及 uint32 回绕。这证明解析逻辑处理这些输入,不模拟 UART、USB 调度、FIFO 或电气。

代码 / 配置
arduino-cli compile --fqbn 'esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc' .
clang++ -std=c++17 -Wall -Wextra -Werror tests/parser_test.cpp -o /tmp/p07-test
/tmp/p07-test
做到这里,你应该看到
构建 Exit 0;主机输出 8 parser fixtures passed。实板与第二根串口线仍须单独验收。
07 / 编程实验

按字节故意拆坏输入,确认恢复到下一帧

上传到桥端口后打开 115200 日志;看到 READY,再向 PID954 数据端口发送。下载包 send-fixture.py 使用 pyserial==3.5,精确控制分片间隙;先在独立 venv 安装 requirements-fixture.txt,再执行脚本。Windows 将 .venv/bin/python 换成 .venv\Scripts\python.exe。

串口 GUI 若未启用转义,输入两个字符反斜杠 n 不等于 LF;可改用十六进制 0A。最小排错帧为 40 4F 4B 0A,即 @OK LF。下表是预期检查,不是本站实际线缆记录。

  1. 先只运行一遍脚本,接受序列应为 TEMP=23.4、A、B、OK、NEXT。
  2. 停止脚本,再发送 @Z LF;序号应继续且没有重启。
  3. 记录 acceptance.csv 的 observed/pass/日期/板与线缆,不把主机 fixture 当该表实测。
注入预期完整帧/错误检查点
@TE;隔50 ms;MP=23.4 LFTEMP=23.4,仅一帧半包不提前打印
@A LF @B CR LF 一次连发A、B,两帧结束符拆开消费
@lost;隔350 ms;tail LF @OK LF一次 timeout;随后 OK残尾不成帧
@ +33个x+LF @NEXT LF一次 overlong;随后 NEXT超长不溢出,下一帧恢复
代码 / 配置
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements-fixture.txt
.venv/bin/python send-fixture.py --data-port YOUR_DATA_PORT
做到这里,你应该看到
分段、连发、缺结束符和超长四项都保留原始调试日志;本站尚未执行此硬件检查。
08 / 编程实验

按“端口→电平→字节→状态”定位

没有 READY,先查桥端口、板载接口、115200、上传结果;STOP 表示缓冲申请或 UART 初始化失败,保留日志再重启。READY 后无 FRAME,先确认发的是数据端口、绿线到 GPIO17、共地且无流控,再核对真正的 LF。乱码先查速率与 3.3 V 信号,不靠加大超时掩盖错误。

只有 REJECT 时回到 @OK LF;它通过后逐项加回故障。持续流量下缺帧,必须进一步检查驱动溢出、debug 输出成本与应用消费速率。本教程协议没有 CRC、序号、重传或反压,不能声称数据端到端可靠。接真正传感器时,先按它的协议补齐长度、校验和错误恢复,避免只换串口引脚就当完成移植。

恢复先断电,拆掉数据线后刷回旧项目;PID954 红线始终不接板。接下来可回看毫秒状态机,将解析事件交给其他任务;本例未发布的后续串口项目不创建空路由。

参考:[1] Arduino-ESP32 3.3.12 HardwareSerial.h:begin/rxPin/txPin、缓冲与逐字节读取[3] Adafruit PID954:3.3 V RX/TX、线色、5 V 电源与修订

BACK TO THE SOURCE

资料来源与版本说明

核对 3.3.12 HardwareSerial 声明、v1.1 固定引脚表和 PID954 厂家电平/线色;帧格式与超时是本站原创应用协议。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. Arduino-ESP32 3.3.12 HardwareSerial.h:begin/rxPin/txPin、缓冲与逐字节读取
  2. DevKitC-1 v1.1 固定提交:J1-10/11、GPIO17/18
  3. Adafruit PID954:3.3 V RX/TX、线色、5 V 电源与修订