402 lines
15 KiB
Markdown
402 lines
15 KiB
Markdown
# 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
|
||
``` |