ESP32纯C实现ONVIF相机:从零接入NVR的完整指南

发布时间:2026/10/26 17:43:36
ESP32纯C实现ONVIF相机:从零接入NVR的完整指南

1. 为什么要在 ESP32 上折腾 ONVIF 相机

先说清楚这个项目到底在干什么。onvif-c是一个用纯 C 写的、基于 ESP-IDF 的组件,目标很明确:把一块 ESP32 变成一台能被标准 NVR(网络录像机)直接添加、自动发现、正常拉流的网络摄像机。注意这里的关键词是"能被 NVR 添加"——这跟"能出图"完全是两码事。很多人用 ESP32 摄像头跑通了 HTTP 的 MJPEG 流,浏览器里看得挺爽,结果一接到海康、大华、群晖 Surveillance Station 上,NVR 死活搜不到设备,或者搜到了添加失败,或者添加成功但一直显示"连接中"。问题就出在 ONVIF 这套协议栈上。

ONVIF 不是单一协议,它是一整套基于 SOAP/XML 的规范集合,摄像机要跟 NVR 打交道,至少得实现三块:设备发现(WS-Discovery)、设备管理(Device Management)、媒体服务(Media Service)。NVR 添加一台相机的典型流程是这样的:先通过 WS-Discovery 的组播探测拿到设备地址,然后调用GetDeviceInformation、GetCapabilities、GetProfiles、GetStreamUri这一串 SOAP 接口,最后拿 RTSP 地址去拉流。任何一环返回的 XML 格式不对、命名空间错了、SOAPAction 头缺失,NVR 就直接放弃。

那为什么非要用 ESP32 做这件事?因为便宜、低功耗、体积小,而且 ESP-IDF 自带的esp_http_server和 lwIP 协议栈足够撑起一个轻量 ONVIF 服务端。一块 ESP32-S3 核心板加个 OV2640/OV5640 摄像头模组,成本压到几十块,做出来的东西能进标准安防生态,这在做小批量定制监控、DIY 家庭 NVR、边缘 AI 视觉节点的时候非常香。这个组件适合谁看?适合已经会 ESP-IDF 基本工程搭建、懂一点 HTTP 和 XML、想把自己的 ESP32 摄像头接入正规 NVR 的开发者。如果你连idf.py build都没跑过,建议先把 ESP-IDF 的环境搭起来再回来。

我踩过的第一个大坑就是:以为 ONVIF 就是个 HTTP 服务器返回几段固定 XML 就行。实际上 NVR 的兼容性测试极其严格,尤其是 WS-Discovery 的组播响应,格式差一个字节都发现不了。下面我把整个从建工程到被 NVR 成功添加的完整路径拆开讲。

2. 整体架构设计与方案选型思路

2.1 为什么选纯 C + esp_http_server 而不是别的

市面上做 ESP32 ONVIF 有几条路:一是用 MicroPython 写,开发快但性能和内存控制差,SOAP 解析容易崩;二是用 Arduino 框架,生态好但底层网络控制不够细,组播和 socket 选项不好调;三是纯 ESP-IDF + C,门槛高但可控性最强。onvif-c 选的是第三条路,原因很实在:ONVIF 服务端需要长时间稳定运行,需要精确控制 socket 的组播加入、需要手动管理 HTTP 的 SOAPAction 头、需要跟 RTSP 服务共享同一套媒体参数,这些在纯 C 下最顺手。

esp_http_server是 ESP-IDF 自带的 HTTP 服务组件,基于 lwIP,支持多 URI handler 注册。用它来承载 ONVIF 的 SOAP 端点(通常是/onvif/device_service和/onvif/media_service)非常合适。它的工作模式是:你注册一个 URI,绑定一个 handler 函数,请求进来后 handler 拿到httpd_req_t,自己解析 body、自己拼 XML 响应。ONVIF 的请求都是 POST,body 是 SOAP 信封,所以 handler 里主要干两件事:从 XML 里抠出操作名和参数,然后按规范拼响应。

