☰
ESP32 上实现 ONVIF 相机:从组件搭建到 NVR 添加实战
2026/10/12 2:52:15 网站建设 项目流程

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

把一块 ESP32 做成能被标准 NVR 直接添加的 IP 相机,这件事听起来像是"用玩具车跑拉力赛",但实际做下来会发现,它比想象中靠谱得多。ONVIF 这套协议本身并不神秘,它本质上是把设备发现、能力协商、媒体配置、流地址获取这几件事,用 SOAP over HTTP 的 XML 消息串起来。ESP32 系列芯片算力足够跑 RTSP 服务端和 ONVIF 的 SOAP 响应,只要把内存和并发连接数控制好,做一台低功耗、低成本、可批量部署的相机完全可行。

我最初接触这个方向,是因为一个园区做周界监控的小项目,需要几十个点位,但传统 IPC 的功耗和成本都偏高,而且很多场景只需要 720p 到 1080p 的固定视角。用 ESP32-CAM 类模组配合 ONVIF 组件,单点成本能压到很低,还能直接接入现有的 NVR 平台,不需要额外开发私有协议对接层。这个思路跑通之后,我把整个组件抽出来做成了 ESP-IDF 的 plain C 组件,也就是标题里说的 onvif-c。

这篇文章面向三类人:一是手里有 ESP32 模组、想把它变成标准网络相机的开发者;二是做安防集成、需要低成本前端设备的工程人员;三是想理解 ONVIF 协议在嵌入式端怎么落地、而不是只停留在抓包层面的技术爱好者。全文会从工程搭建讲到 NVR 实际添加成功,把中间那些文档里不会写、但一定会踩的坑都摊开说。

需要先明确一个边界:ONVIF 规范非常庞大,完整实现 Profile S 的全部内容对 MCU 来说不现实。我们做的是 Profile S 的核心子集——设备发现(WS-Discovery)、设备信息服务、媒体服务(获取 profiles、获取 stream URI)、以及配套的 RTSP 服务端。这套子集足以让绝大多数主流 NVR 把它当成一台普通相机添加进来。下面所有内容都围绕这个子集展开。

2. 工程骨架搭建与组件目录设计

2.1 用 idf.py 建工程时最容易忽略的组件依赖顺序

很多人第一步就卡住:直接把 onvif-c 组件丢进 components 目录,编译报一堆头文件找不到。原因在于 ESP-IDF 的组件依赖是显式声明的,plain C 组件不会自动帮你拉依赖。正确的做法是在组件根目录放一个CMakeLists.txt,用idf_component_register把REQUIRES和PRIV_REQUIRES写清楚。

idf_component_register( SRCS "onvif_device.c" "onvif_media.c" "onvif_discovery.c" "onvif_soap.c" "rtsp_server.c" INCLUDE_DIRS "include" REQUIRES esp_http_server nvs_flash esp_netif lwip PRIV_REQUIRES mbedtls json )

这里有个经验点:esp_http_server必须放在REQUIRES而不是PRIV_REQUIRES,因为你的公开头文件里如果暴露了httpd_handle_t类型,下游工程编译时会需要这个头文件路径。我踩过一次,现象是组件自己能编过,但主工程 include 组件头文件时报httpd.h not found,排查了半天才发现是依赖可见性问题。

工程目录建议这样组织,后面维护会舒服很多:

project/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ └── main.c └── components/ └── onvif-c/ ├── CMakeLists.txt ├── include/ │ ├── onvif_device.h │ ├── onvif_media.h │ └── onvif_discovery.h └── src/ ├── onvif_device.c ├── onvif_media.c ├── onvif_discovery.c ├── onvif_soap.c └── rtsp_server.c

2.2 分区表与内存预算:别等跑起来才发现堆不够

ONVIF 的 SOAP 消息是 XML,字符串拼接和解析都会吃内存。加上 RTSP 服务端要为每个客户端维护会话状态,ESP32 默认的分区表和堆配置很容易在第二个客户端接入时就 OOM。我的做法是先把内存账算清楚。

