Files
ESP32S3_vt/README.md
T
Horldsence eafa112959 文档:写防作弊、缩放布局、槽位语义
client/README.md 新增三节:

  防作弊  说明为什么必须在**接收端**挡而不是界面里隐藏——Hello 广播已经带着
          MAC 派生 id 和 IP,对手 tcpdump 一下就有,而数据本来也已收下解码。同时
          明确写出这一版挡不住什么:对手知道 IP 后可以自己发 Subscribe,固件会向
          任何订阅者推流。要真正封住得回固件加配对 token。

  缩放    image-fit 必须跟着 zoom 在 contain/fill 之间切,否则放大不会改变画面;
          缩回 1.0 要归位,否则下次放大从偏移开始像跳了一下。

  槽位    记下"按 id 顺序对号入座"这个做法错在哪,以及为什么会出现画面左右互换。

根 README 排查表加两条:两格宽窄不一的检查点,以及放大后画面没变清晰的原因。
2026-10-05 21:45:24 +08:00

350 lines
16 KiB
Markdown
Raw 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.
# ESP32-S3 摄像头图传(Rust / ESP-IDF / UDP)
两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV5640),向队伍客户端与裁判终端推
UDP 图传。
固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;客户端是 Rust + Slint
桌面程序。
```text
protocol/ 线路格式 + 帧重组 两端共用,无 ESP-IDF 依赖,13 个单元测试
. 固件 -> ESP32-S3 交叉编译,Xtensa
client/ 桌面客户端 Slint,队伍端 / 裁判端两种身份
```
三个 crate 各自独立构建,各有自己的 `.cargo/config.toml`。
## 拓扑
```text
┌──────────────────────────────┐
│ 裁判终端(PC,仅显示) │
│ 队伍一 + 队伍二 + 双方血量 │
└───────────────┬──────────────┘
│ 同一个局域网,广播可用
┌────────────────────┼────────────────────┐
│ │ │
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
│ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
│ (队伍一) │ │ (队伍二) │ │ (预留,未实现) │
└───────────────┘ └───────────────┘ └───────────────┘
```
**两块板子不区分队伍。** 它们只知道自己是谁(MAC 派生的 `sender_id`),"哪一边"由
客户端决定。烧同一份固件即可,配置全在客户端一侧。
裁判终端同时收两块板子。队伍客户端也收全部板子的视频,然后在**界面层**过滤到绑定的
那一块。
## 实测性能
VGA 640x480,传感器产出上限 22 fps(OV5640 在此配置下的实测值)。
| | TCP(HTTP MJPEG) | UDP(现在) |
|---|---|---|
| 传感器实际产出 | 22 fps | 22 fps |
| 客户端实际收到 | **1.9 fps** | **22 fps** |
| 实际吞吐 | 0.28 Mbit/s | 1.6 Mbit/s |
| 丢帧 | — | 0% |
TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好 2.4GHz 链路上 TCP 实测上限约
20–30 Mbit/s)。**链路是空的**,帧却堵在队头阻塞后面等一个实时画面等不起的重传计时
器。
UDP 没有重传与确认:缺了分片的帧整个丢弃,下一帧顶上。丢帧率实测 0%,因为带宽从来
不是瓶颈——即便如此也只用掉 6–9%。
其余数字:平均帧 22 kB,约 350 包/秒,每帧约 17 个分片,客户端观测的帧间隔 7 ms。
## 为什么视频走 UDP 而不是 TCP
**延迟优先。** 丢失 5% 的画面远好于等待 500 ms 的重传。
**没有队头阻塞。** 一帧的任一分片卡住,后面所有帧都跟着卡——对实时画面这正好是错
的方向。
**带宽不是约束。** 见上表,TCP 那栏证明了链路是空的,所以 TCP 的优势在这里换不来
任何东西。
代价是丢包丢整帧。这被接受了:**撕裂的画面比过期的画面更糟**,所以接收端不做补洞,
只等下一张完整帧。
## 帧率必须按计数除以墙钟
"22 fps" 这个数字不是对帧间隔取平均得来的,是每个窗口的**帧数增量除以窗口时长**。
一次 UI 轮询(33ms)之间可能完成多帧,平均间隔量的是**显示**的刷新快慢而不是**相机**
的出帧快慢——那会把上限 22fps 的传感器报成 36fps。**一个笃定的错误数字比没有数字更
糟**,现场你会以为还有性能余量,然后花时间去找一个不存在的问题。
这个错误在本项目里出现过两次(客户端的 `View::update_fps`、探测脚本的分窗打印),两
次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。
## 快速开始
### 1. 固件
```sh
cp .env.example .env # 填 WIFI_SSID / WIFI_PASS
./scripts/flash.sh --monitor 20
```
`flash.sh` 会编译、挑选避开坏字节的 app 偏移、烧录三段(bootloader / 分区表 /
app)、打印串口日志。ESP-IDF 位置从 `.cargo/config.toml` 读,不用先 `activate`。
`.env`:
```sh
WIFI_SSID=你的2.4G网络
WIFI_PASS=密码
# 以下都有默认值:
#CAM_RES=vga # qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
#CAM_QUALITY=12 # 0-63,数字越小画质越好、字节越少
#CAM_VSYNC_INVERT=1 # 若板子在硬件上反相了 VSYNC,填 0
#WIFI_AUTH=wpa2 # 固定认证方式,仅当扫描看不到 AP 时需要(如隐藏 SSID)
```
凭据由 `env!` 在**编译期**读取,所以没有运行时解析路径,也不会因为忘了读 `.env` 而
在板子上带着别人的密码。
`WIFI_AUTH` 一般不用设:固件先扫描 AP,用它报告的真实认证方式去连,只有扫不到时才
需要人工指定。指定之后**只**试这一种方式——不成功就直接报错,而不是挨个试 10 秒。
挨个试等于在猜一个本该已知的值。
### 2. 客户端
```sh
cd client && cargo build --release
./target/aarch64-apple-darwin/release/cam-client --list
```
`--list` 列出发现的板子。然后:
```sh
cam-client # 裁判端,无需配置
cam-client --role team --team 1 # 队伍端,首次运行问绑定
```
**`client/.cargo/config.toml` 里的 `target = "aarch64-apple-darwin"` 换机器必须改。**
client/ 嵌在固件的目录树里,会继承父目录那个 `xtensa-esp32s3-espidf` 的交叉编译配
置,然后在依赖深处以 `libc` 找不到 `MAP_FAILED` 的形式失败。
## 发现是双向的
单靠一个方向建立不了"谁要看我":
- 板子每 **2 秒**广播一次 Hello —— "我在线,我是谁"
- 客户端每 **800 ms**广播一次 Subscribe —— "我是这个角色,请给我画面"
板子从订阅的**源地址**学到推流目标(所以客户端的 socket 必须固定绑 5600 端口,不能
用临时端口)。
对端靠**源地址**记忆,不发探测包。续订本来就每 800 ms 一次,所以"还活着吗"这个问题
由续订本身回答了——超时即代表它离开了,多一份心跳状态就多一个能出错的地方。
两端各有自己的超时淘汰,阈值不同:
| | 超时 | 为什么是这个量级 |
|---|---|---|
| 固件丢弃订阅者 | 6 s | 续订间隔 800 ms,7 倍余量,偶发丢包不会把人踢掉 |
| 客户端移除板子 | 60 s | 只影响界面列表,不影响推流 |
客户端的 60 s 刻意很长:界面列表里留着一块刚重启还没广播的板子,比它短暂消失更可接
受。而它每 5 秒才扫一次,不值得为这个上一份状态。
固件同时最多向 **4 个**订阅者推流(`MAX_PEERS`)。每个对端每帧要一次 `sendto`,这是帧
CPU 成本的倍数;三台客户端(两队 + 裁判)够用,且不让一个陈旧的地址条目拖慢每一帧。
## 身份:为什么用 MAC 派生
`protocol::id_from_mac()` 用 FNV-1a 从 MAC 派生 `sender_id`,避开 0(裁判终端的保留
值)。
**用 MAC 而不是 IP**,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换地
址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。
## 线路格式
27 字节头 + 至多 1400 字节载荷,全部小端。细节在 `protocol/README.md`,那里有完整
的字节图和每个拒收条件。
三处关键决定:
- **27 字节不凑整。** 头只占每个 1427 字节数据报的 1.9%,而为对齐在两端各写一段
手写偏移常量,是引入静默错位的更划算的代价。
- **每个数据报 1427 字节,低于 1500 MTU。** 超 MTU 的数据报要么被 AP 丢、要么被 IP
分片,两种情况的丢包都记在应用账上而不是网络账上——后者本来更容易排查。
- **`version` 不匹配就拒收**,而不是按错误布局继续解析。后者表现为随机崩溃或画面错
乱,比拒绝连接难查得多。
## 硬件
Freenove ESP32-S3-WROOM CAM,ESP32-S3-WROOM-1-N16R8(16 MB flash + 8 MB octal
PSRAM),OV5640。
### 引脚
`src/camera.rs` 的 `pins` 模块:
| | GPIO |
|---|---|
| RESET | 0 |
| XCLK | 15 |
| PCLK | 13 |
| VSYNC | 6 |
| HREF | 7 |
| SIOD (SDA) | 4 |
| SIOC (SCL) | 5 |
| DATA[0..8] | 11, 9, 8, 10, 12, 18, 17, 16 |
### 千万不要套用别的板子的引脚表
这块板子的 GPIO 分配是死的:
| 范围 | 占用 |
|---|---|
| 26–32 | SPI flash |
| 33–37 | octal PSRAM |
| 19 / 20 | USB |
| 43 / 44 | console |
| 45 / 46 | 模组内部 |
直接套用 AI-Thinker ESP32-CAM 的引脚表会有 **7 根冲突**。冲突的表现不是"某根线不
通",而是 `PIN_FUNC_SELECT` 把某个外设接到了 flash 或 PSRAM 上,**CPU 在传输中途挂
死**——没有报错、没有回溯、串口直接静默。
改引脚前先对照上面这张表。
### 那块坏掉的 flash 字节
测试用的这块模组在 `0xFADE7` 有个字节的 bit 5–7 **写不进去**。镜像放在哪里会决定哪
个镜像字节落到这个地址,落上去就校验失败、bootloader 拒绝启动。
镜像本身不因位置而变,所以移动 app 分区就够了。`scripts/flash.sh` 会挑最低的 64 KB
对齐、且该地址处字节 flash 真的能存的偏移,写回 `partitions.csv`,然后重新编译
(重编译会让镜像位移,所以编译后再验证一次)。
**换到健康硬件**:把 `factory` 分区放回 `0x10000`,删掉 `flash.sh` 里的
`BAD_FLASH_ADDR`,别的什么都不用改。
Flash 跑 DIO @ 40 MHz 而不是这颗片子的标称 QIO/80 MHz:测试模组的坏字节在 quad 模式
下会被推到 boot loop。QIO 对**代码执行**速度的提升是明显的(程序从 flash 跑,cache
miss 直接打在这条总线上),健康硬件上值得换回去试。
### `PSRAM DMA` 是故意关掉的
开着的话,esp32-camera 让 GDMA 直接把帧缓冲写进 PSRAM,然后**只在最后一个 DMA 节点
(1 KB)里**找 JPEG 结束标记。标记落在窗口外就丢帧并报 `NO-EOI`——在这块板子上是大部
分的帧,几帧之后驱动干脆不再交出任何帧,而且不恢复。
关掉之后驱动 DMA 到内部 RAM 的中转缓冲,再 memcpy 进 PSRAM。每帧多一次约 23 KB 的
memcpy,但搜索整个缓冲找标记。对一条能用的图传链路,这是对的取舍。
### CPU 频率
**`CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y`** 是 `sdkconfig.defaults` 里最值钱的一行,
而且免费。
ESP-IDF v5.5 的 Kconfig 把 ESP32-S3 默认成 **160** MHz(见
`components/esp_system/port/soc/esp32s3/Kconfig.cpu`),所以默认编译悄悄浪费了一半
CPU。相机采集、esp32-camera 里的 JPEG 搜索、lwIP、发送路径全是 CPU 密集的,这一行一
次性把它们全部搬上去。
### WiFi 缓冲留在内部 RAM
`CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=n`。
这个值曾经按"PSRAM 很空闲"的直觉写成 `y`,方向恰好相反:WiFi 描述符和 TCP 段**每个
包都要碰**,内部 SRAM 比隔着内存总线的 PSRAM 快一个数量级。把它们放进 PSRAM 的表现是
高延迟和差吞吐,无论 PSRAM 还剩多少。
帧缓冲相反——它大、相对少碰,留在 PSRAM。
### 相机任务钉在 CPU1
`CONFIG_CAMERA_CORE0=n`(即跑在 CPU1)。WiFi 任务钉在 CPU0。esp32-camera 的采集任务
时间是短促且延迟敏感的(DMA 完成、JPEG 标记搜索、拷进帧缓冲),在共享核上会被抢占,
每一帧都被拉长。双核芯片上没有理由共享。
## vendored esp32-camera
`components/esp32-camera/` 是上游 2.1.7 的副本,因为组件注册表的版本求解器找不到它。
它打了一处补丁:`target/esp32s3/ll_cam.c` 里的 `CAM_IO_MUX_REG()`。这个宏负责把引脚
接到 I2C(OV5640 的 SCCB 走 I2C),不接的话传感器根本不会被发现。
**注意这个目录叫 `target/`,但它不是构建目录。** 它是上游组件的按芯片分目录
(`esp32/`、`esp32s2/`、`esp32s3/`),里面是 `ll_cam.c` 等源码,包含上述补丁。所以
`.gitignore` 里三条 `target/` 规则全部锚定在根目录——这是有意的。
## 改过 C 文件之后
```sh
rm -rf target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*
```
不删不会重新编译,你的补丁看起来像完全没生效。
## 摄像头端不编码,客户端不编码
OV5640 的 JPEG 编码器**在传感器内部**。DVP 接口出来的一帧要么是 JPEG,要么是 YUV 裸
数据。
不编码的话,单帧从约 21 KB 涨到 **450 KB**(21 倍),而 ESP32-S3 **没有硬件 JPEG
编码器**(整个 ESP32 系列只有 P4 有,`esp_driver_jpeg` 里
`CONFIG_IDF_TARGET_ESP32P4` 是写死的)。
同理,客户端也不编码:板子出的就是传感器里的 JPEG,客户端只解码。ESP32-S3 也没有硬
件 JPEG 解码器。
## 为什么收包和解码要分开线程
一张 VGA 的 JPEG 解码要几毫秒。放在收包线程里会挤进数据报之间,造成**链路上本不存在
的丢帧**——不是真的丢了网络包,是应用来不及取走 socket 缓冲。
`slint::Image` 不是 `Send`,所以 worker 线程只能处理纯数据(`Vec<u8>` + 尺寸),`Ima
ge` 的构造必须留在事件循环。这条约束决定了整个客户端的线程结构,细节在
`client/README.md`。
## 排查
| 现象 | 原因 |
|---|---|
| 板子无输出、串口静默 | GPIO 冲突。CPU 在传输中途挂了,见上面那张占用表 |
| 几帧后停止出图,报 `NO-EOI` | `PSRAM DMA` 被打开了 |
| 板子在线但 `0 clients subscribed` | 订阅包格式不对。`Subscribe` 的 `role` 字节紧跟 27 字节头,在偏移 27 |
| 一直接到但画面偶尔解不出来 | 帧重组的问题。**先怀疑你的测量工具**:本项目的探测脚本就因为重组状态没按 `frame_id` 隔离,误报过 34 张坏帧 |
| `libc` 找不到 `MAP_FAILED` | `client/.cargo/config.toml` 的 target 没改成宿主三元组 |
| 串口看不到任何输出 | 非 TTY 下 `espflash monitor` 不可用,用 `/tmp/rd2.py` 裸读 |
| WiFi 连不上 | SSID 必须在 2.4GHz。5G 频段看不见 |
| 裁判端两块画面宽窄不一 | 检查那两格是否都写了相同的 `preferred-width`,且占位文字是绝对定位(`x`/`y`)而不是普通布局 |
| 放大后画面没变清晰 | `image-fit` 必须跟着 zoom 在 `contain`/`fill` 之间切。只用 `contain` 的话画面不会跟着放大 |
独立核对线路格式:
```sh
python3 scripts/udp_probe.py 10
```
它手写了每一个协议常量,**不依赖 `protocol` crate**——正因为如此,如果 crate 的编码
有错,脚本会立刻报出不一致。
## 三份文档
| | |
|---|---|
| 根 `README.md` | 你在这里。拓扑、性能、构建烧录、硬件坑 |
| `protocol/README.md` | 线路格式、帧重组的取舍、每个拒收条件 |
| `client/README.md` | 命令行、绑定流程、Slint 的硬约束、线程模型 |
## 装甲板(预留)
`protocol::ArmorHealth`、`ARMOR_PORT`(5601)、`MsgType` 5 号都已就位。客户端有真实的
`ArmorSource` trait,当前实现是 `NoArmorYet`。
**界面显示占位符而不是 0。** 0 看起来像真值——现场看到两个 0 会以为对方已经没血了。
接上以后板子向 `ARMOR_PORT` 发这个结构,客户端实现 `ArmorSource` 即可,界面和其余代
码都不用改。