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-Discovery | UDP | 3702 | 组播,固定端口,不可改 |
| ONVIF HTTP | TCP | 80 | SOAP 请求入口 |
| RTSP | TCP | 554 | 视频流拉取 |
| HTTP 快照 | TCP | 8080 | 可选,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:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:wsd="http://schemas.xmlsoap.org/ws/2005/04/discovery"> <soap:Header> <wsa:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</wsa:Action> <wsa:MessageID>uuid:xxx</wsa:MessageID> <wsa:To>urn:schemas-xmlsoap-org:ws:2005:04:discovery</wsa:To> </soap:Header> <soap:Body> <wsd:Probe> <wsd:Types>dn: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 URL,IP 地址要填设备实际的 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 POST,Content-Type 是application/soap+xml。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:Manufacturer>MyVendor</tds:Manufacturer> <tds:Model>ESP32-CAM</tds:Model> <tds:FirmwareVersion>1.0.0</tds:FirmwareVersion> <tds:SerialNumber>ESP32-001122</tds:SerialNumber> <tds:HardwareId>1.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:XAddr>http://192.168.1.100/onvif/device_service</tt:XAddr> </tt:Device> <tt:Media> <tt:XAddr>http://192.168.1.100/onvif/media_service</tt:XAddr> <tt:StreamingCapabilities> <tt:RTPMulticast>false</tt:RTPMulticast> <tt:RTP_TCP>true</tt:RTP_TCP> <tt:RTP_RTSP_TCP>true</tt:RTP_RTSP_TCP> </tt:StreamingCapabilities> </tt:Media> </tds:Capabilities> </tds:GetCapabilitiesResponse>RTP_RTSP_TCP必须设为 true,因为 ESP32 上实现 UDP 的 RTP 传输比较麻烦,用 RTSP over TCP 更稳定。NVR 看到这个能力后,会优先用 TCP 方式拉流。
4.4 GetProfiles 与 GetStreamUri
GetProfiles 返回设备支持的媒体配置(Profile),至少要有 MainStream 和 SubStream 两个。每个 Profile 包含视频编码参数、分辨率、帧率等。
GetStreamUri 根据 ProfileToken 返回对应的 RTSP 地址。响应格式:
<trt:GetStreamUriResponse> <trt:MediaUri> <tt:Uri>rtsp://192.168.1.100:554/stream1</tt:Uri> <tt:InvalidAfterConnect>false</tt:InvalidAfterConnect> <tt:InvalidAfterReboot>false</tt:InvalidAfterReboot> <tt:Timeout>PT0S</tt:Timeout> </trt:MediaUri> </trt:GetStreamUriResponse>URI 里的 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 格式的文本,描述视频流的编码参数:
v=0 o=- 0 0 IN IP4 192.168.1.100 s=ESP32 Stream c=IN IP4 0.0.0.0 t=0 0 m=video 0 RTP/AVP 96 a=rtpmap:96 H264/90000 a=fmtp:96 packetization-mode=1;profile-level-id=42C01E a=control: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 格式 | 检查响应 XML | IP 填错或端口格式问题 |
| 防火墙 | 检查路由器设置 | 组播被路由器拦截 |
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 这两个环节调通,后面就顺了。