内存用途预估占用说明
SOAP 请求/响应缓冲4~8 KB单次请求峰值,媒体描述响应较大
RTSP 会话状态每客户端约 2 KB含 RTP 序列号、SSRC、传输参数
视频帧缓冲20~60 KB取决于分辨率和 JPEG 质量
协议栈与 lwip约 40 KB固定开销
安全余量至少 30 KB防止突发请求打爆

基于这个预算,建议把CONFIG_ESP32_SPIRAM打开(如果模组带 PSRAM),并把 lwip 的 TCP 发送缓冲适当调大。分区表用自定义的,给应用分区留足空间,因为 ONVIF 组件加上 RTSP 逻辑编译出来通常比纯 HTTP 服务大不少。

提示:如果用的是没有 PSRAM 的模组,把视频分辨率压到 VGA、JPEG 质量降到 12 左右,仍然可以稳定跑,但并发客户端建议限制在 2 个以内。

2.3 网络初始化与设备身份信息的注入时机

ONVIF 设备在响应 WS-Discovery 和 GetDeviceInformation 时,需要返回厂商、型号、固件版本、序列号等信息。这些信息不要硬编码在组件内部,而是通过一个初始化结构体从 main 传进去。这样同一份组件代码可以用在不同产品上。

typedef struct { const char *manufacturer; const char *model; const char *firmware_version; const char *serial_number; const char *hardware_id; uint16_t http_port; uint16_t rtsp_port; } onvif_device_info_t;

初始化顺序很关键:先nvs_flash_init,再esp_netif_init和事件循环,然后等拿到 IP 之后再启动 ONVIF 的 HTTP 服务和 WS-Discovery。我见过有人在没拿到 IP 时就启动 discovery,结果组播 socket 绑定到了错误的网卡,NVR 死活搜不到设备。正确做法是监听IP_EVENT_STA_GOT_IP,在回调里再启动服务。

3. ONVIF 核心服务的实现拆解

3.1 WS-Discovery:NVR 是怎么"看见"你的

NVR 添加设备的第一步通常是自动发现,它往组播地址239.255.255.250:3702发一个 Probe 消息,设备收到后要回一个 ProbeMatch。这一步不通,后面全都白搭。实现上有几个细节必须注意。

第一,组播接收要正确加入组播组。用 lwip 的 socket 时,需要设置IP_ADD_MEMBERSHIP,并且绑定到INADDR_ANY的 3702 端口。第二,回复的 SOAP 消息里RelatesTo字段必须回填请求里的MessageID,很多 NVR 靠这个做请求响应匹配,填错了它就直接忽略。第三,回复的XAddrs里要带上设备的实际 IP 和 HTTP 端口,格式是http://192.168.x.x:80/onvif/device_service。

// 组播加入的关键片段 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));

实测下来,海康、大华、宇视这几家的 NVR 对 ProbeMatch 的解析都比较严格,Types字段必须是tdn:NetworkVideoTransmitter,命名空间前缀也要对。我一开始自己起了个前缀,结果 NVR 能收到回复但列表里不显示,改成标准前缀后立刻正常。

3.2 设备服务:GetDeviceInformation 与能力声明

设备服务是 NVR 添加设备后调用的第一个正式接口。它通常会依次调用GetDeviceInformation、GetCapabilities、GetServices。这三个接口的响应决定了 NVR 认为你具备哪些能力。

GetCapabilities的响应里,Media节点必须声明XAddr指向媒体服务地址,StreamingCapabilities里要声明支持 RTSP。如果这里漏了,NVR 会认为设备不支持视频流,添加流程直接终止。我的建议是把这个响应模板固化下来,只替换 IP 和端口。

static const char *CAPABILITIES_TMPL = "<tds:GetCapabilitiesResponse>" "<tds:Capabilities>" "<tds:Media><tds:XAddr>http://%s:%u/onvif/media_service</tds:XAddr>" "<tds:StreamingCapabilities><tt:RTSP/></tds:StreamingCapabilities>" "</tds:Media>" "</tds:Capabilities>" "</tds:GetCapabilitiesResponse>";

