给MibeeNvr 0.6调试的Esp32和树莓派的三个摄像头项目的技术更新细节

同期发布的 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 缓冲区指针,而不是拷贝。这意味着:

  1. 录制任务长时间持有 fb 时,MJPEG 流任务拿不到帧,TCP 连接因无数据发送而超时断开
  2. 两端同时读 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_EVENTSnotify_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 和密码(已脱敏),导致函数参数 ssidpass(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

子进程管理的几个关键细节:

  1. 进程监控 :用 Go 的 os.Process.Signal(syscall.Signal(0)) 每 5 秒探测 FFmpeg 是否存活。进程意外退出时自动重启,重启间隔从 1s 指数退避到 30s,避免反复崩溃时疯狂 fork。
  2. SIGPIPE 处理 :FFmpeg 写管道时如果读端已关闭(比如 HLS 客户端断开),默认的 SIGPIPE 会杀死进程。必须在 Go 中设置 signal.Ignore(syscall.SIGPIPE)
  3. 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 认证的设计:

  1. 登录接口POST /api/auth/login 验证用户名密码,返回一个 256-bit 随机 token(crypto/rand 生成)
  2. Token 存储 :内存中维护一个 sync.Map,key 是 token 的 SHA256 哈希(防止 timing attack),value 是过期时间
  3. 中间件 :Gin 的 AbortWithStatusJSON(401) 在 token 过期或无效时直接拒绝
  4. 首字符区分 :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 系统,这些项目可以当作参考实现或直接组件使用。


相关项目链接

相关推荐
handler012 天前
【C++11 】Lambda 表达式、std::function 与 std::bind 解析
c++·c·c++11·bind·解耦·function·lamda
handler017 天前
【C++】二叉搜索树详解及其模拟实现(代码)
开发语言·c++·算法·c··二叉搜索树·搜索树
爱学习的程序媛8 天前
C 语言全景指南:从底层原理到工业级实战
c++·c#·c
dozenyaoyida9 天前
RISC-V嵌入式开发:彻底解决“undefined reference to isatty“错误全攻略
经验分享·c·cmake·嵌入式开发·isatty·没有定义问题
Shadow(⊙o⊙)10 天前
模拟实现:glibc_1.0-文件操作函数fopen fclose fwrite fflush实现。
开发语言·c++·学习·c
liulilittle12 天前
TCP UCP:基于卡尔曼滤波的BBR增强型拥塞控制算法
linux·网络·c++·tcp/ip·算法·c·通讯
weixin_4217252613 天前
C语言、C++与C#深度研究报告:从底层控制到现代企业级开发的演进
c语言·c++·c·内存管理·编译模型
不吃土豆的马铃薯15 天前
Spdlog 入门:日志记录器与日志槽基础详解
服务器·开发语言·c++·c·日志·spdlog
金创想15 天前
积木移动题目分析及解题思路——木块问题(1)
c++·算法·字符串·c·刷题·信息学奥赛·积木