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