这里有个容易忽略的点:SOAP 响应的Content-Type必须是application/soap+xml,并且charset=utf-8要带上。有些 NVR 对 Content-Type 很敏感,返回text/xml会被拒绝。我在调试时用抓包工具对比过正常设备和自己的响应,发现就差在这个头字段上。

3.3 媒体服务:Profile 与 Stream URI 的生成逻辑

媒体服务是 ONVIF 里和视频流关系最紧密的部分。NVR 会先调GetProfiles拿到 profile 列表,再对每个 profile 调GetStreamUri拿到 RTSP 地址。我们要做的是维护一个或多个 profile,每个 profile 包含视频编码配置、分辨率、帧率等信息。

对于 ESP32 这种资源受限设备,建议只提供一个 profile,编码固定为 H.264 或 MJPEG。如果用的是 OV2640/OV3660 这类传感器,输出 JPEG 更自然,那就声明 MJPEG。但要注意,部分 NVR 对 MJPEG 的 ONVIF 支持不完整,H.264 兼容性更好。如果模组支持硬件 H.264 编码(比如某些带编码芯片的方案),优先用 H.264。

GetStreamUri返回的地址格式是rtsp://192.168.x.x:554/stream1。这个地址必须和你的 RTSP 服务端实际监听的路径一致。我建议把 profile token 和 RTSP 路径做个映射表,避免硬编码混乱。

Profile Token编码分辨率RTSP 路径
profile_mainH.2641280x720/stream1
profile_subMJPEG640x480/stream2

3.4 SOAP 解析:不要用完整 XML 库,手写轻量解析更稳

在 MCU 上跑完整的 XML 解析库(比如 libxml2)是不现实的,内存和代码体积都扛不住。我的做法是针对 ONVIF 的固定消息结构,写一个轻量的字符串查找解析器。核心思路是:先找到 SOAP Body 里的操作名,再根据操作名去提取需要的字段。

static onvif_op_t parse_operation(const char *body) { if (strstr(body, "GetDeviceInformation")) return OP_GET_DEV_INFO; if (strstr(body, "GetCapabilities")) return OP_GET_CAPS; if (strstr(body, "GetProfiles")) return OP_GET_PROFILES; if (strstr(body, "GetStreamUri")) return OP_GET_STREAM_URI; return OP_UNKNOWN; }

这种写法看起来"土",但在嵌入式场景下非常实用:代码量小、无动态内存分配、解析速度快。唯一要注意的是字段提取时要做边界检查,防止请求体被截断导致越界读。我一般会在提取函数里传入缓冲区长度,所有strstr的结果都要和缓冲区末尾做比较。

注意:ONVIF 请求里的命名空间前缀可能变化,有的 NVR 用tds:,有的用ns1:。所以解析时不要匹配前缀,直接匹配本地名(比如GetProfiles)更稳妥。

4. RTSP 服务端的落地细节

4.1 RTSP 方法子集:DESCRIBE、SETUP、PLAY 就够了

ONVIF 只负责告诉 NVR "流地址在哪",真正拉流是 RTSP 协议的事。一个能被 NVR 正常拉流的 RTSP 服务端,至少要实现 OPTIONS、DESCRIBE、SETUP、PLAY、TEARDOWN 这几个方法。PAUSE 和 GET_PARAMETER 可选,但建议实现 GET_PARAMETER 作为心跳保活,否则有些 NVR 会在空闲一段时间后断开。

DESCRIBE 的响应是 SDP 描述,里面要包含媒体类型、编码、时钟频率、控制路径。以 H.264 为例,m=video 0 RTP/AVP 96,a=rtpmap:96 H264/90000,a=control:track1。这些字段必须和 SETUP 时客户端请求的 track 路径对应上。

