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.c2.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-DiscoveryNVR 是怎么看见你的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:Mediatds:XAddrhttp://%s:%u/onvif/media_service/tds:XAddr tds:StreamingCapabilitiestt:RTSP//tds:StreamingCapabilities /tds:Media /tds:Capabilities /tds:GetCapabilitiesResponse;这里有个容易忽略的点SOAP 响应的Content-Type必须是application/soapxml并且charsetutf-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/stream1profile_subMJPEG640x480/stream23.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 为例mvideo 0 RTP/AVP 96artpmap:96 H264/90000acontrol:track1。这些字段必须和 SETUP 时客户端请求的 track 路径对应上。static const char *SDP_H264 v0\r\n o- 0 0 IN IP4 %s\r\n sESP32 Stream\r\n cIN IP4 %s\r\n t0 0\r\n mvideo 0 RTP/AVP 96\r\n artpmap:96 H264/90000\r\n acontrol:track1\r\n;4.2 RTP 打包FU-A 分片是绕不过去的坎H.264 的 NAL 单元如果超过 MTU通常 1400 字节左右必须用 FU-A 方式分片。这是 RTSP 服务端最容易出 bug 的地方。分片规则是第一个分片的 FU indicator 的 type 为 28FU 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_typeS1中间片0x7Cnal_typeS0,E0末片0x7C0x40 | nal_typeE14.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 的黑盒行为里绕圈子。这个习惯让我在联调阶段少走了很多弯路。