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

15 KiB
Raw Blame History

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。)


固件

环境要求

  • 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,用来烧录。

先确认本机已具备:

rustc --version          # 必须是 esp 工具链
espflash --version
python3 --version

配置

WiFi 凭据在编译期由 env! 读取,所以放在 .env 里,不进源码。.env 已在 gitignore 中。

cp .env.example .env
# .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

编译

./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 的依赖追踪里,所以改它们不会触发重新编译,会静默地用旧代码。先清缓存:

rm -rf target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*
./scripts/build.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 写三段:

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! 失败。

找串口

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 的输出。

编译与运行

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 时用一步到位的方式:

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。一个笃定的错误数字比没有数字更糟。

诊断

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/ 是最容易出现细微错误、又无法在固件上验证的部分,所以它有真正的单元测试 并且为宿主架构编译:

cd protocol && cargo test
7 passed