static const char *SDP_H264 = "v=0\r\n" "o=- 0 0 IN IP4 %s\r\n" "s=ESP32 Stream\r\n" "c=IN IP4 %s\r\n" "t=0 0\r\n" "m=video 0 RTP/AVP 96\r\n" "a=rtpmap:96 H264/90000\r\n" "a=control:track1\r\n";

4.2 RTP 打包:FU-A 分片是绕不过去的坎

H.264 的 NAL 单元如果超过 MTU(通常 1400 字节左右),必须用 FU-A 方式分片。这是 RTSP 服务端最容易出 bug 的地方。分片规则是:第一个分片的 FU indicator 的 type 为 28,FU header 的 S 位为 1;中间分片 S 和 E 都为 0;最后一个分片 E 位为 1。原始 NAL 的 type 要放在 FU header 的低 5 位。

我踩过的坑是:分片时忘了去掉原始 NAL 的起始码(00 00 00 01),导致 NVR 解码花屏。正确做法是先定位 NAL 起始码,跳过它,再对 payload 做分片。另外,RTP 时间戳要按 90000Hz 时钟递增,同一帧的多个分片时间戳必须相同,否则解码器会认为是不同帧。

分片位置FU indicatorFU header说明
首片0x7C0x80 | nal_typeS=1
中间片0x7Cnal_typeS=0,E=0
末片0x7C0x40 | nal_typeE=1

4.3 传输方式:TCP interleaved 比 UDP 更适合嵌入式

RTSP 支持 UDP 和 TCP 两种传输方式。UDP 延迟低,但丢包后 NVR 端会花屏;TCP interleaved 把 RTP 包直接嵌在 RTSP 的 TCP 连接里传输,可靠性高,穿透 NAT 也更好。对于 ESP32 这种无线连接为主的设备,我强烈建议默认走 TCP interleaved。

TCP interleaved 的格式是:每个 RTP 包前面加 4 个字节,第一个字节是$(0x24),第二个字节是通道号,后面两个字节是包长度(大端)。通道号在 SETUP 阶段协商,通常 RTP 用偶数通道,RTCP 用奇数通道。

// TCP interleaved 发送 uint8_t header[4]; header[0] = '$'; header[1] = rtp_channel; header[2] = (len >> 8) & 0xFF; header[3] = len & 0xFF; send(sock, header, 4, 0); send(sock, rtp_packet, len, 0);

实测下来,走 TCP 后 NVR 端的画面稳定性明显提升,尤其是在 Wi-Fi 信号一般的环境下。代价是延迟会比 UDP 高几十毫秒,但监控场景完全能接受。

4.4 会话保活与超时清理

RTSP 会话是有生命周期的。NVR 会定期发 GET_PARAMETER 或 OPTIONS 作为心跳,如果服务端长时间收不到任何请求,就应该主动清理会话,释放内存。我一般设置 60 秒超时,用一个定时器扫描所有会话,超时的直接关闭 socket 并回收资源。

这里有个细节:清理会话时要先发 TEARDOWN 响应(如果客户端发了 TEARDOWN),再关闭连接。直接 close 会导致 NVR 端报错,虽然不影响功能,但日志里会很难看。另外,会话结构体里的 socket fd 在关闭后要立即置为 -1,防止重复关闭。

5. 让 NVR 真正添加成功的联调过程

5.1 抓包对比:自己的响应和标准设备差在哪

联调阶段最有效的手段就是抓包对比。用一个正常的 IPC 和你的 ESP32 设备,分别让 NVR 去添加,把两边的网络交互抓下来逐字段对比。我通常关注这几个点:WS-Discovery 的 ProbeMatch 是否及时(NVR 一般等 3 秒)、GetCapabilities 的响应结构是否完整、GetStreamUri 返回的地址是否可达。

有一次我遇到 NVR 能发现设备但添加失败,抓包发现 NVR 在 GetCapabilities 之后就不再发请求了。对比正常设备,发现我的响应里少了tds:Device节点。补上之后,NVR 立刻继续走后续流程。这种问题看日志是看不出来的,必须抓包。

5.2 常见添加失败原因排查表

