☰
ESP32-CAM接入NVR实战:onvif-c让摄像头被监控系统自动发现
2026/10/11 3:25:37 网站建设 项目流程

很多人以为把 ESP32-CAM 拍到的画面推到浏览器里就算做完了一个网络相机,但真正要用的时候会发现:你在 NVR 后台"添加摄像头"的列表里永远扫不到这台设备。原因很简单,市面上几乎所有商业 NVR 都是通过 ONVIF 协议去发现和接入摄像头的,而 ESP32 默认固件根本不带这个协议栈。这篇文章要讲的 onvif-c 就是专门解决这个问题的 —— 它是基于 ESP-IDF、用一个纯 C 组件实现的轻量 ONVIF 协议栈,能让你的 ESP32 相机被主流 NVR 当作标准 IP 摄像头直接添加并拉流。我最早接触它的时候资料非常零散,几乎是靠啃源码和抓包一个个试出来的,所以这篇我按自己的实际踩坑顺序来写,适合已经能用 ESP32-CAM 出图、想进一步接入监控生态的开发者。

1. ONVIF 到底做了什么:先搞清楚 NVR "添加摄像头"背后发生了什么

在动代码之前,我觉得有必要先把 ONVIF 这套东西的底裤扒干净。NVR 添加一个网络摄像头,表面上你只是填了个 IP、用户名和密码,实际上后台走了一串协议流程。如果你不理解这套流程,后面就算把 onvif-c 编译过了,也不知道该去调哪个函数、看哪条日志。

1.1 设备发现:WS-Discovery 与"被扫到"这件事

NVR 添加摄像头通常有两种方式:手动填 IP,或者在局域网里扫描自动发现。自动发现靠的就是 WS-Discovery(Web Services Dynamic Discovery,ONVIF 规范里的 Device Discovery 部分)。它的本质很简单:NVR 向局域网多播地址239.255.255.250:3702发一个 Probe 请求,设备端收到 Probe 后,回一个 ProbeMatch 响应,告诉对方"我在这,我是这种设备"。

这个机制和我最早想象的不太一样 —— 我一度以为摄像头得定期广播自己的存在,实际上更多是"被动响应 + 启动时主动宣告"的组合。设备上电后,onvif-c 会主动发送一个 Hello 多播报文,告诉网络里现有设备有个新人加入;之后 NVR 发 Probe 来扫描时,设备再响应 ProbeMatch。两者配合,才能保证 NVR 不管是开机扫描还是运行中刷新,都能找到你。

1.2 服务与能力协商:设备信息、能力、媒体配置的层层调用

发现只是第一步。NVR 确认摄像头存在后,会按顺序调用一系列 SOAP 请求,我画了个主干逻辑:

  1. GetDeviceInformation:拿厂商、型号、固件版本、序列号;
  2. GetCapabilities:问设备支持哪些能力(媒体、PTZ、事件等);
  3. GetProfiles:拿设备的媒体配置文件,每个 Profile 对应一路视频流;
  4. GetStreamUri:根据 Profile 拿到 RTSP 拉流地址;
  5. 使用 RTSP 协议实际连接并拉取视频流。

每一步都是标准 SOAP/XML 报文,走 HTTP 传输。onvif-c 的核心工作就是把这些报文按最小必要集实现出来,并且保证 XML 格式、命名空间、动作名都和 ONVIF 规范一致。规范全文上千页,但真正被 NVR 用的通常就上面几个接口,这也是轻量实现能落地的前提。

1.3 为什么必须用 C 而不是上现成的 C++/Python 方案

ESP32 的资源情况摆在那:双核跑在 240MHz,内部 RAM 通常只有 300-400KB 可用,还要留给 WiFi 协议栈和摄像头帧缓冲。ONVIF 官方参考实现很多是拿 C++ 甚至 Java 写的,配合庞大的 SOAP 工具链(比如 gSOAP)生成一堆代码,编译完直接几百 KB,放到 ESP32 上不仅 flash 紧张,运行时内存也会吃紧。

