Files
ESP32S3_vt/README.md
T
2026-10-05 17:58:49 +08:00

402 lines
15 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** + OV2640 / OV3660 / OV5640(DVP 接口)实时图传,
客户端是 Rust + Slint 桌面程序。固件是跑在 ESP-IDF 上的 Rust,用官方
`esp32-camera` 组件做采集。
```
protocol/ 线路格式 + 帧重组 (有单元测试,为宿主架构编译)
. 固件 -> ESP32-S3 (交叉编译,Xtensa)
client/ 桌面客户端 (Slint,macOS/Linux/Windows)
```
`protocol/` 被两端共用,所以线路格式不可能各自漂移。
## 实测性能
VGA 640x480,JPEG 质量 12,板子与 2.4GHz AP 相距约一间房:
| | TCP(HTTP MJPEG) | UDP |
|---|---|---|
| 帧率 | 1.9 fps | **20.8 fps** |
| 丢帧 | 大量超时 | **0.0%**(146 帧 / 1270 个数据报) |
| 帧间隔 | 测不出来 | 35 ms |
| 实际吞吐 | 0.28 Mbit/s | 1.72 Mbit/s |
| 传感器自身上限 | 22.2 fps | 22.2 fps |
UDP 这条路已经跑到传感器的极限,网络不再是瓶颈——即便如此也只用掉链路的
6–9%,说明瓶颈从头到尾都不在带宽上。
(为什么用 UDP、以及 TCP 那组数字到底说明什么,见
[`protocol/README.md`](protocol/README.md)。)
---
# 固件
## 环境要求
- **Rust 的 `esp` 工具链。** 仓库里有 `rust-toolchain.toml`,所以
`rustup toolchain install esp` 就够;否则用 `espup install`。
- **ESP-IDF。** `.cargo/config.toml` 通过 `IDF_PATH` 指向已有的安装。删掉那两行
的话,`esp-idf-sys` 会自己 clone 并构建它固定的那个版本——能用,但要下载几百 MB。
- **Python 3**(ESP-IDF 构建系统要用)。
- **esptool** 或 **espflash**,用来烧录。
先确认本机已具备:
```sh
rustc --version # 必须是 esp 工具链
espflash --version
python3 --version
```
## 配置
WiFi 凭据在**编译期**由 `env!` 读取,所以放在 `.env` 里,不进源码。`.env` 已在
gitignore 中。
```sh
cp .env.example .env
```
```sh
# .env
WIFI_SSID=你的2.4G频段ssid
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
```
SSID **必须是 2.4GHz**。ESP32-S3 的射频不支持 5GHz,5GHz 的 SSID 对它完全不可见,
板子只会一直超时等待。固件在关联前会先扫描并打印可见网络,正是为了让这个问题
能被一眼看出:
```
I esp32: scanning for 2.4 GHz networks...
I esp32: SR-602-2G4 ch 6 -27 dBm Some(WPAWPA2Personal) <- target
E esp32: "SR-602-5G" was not in the scan results
```
## 编译
```sh
./scripts/build.sh
```
这就是"读取 `.env` 后执行 `cargo build --release`"。产物在
`target/xtensa-esp32s3-espidf/release/esp32`(约 1.1 MB)。
> **改了 C 文件?**
> `components/esp32-camera/**` 和 `components/cam_wrapper/**` 不在 `esp-idf-sys`
> 的依赖追踪里,所以改它们**不会**触发重新编译,会静默地用旧代码。先清缓存:
>
> ```sh
> rm -rf target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*
> ./scripts/build.sh
> ```
## 烧录并看日志
```sh
./scripts/flash.sh --monitor 30
```
它会编译、烧录、打印串口日志。预期输出:
```
I (2790) cam_wrapper: fps probe: 20 frames in 900 ms -> 22.2 fps, avg 9246 bytes
I (9817) esp32::udp: UDP sender listening on port 5600, streaming on request
I (9817) esp32: ESP32 camera is live on 192.168.1.139
I (9817) esp32: point the client at 192.168.1.139:5600 (UDP)
```
`fps probe` 那一行是**传感器上限**,在 WiFi 启动之前测的。排查流速慢时要拿它做
对照:如果 probe 说 22fps 而实际流不到,问题就在两者之间。
### 手动烧录
`scripts/flash.sh` 存在的原因之一是有一块坏 flash 字节(见
[硬件相关](#硬件相关))。它用 `esptool` 写三段:
```sh
B=$(ls -d target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*/out/build)
espflash save-image --chip esp32s3 --format esp-idf \
target/xtensa-esp32s3-espidf/release/esp32 /tmp/app.bin
~/.espressif/tools/python/*/venv/bin/python -m esptool \
--chip esp32s3 --port /dev/cu.usbmodemXXX --baud 460800 write-flash \
0x0 "$B/bootloader/bootloader.bin" \
0x8000 "$B/partition_table/partition-table.bin" \
0x10000 /tmp/app.bin
```
注意 app 偏移必须和 `partitions.csv` 里的 `factory` 一致。这里不能用
`cargo espflash flash`:它只写 app 段,而且会在没有 `.env` 的情况下重新编译,
导致 `main.rs` 里的 `env!` 失败。
### 找串口
```sh
ls /dev/cu.usbmodem* # 这块板子显示为 /dev/cu.usbmodem111301
```
`scripts/flash.sh` 会取第一个匹配项,也可以 `ESP_PORT=/dev/cu.usbmodemXXX` 指定。
---
# 客户端
## 环境要求
- Rust(普通 stable 或 nightly,**不要**用固件那个 `esp` 工具链)
- 不需要额外的系统库:macOS 和 Linux 上 Slint 用 `femtovg` 渲染,也不需要 `skia`
> `client/.cargo/config.toml` 把 `target` 钉成 `aarch64-apple-darwin`,因为这个
> 目录位于固件源码树内部,否则会继承 Xtensa 交叉编译配置。**要按你的机器改**——
> Intel Mac 用 `x86_64-apple-darwin`,Linux 用 `rustc -vV | grep host` 的输出。
## 编译与运行
```sh
cd client
cargo build --release
./target/aarch64-apple-darwin/release/cam-client # 用下面的默认地址
./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
```
```
用法: cam-client [CAMERA_IP] [PORT]
CAMERA_IP ESP32 摄像头地址(默认 192.168.1.139)
PORT UDP 视频端口 (默认 5600)
```
改 `ui/camera.slint` 时用一步到位的方式:
```sh
cd client && cargo run --release -- 192.168.1.139
```
## 界面读数
```
streaming 20.8 fps 10.1 kB per frame 35 ms between frames
640x480 shown 146 dropped 0 (0.0%) 1270 datagrams
```
**丢帧率和帧率同等重要**,这正是把统计放在界面上的原因。这条传输链路是**整帧丢弃
而不重传**,所以单看帧率是有歧义的:
| 帧率 | 丢帧 | 含义 |
|---|---|---|
| 达到传感器上限 | 0 | 链路没问题,瓶颈是传感器 |
| 远低于上限 | 有 | 瓶颈是网络 |
| 低 | 0,且显示 "no frames - camera?" | 板子没在推流 |
状态文字的颜色是同一信息的粗略版本:推流中绿色,否则琥珀色。
### 帧率是"计数"算出来的,不是"平均间隔"
不是对帧间隔取平均。一次 UI 轮询之间可能已完成好几帧,所以平均间隔量的是
**显示**的刷新快慢,而不是**相机**的出帧快慢——那曾把上限 22fps 的传感器报成
36fps。一个笃定的错误数字比没有数字更糟。
## 诊断
```sh
RUST_LOG=info ./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
```
```
frames shown 146 | dropped 0 (0.0%) | datagrams 1270 | undecodable 0 | short 0
```
| 计数器 | 含义 |
|---|---|
| `frames shown` | 完整重组并交给界面的帧 |
| `dropped` | 因分片缺失而被放弃的帧 |
| `datagrams` | 能解析为分片的数据报 |
| `undecodable` | 收到了但解析不了的数据报 |
`undecodable` 非零意味着板子的数据报**到了但没看懂**——是协议或端口对不上,不是网络
问题。这两者很容易混淆,而这个计数器正是用来区分它们的。
## 排查
| 现象 | 原因 |
|---|---|
| 一直显示 `connecting to …` | 板子不在网上,或防火墙拦了入站 UDP 5600 |
| `no frames - camera?` | 板子停止推流:重启了,或掉线了 |
| 完全没有输出,也没有日志 | host triple 不对,见[目标架构](#目标架构) |
| 只有第一帧然后卡住 | 另一个客户端连在别的端口上;板子只推给最后宣告的那个 |
| 颜色错乱 | 通道顺序。当前构建用 `Rgba8Pixel`,另一个选项是 `Bgra8Pixel` |
| 编译在 `libc` / `memmap2` 报错 | 用的是 `esp` 工具链,或缺少 target triple 覆盖 |
---
# 工作原理
## 采集 → UDP
```
OV5640 ──DVP──> LCD_CAM/GDMA ──> PSRAM 帧缓冲
│
frame::next_frame() (阻塞)
│
切成 1400 字节一个的数据报
│
对每个 peer sendto()
```
`esp_camera_fb_get` 会阻塞到传感器出帧,所以帧率跟随传感器,中间没有任何可能被
积压 overrun 的队列。数据报一进内核,帧缓冲立刻还给驱动——多占着只会增加延迟。
## 对端
板子是服务端,客户端是对等端。客户端绑定 UDP 5600 并发一个宣告数据报;板子随后
就往它看到的源地址推流,当存活探测开始失败时移除该对端。没有握手、没有发现协议,
而且客户端晚连(或板子重启)都能自恢复——因为宣告每 500ms 重发一次。
最多并发 4 个对端。
## 为什么要 `cam_wrapper`
`esp_camera.h` 里的 `camera_config_t` 对已废弃的 SCCB 引脚名用了匿名 union,bindgen
会生成成 unsafe union 字段,从 Rust 构造很别扭。`components/cam_wrapper` 接收一个
纯 int 结构体,在 C 侧填 union,于是 Rust 侧的绑定简单到可以安全构造。
## 客户端线程模型
三个线程,且**没有任何 worker 线程碰 Slint 组件**:
- `udp-reader` —— 收包与重组
- `jpeg-decode` —— JPEG 解码成 RGBA
- 主线程 / 事件循环 —— 转成 `slint::Image` 并更新属性,每 30ms 一次
这是 Slint 的硬性约束而非风格偏好:组件只能在拥有它的线程上使用,
`Weak<App>` 是 `!Send` 就是为了强制这一点。worker 线程去 upgrade 一个 weak handle
会拿到 `None` 然后什么都不做——现象和"界面卡死"完全一样。
---
# 硬件相关
## 引脚表
默认匹配 **Freenove ESP32-S3-WROOM CAM** 的摄像头座(`src/camera.rs` 的 `pins`
模块):
| 信号 | GPIO | | 信号 | GPIO |
|---|---|---|---|---|
| RESET | 0 | | D0 (Y2) | 11 |
| XCLK | 15 | | D1 (Y3) | 9 |
| PCLK | 13 | | D2 (Y4) | 8 |
| VSYNC | 6 | | D3 (Y5) | 10 |
| HREF | 7 | | D4 (Y6) | 12 |
| SIOD | 4 | | D5 (Y7) | 18 |
| SIOC | 5 | | D6 (Y8) | 17 |
| | | | D7 (Y9) | 16 |
数据总线是传感器的 **Y2..Y9**,即 10 位像素字的低字节,所以 Y2 是 D0、Y9 是 D7。
### 千万不要套用别的板子的引脚表
上面每个引脚都是 ESP32-S3 空出来的。下面这些**不是**:
| GPIO | 被什么占用 | 拿来用会怎样 |
|---|---|---|
| 26–32 | 模组的 SPI flash | `PIN_FUNC_SELECT` 改掉 flash 的复用,CPU **在传输中途挂死** |
| 33–37 | octal PSRAM | 同上,对内存总线而言 |
| 19, 20 | 原生 USB | 串口 console 断掉 |
| 43, 44 | console UART | 日志断掉 |
| 45, 46 | 模组内部 | 保留 |
在 ESP32-S3 上套用 AI-Thinker ESP32-CAM 的引脚表是踩这个坑的经典方式:15 根信号里
有 7 根落在 flash、PSRAM 或 USB 上。现象是板子能正常枚举、摄像头也能配置成功,
然后在第一次 `PIN_FUNC_SELECT` 时锁死。
## Flash:DIO @ 40MHz
`sdkconfig.defaults` 用 DIO/40MHz 而不是这个片子标称的 QIO/80MHz,因为测试用的模组
有一块坏 flash 字节(见下),四线模式会把它推进启动循环。硬件正常的话可以换回
QIO + 80MHz——程序是从 flash 运行的,这会明显加快**代码**执行。
## 坏的那块 flash 字节
用于点亮的这块模组,其 SPI flash 在 **`0xFADE7`** 有一个字节的 bit5–7 写不进去。
镜像放在该地址上的任何值都会被破坏,校验和失败,bootloader 拒绝启动。
镜像内容不受它在 flash 中**位置**的影响,所以 `scripts/flash.sh` 会挑一个 64KB 对齐
的 app 偏移,使该地址上的字节是这块 flash 存得下的,相应改写 `partitions.csv`,并在
烧录前校验。
**这是为坏硬件准备的绕行方案,不是设计选择。** 换到正常模组时:删掉
`scripts/flash.sh` 里的 `BAD_FLASH_ADDR` 逻辑,把 `partitions.csv` 的 `factory` 改回
`0x10000`,之后就可以用 `cargo espflash flash`。
## `PSRAM DMA` 是故意关掉的
`sdkconfig.defaults` 里 `CONFIG_CAMERA_PSRAM_DMA=n`,原因就写在那个文件里。简要说:
开着时 `esp32-camera` 只在最后 1KB 的 DMA 节点里找 JPEG 结束标记(EOI)。标记一旦
落在窗口外,这一帧就被 `NO-EOI` 丢弃——在这块板子上是大部分帧,之后驱动彻底不再
交出任何帧且不会恢复。关掉后每帧多一次约 20KB 的 `memcpy`,但搜索范围是整个缓冲。
## CPU 频率
显式设置了 `CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y`,因为 **ESP-IDF v5.5 把 ESP32-S3
的默认值设成 160MHz**(`components/esp_system/port/soc/esp32s3/Kconfig.cpu`)。默认
构建会白白浪费一半 CPU,而采集、JPEG 搜索、lwIP、HTTP 全是 CPU 密集的。启动时确认:
```
I (891) cpu_start: cpu freq: 240000000 Hz
```
## WiFi 缓冲留在内部 RAM
`CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=n`。这里原本是 `y`,理由是"PSRAM 空间充足"——
恰好搞反了。WiFi 描述符和 TCP 段每个包都要碰,而内部 SRAM 比隔着内存总线的 PSRAM
快一个数量级。帧缓冲体积大、相对少碰,仍然留在 PSRAM。
运行时也关掉了 WiFi 省电模式:开着时空闲的信标间隔要花几百毫秒,表现为卡顿和
200–300ms 的 ping。
---
# 可调项
| 项目 | 位置 | 说明 |
|---|---|---|
| 分辨率 | `.env` 的 `CAM_RES` | QVGA..UXGA。CIF 以上需要 PSRAM。 |
| JPEG 质量 | `.env` 的 `CAM_QUALITY` | 0–63,越小越好。VGA 下 12 约 10–20KB/帧。 |
| 帧缓冲数 | `camera.rs` 的 `fb_count` | 3:一个在填、一个在发、一个备用。 |
| 指定传感器 | `main.rs` 的 `SensorHint` | 除非探测失败,否则用 `Auto`。 |
| XCLK | `camera.rs` 的 `xclk_freq_hz` | 20MHz;OV5640 的 PLL 只为此调校过。 |
| VSYNC 极性 | `.env` 的 `CAM_VSYNC_INVERT` | 仅当板子在硬件上反相时需要改。 |
| 采集核心 | `CONFIG_CAMERA_CORE0` | `n` 把采集放到 CPU1,避开 CPU0 上的 WiFi 任务。 |
| 分片大小 | `protocol/` 的 `MAX_FRAGMENT_PAYLOAD` | 1400 保持在 1500 MTU 以内。再大就有 IP 分片风险。 |
## 继续提高帧率
传感器在 VGA 下大约封顶 22fps,而链路现在已经跟得上,所以下一个杠杆在传感器侧:
- **SVGA(800x600)** 牺牲的是分辨率而非帧率——OV5640 的帧率基本由像素时钟决定。
- **调大 `CAM_QUALITY` 的数字** 会缩小帧体积、减少字节数,但瓶颈是每秒帧数而不是
带宽。
- 如果要的是低延迟而不是细节,用 **QQVGA/QVGA**:DVP 总线和 PSRAM 带宽都随像素数
线性变化。
## 协议测试
`protocol/` 是最容易出现细微错误、又无法在固件上验证的部分,所以它有真正的单元测试
并且为宿主架构编译:
```sh
cd protocol && cargo test
```
```
7 passed
```