这里有个设计决策值得说:要不要用 XML 解析库。ESP-IDF 里可以塞进 tinyxml2 或者 expat,但会吃掉不少 flash 和 RAM。onvif-c 的做法是轻量字符串匹配——因为 ONVIF 请求的操作名都在固定的标签里(比如<GetStreamUri>),用strstr定位标签、再截取内容,比完整解析整棵树省得多。这个取舍在资源紧张的 ESP32 上是合理的,代价是代码里字符串处理要格外小心,遇到命名空间前缀变化(有的 NVR 用trt:,有的用tt:)就得做兼容。

2.2 三大服务模块的职责划分

整个组件按 ONVIF 规范切成三块,每块对应一组 SOAP 操作:

模块对应规范核心操作承载方式
设备发现WS-DiscoveryProbe / ProbeMatch / HelloUDP 组播 239.255.255.250:3702
设备管理Device ManagementGetDeviceInformation / GetCapabilities / GetSystemDateAndTimeHTTP SOAP
媒体服务Media ServiceGetProfiles / GetStreamUri / GetVideoEncoderConfigurationHTTP SOAP

WS-Discovery 走的是 UDP 组播,跟 HTTP 完全分开,这是最容易漏掉的一块。NVR 开机后会往239.255.255.250:3702发一个 Probe 组播包,里面带一个wsd:Probe的 SOAP 信封,设备收到后必须单播回一个ProbeMatch,里面包含自己的 UUID、设备类型(tds:Device或tds:NetworkVideoTransmitter)和服务地址。很多人的 ESP32 相机 NVR 搜不到,90% 是这一步没做或者做错了。

设备管理和媒体服务都挂在 HTTP 上,但要注意 ONVIF 规范里这两个服务可以放在同一个端口的不同路径,也可以分端口。onvif-c 默认用同一个esp_http_server实例,注册两个 URI,这样省一个 socket,也方便共享设备信息。

2.3 与 RTSP 服务的关系

ONVIF 本身不传视频,它只负责"告诉 NVR 去哪里拉流"。所以GetStreamUri返回的是一个 RTSP URL,比如rtsp://192.168.1.100:554/stream1。这意味着你的 ESP32 上还得跑一个 RTSP 服务端,把摄像头数据封装成 RTP 包发出去。ONVIF 组件和 RTSP 组件之间通过一个共享的配置结构体通信——分辨率、帧率、编码格式这些参数,ONVIF 的GetVideoEncoderConfiguration要能报出来,RTSP 的实际推流也要按这个来,两边必须一致,否则 NVR 拿到的 profile 跟实际流对不上,会花屏或者直接拒绝。

我的建议是:先把 RTSP 推流跑通,确认 VLC 能拉到流,再上 ONVIF。因为 ONVIF 调试的时候你没法确定问题出在协议层还是流层,分开验证能省大量时间。

3. 核心细节解析与实操要点

3.1 WS-Discovery 的组播实现细节

WS-Discovery 是整个项目里最"玄学"的部分,因为它依赖 UDP 组播,而组播在不同网络环境下的行为差异很大。实现上要做这几件事:

第一,创建一个 UDP socket,绑定到 3702 端口。注意要设置SO_REUSEADDR,否则重启程序时端口可能还被占用。第二,加入组播组239.255.255.250,用IP_ADD_MEMBERSHIP。第三,设置 socket 为可接收组播,并且要处理SO_BROADCAST相关选项。第四,起一个独立任务循环recvfrom,收到 Probe 就解析并回 ProbeMatch。

// 加入组播组的核心代码示意 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));

回 ProbeMatch 的时候有个关键点:响应必须单播回发送方的源地址和源端口,不能也组播回去。NVR 发 Probe 时用的源端口通常是随机的,你要从recvfrom拿到的struct sockaddr_in里取出来,原样回过去。另外 ProbeMatch 里的XAddrs字段要填你设备的 HTTP 服务地址,NVR 拿到这个地址后才会去调 SOAP 接口。

注意:ESP32 默认的 lwIP 配置里组播可能是关的,需要在menuconfig里确认LWIP_IGMP打开,否则IP_ADD_MEMBERSHIP会失败。这个坑我调了一整天才发现。

3.2 SOAP 请求的解析与响应拼装