onvif-c 取了个巧:不依赖 SOAP 自动生成框架,直接用 C 语言手写 XML 构造器和解析器,只保留 ONVIF 的设备发现、设备信息、媒体配置、RTSP 这几个少而精的接口。整个组件编译出来很小,运行内存可控,这正是它适合 ESP-IDF 环境的原因。

2. 工程结构搭建:把 onvif-c 组件正确植进 ESP-IDF 工程

组件再好,装不进工程就等于零。我最初在这部分折腾了最久,因为 ESP-IDF 的组件系统看着简单,但版本匹配、依赖关系、CMakeLists 写法一不对就编不过。

2.1 硬件选型与依赖梳理(直接用我的配置单)

我能跑通这套方案的硬件组合如下,不一定必须一模一样的型号,但关键约束要满足:

部件推荐方案说明
主控ESP32-S3(带 PSRAM 的版本)内存大一些,跑 ONVIF + RTSP + 摄像头编码压力小很多
摄像头OV2640 / OV5640出 RAW 或 JPEG,后续接编码器方便
存储8MB 以上 Flashonvif-c 本身不大,但完整固件加文件系统余量要留足

软件依赖主要是两块:esp32-camera驱动和 ESP-IDF 自带的lwIP(TCP/IP 协议栈)。ONVIF 的 SOAP 走 HTTP,RTSP 走 TCP,这些在 lwIP 里都有。另建议开着mDNS组件,后面开发调试时能用esp32-cam.local访问设备,和 ONVIF 的 WS-Discovery 并不冲突。

2.2 组件目录结构与 CMake 配置

我把 onvif-c 放在工程根目录下的components/onvif-c里,项目结构大致是:

my_camera_project/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ └── app_main.c └── components/ └── onvif-c/ ├── CMakeLists.txt ├── include/ ├── src/ └── idf_component.yml

关键点是组件的CMakeLists.txt要声明好依赖关系,让它能正确引用esp32-camera和 lwIP:

idf_component_register( SRCS "src/onvif_server.c" "src/onvif_discovery.c" "src/onvif_media.c" "src/onvif_rtsp.c" INCLUDE_DIRS "include" REQUIRES lwip esp_wifi esp_event ) REQUIRES esp32-camera

REQUIRES里如果你用的是 ESP32-S3 的摄像头驱动,可以直接把esp32-camera作为一个外部组件链接进来。这里有个坑:如果工程里同时有多个组件依赖esp32-camera,得保证版本统一,不然会出现摄像头回调函数反复定义之类的链接错误。

2.3 主程序框架:摄像头初始化与 ONVIF 任务的启停

主程序逻辑不大,核心是三步:初始化摄像头、初始化网络、启动 ONVIF 服务。我贴一段自己用的骨架:

#include "esp_camera.h" #include "onvif_server.h" void app_main(void) { // 1. 初始化摄像头 camera_config_t camera_config = { .pin_pwdn = -1, .pin_reset = -1, .pin_xclk = 15, .pin_sccb_sda = 4, .pin_sccb_scl = 5, .pin_d7 = 13, .pin_d6 = 12, // ... 其余引脚按你的板子填 .fb_location = CAMERA_FB_IN_PSRAM, .jpeg_quality = 12, .fb_count = 2, }; esp_camera_init(&camera_config); // 2. 等 WiFi 连接完成(用事件组或简单延时都可以) // 3. 启动 onvif 服务 onvif_server_start(); }

注意fb_location = CAMERA_FB_IN_PSRAM这一行,我吃过没写这行的亏。ONVIF 和 RTSP 同时跑起来后内存非常紧张,不把帧缓冲放 PSRAM,系统会频繁重启。

3. 设备发现与 Hello/Probe 响应:让 NVR 的扫描列表里出现你的相机

