ESP32摄像头变身ONVIF网络摄像机:NVR直连实战
把 ESP32 摄像头变成一台能被 NVR 直接添加的 ONVIF 网络摄像机,这个目标我是靠 onvif-c 这个纯 C 的 ESP-IDF 组件达成的。折腾这个项目的起因很朴素:我手里有几块吃灰的 ESP32-CAM,本地预览、App 拉流都玩过了,但把它们拿到海康、群晖这类 NVR 的设备扫描列表里一搜,要么直接失踪,要么提示协议不支持。后来我把 onvif-c 接入工程,补上 ONVIF 协议栈,才真正让 NVR 把 ESP32 当成一台标准 IPC 来添加、预览和录像。这篇文章就是我完整过程的记录,从 ESP-IDF 建工程、驱动摄像头,到写配置、调兼容,尽量把我踩过的坑和验证过的办法讲透。
如果你正准备做“能被 NVR 管理的 ESP32 相机”,或者只是想知道 ONVIF 这套东西在嵌入式上怎么落地,这篇适合你。下面内容按实际动手顺序走,代码和配置都是我可复现的方案,但不是唯一方案,重点是讲清楚每一步为什么这么做。
1. 项目全貌:ESP32、ONVIF 与 onvif-c 之间到底是什么关系
1.1 为什么 NVR 点名要 ONVIF
先纠正一个容易搞混的点:NVR 添加摄像头,不是简单填个 RTSP 地址就能稳定工作的。早期我也试过直接把rtsp://192.168.1.50:8554/jpeg填进 NVR 的自定义通道,有的机器确实能出画面,但只要摄像头重启、IP 漂移,通道就断,而且 NVR 里没有设备信息、没有在线状态、没有能力协商,体验非常原始。
ONVIF 协议解决的就是“设备管理”和“媒体能力协商”这一层。NVR 通过 ONVIF 能自动发现局域网里的设备,拿到厂商、型号、固件版本、序列号,获取媒体 Profile 和取流地址,完成用户名密码校验,然后在界面上把设备当成一台真正的网络摄像机来管理。换句话说,RTSP 只负责把视频流传出去,ONVIF 负责让 NVR“认识”这台设备并知道怎么取流。ESP32 相机想进入商业监控体系,这两者缺一不可。
1.2 为什么选 ESP-IDF + plain C 而不是 Arduino 或 C++ 封装
网上 ESP32 摄像头的 Arduino 方案非常多,但要把 ONVIF 完整跑起来,我最终没有选 Arduino。原因有三:第一,Arduino 的 WiFi、HTTP、任务调度都不够透明,ONVIF 涉及多线程、UDP 组播、SOAP 长连接,Arduino 封装层不好控制 bug。第二,onvif-c 本身就是为嵌入式 C 环境设计的,ESP-IDF 也是 C 为主体的框架,二者搭配不需要跨语言胶水层。第三,ESP-IDF 对摄像头驱动、PSRAM 管理、lwIP 协议栈的暴露更彻底,出问题时能直接看到网络收发细节和内存占用。
这个项目用 plain C 还有一个现实原因:ESP32 的 SRAM 只有 520KB,跑 C++ 运行时、异常处理、大量模板代码会额外占资源。ONVIF 的 SOAP/XML 解析本身就吃内存,再用重量级封装,留给图像帧缓冲的余量就很少。onvif-c 的代码风格是教科书式的 C,结构体、回调、静态分配为主,配合 ESP-IDF 的 FreeRTOS 任务模型很顺手。
1.3 硬件预期与适用边界
我确认过的硬件组合是 ESP32(带 PSRAM)+ OV2640/OV5640 摄像头模块,外接一个可调电源,因为 ESP32 开启 WiFi 和摄像头后瞬时电流会到 300mA 以上,电脑 USB 口经常扛不住。PSRAM 不是可选项,OV2640 输出 JPEG 帧需要帧缓冲,没有 PSRAM 只能跑到极低分辨率,MJPEG 推流会非常卡。
还要有硬件预期:ESP32 跑 H.264/H.265 硬编码不现实,所以视频编码用 MJPEG,分辨率 640×480 到 800×600,帧率 10-15fps。这个画质进 NVR 预览和录像完全够用,但不要指望和 200 万像素的商用 IPC 比清晰度。如果你需要 1080p 甚至更高,换 ESP32-S3 加外部摄像头方案,原理相同,但本文的配置细节未必完全复用。
2. 搭建工程:从 ESP-IDF 环境到 ESP32 相机运行起来
2.1 环境准备与创建 ESP-IDF 工程
我的开发环境是 Windows + ESP-IDF v5.x,也可以用 Linux,后面步骤差别不大。Windows 下编译慢是常见痛点,我验证过的优化组合是:使用 ESP-IDF 自带 PowerShell 终端,同时打开IDF_CCACHE_ENABLE=1环境变量,并把sdkconfig固定成默认配置,避免每次增量编译反复解析 Kconfig。Ninja 比 Make 快很多,v5.x 默认就是 Ninja,不用额外配。
创建工程不需要从模板硬套,我推荐直接手动搭一个空工程,结构如下:
esp32_onvif/ ├── CMakeLists.txt ├── sdkconfig.defaults ├── main/ │ ├── CMakeLists.txt │ └── app_main.c └── components/ ├── esp32-camera/ ├── onvif-c/ └── rtsp_server/顶层CMakeLists.txt只需要这一行:
cmake_minimum_required(VERSION 3.16) include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(esp32_onvif)然后idf.py set-target esp32,再idf.py menuconfig确认 Flash 大小和 PSRAM。这里有个关键点:sdkconfig.defaults一定要在第一次编译前写好,否则默认配置下 PSRAM 可能没开,后面摄像头初始化会直接崩。
CONFIG_ESP32_SPIRAM_SUPPORT=y CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ_240=y CONFIG_ESP32_SPIRAM_BOOT_INIT=y CONFIG_ESP32_SPIRAM_USE_CAPS_ALLOC=y CONFIG_LWIP_IPV4_ESP32_MULTICAST=yCONFIG_LWIP_IPV4_ESP32_MULTICAST这一项特别容易漏,ONVIF 的 WS-Discovery 要收发 UDP 组播包,lwIP 默认配置里组播支持未必打开。不用这个选项,摄像头能被手动添加但永远不能被 NVR 自动发现。
2.2 接入摄像头芯片:OV2640 与 PSRAM 配置
摄像头驱动用的是 ESP32 官方维护的esp32-camera组件。把它放进components/目录后,main的 CMakeLists 里添加依赖:
idf_component_register( SRCS "app_main.c" INCLUDE_DIRS "." PRIV_REQUIRE esp32-camera esp_wifi nvs_flash )初始化代码的核心部分如下。引脚映射因板子而异,我用的是 ESP32-CAM 经典接线:OV2640 的 SCCB 挂在 GPIO2/GPIO14,VSYNC 在 GPIO25,PCLK 在 GPIO21。官方车库板一般印在板上,自己画板的话要对照数据手册查。
camera_config_t config = { .ledc_channel = LEDC_CHANNEL_0, .ledc_timer = LEDC_TIMER_0, .pin_d0 = Y2_GPIO_NUM, .pin_d1 = Y3_GPIO_NUM, /* 省略完整引脚 */ .pin_vsync = VSYNC_GPIO_NUM, .pin_pclk = PCLK_GPIO_NUM, .xclk_freq_hz = 20000000, .pixel_format = PIXFORMAT_JPEG, .frame_size = FRAMESIZE_VGA, .jpeg_quality = 12, .fb_count = 2, .grab_mode = CAMERA_GRAB_LATEST }; esp_err_t err = esp_camera_init(&config);pixel_format用PIXFORMAT_JPEG,不要用 RGB565 再转 JPEG,ESP32 的 DVP 接口直接输出 JPEG 能省下大量 CPU。fb_count设 2,因为 RTSP 取帧和 ONVIF 的 HTTP 响应可能在两个任务里同时发生,单缓冲会互相覆盖,导致画面花屏。
2.3 把 onvif-c 作为组件集成进工程
onvif-c 的引入方式我试过两种。第一种是直接把源码复制到components/onvif-c,然后在它的CMakeLists.txt里把源文件列清楚。第二种是做成idf_component.yml依赖,让 ESP-IDF 组件管理器自动拉取。如果你要长期维护,推荐第二种;如果只是快速验证,第一种更省心。
我最后用了手动集成,因为 onvif-c 的例子里有些和我的目标硬件不相关的功能模块,比如 PTZ、云台控制,提前裁剪掉能显著减少代码体积。集成后要确认它编译时需要哪些依赖:必须依赖esp_wifi、lwip、mbedtls、esp_http_server。这些在 ESP-IDF 里基本都有。
组件编译阶段常见的坑是头文件路径冲突。onvif-c 里自带一套wsdl头文件定义,比如wsdd.h、onvif.h,如果工程里另一个组件也有同名头文件,会出现难以定位的宏冲突。我在components/onvif-c/CMakeLists.txt里特意把INCLUDE_DIRS只保留本地目录,避免污染全局搜索路径。
3. 理解 onvif-c:协议栈、源码结构与应用配置
3.1 ONVIF 在本地网络里做哪几件事
ONVIF 不是一个单协议,它是一组 Web 服务接口的集合。对一台 IPC 来说,NVR 最关心四个服务。
第一个是设备发现服务,基于 WS-Discovery,走 UDP 组播 239.255.255.250:3702。NVR 在局域网里发 Probe 消息,摄像头收到后回 ProbeMatch,附上自己的 HTTP 服务地址。第二个是设备管理服务,提供GetDeviceInformation、GetCapabilities、GetSystemDateAndTime等操作,NVR 通过这些接口拿到设备基本信息。第三个是媒体服务,提供GetProfiles、GetVideoEncoderConfigurations、GetStreamUri,NVR 从这里知道摄像头有哪些编码配置、RTSP 取流地址是什么。第四个是鉴权服务,通常集成在 SOAP Header 的 WS-Security UsernameToken 里,NVR 在每次请求时带上用户名和密码摘要,摄像头验证通过才返回正常数据。
onvif-c 的核心工作,就是把这四件事用纯 C 实现成一组可以跑在 ESP32 上的服务。它内部维护一个 SOAP 引擎,接收 HTTP POST 上来的 XML 请求,解析 Action,分发到对应的处理函数,再序列化 XML 响应返回。
3.2 onvif-c 的纯 C 实现要点
onvif-c 的源码结构里,我印象最深的是它对 XML 的解析方式。ESP32 上没有完整 DOM 解析器的内存余量,它用的是一个类似 SAX 的轻量遍历机制,逐个 token 匹配节点,找到关键元素后直接拷贝内容。这种方式内存峰值可控,但对 XML 的格式比较敏感,NVR 发来的请求如果带上额外命名空间前缀,解析器需要正确处理,不然会出现“明明协议没变,但某个 NVR 就是不认”的怪问题。
WS-Discovery 实现是单独的线程,监听 UDP 3702 端口并加入组播组。代码里有几个关键状态:收到 Probe 后校验 Types 和 Scopes,匹配则构造 ProbeMatch 响应;收到 Bye 消息则下线。NVR 搜索时,如果摄像头处于半初始化状态,比如 WiFi 刚连上但媒体服务还没起来,此时回复 ProbeMatch 会暴露一个“活着的但取不了流”的设备,NVR 后续添加时大概率失败。所以我在代码里加了一个标志位,只有摄像头初始化完成、RTSP 服务就绪后才启动 WS-Discovery 响应。
SOAP 请求的解析和响应序列化都集中在onvif_soap.c这类文件里。它内部用的是函数指针表,把http://www.onvif.org/ver10/device/wsdl下的方法映射到回调函数。新增方法非常方便,改表、加函数、注册即可,这也是我选这个库而不是从零写 SOAP 框架的原因。
3.3 需要关注的配置项与内存规划
onvif-c 组件里有几个配置项决定了它能跑多稳。首先是 SOAP 请求缓冲大小,NVR 发出的GetProfiles响应可能很大,尤其是 Profile 多、视频源配置多的时候。我实测把响应缓冲从默认的 4KB 调到 16KB,基本不会再出现截断。其次是 WS-Discovery 的回复内容,XAddrs字段必须填摄像头真实局域网 IP,不能填网关地址,否则 NVR 会拿着错误的地址去连。
内存规划上,我单独开了一个onvif_task,任务栈给到 12KB。之前用默认 4KB,日志里经常出现Task watchdog触发或者栈溢出重启。因为 SOAP 解析时会用到大量局部变量和临时缓冲,栈给少了非常不稳定。另外建议把日志等级按模块调整,ONVIF 模块开 DEBUG,其余保持 WARN,方便定位。
4. 让 NVR 添加成功:鉴权、RTSP 与完整交互流程
4.1 RTSP 流服务和 ONVIF 的配合
ONVIF 本身不传视频,视频靠 RTSP。NVR 的取流逻辑是:先用 ONVIF 拿到GetStreamUri返回的 RTSP URL,再拿这个 URL 去连接 RTSP 服务器,进行OPTIONS、DESCRIBE、SETUP、PLAY协商,最后接收 RTP 数据包。
所以工程里必须有一个能正常工作的 RTSP 服务器。我用的是适配 ESP32-CAM 的 MJPEG RTSP 实现,绑定 8554 端口,把esp_camera_fb_get()拿到的 JPEG 帧按 RTP 打包后发出去。这里有个细节:RTSP 的DESCRIBE响应里,SDP 的编码名要写JPEG,RTP 时间戳频率要写90000,这是 RFC 2435 约定好的,NVR 才能正确解码。有些实现会写成MP4V-ES或者H264,但 ESP32 编码器根本不动,NVR 一旦发现实际流和 SDP 声明不符,会直接断开。
ONVIF 那边的GetStreamUri返回的结构体里有几个字段:URI 是rtsp://192.168.1.50:8554/mjpeg,ValidUntil 是过期时间。NVR 拿到 URI 后一般不缓存太久,所以这个地址必须真实有效。我在回调里直接拼字符串,IP 从当前网络接口动态获取,而不是写死在代码里,这样 DHCP 分配变化后依然能添加。
4.2 NVR 添加摄像头的标准交互过程
不同 NVR 的界面措辞不同,但添加流程背后的逻辑高度一致。自动发现时,NVR 会先向局域网发 WS-Discovery Probe,收到 ProbeMatch 后显示设备名和 IP。接下来它会依次调用这几个 ONVIF 方法。
第一步是GetCapabilities,拿到设备服务的 XAddr、媒体服务的 XAddr、事件服务的 XAddr。第二步是GetDeviceInformation,拿到厂商、型号、固件版本、序列号,这些会显示在 NVR 的设备管理界面里。第三步是GetProfiles,拿到视频编码配置列表和对应的 ProfileToken。第四步是GetStreamUri,传入 ProfileToken,拿回 RTSP 地址。之后才走 RTSP 拉流。
我抓过 NVR 和海康 IPC 之间的完整交互,发现超时非常严格:每个 SOAP 请求间隔不超过几百毫秒,总流程要求在几秒内完成。ESP32 如果还在慢悠悠地处理 WiFi 扫描或者摄像头初始化,NVR 那边可能已经判定添加失败。因此我建议在app_main里先把摄像头、RTSP、onvif 服务全部初始化完,再去做网络相关的宣示,顺序不要反。
4.3 实际配置与操作记录
以下是操作记录,每一步对应的效果我都验证过。
给 onvif 配置设备信息时,我用一组固定的产品字段,避免 NVR 因为空字段解析异常:
const char *manufacturer = "Espressif"; const char *model = "ESP32-CAM"; const char *firmware = "1.0.0"; const char *serial = "ESP32ONVIF0001";鉴权这边,ONVIF 的 UsernameToken 摘要算法看起来绕,实际就一步:
// PasswordDigest = Base64(SHA1(Base64Decode(NonceEncoded) + CreatedUtcString + PasswordUtf8)) // 上一步编码后的 Nonce 是Base64字符串,解码回原始字节再参与SHA1我在 onvif-c 里注册的用户名是admin,密码是esp32cam。NVR 添加时填的账号密码会通过 WS-Security 带过来。要注意 onvif-c 默认支持完成摘要校验,但有些版本拿到的 Created 字符格式可能是2025-04-15T09:12:22Z,解析时按strptime处理。我遇到过 NVR 自带时间格式有毫秒和时区后缀,解析失败导致鉴权直接拒绝,后来给解析函数加了几种格式兜底,问题才解决。
RTSP 服务端也加上 Basic 或 Digest 认证,和 ONVIF 使用同一套账号。按我的经验,只做 ONVIF 鉴权、不做 RTSP 鉴权,NVR 基本都能正常跑;但反过来,如果 RTSP 鉴权类型和 ONVIF 不一致,某些品牌的 NVR 会在“取流 URL 已获取,连接超时”这一步卡住。
5. 兼容性复盘与调试经验
5.1 不同 NVR 的“认”与“不认”
我手上能借到的测试设备有三类:海康的某款 8 路 NVR、大华的一款网络录像机、群晖的 Surveillance Station。对同一套 ESP32 固件,它们的行为差异大得离谱。
海康的 NVR 默认私有协议优先,得在设备添加界面的协议下拉框里手动选“ONVIF”,否则它虽然能看到设备,但添加时会尝试走私有 SDK,直接提示失败。大华对 ONVIF 的容忍度不错,自动发现和手动添加都能用,但它在GetProfiles阶段会发一些扩展字段,比如IrCutFilter、LowLight,onvif-c 如果没有对未知元素做跳过处理,XML 解析会把整个响应搞坏。
群晖的 Surveillance Station 是另一个极端,它把 ONVIF 兼容性校验做得非常细,GetDeviceInformation里的序列号必须是纯数字或纯字母,不能有空格,固件版本必须能转成major.minor.build,否则界面直接报“设备信息无效”。我后来把序列号改成固定十六进制字符串,固件版本按1.0.0格式返回,才顺利通过。
所以做兼容性的核心思路不是“尽量多实现功能”,而是“让响应符合大多数 NVR 的宽容字段预期”。未知的 XML 节点一律跳过,而不是报错退出。
5.2 排查工具:抓包与 ONVIF Device Manager
调试 ONVIF 最有效的工具不是打印日志,而是 Wireshark。局域网内一台电脑开抓包,过滤条件直接写udp.port == 3702 || http || rtsp,就能完整看到 NVR 和 ESP32 之间的每一帧交互。我排过最经典的一个问题:ESP32 响应了 WS-Discovery,但 NVR 添加时总在“获取能力信息”失败。抓包发现 NVR 发的GetCapabilities请求里带了一个非常长的MessageID,ESP32 响应后的RelatesTo字段复制的 MessageID 长度超过了我预设的 64 字节缓冲,被截断后 SOAP Header 校验不过。
这种问题光靠看串口日志根本找不到,因为它不是错误信息,而是协议细节不匹配。抓包后对比标准 ONVIF 设备的响应,逐字节检查,效率比肉眼盯日志高一个数量级。
还有一个免费的图形工具叫 ONVIF Device Manager,可以直接连设备,手动调用所有 ONVIF 操作。我在每次固件更新后用这个工具跑一遍“Discover → GetDeviceInformation → GetProfiles → GetStreamUri”,能快速确认基础服务是否健康,然后再让 NVR 去添加。
5.3 资源优化与长期稳定运行
ESP32 上同时跑 WiFi 接收 RTSP 请求、HTTP 处理 SOAP、线程响应 WS-Discovery,资源紧张是常态。实测量到主要内存占用:SOAP 响应缓冲 16KB、RTSP 发送缓冲 8KB、摄像头帧缓冲约 80KB(VGA JPEG 双缓冲)、onvif 任务栈 12KB,这些加起来已经占据不少可用内存。我把系统任务、lwip的TCP_SEND_BUF做了一定缩减,才给图像通路腾出足够空间。
另一点是我的稳定性心得:NVR 可能会在添加后每隔几十秒做一次保活探测,比如GetSystemDateAndTime或GetNetworkInterfaces。如果 onvif 服务不能及时响应,NVR 会标成离线然后自动重连,来回几次就把内存碎片堆高了。我会在 onvif 任务里加一个简单的请求计数,异常时主动重启 onvif 服务而不是让整个系统吃满内存爆挂。
最后给个非常实际的小技巧:如果 NVR 在预览画面里出现“视频流不稳定”这类提示,先把分辨率从 800×600 降到 640×480,再把 JPEG 质量从 12 调到 15。MJPEG 的码率完全取决于画面内容,同一个场景可能因为纹理复杂导致码率翻倍,NVR 的解码压力和 Wi-Fi 带宽都会被拖垮。调参后稳定性通常立刻就有明显改善。
这套方案我实际跑了几个月,稳定过夜、自动重连都验证过。回头总结,最难的部分其实不是写代码,而是把 ONVIF 协议的细枝末节和 ESP32 的资源限制对齐——每个缓冲大小、每个超时时长、每个字段格式,都可能成为 NVR 兼容性的分水岭。希望这篇记录能帮你少走几步弯路,少拔几次电源线。