Files
ESP32S3_vt/protocol/README.md
Horldsence 7be8d7d2ba protocol: 更正上一个提交的归因——坏帧不在这里
上一个提交(1c65eaa)说"34 张 JPEG 不完整是重复分片凑数导致的",并说修完从
34 降到 1。**这个结论是错的**,是我归因到了错误的地方。

实测复查:一到两个订阅者、20 秒 2000+ 个分片,重复投递数为 0(固定端口和
临时端口两种订阅方式都试过)。这条链路上根本不产生重复。

34 张坏帧的真实来源是探测脚本自己:scripts/udp_probe.py 的重组状态只按
sender_id 存,不按 frame_id 存。于是某帧丢了分片之后,下一帧的分片会接着往
同一个计数器里凑,凑够了就交出去一张混着两帧的 JPEG。

FrameAssembler 一直按 frame_id 隔离(换个 frame_id 就重置),所以它没这个
问题——脚本原先跟它不一致,而我又把脚本的输出当成了 crate 的行为。

本次改动:

  protocol/src/lib.rs
    位图去重保留,理由改为正确性而非正在发生的故障,并在注释里写明实测重复
    为 0、以及真正的坏帧来源。删掉一个死变量 `last`。行为不变。

  protocol/README.md
    "重复分片必须去重" 一节改写:说清防护的定位是"哪天真的重复了"。
    新增"按 frame_id 隔离,换帧即放弃"一节,讲清不隔离的具体后果。
    记下探到坏帧时先怀疑测量工具。

值得记的不是位图本身,是那个错误归因:34 张坏帧配上"帧率正常、无报错线索"
这个症状,太像一个真的丢包/重复 bug 了,于是一路写到了注释、文档和提交说明
里。以后探到链路异常,先确认测量工具和被测对象说的是同一件事。
2026-10-05 19:13:24 +08:00