这章单独拎出来讲,是因为我发现很多人编译过了、服务也启动了,但 NVR 就是扫不到设备,最后排查下来全部卡在发现环节。这不怪 onvif-c,而是 WS-Discovery 本身有细节容易忽略。

3.1 多播监听与报文格式:为什么总是收不到 Probe

WS-Discovery 使用 UDP 多播,设备要加入239.255.255.250:3702这个多播组。在 ESP-IDF 里,这意味着你得用 lwIP 的 UDP 接口绑定端口、加入多播组,并且监听来自任意网卡的报文。代码上大致是:

struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(3702); addr.sin_addr.s_addr = htonl(INADDR_ANY); bind(sock, (struct sockaddr *)&addr, sizeof(addr)); struct ip_mreq mreq; mreq.imr_multiaddr.s_addr = inet_addr("239.255.255.250"); mreq.imr_interface.s_addr = htonl(INADDR_ANY); setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq));

很多人卡在 bind 地址上 —— 如果 bind 了某个具体的本机 IP,其他网卡上进来的多播包就收不到;直接 bindINADDR_ANY是稳妥的。另外 WiFi 进入省电模式或者 802.11 多播过滤设置不对,也会导致丢包,调试时可以把CONFIG_POWER_SAVE关掉,环境变量CONFIG_LWIP_IP_FORWARD不用管,那个不影响。

3.2 Probe/ProbeMatch 的 XML 细节:一处不对 NVR 就静默忽略

收到 Probe 之后,设备要回一个标准的 ProbeMatch。ONVIF 规范的 XML 命名空间非常严格,少一个xmlns或者Types字段格式不对,NVR 会直接丢弃这个包,而且不会有任何报错提示。我用的典型响报文体是这样:

<?xml version="1.0" encoding="UTF-8"?> <e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" xmlns:tds="http://www.onvif.org/ver10/device/wsdl"> <e:Header> <wsa:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/ProbeMatches</wsa:Action> <wsa:MessageID>uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</wsa:MessageID> <wsa:To>urn:schemas-xmlsoap-org:ws:2005:04:discovery</wsa:To> </e:Header> <e:Body> <d:ProbeMatch> <d:EndpointReference><wsa:Address>uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</wsa:Address></d:EndpointReference> <d:Types>tds:NetworkVideoTransmitter</d:Types> <d:Scopes>onvif://www.onvif.org/type/video_encoder onvif://www.onvif.org/hardware/ESP32Cam</d:Scopes> <d:XAddrs>http://192.168.1.100:80/onvif/device_service</d:XAddrs> </d:ProbeMatch> </e:Body> </e:Envelope>

几个容易翻车的点:MessageID每次响应都应不同,最好用随机 UUID,固定值会导致某些 NVR 判重;XAddrs里填的地址必须是 NVR 能直接访问到设备的地址,不能是127.0.0.1或 mDNS 域名;Types里的tds:NetworkVideoTransmitter是摄像头的标准类型,不能省略。

3.3 Hello 报文:上电广播别忽略

Hello 报文是设备启动时主动发往同一个多播地址的,作用是告诉网络里的 NVR 和设备管理工具"我上线了"。它的格式和 ProbeMatch 高度相似,主要区别是 Header 里的 Action 变成Hello。

实际测试告诉我,Hello 报文的作用比你想象的大。某些 NVR 并不会频繁发 Probe,它们更依赖设备上线时的 Hello 来刷新列表。如果你的固件启动比 NVR 晚,Hello 广播没发出去,NVR 可能得等几分钟甚至手动刷新才扫到这台机器。所以 onvif-c 里上电发送 Hello 这步,我建议一定保留。

4. 媒体能力协商与 RTSP 拉流:画面怎么真正送到 NVR 上

NVR 能发现设备只是第一步,它添加设备后真正关心的是"我能不能看到画面"。这涉及两套东西:一是 SOAP 层面的媒体服务接口,二是实际的 RTSP/RTP 媒体传输通道。onvif-c 把这两块都涵盖了。