现象可能原因排查方法
NVR 搜不到设备组播未加入或端口不对检查 3702 端口和 IP_ADD_MEMBERSHIP
搜到但添加失败GetCapabilities 响应不完整抓包对比标准设备响应
添加成功但无画面RTSP 地址不可达或 SDP 错误用播放器直接拉流验证
画面花屏RTP 分片或时间戳错误检查 FU-A 分片逻辑
一段时间后掉线会话超时未保活实现 GET_PARAMETER 响应

5.3 用通用播放器做中间验证

在接 NVR 之前,先用通用播放器(比如支持 RTSP 的桌面播放器)直接拉rtsp://设备IP:554/stream1。这一步能把 ONVIF 问题和 RTSP 问题隔离开。如果播放器能正常出画面,说明 RTSP 服务端没问题,那 NVR 添加失败就大概率是 ONVIF 响应的问题。这个隔离思路帮我省了大量时间。

提示:有些播放器默认走 UDP,如果画面花屏,手动切换到 TCP 再试。如果 TCP 正常 UDP 花屏,说明你的 RTP 分片在丢包场景下有问题,重点检查分片边界。

6. 实战中总结的稳定性与性能经验

6.1 并发连接数与内存碎片

ESP32 的堆内存有限,频繁的 malloc/free 会产生碎片。ONVIF 的 SOAP 响应我建议用静态缓冲区,RTSP 的 RTP 包也用预分配的缓冲池。我试过在每帧发送时 malloc 一个包,跑几个小时之后堆就碎了,表现为随机崩溃。改成固定大小的环形缓冲池之后,连续跑一周都很稳。

并发方面,ESP32 单芯片同时处理 2 路 RTSP 拉流基本是上限,再多就会因为 CPU 和带宽不足导致卡顿。如果项目需要更多路,建议一个点位一台设备,而不是一台设备扛多路。

6.2 视频采集与编码的配合节奏

ONVIF 和 RTSP 只是"管道",真正决定画面质量的是采集和编码。OV2640 输出 JPEG 时,帧率受限于传感器和 JPEG 编码速度,720p 下大概能到 10~15 fps。如果强行提高帧率,会出现帧丢弃和延迟累积。我的做法是用一个独立任务负责采集,采集到的帧放入队列,RTSP 发送任务从队列取帧。队列长度设为 2~3,既能平滑抖动,又不会引入太大延迟。

6.3 固件升级与配置持久化

设备部署之后,IP、端口、profile 配置这些信息需要持久化。用 NVS 存储是最自然的选择。但要注意,NVS 的写入寿命有限,不要每帧都写。配置只在初始化时读一次,修改时写一次。另外,ONVIF 规范里有SetNetworkInterfaces等配置接口,如果 NVR 尝试修改网络配置,你的设备要么正确响应,要么明确返回不支持,不要返回错误格式的响应,否则 NVR 可能会反复重试。

7. 关于这套组件后续可以怎么用

把 onvif-c 跑通之后,它的价值不只是"做一台相机"。我后来把它用在了几个不同的场景:一是作为教学 demo,让新人理解 ONVIF 和 RTSP 的完整链路;二是作为协议转换网关的基础,把非 ONVIF 的传感器数据包装成标准相机接入 NVR;三是作为低功耗巡检设备,定时唤醒、推流、休眠。

如果你打算继续深挖,我建议下一步做两件事:一是把 Profile S 里的GetSnapshotUri补上,很多 NVR 的预览图靠这个接口;二是把 HTTPS 和认证加上,虽然内网场景用得少,但有些 NVR 会要求 WS-UsernameToken 认证。这两块补完,兼容性会再上一个台阶。

最后分享一个调试习惯:每次改完 ONVIF 响应模板,先用 curl 手动发一个 SOAP 请求验证响应格式,再去接 NVR。这样能把问题定位在组件内部,而不是在 NVR 的黑盒行为里绕圈子。这个习惯让我在联调阶段少走了很多弯路。

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

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

立即咨询