211 lines
9.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# protocol
摄像头 UDP 图传的线路格式,**固件与两端客户端共用**。
```
protocol/ 线路格式 + 帧重组 (13 个单元测试,无 ESP-IDF 依赖)
. 固件 -> ESP32-S3 (交叉编译,Xtensa)
client/ 桌面客户端 (Slint,两种身份)
```
这是本仓库里最该被测试的部分:帧重组和编码都极易出微妙错误,而这两件事都无法
在固件上验证——你只会看到"不出画面"。
因此它为宿主架构编译,与 ESP-IDF 完全无关:
```sh
cd protocol && cargo test
```
```
13 passed
```
## 拓扑
```text
┌──────────────────────────────┐
│ 裁判终端(PC,仅显示) │
│ 队伍一 + 队伍二 + 双方血量 │
└───────────────┬──────────────┘
│ 同一个局域网,广播可用
┌────────────────────┼────────────────────┐
│ │ │
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
│ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
│ (队伍一) │ │ (队伍二) │ │ (预留,未实现) │
└───────────────┘ └───────────────┘ └───────────────┘
```
两块板子不区分队伍——它们只知道自己是谁(MAC 派生的 id),"哪一边"由客户端决定。
烧同一份固件即可,配置在客户端一侧。
## 线路格式
每个数据报 = **27 字节头** + 至多 **1400 字节**载荷:
```text
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-------------------------------+-------+-----------------------+
| magic (u32) | type | version (u16) |
+-------------------------------+-------+-----------------------+
| sender_id (u32) | frame_id (u32) |
+-------------------------------+-------+-----------------------+
| frag_index (u16) | frag_count (u16) |
+-------------------------------+-------+-----------------------+
| total_len (u32) | width (u16) | height (u16) |
+-------------------------------+---------------+---------------+
| payload (0..=1400) |
+---------------------------------------------------------------+
```
全部整数**小端**——ESP32-S3(Xtensa)和所有桌面架构都原生如此,编解码就是
`to_le_bytes` / `from_le_bytes`,没有字节序转换步骤。
`msg_type` 决定同一端口上的字节如何解释,这是视频、发现、装甲板能共用一个端口的
前提:
| type | 方向 | 用途 |
|---|---|---|
| 1 `Video` | 板子 → 客户端 | 一帧 JPEG 的第 N 个分片 |
| 2 `Subscribe` | 客户端 → 板子 | "我是这个角色,请给我画面" |
| 3 `Hello` | 板子 → 客户端 | "我在线,我是谁" |
| 4 `Heartbeat` | 板子 → 客户端 | 同上,心跳形式 |
| 5 `ArmorHealth` | 装甲板 → 客户端 | 血量(**预留,当前无实现**) |
### 三个设计决定
**27 字节不凑整。** 头只占每个 1427 字节数据报的 1.9%,而为对齐在固件和客户端两侧
各写一段手写偏移常量,是引入静默错位的更划算的代价。
**`sender_id` 是多路的前提。** 裁判终端同时收两块板子,靠它在同一端口上区分来源。
**`width`/`height` 每个分片都重复携带**(不只是第 0 个),这样每个数据报都能独立
解析,中途加入的接收端也能立刻知道分辨率。
**`version` 不匹配就拒收**,而不是按错误布局继续解析——后者会表现为随机崩溃或画面
错乱,比拒绝连接难查得多。
## 为什么视频走 UDP 而不是 TCP
实测(VGA 640x480,传感器产出 22 fps):
| | TCP(HTTP MJPEG) | UDP |
|---|---|---|
| 传感器实际产出 | 22 fps | 22 fps |
| 客户端实际收到 | **1.9 fps** | **20.8 fps** |
| 实际吞吐 | 0.28 Mbit/s | 1.72 Mbit/s |
TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好 2.4GHz 链路上 TCP 实测上限
约 20–30 Mbit/s)。**链路是空的**,帧却堵在队头阻塞后面等一个实时画面等不起的
重传计时器。
UDP 没有重传与确认:缺了分片的帧整个丢弃,下一帧顶上。这换来低延迟,也把本来就
有的带宽真正用起来了——但即便如此也只用掉 6–9%,说明瓶颈从头到尾都不在带宽上。
## 接收端的取舍
`FrameAssembler` 的策略就是全部要点:**一张帧只有所有分片都到齐才交付,且最新的
帧永远优先。**
UDP 不保证跨数据报的顺序与到达,所以试图"补洞"的接收端做的功比"等下一张完整帧"
更多——而且补的过程中会显示撕裂的画面。
### 按发送方分开
**每个发送方必须有自己的重组器。** 两块板子的 `frame_id` 各自独立计数,塞进同一个
重组器会让它们互相顶掉,两路都出不来。
### 重复分片必须去重
`FrameAssembler` 按分片下标记录是否收到,重复的直接跳过且不计数,并显式确认最后一个
分片确实到过。
用单一计数 `seen >= expected` 判断收齐不够健壮:一个丢失的分片若能被某个分片的副本
凑数,计数就达标了,而缓冲区里留着空洞,交出去一张尾部是 0 的 JPEG。两个测试专门覆盖
它(`a_duplicate_cannot_complete_a_frame_with_a_hole`、
`a_gap_that_is_never_filled_never_yields_a_frame`)。
**不过要说清楚:在实测链路上一次重复投递都没出现过。** 一到两个订阅者、20 秒 2000+
个分片,重复数为 0(固定端口与临时端口两种订阅方式都试过)。所以这层防护是为了正确
性,不是为了修一个正在发生的故障。
它防的是"哪天真的重复了"——那时症状是**帧率完全正常、没有任何报错线索**,只有偶尔
几张图解不出来,比一次崩溃难查得多。
### 按 frame_id 隔离,换帧即放弃
每个 `FrameAssembler` **按 `frame_id` 隔离**状态:`frame_id` 一变就重置,之前那帧的半
成品直接丢掉。
不隔离的后果很具体——某帧丢了分片之后,下一帧的分片会接着往同一个计数器里凑,凑够
了就交出去一张**混着两帧**的 JPEG。
这个坑我踩过:`scripts/udp_probe.py` 最初只按 `sender_id` 存重组状态,于是 10 秒里报出
34 张"JPEG 不完整"。那个数字看起来像丢包或重复分片的证据,我据此给 `protocol` 写了一
个"修复重复分片"的提交和一段因果说明——**结论是错的**,那段归因比它揭示的 bug 更值得
记下来。修掉之后同样 14 秒内是 0 张。
探到坏帧时,先怀疑测量工具。
### 有界超时
发送方在帧中途死掉时,半截缓冲会永远留在重组器里。`expire()` 堵住这个。
### 发送端的复用
`Fragment::encode_into()` 追加写入而不分配:发送端对每帧的每个分片复用同一个缓冲,
在 22fps 下这是每秒约 250 次分配与不节省别的差别——在这块同时要抢内部 RAM 的芯片
上很实在。
`fragment_frame()` 从 JPEG 长度推出分片数,因此调用方不可能产出与自身矛盾的集合。
## 拒收条件
`Fragment::decode` 会挡掉这些,`FrameAssembler` 不必再操心:
| 条件 | 为什么 |
|---|---|
| `magic` 不符 | 同端口上可能有别的协议 |
| `version` 不符 | 按错误布局解析比拒收难查 |
| 长度不足 27 字节 | 可能是存活探测包(1 字节) |
| `frag_count == 0` | 自相矛盾 |
| `frag_index >= frag_count` | 自相矛盾 |
| 载荷比 `total_len` 还长 | 损坏,且在造成大额分配之前就该挡住 |
## 不变量
- 一个数据报不超过 `HEADER_LEN + MAX_FRAGMENT_PAYLOAD` = **1427 字节**,低于典型
以太网的 1500 MTU。
这一点比看起来重要:超 MTU 的数据报要么被 AP 丢弃,要么被 IP 分片,两种情况的
丢包都**记在应用账上**而不是网络账上——后者本来更容易排查。
- VGA 下每帧约 17 个分片。
- `DEFAULT_PORT` = 5600,两端共用同一个常量。
- `ARMOR_PORT` = 5601,装甲板预留,当前无实现。
## 身份
`id_from_mac()` 用固定长度输入的 FNV-1a 从 MAC 派生 `sender_id`。
**用 MAC 而不是 IP**,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换
地址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。
返回值避开 0,那是裁判终端的保留值。哈希有测试保证稳定性与不撞车。
## 独立核对
`scripts/udp_probe.py` 手写了这里的每一个常量,**不依赖本 crate**。
这正是它的价值:如果本 crate 的编码有错,脚本仍按文档解析,会立刻报出不一致。反面
例子也有——它的 `role` 字节曾多填 12 个零字节,固件按偏移严格读取因而拒收,表现为
"板子在线但 0 clients subscribed"。改协议时记得同步它。
## 装甲板(预留)
`ArmorHealth` 与 `ARMOR_PORT` 已就位,`MsgType` 也留了 5 号。现在没有实现也没有
数据源,裁判界面显示占位符而不是 0——**0 看起来像真值,现场看到会以为对方已经没
血了**。
接上以后板子向 `ARMOR_PORT` 发这个结构,客户端监听即可,界面与其它代码都不用改。