4.1 GetProfiles 与 GetStreamUri:把"流地址"正确交给 NVR

GetProfiles返回的设备媒体配置里面包含了 video source 配置、编码格式、分辨率、帧率,最关键的是每个 Profile 有一个固定标识(比如Profile_0)。NVR 会拿这个标识去调GetStreamUri,拿到类似这样的返回:

<tt:MediaUri> <tt:Uri>rtsp://192.168.1.100:554/stream1</tt:Uri> <tt:InvalidAfterConnect>false</tt:InvalidAfterConnect> <tt:InvalidAfterReboot>false</tt:InvalidAfterReboot> <tt:Timeout>PT10S</tt:Timeout> </tt:MediaUri>

这里的 Uri 就是设备端 RTSP 服务地址。你会注意到 Port 是 554,这是 RTSP 的标准端口。如果设备端 RTSP 服务在别端口,这里要写成一致,否则 NVR 拉流会失败。我调试时经常遇到的一种情况是:GetStreamUri返回的地址里 IP 写错了。如果你设备是多网卡或者用了桥接,最好直接把 IP 写死在配置里,别去动态取,省得 NVR 拿着一个错误地址去连。

4.2 RTSP 握手流程:OPTIONS、DESCRIBE、SETUP、PLAY

RTSP 本身并不复杂,NVR 作为客户端,会依次发四个请求:

  1. OPTIONS:探测设备端支持哪些方法;
  2. DESCRIBE:拿 SDP 描述,里面含有编码格式、分辨率、帧率等信息;
  3. SETUP:协商 RTP 传输通道,通常是RTP/AVP/TCP或RTP/AVP/UDP,NVR 很可能会要求 TCP 穿防火墙;
  4. PLAY:开始播放。

onvif-c 里实现 RTSP 服务时,更多是在处理字符串解析和状态机。因为这个组件是纯 C 写的,没有标准库之外的依赖,你需要手写一个极简的 RTSP 解析器,把请求行、头字段解析出来,然后填充 SDP。

SDP 的最小结构大致是:

v=0 o=- 0 0 IN IP4 192.168.1.100 s=Session stream t=0 0 m=video 0 RTP/AVP 96 a=rtpmap:96 H264/90000 a=fmtp:96 packetization-mode=1

这里的96是动态 RTP payload type,摄像头编码后的数据要按这个编号封装进 RTP 包里。如果你用的是 M-JPEG 编码,则rtpmap会变成JPEG/90000,payload type 一般是26。

4.3 编码选型:H.264 还是 M-JPEG,码率怎么控制

ESP32 的性能摆在那里,GPU 是没有的,主流的做法是用芯片内置的硬件 JPEG 编码器输出 M-JPEG 流,或者用纯 CPU 跑一个轻量级 H.264 编码库。我的经验是:

  • M-JPEG:实现最简单、帧率高、CPU 占用低,但码率偏大,1080p 下可能要 8-12 Mbps,对 WiFi 和存储都是压力;
  • H.264:码率低,但纯软编码在 ESP32-S3 上跑 720p 都比较吃力,帧率会掉到 10-15fps 左右,需要花心思调编码参数。

如果你的目标是"能被 NVR 识别并流畅预览",M-JPEG 是成本最低、最不容易出问题的起点。先把链路跑通,日后再考虑 H.264 软编码。码率控制可以通过调整 JPEG 质量参数实现。jpeg_quality从 12 往下降是更清晰、码率更高;往上升是更模糊、码率更低。我在实测中把jpeg_quality设为 15、分辨率 720p、帧率 15fps 时,RTSP 拉流很稳定,带宽占用大约 4-5 Mbps。

4.4 设备鉴权:Digest 认证才是真正消耗耐心的地方