ONVIF 的 SOAP 请求长这样(简化):

<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"> <soap:Body> <GetStreamUri xmlns="http://www.onvif.org/ver10/media/wsdl"> <ProfileToken>profile_1</ProfileToken> </GetStreamUri> </soap:Body> </soap:Envelope>

解析的时候,先找<soap:Body>之后的内容,再找第一个子标签的名字,那就是操作名。参数从子标签里抠。响应要按对应命名空间拼,比如媒体服务的响应命名空间是http://www.onvif.org/ver10/media/wsdl,设备管理是http://www.onvif.org/ver10/device/wsdl。命名空间写错是 NVR 拒绝的第二大原因。

响应里还要注意 SOAP 版本。ONVIF 用的是 SOAP 1.2,Content-Type 必须是application/soap+xml; charset=utf-8,而且action参数要带上完整的操作名,比如action="http://www.onvif.org/ver10/media/wsdl/GetStreamUri"。有些 NVR 会检查这个头,缺了就直接 400。

3.3 设备信息与 Capabilities 的构造

GetDeviceInformation要返回制造商、型号、固件版本、序列号、硬件 ID。这些字段虽然是字符串,但 NVR 可能会拿来做去重和展示,建议填真实有意义的值,别全填 "test"。序列号最好用 ESP32 的 MAC 地址生成,保证唯一。

GetCapabilities更关键,它告诉 NVR 这台设备支持哪些服务、每个服务的地址在哪。响应里要列出 Device、Media、Events(如果支持)等分类,每类给出XAddr。这个地址必须跟实际 HTTP 服务地址一致,否则 NVR 后续调用会打到不存在的端口。

<tds:Capabilities> <tds:Media> <tt:XAddr>http://192.168.1.100/onvif/media_service</tt:XAddr> </tds:Media> </tds:Capabilities>

3.4 Profile 与 StreamUri 的对应关系

ONVIF 里 Profile 是"一路视频配置"的抽象,包含视频编码、分辨率、帧率、码率,以及对应的流地址。GetProfiles返回所有 profile 的列表,每个 profile 有个 token(比如profile_1)。GetStreamUri根据传入的 token 返回对应的 RTSP 地址。

对 ESP32 来说,通常只支持一路流,所以返回一个 profile 就够了。但要注意 profile 里的VideoEncoderConfiguration参数要跟 RTSP 实际推流参数严格一致。我见过有人 profile 里写 1080p,实际推的是 VGA,NVR 拉流后一直花屏,查了半天才发现是这里对不上。

4. 完整实操流程:从建工程到 NVR 添加成功

4.1 工程搭建与组件集成

先建一个标准 ESP-IDF 工程:

idf.py create-project esp32_onvif_cam cd esp32_onvif_cam

然后把 onvif-c 组件放进components/目录。ESP-IDF 的组件机制会自动扫描components/下的每个子目录,只要里面有CMakeLists.txt就会被编译进来。onvif-c 的CMakeLists.txt里要声明依赖:

idf_component_register(SRCS "onvif_device.c" "onvif_media.c" "onvif_discovery.c" INCLUDE_DIRS "include" REQUIRES esp_http_server lwip nvs_flash)

主工程的CMakeLists.txt里REQUIRES加上onvif-c。摄像头驱动用esp32-camera组件,RTSP 服务可以用现成的esp_rtsp或者自己写。这里假设你已经有一路能出图的 RTSP 流。

4.2 网络与组播初始化

WiFi 连上之后,先初始化 ONVIF 的组播监听。这一步要在 HTTP 服务启动之前做,因为 NVR 可能先发 Probe 再调 SOAP。

void onvif_discovery_start(void) { int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(3702), .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)); xTaskCreate(discovery_task, "onvif_disc", 4096, (void*)sock, 5, NULL); }

discovery_task里循环recvfrom,收到 Probe 就调build_probe_match拼响应,然后sendto回源地址。

4.3 HTTP 服务与 SOAP 端点注册

启动esp_http_server,注册两个 URI:

httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_handle_t server = NULL; httpd_start(&server, &config); httpd_uri_t device_uri = { .uri = "/onvif/device_service", .method = HTTP_POST, .handler = device_service_handler }; httpd_register_uri_handler(server, &device_uri); httpd_uri_t media_uri = { .uri = "/onvif/media_service", .method = HTTP_POST, .handler = media_service_handler }; httpd_register_uri_handler(server, &media_uri);

handler 里先读 body,判断操作名,分发到对应的构造函数。响应通过httpd_resp_send发出去,记得设置正确的 Content-Type。

4.4 参数计算:分辨率与码率的匹配

ESP32 的算力和内存有限,摄像头分辨率不能盲目拉满。以 ESP32-S3 + OV2640 为例,实测下来:

分辨率帧率JPEG 质量内存占用稳定性
VGA 640x48015fps12低很稳
SVGA 800x60012fps12中稳
HD 1280x7208fps10高勉强
FHD 1920x10805fps10很高易崩

ONVIF profile 里报的参数要跟这个表对齐。如果你报 1080p 但实际只能跑 5fps,NVR 那边可能因为帧率太低判定流异常。建议初期用 VGA 或 SVGA,跑稳了再往上调。

码率方面,MJPEG 的码率约等于分辨率像素数 × 帧率 × 压缩系数。VGA 15fps 大概 2-4 Mbps,这个量级在局域网里没问题。RTSP 推流时 RTP 包大小建议 1400 字节左右,避免 IP 分片。

4.5 NVR 添加实测记录

我用群晖 Surveillance Station 和海康 NVR 各测了一遍。群晖的兼容性相对宽松,海康更严格。添加流程:

  1. NVR 自动搜索,几秒内应该能看到设备出现在列表里,显示为 "ONVIF Device" 或你填的制造商名。
  2. 手动添加时填 IP、ONVIF 端口(默认 80)、用户名密码(如果没做鉴权就留空或随便填)。
  3. 添加后 NVR 会调一串 SOAP 接口,观察串口日志能看到GetDeviceInformation、GetCapabilities、GetProfiles、GetStreamUri依次进来。
  4. 全部返回正确后,NVR 开始拉 RTSP 流,画面上线。

第一次测的时候海康一直添加失败,串口日志显示GetCapabilities返回后就没下文了。后来抓包发现是 Capabilities 里的XAddr我写成了http://192.168.1.100:80/onvif/media_service,多了个:80,海康不认。去掉端口号后立刻通过。这种细节就是纯靠踩坑积累的。

5. 常见问题与排查技巧实录

5.1 NVR 搜不到设备

这是最高频的问题。排查顺序:

  • 确认 ESP32 和 NVR 在同一网段,且中间没有隔离组播的交换机或路由器。很多企业级 AP 默认关闭 IGMP snooping 或者隔离客户端,组播包根本到不了。
  • 用 Wireshark 抓包,看 NVR 有没有发 Probe 到 239.255.255.250:3702。如果没发,是 NVR 侧问题;如果发了但 ESP32 没回,是组播加入失败。
  • 检查menuconfig里LWIP_IGMP是否开启。
  • 检查 ProbeMatch 的XAddrs地址是否是 NVR 能访问到的地址(别填成 127.0.0.1 或 AP 的地址)。

5.2 添加成功但一直"连接中"

通常是 RTSP 流拉不起来。先用 VLC 手动填rtsp://设备IP:554/stream1测试,能出图说明流没问题,问题在 ONVIF 返回的 URL 上。检查GetStreamUri返回的地址里的 IP 是不是设备真实 IP,端口是不是 RTSP 实际监听端口。

5.3 画面花屏或卡顿

多半是 profile 参数和实际流不一致。检查GetVideoEncoderConfiguration返回的分辨率、帧率是否跟 RTSP 推流参数匹配。另外 RTP 包大小和 MTU 也要注意,ESP32 的 lwIP MTU 默认 1500,RTP 负载超过这个会分片,分片丢失就花屏。

5.4 常见问题速查表

