模拟任务书
UART1 使用 TX GPIO17、RX GPIO18、115200 8N1 与上位机通信。接收帧格式为 AA LEN CMD DATA... SUM,LEN 表示 CMD 与 DATA 的总字节数,SUM 为 LEN、CMD、DATA 逐字节相加后的低 8 位。应答帧头为 55。
| 命令 | 数据 | 功能 |
|---|---|---|
| 0x01 | 无 | 查询模式和 ADC |
| 0x10 | R G B | 设置全彩 LED |
| 0x11 | HzL HzH MsL MsH | 蜂鸣器发声 |
| 0x20 | 无 | 读取 ADC |
| 0x30 | Mode | 设置模式 |
| 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 向上位机逐字节发送 |
| 命令字和 DATA | handleFrame() | 检查长度、执行命令、生成应答 |
| 接收帧头、LEN、校验 | consumeByte() | 逐字节推进接收状态机 |
| 字节间超时 | consumeByte()与 tick() | 超过 100 ms 丢弃半帧 |
| UART1 持续读取 | tick() | 读完缓冲区已有字节 |
| 是否为二进制 UART1 | createTaskHooks() | 最后的 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 | 比较最后一个校验字节 |
先手算一帧
| 字节 | AA | 01 | 01 | 02 |
|---|---|---|---|---|
| 含义 | 帧头 | LEN:只有 1 字节 CMD | CMD:查询状态 | 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。
不要只改一处超时:接收新字节前和缓冲区暂时无字节时都要检查,否则半帧可能一直残留。
改完后的最短验收
- 确认
contest_user_config.h中的CT_ENABLE_UART1仍为 1,TX/RX 交叉并共地。 - 先发送一帧正确查询,
STATUS中的 GOOD 应加 1。 - 故意发错 SUM,BAD 应加 1,设备不能执行命令。
- 只发送半帧并等待超过超时,再发正确帧,解析器应自动恢复。
- 分别测试 DATA 长度少一字节、多一字节和未知命令。
- 用逻辑分析仪或串口十六进制工具检查应答,不能用文本串口助手的换行模式猜测。
不要改:createTaskHooks()最后的 true和 UART1 启用宏。不要向 app.uart1()发送调试文字;调试日志继续使用 app.emit()走 USB 串口。