押题六:UART 二进制帧协议

命中方向:UART1、字节流状态解析器、帧头、长度、命令字、校验和、超时复位、应答帧。

模拟任务书

UART1 使用 TX GPIO17、RX GPIO18、115200 8N1 与上位机通信。接收帧格式为 AA LEN CMD DATA... SUM,LEN 表示 CMD 与 DATA 的总字节数,SUM 为 LEN、CMD、DATA 逐字节相加后的低 8 位。应答帧头为 55

命令数据功能
0x01查询模式和 ADC
0x10R G B设置全彩 LED
0x11HzL HzH MsL MsH蜂鸣器发声
0x20读取 ADC
0x30Mode设置模式
0x40保存 NVS

帧间字节超过 100 ms 未收完时丢弃当前帧;长度错、校验错和未知命令必须返回或记录明确错误。

建议评分点

评分项分值
UART1 引脚与参数正确10
逐字节非阻塞解析状态机20
长度与校验和验证15
100 ms 超时恢复10
六个命令功能25
应答帧与错误状态10
统计正确帧/错误帧10

总分:100

直接使用

工程:arduino_pio/mock_06_binary_uart。需要外接 3.3 V USB-TTL:对方 TX 接 GPIO18,对方 RX 接 GPIO17,两端 GND 共地。

PROTO
STATUS

查询状态发送:AA 01 01 02
红灯发送:    AA 04 10 FF 00 00 13

主要修改:src/task_logic.cpp中的 consumeByte()handleFrame()sendResponse()

高概率变题

  • 帧头改为双字节 55 AA。
  • SUM 改异或校验或 CRC8。
  • 长度字段改为仅表示 DATA 长度。
  • 要求状态变化时主动发送上报帧。

现场修改指南:先抄清楚帧格式

先在纸上写出一帧每个字节的位置,再改代码。二进制协议最常见的失分不是不会收串口,而是帧头、LEN 含义、大小端或校验范围少算一个字节。

协议字段对应哪里

协议要求函数/状态作用
应答帧头、应答校验sendResponse()从 ESP32 向上位机逐字节发送
命令字和 DATAhandleFrame()检查长度、执行命令、生成应答
接收帧头、LEN、校验consumeByte()逐字节推进接收状态机
字节间超时consumeByte()tick()超过 100 ms 丢弃半帧
UART1 持续读取tick()读完缓冲区已有字节
是否为二进制 UART1createTaskHooks()最后的 true阻止文本日志混入 UART1

变量翻译

代码含义
expected_length当前帧还应接收的 CMD+DATA 总长度
body[]存放 CMD 和 DATA 的接收缓存
body_index当前已经收到正文的第几个字节
checksum边接收边计算的 SUM 或其他校验值
last_byte_ms上一字节到达时刻,用来判断半帧超时
good_frames/bad_frames正确帧和错误帧累计数量

接收状态翻译

状态正在等待
WAIT_HEADER等待接收帧头 0xAA
READ_LENGTH下一个字节是 LEN
READ_BODY接收 CMD 和全部 DATA
READ_CHECKSUM比较最后一个校验字节

先手算一帧

字节AA010102
含义帧头LEN:只有 1 字节 CMDCMD:查询状态SUM:01+01=02

红灯帧 AA 04 10 FF 00 00 13:LEN 为 4,CMD 为 0x10,DATA 为 FF 00 00,校验低 8 位为 04+10+FF+00+00=0x113 → 0x13

收发分开检查:接收帧头当前是 0xAA,应答帧头当前是 0x55;接收 CMD 原样,应答 CMD 自动加 0x80;应答 DATA 前还有一个 status 字节。

四种高概率变题:必须成对修改

示例 A:接收帧头改 A5,应答帧头改 5A

接收端:在 consumeByte()WAIT_HEADER分支中找到:

if (value == 0xAA) parse_state = READ_LENGTH;

改为:

if (value == 0xA5) parse_state = READ_LENGTH;

发送端:在 sendResponse()中找到:

port.write(0x55);

改为:

port.write(0x5A);

如果任务书只给一个统一帧头,就让收发两端使用同一个值;不要凭当前示例保留 0x55。

示例 B:SUM 累加校验改成逐字节 XOR

发送端:完整替换 sendResponse()

void sendResponse(contest::ContestApp &app, uint8_t command,
                  uint8_t status, const uint8_t *data = nullptr,
                  uint8_t data_length = 0) {
    HardwareSerial &port = app.uart1();
    const uint8_t length = static_cast<uint8_t>(2 + data_length);
    const uint8_t response_command = static_cast<uint8_t>(command | 0x80);
    uint8_t check = length;

    port.write(0x55);
    port.write(length);
    port.write(response_command);
    check ^= response_command;
    port.write(status);
    check ^= status;

    for (uint8_t i = 0; i < data_length; ++i) {
        port.write(data[i]);
        check ^= data[i];
    }
    port.write(check);
}

接收端:保留 READ_LENGTH中的 checksum = value,把 READ_BODY中的:

checksum = static_cast<uint8_t>(checksum + value);

改为:

checksum ^= value;

必须两端同时改:只改接收会导致应答仍使用 SUM;只改发送会导致所有接收帧仍按 SUM 校验。

示例 C:增加命令 0x50,控制 GPIO21 开关

插入位置:handleFrame()switch (command)中、default之前加入:

case 0x50:
    if (data_length != 1 || data[0] > 1) {
        status = 3;
    } else {
        app.setDigitalOutput(data[0] == 1);
    }
    sendResponse(app, command, status);
    break;

开灯发送示例:

AA 02 50 01 53

校验为 02+50+01=53。关灯时 DATA 改 00,校验改 52。

示例 D:字节超时从 100 ms 改 500 ms,最大正文仍为 32 字节

搜索 > 100,在 consumeByte()tick()中各有一处,都改成 > 500

最大正文由 uint8_t body[32]决定。任务书要求 64 字节时,可改为 body[64];长度检查使用 sizeof(body)会自动跟随。ESP32-S3 内存足够,但 LEN 只有一个字节,最大仍不能超过 255。

不要只改一处超时:接收新字节前和缓冲区暂时无字节时都要检查,否则半帧可能一直残留。

改完后的最短验收

  1. 确认 contest_user_config.h中的 CT_ENABLE_UART1仍为 1,TX/RX 交叉并共地。
  2. 先发送一帧正确查询,STATUS中的 GOOD 应加 1。
  3. 故意发错 SUM,BAD 应加 1,设备不能执行命令。
  4. 只发送半帧并等待超过超时,再发正确帧,解析器应自动恢复。
  5. 分别测试 DATA 长度少一字节、多一字节和未知命令。
  6. 用逻辑分析仪或串口十六进制工具检查应答,不能用文本串口助手的换行模式猜测。

不要改:createTaskHooks()最后的 true和 UART1 启用宏。不要向 app.uart1()发送调试文字;调试日志继续使用 app.emit()走 USB 串口。