NVR 添加设备时,几乎都会填写用户名密码,所以设备端必须实现鉴权。ONVIF 的 SOAP 接口支持 WS-Security,但 onvif-c 这种轻量实现一般只做了 HTTP Digest 认证。这个认证流程是:

  1. 客户端请求接口;
  2. 服务端返回401 Unauthorized,并附带WWW-Authenticate头(里面包含 realm、nonce);
  3. 客户端用用户名、密码、nonce、HTTP method、URI 计算 MD5 摘要,再重新发起认证请求;
  4. 服务端校验摘要,通过则返回真正的业务响应。

如果你没实现鉴权,NVR 添加设备时会出现"用户名或密码错误"的提示,但逻辑上你可能压根没有校验密码 —— 问题就出在 NVR 根本没走到校验那一步,你的服务端不认识它发来的认证头,直接回了 401。这个坑在抓包里一眼就能看出来:连续两次相同请求,第二次多了Authorization头,如果你服务端没有对第二次请求做正确处理,说明鉴权处理代码少了分支。

5. 从接线到 NVR 添加成功的完整走查:按这个顺序排查,半小时内解决问题

我把自己一次从零调通的经验总结成了一条"发布前检查链",你按这个顺序走,能少走很多弯路。

5.1 固件烧录后的三项自检

上电后,不要急着打开 NVR,先在电脑上做三件小事:

  1. 能 Ping 通:确保 ESP32 已拿到局域网 IP;
  2. 能访问服务地址:浏览器打开http://<设备IP>:80/onvif/device_service,能收到一个 SOAP 错误报文也算服务在线,比收不到连接强一百倍;
  3. 能看到组播报文:用 Wireshark 抓239.255.255.250:3702的包,确认设备启动后有 Hello 发出,收到电脑发的 Probe 后有 ProbeMatch 响应。

第 2 步和第 3 步是最快的诊断方式,能帮你把"网络没连通""服务没起来""ONVIF 响应格式错"三类问题区别开来。我这里强调一下:Wireshark 过滤语法可以直接用udp.port == 3702,非常直观。

5.2 PC 端工具测试:ONVIF Device Manager 是你的最佳伙伴

NVR 处于家庭环境时,可能不好抓包,也未必方便立即测试。我会先在电脑上安装 ONVIF Device Manager(一个免费的 ONVIF 设备管理工具,简称 ODM),用它对设备做一遍完整的"发现 + 添加 + 预览"测试。

ODM 能干的几件事非常实用:

  • 自动搜索局域网 ONVIF 设备,快速验证 WS-Discovery 是否生效;
  • 直接修改/查看设备信息、媒体配置,验证 SOAP 接口是否正常;
  • 能显示设备返回的所有 Profile 和 StreamUri,方便和 NVR 里的表现对照。

在这个环节如果 ODM 能看到设备、能出画面,但 NVR 添加失败,那问题基本锁定在 NVR 侧的特殊要求上(比如它要求 HTTPS 或特定的鉴权方式)。

5.3 接入 NVR 时的典型失败清单

现象可能原因解决方向
NVR 扫描不到设备Hello 没发、ProbeMatch 没回、多播过滤抓包确认报文,关 WiFi 省电模式
能看到设备但添加失败GetDeviceInformation 格式不对或设备类型不匹配检查 Types、Scopes、XAddrs
"用户名或密码错误"Digest 认证实现不完整重点检查 WWW-Authenticate 头的 realm、nonce
"连接超时"RTSP 服务端口没监听,或 StreamUri IP 错误检查 RTSP 端口、GetStreamUri 返回地址
添加成功但黑屏SDP 编码参数和实际发送数据不符核对 payload type、编码格式、分辨率

5.4 添加成功后的"最后一公里":VLC 二次验证

NVR 拉流成功后,我还习惯做一次 VLC 验证:在电脑上用 VLC 打开rtsp://<设备IP>:554/stream1,输入用户名密码后如果能出画面,说明 RTSP 服务和 RTP 封包是标准的,后面就算换品牌 NVR 出问题,也可以基本撇清设备端责任。

6. 内存、时间、兼容性:实测中藏得比较深的一批坑

