
1. 为什么要在 ESP32 上折腾 ONVIF 相机先说清楚这个项目到底在干什么。ONVIF 是一套网络视频设备之间的通信标准简单理解就是让摄像头和录像机之间能互相认识、互相说话的通用语言。你手上有一块 ESP32接了一个摄像头模组想让它被市面上常见的 NVR网络录像机直接搜索到、添加进去、看到画面——那你就必须让 ESP32 说 ONVIF 这套语言。这个项目的核心就是用纯 C 语言、基于 ESP-IDF 框架写一个 ONVIF 组件让 ESP32 变成一个能被 NVR 认出来的相机。注意这里的关键词是plain C和ESP-IDF 组件意味着不依赖 Arduino、不依赖任何高级封装就是老老实实用 ESP-IDF 的原生组件机制来组织代码。为什么这件事值得单独拿出来讲因为市面上关于 ESP32 做 RTSP 推流的资料很多但真正把 ONVIF 设备发现WS-Discovery、设备服务Device Service、媒体服务Media Service这一整套流程跑通、并且能被主流 NVR 成功添加的完整参考少得可怜。大部分人卡在两个地方一是 WS-Discovery 的组播响应格式不对NVR 根本搜不到设备二是 GetStreamUri 返回的 RTSP 地址格式有问题NVR 添加成功了但拉不到流。这篇文章适合谁看如果你已经会用 ESP-IDF 建工程、烧固件懂基本的 socket 编程想做一个能被 NVR 集成的 IP 相机那这篇就是给你写的。如果你连 ESP-IDF 都没装过建议先把官方 getting started 跑一遍再回来。我踩过的坑主要集中在三个环节组播 socket 的绑定方式、SOAP 响应的 XML 命名空间、以及 RTSP 服务端和 ONVIF 服务端的端口协调。下面会把每个环节拆开讲透。2. 整体架构设计与组件拆分思路2.1 为什么选择纯 C 组件而不是 Arduino 库ESP-IDF 的组件机制component本质上就是一套 CMake 驱动的源码组织方式。你把一组相关的 .c/.h 文件放在一个目录里写一个 CMakeLists.txt它就能被主工程通过idf_component_register引入。这种方式的优势在于编译期依赖清晰、可以单独做单元测试、可以跨项目复用。Arduino 生态里做 ONVIF 的方案基本没有成熟的而且 Arduino 的 WiFi 库对组播的支持比较粗糙UDP 组播的 socket 选项不好控制。ONVIF 的 WS-Discovery 依赖 UDP 组播239.255.255.250:3702需要精确控制 socket 的 SO_REUSEADDR、IP_ADD_MEMBERSHIP 等选项用 ESP-IDF 原生的 lwIP socket API 会顺手很多。所以架构上的第一个决策就是ONVIF 逻辑全部用纯 C 实现只依赖 ESP-IDF 提供的 lwIP、mbedTLS如果需要 HTTPS、以及 FreeRTOS 的任务机制。不引入任何第三方 XML 库手写轻量级 XML 拼装和解析因为 ONVIF 的 SOAP 消息结构相对固定用通用 XML 库反而增加 flash 占用和复杂度。2.2 组件目录结构设计一个可复用的 ONVIF 组件目录结构建议这样组织components/onvif/ ├── CMakeLists.txt ├── include/ │ ├── onvif.h │ ├── onvif_discovery.h │ ├── onvif_device.h │ └── onvif_media.h ├── onvif_discovery.c ├── onvif_device.c ├── onvif_media.c ├── onvif_soap.c └── onvif_http.convif_soap.c负责 SOAP 信封的拼装和解析onvif_http.c负责 HTTP 服务端的请求分发onvif_discovery.c处理 WS-Discovery 的组播收发onvif_device.c和onvif_media.c分别实现 Device Service 和 Media Service 的具体方法。这样拆的好处是每个文件职责单一调试的时候能快速定位是哪一层出了问题。比如 NVR 搜不到设备那问题一定在 discovery 层搜到了但添加失败问题在 device 层添加成功但没画面问题在 media 层或 RTSP 层。2.3 服务端端口规划ONVIF 涉及多个网络服务端口规划必须提前定好否则后面改起来很痛苦。我的方案是服务协议端口说明WS-DiscoveryUDP3702组播固定端口不可改ONVIF HTTPTCP80SOAP 请求入口RTSPTCP554视频流拉取HTTP 快照TCP8080可选JPEG 快照WS-Discovery 的 3702 是 ONVIF 规范写死的NVR 只会往这个端口发探测包所以必须监听这个端口。ONVIF 的 HTTP 服务用 80 端口是最省事的因为 NVR 在解析设备返回的 XAddr 时如果端口是 80 会省略端口号减少出错概率。RTSP 用 554 也是标准做法。注意ESP32 上同时开 80 和 8080 两个 HTTP 服务端会占用较多内存如果 flash 和 RAM 紧张可以把快照功能合并到 80 端口的 HTTP 服务里用不同的 URL 路径区分。3. WS-Discovery 实现细节与组播踩坑3.1 WS-Discovery 的工作原理WS-Discovery 是 ONVIF 设备被发现的入口。流程是这样的NVR 往组播地址 239.255.255.250 的 3702 端口发一个 Probe 消息SOAP 格式的 XML所有在线的 ONVIF 设备收到后单播回复一个 ProbeMatch 消息里面包含设备的基本信息和服务地址。NVR 收到 ProbeMatch 后就知道有这么个设备存在然后才会去调用 Device Service 获取详细信息。关键点在于Probe 是组播ProbeMatch 是单播。很多人在实现的时候把 ProbeMatch 也发回组播地址结果 NVR 收不到或者收到一堆重复的。正确的做法是从收到的 Probe 包里提取源 IP 和源端口然后单播回复到那个地址。3.2 组播 socket 的创建与绑定这是最容易踩坑的地方。创建 UDP 组播接收 socket 的步骤int sock socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); // 允许地址复用否则重启时可能绑定失败 int reuse 1; setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse)); // 绑定到 3702 端口地址用 INADDR_ANY 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));这里有个细节imr_interface填INADDR_ANY让系统自动选择网络接口。如果你的 ESP32 同时有 WiFi 和以太网可能需要指定具体的接口 IP否则组播包可能从错误的接口出去。实操心得ESP-IDF 的 lwIP 在加入组播组之前必须确保网络接口已经获取到 IP 地址。如果在 WiFi 还没连上时就执行 IP_ADD_MEMBERSHIP会返回错误。正确的做法是在收到 IP_EVENT_STA_GOT_IP 事件之后再初始化 discovery 服务。3.3 Probe 消息的解析NVR 发来的 Probe 消息长这样简化版soap:Envelope xmlns:soaphttp://www.w3.org/2003/05/soap-envelope xmlns:wsahttp://schemas.xmlsoap.org/ws/2004/08/addressing xmlns:wsdhttp://schemas.xmlsoap.org/ws/2005/04/discovery soap:Header wsa:Actionhttp://schemas.xmlsoap.org/ws/2005/04/discovery/Probe/wsa:Action wsa:MessageIDuuid:xxx/wsa:MessageID wsa:Tourn:schemas-xmlsoap-org:ws:2005:04:discovery/wsa:To /soap:Header soap:Body wsd:Probe wsd:Typesdn:NetworkVideoTransmitter/wsd:Types /wsd:Probe /soap:Body /soap:Envelope解析的时候不需要完整的 XML 解析器只需要用字符串查找判断 Action 是不是 Probe以及 Types 里是否包含NetworkVideoTransmitter。因为 ESP32 的内存有限用strstr做关键字匹配就够了。但要注意Probe 消息里可能包含多个 Types用空格分隔。判断的时候要检查是否包含目标类型而不是精确匹配整个字符串。3.4 ProbeMatch 响应的构造ProbeMatch 的响应格式必须严格符合规范否则 NVR 会忽略。核心字段包括wsa:Action必须是http://schemas.xmlsoap.org/ws/2005/04/discovery/ProbeMatcheswsa:RelatesTo要填 Probe 消息里的 MessageIDwsd:Types填dn:NetworkVideoTransmitterwsd:XAddrs填设备的 ONVIF 服务地址比如http://192.168.1.100/onvif/device_servicewsd:Scopes可选但建议填上包含设备名称和类型XAddrs 的格式特别重要。必须是完整的 HTTP URLIP 地址要填设备实际的 IP不能填 0.0.0.0 或 127.0.0.1。端口如果是 80 可以省略但建议显式写上避免某些 NVR 解析出错。踩坑记录我一开始 XAddrs 填的是http://192.168.1.100:80/onvif/device_service某品牌 NVR 能搜到但添加失败。后来改成不带端口号的http://192.168.1.100/onvif/device_service就正常了。原因是那个 NVR 的 URL 解析器对带默认端口的 URL 处理有 bug。所以建议端口是 80 时省略端口号。3.5 组播响应的发送方式ProbeMatch 是单播回复但发送的时候有个细节源端口应该用 3702而不是随机端口。有些 NVR 会检查响应包的源端口如果源端口不是 3702 就丢弃。struct sockaddr_in dest { .sin_family AF_INET, .sin_port htons(src_port), // 从 Probe 包里提取的源端口 .sin_addr.s_addr src_ip // 从 Probe 包里提取的源 IP }; sendto(sock, response, response_len, 0, (struct sockaddr *)dest, sizeof(dest));用同一个监听 3702 的 socket 来发送响应源端口自然就是 3702。不要新建一个 socket 来发那样源端口是随机的。4. Device Service 与 Media Service 的实现4.1 HTTP 服务端的请求分发ONVIF 的所有 SOAP 请求都走 HTTP POSTContent-Type 是application/soapxml。ESP-IDF 自带的esp_http_server组件可以用来做 HTTP 服务端注册一个 POST handler 处理/onvif/device_service和/onvif/media_service两个路径。请求分发的逻辑是读取 HTTP body 里的 SOAP Action根据 Action 调用对应的处理函数。Action 的格式是http://www.onvif.org/ver10/device/wsdl/GetDeviceInformation这种用字符串匹配最后一段就能确定要调用哪个方法。static esp_err_t onvif_post_handler(httpd_req_t *req) { char *buf malloc(req-content_len 1); int ret httpd_req_recv(req, buf, req-content_len); buf[req-content_len] \0; const char *action extract_soap_action(buf); if (strstr(action, GetDeviceInformation)) { handle_get_device_information(req, buf); } else if (strstr(action, GetCapabilities)) { handle_get_capabilities(req, buf); } else if (strstr(action, GetProfiles)) { handle_get_profiles(req, buf); } else if (strstr(action, GetStreamUri)) { handle_get_stream_uri(req, buf); } // ... free(buf); return ESP_OK; }注意httpd_req_recv可能一次读不完所有数据尤其是 content_len 较大的时候。稳妥的做法是循环读取直到读满 content_len 或返回错误。4.2 GetDeviceInformation 的实现这是 NVR 添加设备后调用的第一个方法用来获取设备厂商、型号、固件版本、序列号等信息。响应格式tds:GetDeviceInformationResponse tds:ManufacturerMyVendor/tds:Manufacturer tds:ModelESP32-CAM/tds:Model tds:FirmwareVersion1.0.0/tds:FirmwareVersion tds:SerialNumberESP32-001122/tds:SerialNumber tds:HardwareId1.0/tds:HardwareId /tds:GetDeviceInformationResponse这些字段的值可以硬编码但序列号建议用 ESP32 的 MAC 地址生成保证唯一性。用esp_efuse_mac_get_default读取 MAC格式化成字符串。4.3 GetCapabilities 的关键点GetCapabilities 返回设备支持哪些服务NVR 会根据这个响应决定后续调用哪些接口。响应里必须包含 Device、Media、Events可选等服务的 XAddr。tds:GetCapabilitiesResponse tds:Capabilities tt:Device tt:XAddrhttp://192.168.1.100/onvif/device_service/tt:XAddr /tt:Device tt:Media tt:XAddrhttp://192.168.1.100/onvif/media_service/tt:XAddr tt:StreamingCapabilities tt:RTPMulticastfalse/tt:RTPMulticast tt:RTP_TCPtrue/tt:RTP_TCP tt:RTP_RTSP_TCPtrue/tt:RTP_RTSP_TCP /tt:StreamingCapabilities /tt:Media /tds:Capabilities /tds:GetCapabilitiesResponseRTP_RTSP_TCP必须设为 true因为 ESP32 上实现 UDP 的 RTP 传输比较麻烦用 RTSP over TCP 更稳定。NVR 看到这个能力后会优先用 TCP 方式拉流。4.4 GetProfiles 与 GetStreamUriGetProfiles 返回设备支持的媒体配置Profile至少要有 MainStream 和 SubStream 两个。每个 Profile 包含视频编码参数、分辨率、帧率等。GetStreamUri 根据 ProfileToken 返回对应的 RTSP 地址。响应格式trt:GetStreamUriResponse trt:MediaUri tt:Urirtsp://192.168.1.100:554/stream1/tt:Uri tt:InvalidAfterConnectfalse/tt:InvalidAfterConnect tt:InvalidAfterRebootfalse/tt:InvalidAfterReboot tt:TimeoutPT0S/tt:Timeout /trt:MediaUri /trt:GetStreamUriResponseURI 里的 IP 必须是设备实际 IP不能是 0.0.0.0。这里有个技巧可以在运行时动态获取当前 IP 拼接到 URI 里而不是硬编码。实操心得某些 NVR 在添加设备时会先调用 GetStreamUri 拿到 RTSP 地址然后立即尝试连接。如果此时 RTSP 服务还没启动NVR 会认为设备不可用。所以 RTSP 服务必须在 ONVIF 服务启动之前就绪或者至少在同一时刻启动。5. RTSP 服务端的配合实现5.1 RTSP 与 ONVIF 的关系ONVIF 本身不传输视频流它只负责告诉 NVR 去哪里拉流。真正的视频流是 RTSP 协议传输的。所以 ESP32 上必须同时跑一个 RTSP 服务端ONVIF 的 GetStreamUri 返回的地址要指向这个 RTSP 服务端。RTSP 服务端的实现可以用 ESP-IDF 的esp_rtsp组件如果有的话或者自己用 socket 实现一个精简版。核心是处理 DESCRIBE、SETUP、PLAY 三个方法返回 SDP 描述和 RTP 包。5.2 SDP 描述的构造DESCRIBE 请求的响应是 SDP 格式的文本描述视频流的编码参数v0 o- 0 0 IN IP4 192.168.1.100 sESP32 Stream cIN IP4 0.0.0.0 t0 0 mvideo 0 RTP/AVP 96 artpmap:96 H264/90000 afmtp:96 packetization-mode1;profile-level-id42C01E acontrol:track1profile-level-id要和实际编码器输出的 H.264 profile 一致否则 NVR 可能解码失败。ESP32 的硬件编码器通常输出 Baseline 或 Main Profile对应的 profile-level-id 是 42C01E 或 4D401E。5.3 RTP 打包的注意事项H.264 数据要按 RTP 规范打包大帧需要分片FU-A。每个 RTP 包的 payload 前面要加 12 字节的 RTP 头时间戳按 90kHz 时钟递增。踩坑记录我一开始时间戳用的是毫秒结果 NVR 播放速度飞快。后来改成 90kHz 时钟每帧递增 90000/fps就正常了。比如 25fps每帧时间戳递增 3600。6. 常见问题与排查技巧实录6.1 NVR 搜不到设备这是最常见的问题排查顺序排查项检查方法常见原因组播是否加入成功打印 setsockopt 返回值WiFi 未连接就加入组播3702 端口是否监听netstat 或日志端口被占用或绑定失败ProbeMatch 是否发出抓包或日志解析 Probe 失败未触发响应XAddrs 格式检查响应 XMLIP 填错或端口格式问题防火墙检查路由器设置组播被路由器拦截6.2 搜到了但添加失败通常是 Device Service 的响应有问题。用 ONVIF Device Manager 这类工具手动调用 GetDeviceInformation 和 GetCapabilities看响应是否符合规范。重点检查 XML 命名空间是否正确SOAP Action 是否匹配。6.3 添加成功但无画面问题在 RTSP 层。先用 VLC 直接拉 RTSP 地址看能不能播放。如果 VLC 能播但 NVR 不能说明是 SDP 或 RTP 打包的问题。如果 VLC 也不能播说明 RTSP 服务端本身有问题。6.4 内存不足导致崩溃ESP32 的 RAM 有限同时跑 WiFi、HTTP、RTSP、ONVIF 很容易内存不足。优化方向减小 HTTP 服务的栈大小、复用缓冲区、避免动态分配大块内存。用heap_caps_get_free_size监控内存使用。实操心得把 ONVIF 的 XML 响应缓冲区设为 2048 字节通常够用RTSP 的 RTP 缓冲区设为 1500 字节一个 MTU。不要用 malloc 频繁分配释放用静态缓冲区。7. 调试工具与验证方法7.1 用 ONVIF Device Manager 验证ONVIF Device Manager 是一个开源的 ONVIF 客户端工具可以搜索设备、查看设备信息、获取 RTSP 地址。用它来验证 ONVIF 实现是否正确比直接用 NVR 调试方便得多因为能看到详细的请求和响应。7.2 抓包分析用 Wireshark 抓包过滤udp.port 3702看 WS-Discovery 的交互过滤tcp.port 80看 SOAP 请求响应。抓包能看到 NVR 实际发了什么、设备回了什么是排查协议问题最直接的手段。7.3 日志分级在代码里加日志分级Discovery 层用 DEBUG 级别Device/Media 层用 INFO 级别错误用 ERROR 级别。这样正常运行时只看 INFO 和 ERROR调试时打开 DEBUG 看详细流程。这个项目后续还可以这样扩展加入 Events 服务支持移动侦测事件上报加入 PTZ 服务支持云台控制或者把 ONVIF 组件做成可配置的支持不同的视频分辨率和编码参数。我自己在实际操作中的体会是ONVIF 的坑主要集中在协议细节上把 WS-Discovery 和 GetStreamUri 这两个环节调通后面就顺了。