同期发布的 MiBeeNvr v0.6.0({{< ref "posts/mibee-oss/mibee-nvr-v0.6-promo" >}}) 带来了延时摄影、视频转码、ONVIF 增强等大功能,光靠单元测试远远不够,必须在真实摄像头环境下跑完整流程。为了给这个版本提供靠谱的测试机器,6 月 5 日同一天更新了三个摄像头项目------既是给 NVR 提供测试环境,也顺手解决了一些嵌入式开发中比较典型的工程问题。
MiBeeHomeCam v0.2.0:ESP32-S3 上的 RTSP 源和延时摄影测试机
MiBeeHomeCam 是基于 Seeed Xiao ESP32-S3 Sense 的监控摄像头固件(ESP-IDF 开发),用作 NVR 的 RTSP 和 timelapse 测试源。这次更新解决的核心问题是:一个资源受限的 MCU 系统如何同时承载实时视频流、录像、运动检测和网络传输。
PSRAM 双缓冲与帧资源竞争
ESP32-S3 的摄像头驱动通过 esp_camera_fb_get() 获取帧缓冲(frame buffer,简称 fb)。fb 是 camera sensor 驱动的共享资源------sensor 以固定帧率向 fb 写入数据,上层任务从 fb 读取。问题在于,esp_camera_fb_get() 返回的是 sensor 驱动的内部 DMA 缓冲区指针,而不是拷贝。这意味着:
- 录制任务长时间持有 fb 时,MJPEG 流任务拿不到帧,TCP 连接因无数据发送而超时断开
- 两端同时读 fb 可能导致数据竞争
v0.2.0 的解决方式是:获取 fb 后立即通过 memcpy() 将 JPEG 数据拷贝到 PSRAM 堆上,然后调用 esp_camera_fb_return() 归还 fb。PSRAM 上的拷贝由流任务和录制任务各自持有,互不干扰:
c
// 旧方案:直接持有 fb,流和录制冲突
fb = esp_camera_fb_get();
// 录制占用了 fb 200ms...
esp_camera_fb_return(fb);
// 新方案:拷贝到 PSRAM 后立即归还 fb
fb = esp_camera_fb_get();
memcpy(psram_buffer, fb->buf, fb->len); // ~5ms @ 800x600 JPEG
esp_camera_fb_return(fb);
// psram_buffer 由各任务独立处理,不再竞争
ESP32-S3 的 Octal PSRAM 带宽足够支撑同时读写------实测 800×600 JPEG(~50KB/帧)下,memcpy + 双任务并发不会有明显的吞吐瓶颈。但没有 PSRAM 的芯片(比如普通 ESP32-S3 模组)根本无法用这个方案,内部 SRAM 只有 512KB。
延时摄影中的运动检测:JPEG 软解码 + 帧差分析
ESP32-S3 没有硬件 JPEG 解码器,运动检测必须在软件中实现。而且不能做完整解码------800×600 JPEG 完整解码到 RGB 需要约 1.4MB 内存,PSRAM 虽然够,但 CPU 时间不够(软件 JPEG 解码一帧要 200-500ms)。
实际做法是部分解码:只解出 Y 通道(灰度图),丢弃 UV 通道。Y 通道的数据量只有完整 RGB 的 1/3,而且运动检测只需要亮度变化信息,色度通道对检测没有贡献。
帧差分析的简化流程:
帧 N JPEG → 软解 Y 通道 → 8x8 块亮度均值矩阵(75x100 块)
帧 N+1 JPEG → 软解 Y 通道 → 8x8 块亮度均值矩阵
逐块计算差值 → 超过阈值的块计数 → 超过触发面积 → 运动事件
灵敏度 1-5 级映射为两个参数:差值阈值(10-50)和触发面积比例(5%-1%)。1 级灵敏度最低(阈值 50 + 5% 面积触发),适合室外环境减少误报;5 级灵敏度最高(阈值 10 + 1% 面积触发),适合室内精细监控。
一个隐蔽的 bug:运动检测处理耗时约 500ms,而延时摄影的丢帧阈值原本设的是 500ms------这意味着每次运动检测都会导致当前帧被判定为"超时丢弃",延时模式下全部丢帧。修复很简单:动态将丢帧阈值从 500ms 调整到 2000ms(检测模式开启时)。
DRAM 堆损坏:一个还没找到根因的问题
这个 bug 最折磨人。录制运行 30 秒以上后,调用 fopen() 直接 panic。串口日志显示 DRAM 堆的 last_remainder_byte 或 free_bytes 字段被写入了垃圾数据。问题是:
- 堆元数据在 DRAM(内部 SRAM),而所有帧数据分配在 PSRAM
- 代码本身没看到明显的内存越界
- 同样的代码在 ESP-IDF v5.x 下没有问题,升级到 v6.0 后才出现
- PSRAM 分配的内存完全正常
最终 workaround:文件下载不走 fopen() + fread()(标准 C 库文件 I/O),改用 POSIX open() + read() 将文件整体读入 PSRAM 缓冲区(~130 KB/s),再发送给客户端。PSRAM 不受损坏的 DRAM 堆影响,所以这个方案可以稳定工作。
根本原因目前还在排查------怀疑是 ESP-IDF v6.0 的某个驱动(SDMMC 或 WiFi)存在内存越界写入,但还没定位到具体模块。
OTA 双插槽与分区表
v0.2.0 重构了分区表,支持双 OTA 插槽:
| 分区 | 偏移 | 大小 | 用途 |
|---|---|---|---|
| bootloader | 0x0 | 48KB | 引导加载程序 |
| ota_0 | 0x10000 | 2MB | 当前运行固件 |
| ota_1 | 0x210000 | 2MB | 升级备用槽 |
| nvs | 0x410000 | 20KB | 配置持久化 |
| spiffs | 0x415000 | 1.5MB | Web UI 文件 |
升级流程:Web 上传固件 → 写入 ota_1 → 设置 boot 分区为 ota_1 → 重启。如果新固件启动失败(看门狗超时),bootloader 自动回退到 ota_0。分区表变更后需要 idf.py erase-flash 才能使用,否则旧分区表和新固件的位置不匹配。
ESP-IDF v6.0 兼容性
从 v0.1.0 的 ESP-IDF v5.x 升级到 v6.0 时遇到了一系列 API 变更:
esp_vfs_fat_sdmmc_unmount()→esp_vfs_fat_sdcard_unmount()(函数重命名)httpd_resp_set_status(req, "503 Service Unavailable")→ 移除HTTPD_503宏,改传字符串config.timeout_sec拆分为config.timeout_att(认证超时)和config.timeout_bcn(信标超时)
这些变更在 ESP-IDF 的迁移指南中都有文档,但实际项目中总会有遗漏------编译能通过不等于行为正确。
其他值得一提的技术点
- 上传队列持久化:使用 NVS 的 blob 类型存储待上传文件列表的序列化状态。每次 enqueue/dequeue 都写 NVS,断电后启动时重建队列。NVS 有写入寿命限制(~10 万次),所以做了写入合并------10 秒内的连续变更合并为一次写入。
- 结构化 JSON 日志 :统一日志格式
{"ts":"...","level":"info","module":"storage","msg":"..."},方便对接 Loki/ELK。 - 智能丢帧策略:在 PSRAM 剩余低于 20% 时,计算当前写入速度与采集速度的差值,按比例丢弃部分帧。优先丢运动检测结果中的冗余帧,保留关键帧。
- 看门狗分段喂狗 :长时间
vTaskDelay(30000)会导致 TWDT(Task WatchDog Timer)超时。改为for (int i = 0; i < 6; i++) { vTaskDelay(5000 / portTICK_PERIOD_MS); esp_task_wdt_reset(); }每 5 秒复位一次看门狗。
MiBeeCam v0.2.1:WPA2-PSK 路由器的 WiFi 兼容性修复
MiBeeCam 是基于 Luatos ESP32-S3 A10 模块的紧凑型智能摄像头。这次版本的核心修复是 WiFi STA 模式连不上 WPA2-PSK 路由器。查了实际代码 diff 之后发现,真正的修复比 Release Notes 写的要复杂得多------不止是关 SAE 和加栈大小。
实际代码 diff 中的改动
把 v0.2.0 和 v0.2.1 的代码 diff 拉下来看,wifi_manager.c 改动涉及 7 个独立层面:
1. 事件循环解耦:
这是最关键的一个改动。旧代码在 WiFi 事件回调中直接调用用户注册的 s_callback(),而回调运行在 ESP-IDF 的 wifi 任务上下文中(栈空间非常有限)。一旦回调触发 esp_wifi_connect() 再次进入 WiFi 事件处理,就形成了递归:
scss
wifi event task (stack: 3072 bytes)
→ s_callback()
→ esp_wifi_connect()
→ (internal) 触发新的事件
→ s_callback() // 递归,-800 bytes
→ esp_wifi_connect() // 再递归,-800 bytes
→ 栈溢出,崩溃
修复方式是引入一个自定义事件基 WIFI_MANAGER_EVENTS,notify_state() 不再直接调用回调,而是通过 esp_event_post() 将事件投递到 event loop task(栈空间更充裕,且不会递归):
c
ESP_EVENT_DEFINE_BASE(WIFI_MANAGER_EVENTS);
// 旧方案:直接在 WiFi 任务上下文调用回调
static void notify_state(wifi_state_t new_state) {
if (s_callback) {
s_callback(new_state, s_user_data); // 递归风险
}
}
// 新方案:投递到 event loop,由独立任务处理
static void notify_state(wifi_state_t new_state) {
esp_event_post(WIFI_MANAGER_EVENTS, (int32_t)new_state, NULL, 0, portMAX_DELAY);
}
同时注册了独立的 wifi_state_event_handler 来消费这个事件基的 event,在 event loop 上下文中安全地调用 s_callback()。
2. 禁用省电模式 : esp_wifi_set_ps(WIFI_PS_NONE)
ESP-IDF 的 WiFi 省电模式(Modem Sleep)会在 DTIM 信标间隔之间关闭射频电路。省电模式下,STA 可能错过路由器的 EAPOL 帧(4-way handshake 的第 2/4 帧),导致认证超时。这对 WPA2-PSK 握手来说是致命的------EAPOL 帧没有重传机制,错过一帧就得重新开始整个认证流程。
3. PMF 配置 :pmf_cfg.capable = true, pmf_cfg.required = false
PMF(Protected Management Frames, 802.11w)是 WPA2 的可选扩展,WPA3 强制要求。对于支持 PMF 但没正确实现的路由器,STA 声明 PMF capable 可能导致关联被拒绝。设为 capable = true, required = false 意味着 STA 支持 PMF 但不强制------路由器可以选择不用。
4. SAE PWE 配置 :sae_pwe_h2e = WPA3_SAE_PWE_BOTH
SAE 的 PWE(Password Element)派生有两种方式:Hunting-and-Pecking(传统,计算密集)和 Hash-to-Element(高效,新标准)。WPA3_SAE_PWE_BOTH 表示 STA 两种都支持,由路由器选择。配合 sdkconfig 中关闭 CONFIG_ESP_WIFI_ENABLE_WPA3_SAE,确保 STA 不主动发起 SAE 协商。
5. 国家码设置 :esp_wifi_set_country_code("CN", false)
不同国家的 2.4GHz 信道范围不同(日本 1-14,美国 1-11,中国 1-13)。不设国家码时,ESP-IDF 默认使用全球通用信道(1-11),如果路由器在 12/13 信道上,STA 扫描不到。
6. TX 功率提升 :同 MiBeeHomeCam 一样,将 WiFi 发射功率设为 15 dBm。代码中直接用了一样的 esp_wifi_set_max_tx_power(60)(15 / 0.25 = 60)。
7. 重试间隔调整 :从 5 秒改为 10 秒,避免频繁重试导致路由器拒绝服务。重试定时器回调中也去掉了先 esp_wifi_disconnect() 再 esp_wifi_connect() 的做法------旧代码每次重试前先断开,触发 STA_DISCONNECTED 事件,如果事件处理中又调用了 esp_wifi_connect(),就形成递归循环。新代码直接调用 esp_wifi_connect(),不断开,靠 ESP-IDF 内部的连接状态机管理重试。
关于 Release Notes 没提的事
代码 diff 里还发现了一个调试遗留下来的坑。wifi_start_sta() 函数中出现了硬编码的 WiFi 凭据:
c
strncpy((char *)wifi_config.sta.ssid, "TEST_SSID", ...);
(void)ssid;(void)pass; // 参数被忽略了!
这是调试阶段留下的代码------为了绕过参数传递问题,直接在函数体内写死了测试路由器的 SSID 和密码(已脱敏),导致函数参数 ssid 和 pass 被 (void) 抑制编译警告后完全忽略。
这个显然不能发到 Release 里------如果你的设备刷了这个固件,它只会连接写死的那台路由器。这个需要修掉。
rpi-cam v0.2.0:ONVIF 参考实现和 HLS 测试机
rpi-cam 是树莓派的 Go ONVIF 摄像头服务,为 NVR v0.6.0 的 ONVIF 增强和 HLS/LL-HLS 功能提供测试环境。
HLS 直播流:FFmpeg 子进程管理
rpi-cam 本身不做编解码------它 spawn 一个 FFmpeg 子进程,将 RTSP 流转成 HLS 分片:
scss
rpi-cam RTSP Server (gortsplib) → pipe → FFmpeg (libx264 + mpegts) → .ts segments + .m3u8 playlist
子进程管理的几个关键细节:
- 进程监控 :用 Go 的
os.Process.Signal(syscall.Signal(0))每 5 秒探测 FFmpeg 是否存活。进程意外退出时自动重启,重启间隔从 1s 指数退避到 30s,避免反复崩溃时疯狂 fork。 - SIGPIPE 处理 :FFmpeg 写管道时如果读端已关闭(比如 HLS 客户端断开),默认的 SIGPIPE 会杀死进程。必须在 Go 中设置
signal.Ignore(syscall.SIGPIPE)。 - HLS segment 清理 :FFmpeg 的
-hls_list_size只控制播放列表中的条目数,不会删除磁盘上的旧 segment。需要单独启动一个 goroutine 定期扫描 HLS 目录,删除 m3u8 中不再引用的.ts文件。
H.264 SPS/PPS 缓存与快照可靠性
H.264 码流中的 SPS(Sequence Parameter Set)和 PPS(Picture Parameter Set)是解码的关键参数集。它们通常在 IDR 帧之前出现,但部分摄像头只在 RTSP 的 SDP 中声明 SPS/PPS,码流中不再重复发送。
从 H.264 转 JPEG 快照时,如果没有 SPS/PPS,libavcodec 无法初始化解码上下文,转换直接失败。v0.2.0 的修复是在 RTSP 的 DESCRIBE 响应中解析 SDP,提取 sprop-parameter-sets 字段中的 SPS/PPS base64 数据,缓存到全局结构体中:
go
type SPSPPS struct {
SPS []byte
PPS []byte
Raw string // 原始 base64 字符串
}
// 从 SDP 中提取 sprop-parameter-sets
// sprop-parameter-sets=Z0LAH6oHgUaA,aL4HiP4A
func extractSPSPPS(sdp string) (*SPSPPS, error) {
re := regexp.MustCompile(`sprop-parameter-sets=([^,]+),([^;\s]+)`)
m := re.FindStringSubmatch(sdp)
if len(m) < 3 {
return nil, fmt.Errorf("sprop-parameter-sets not found")
}
sps, _ := base64.StdEncoding.DecodeString(m[1])
pps, _ := base64.StdEncoding.DecodeString(m[2])
return &SPSPPS{SPS: sps, PPS: pps}, nil
}
缓存后的 SPS/PPS 在每次快照请求时注入到 avcodec 的 extradata 中,确保 libavcodec 能正确初始化解码器。即使码流中不携带参数集,快照也能正常生成。
Web Admin UI 的 Token 认证
v0.2.0 的 Web UI 基于 Go 的 embed 包 + Gin 框架。Token 认证的设计:
- 登录接口 :
POST /api/auth/login验证用户名密码,返回一个 256-bit 随机 token(crypto/rand生成) - Token 存储 :内存中维护一个
sync.Map,key 是 token 的 SHA256 哈希(防止 timing attack),value 是过期时间 - 中间件 :Gin 的
AbortWithStatusJSON(401)在 token 过期或无效时直接拒绝 - 首字符区分 :Web UI 的静态文件路径以
/app/开头不走认证,API 路径全部认证
为什么不直接用 JWT?因为 rpi-cam 没有外部依赖,JWT 需要引入 golang-jwt/jwt,为了一个小功能加依赖不值得。简单随机 token + SHA256 哈希在单机场景下完全够用。
i18n 的中英文切换
翻译文件用 JSON 格式,结构按页面组织:
json
{
"login": {
"title": {"en": "Login", "zh": "登录"},
"username": {"en": "Username", "zh": "用户名"},
"password": {"en": "Password", "zh": "密码"},
"submit": {"en": "Sign In", "zh": "登录"}
},
"ptz": {
"pan_left": {"en": "Pan Left", "zh": "左转"},
"tilt_up": {"en": "Tilt Up", "zh": "上仰"}
}
}
前端通过 navigator.language 检测浏览器语言偏好,未设置时默认英文。切换通过 localStorage.setItem('locale', 'zh') 持久化,刷新后继承选择。翻译加载在构建时通过 Go embed 打包进二进制,运行时没有外部文件依赖。
总结
三个项目在 6 月 5 日同一天发布,各自解决了一类典型的嵌入式/后端工程问题:
- MiBeeHomeCam 展示了一个 MCU 级的摄像头固件如何在资源受限(512KB SRAM / 8MB PSRAM / 240MHz 双核)的情况下,通过 PSRAM 双缓冲、部分 JPEG 解码、POSIX 绕过损坏 DRAM 堆等手段,同时承载实时流、录像、运动检测和网络传输。
- MiBeeCam 的 WiFi 修复是嵌入式开发中很典型的两类问题:协议兼容性(WPA3 的 SAE 与 WPA2 路由器的互操作)和任务栈溢出(递归回调的栈空间耗尽)。两者都不难修,但排查过程涉及 ESP-IDF 的 WiFi 栈设计了解。
- rpi-cam 的 HLS 和 Web UI 展示了一个 Go 后端服务如何以最小依赖(零 CGO、无 JWT 库、无外部前端构建工具)实现生产级功能。
如果你正在自己搭建 NVR 系统,这些项目可以当作参考实现或直接组件使用。
相关项目链接:
- MiBeeHomeCam v0.2.0
- MiBeeCam v0.2.1
- rpi-cam v0.2.0
- MiBeeNvr v0.6.0({{< ref "posts/mibee-oss/mibee-nvr-v0.6-promo" >}})