能走进这一步,说明你已经把主干链路跑通了。接下来这些坑属于"低概率但碰到就要命"的类型,提前知道能省下大量排查时间。

6.1 内存不足导致的重启循环

ESP32 开发板看着内存还行,可一旦 ONVIF + RTSP + 摄像头同时工作,自由堆内存可能只剩几十 KB。最直观的表现:一有 NVR 来请求,设备立刻重启,掉电一样。

我当时排查了很久才发现是帧缓冲没有放在 PSRAM。建议:

  • fb_count不要设太高,2 就够了;
  • 优先使用CAMERA_FB_IN_PSRAM;
  • RTSP 发送线程栈大小建议给 4096 字节甚至更高,别省;
  • 可以周期性打印esp_get_free_heap_size(),观察不同工作状态下的内存水位。

6.2 NVR 对设备时间的强制校验

ONVIF 规范里设备时间是个元数据字段,很多 NVR 在添加设备时会要求设备时间与自身时间差在一定范围内,超出就报错。我曾遇到一台 NVR 只要时间差超过 5 分钟就拒绝添加设备,日志里只显示"设备响应异常"。

解决办法简单粗暴:设备启动后向 NTP 服务器同步时间。ESP-IDF 自带esp_sntp组件,几行代码就能配好。如果你设备没联网或 NTP 不可用,至少也要保证设备能正确响应SystemDateAndTime请求,格式要按 ISO 8601 来。

6.3 NVR 强制 HTTPS/TLS 的过渡方案

一些商业 NVR 出于安全策略只允许 HTTPS 连接设备,而 onvif-c 这种轻量组件默认只有 HTTP。遇到这种 NVR,能想到的方案很有限:

  1. 查一下当前固件是否支持 TLS 作为传输层;
  2. 手动切到 HTTP-only 模式,看 NVR 的"兼容模式"或"允许未加密连接"选项是否可用;
  3. 如果实在不行,换品牌 NVR 是更实际的出路,别在一棵树上吊死。

我自己在实测中就遇到过必须开"兼容模式"才能添加的情况,从 NVR 管理界面找到相应开关后就通了。这个属于"标准之外"的妥协,不用太自责。

6.4 GetStreamUri 返回的地址不可达

最后一个坑比较隐蔽:某些 NVR 会在拿到GetStreamUri的响应后,用响应里的 Uri 去做 RTSP 连接。如果你的 Uri 里写了一个 NVR 访问不到的内网地址(比如设备自认为的网卡地址和 NVR 所在网段不通),画面自然拉不起来。

最稳妥的策略是:GetStreamUri返回的 IP 从当前 WiFi 获取的实际 IP 中提取。如果你不想动态获取,就做一个可配置项,手动填一个 NVR 能访问到的设备 IP。我踩过一次设备自认为的网卡地址是169.254.x.x,NVR 根本连不上这个地址,最后在配置里手工指定 IP 才解决。

6.5 一件值得打包带走的小事:抓包是好习惯,不是应急工具

调 ONVIF 这类跨设备协议,最忌讳闷头改代码。我几乎每一次定位问题都会做三件事:先看抓包,再对照规范,最后才改代码。ONVIF 的报文格式非常死板,错一个标签就能静默失败,靠眼睛很难看见。所以个人建议是,从第一天开始就养成"先抓包,再动代码"的习惯,Wireshark 加一条过滤语法就能让你少熬无数个夜。

总的来说,onvif-c 给 ESP32 生态补上了一个很关键的拼图,让一块几十块钱的开发板能被当成正规网络摄像机使用。但协议这个东西,光编译过远远不够,设备发现、鉴权、媒体协商、时间同步、内存心态,每一项都可能是拦路虎。我这篇几乎是把能踩的坑都代你踩了一遍,你照着链路走,应该能比我当初快很多。如果后面调试中碰到别的问题,建议回头看看抓包文件 —— 它往往比任何调试日志都诚实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询