现象可能原因排查方法
NVR 搜不到组播未加入 / IGMP 未开Wireshark 抓 3702 端口
添加失败Capabilities XAddr 错误检查地址格式,去掉多余端口
连接中无画面RTSP URL 错误VLC 手动测试
花屏卡顿profile 与实际流不符对比参数
一段时间后掉线内存泄漏 / 任务栈溢出看 heap 和任务水位
SOAP 返回 400Content-Type 或 action 头错误抓包看请求头

5.5 独家避坑经验

第一个经验:串口日志一定要打全。ONVIF 调试全靠日志,每个进来的 SOAP 操作名、每个发出的响应,都打出来。我习惯在 handler 入口打一行ONVIF REQ: <op_name>,出问题一眼就能看出卡在哪一步。

第二个经验:先用手工构造的 SOAP 请求测。别一上来就接 NVR,用 curl 或者 Postman 往/onvif/device_service发一个GetDeviceInformation,看返回对不对。这样能把协议层和 NVR 兼容性分开调。

curl -X POST http://192.168.1.100/onvif/device_service \ -H "Content-Type: application/soap+xml; charset=utf-8" \ -d '<soap:Envelope ...><GetDeviceInformation/></soap:Envelope>'

第三个经验:UUID 要固定。WS-Discovery 的 ProbeMatch 里有个EndpointReference的 UUID,如果每次重启都变,NVR 会认为是新设备,导致重复添加或者配置丢失。用 MAC 地址生成一个稳定的 UUID。

第四个经验:给 HTTP 服务留足任务栈。esp_http_server默认栈可能不够,SOAP 响应拼装时字符串操作多,栈溢出会直接重启。我把 httpd 的任务栈调到 8192 才稳。

6. 性能优化与稳定性加固

6.1 内存管理

ESP32 的 RAM 是稀缺资源,ONVIF 服务端加上 RTSP 推流,内存很容易吃紧。几个优化点:SOAP 响应用静态缓冲区拼装,别频繁 malloc/free;XML 字符串处理用栈上的固定数组,别用动态分配;摄像头帧缓冲用双缓冲,避免推流和采集抢内存。实测 ESP32-S3 带 8MB PSRAM 的话,跑 720p 推流加 ONVIF 服务,剩余 heap 还能有 100KB 以上,比较安全。

6.2 长时间运行稳定性

ONVIF 服务要 7x24 跑,稳定性比功能更重要。我做了几件事:给 discovery 任务和 httpd 任务都加了看门狗喂狗;socket 操作加了超时,避免阻塞;定期打印 heap 水位,观察有没有缓慢泄漏。跑过连续 72 小时测试,没有重启和掉线。

6.3 多 NVR 并发

如果网络里有多个 NVR 或者客户端同时探测,discovery 任务要能并发处理。因为 UDP 是无连接的,每个 Probe 独立响应即可,问题不大。但 HTTP 这边要注意esp_http_server的并发连接数配置,默认可能只支持几个,多客户端同时调 SOAP 会排队。在httpd_config_t里把max_open_sockets调大一点。

7. 后续可扩展的方向

这套东西跑通之后,能玩的花样不少。比如加个Events 服务,把 ESP32 的 GPIO 触发、移动侦测结果通过 ONVIF 事件推给 NVR,这样 NVR 就能做联动录像。再比如接入边缘 AI,用 ESP32-S3 的向量指令跑个轻量的人形检测,检测到目标时通过 ONVIF 事件通知 NVR 打标签。还有PTZ 控制,如果你的云台是舵机驱动的,实现 ONVIF 的 PTZ 服务后,NVR 界面上就能直接控制转动。

我自己下一步打算把 ONVIF 的鉴权(WS-UsernameToken)加上,现在很多 NVR 要求必须带用户名密码,虽然可以关掉校验,但加上更规范。鉴权的核心是对 nonce、created、password 做 SHA1 摘要,ESP-IDF 里有mbedtls可以直接用,不算复杂。

最后分享一个调试小技巧:如果 NVR 的日志不够详细,可以在 ESP32 侧把所有 SOAP 交互存到一个环形缓冲区里,通过一个额外的 HTTP 接口导出来看。这样即使 NVR 那边不报错,你也能知道它到底调了什么、你回了什么,定位问题快很多。这套方法帮我省了无数次抓包的时间。