1. 为什么要在 ESP32 上折腾 ONVIF 相机
先说结论:如果你手上有一块 ESP32 系列模组,想让它变成一台能被主流 NVR(网络录像机)直接搜索、添加、出流的网络摄像机,那么 ONVIF 协议这一关是绕不过去的。很多人第一次做这个项目时,会误以为只要把 RTSP 流推出去就完事了,结果 NVR 死活搜不到设备,或者搜到了添加失败,卡在"设备未响应"的提示上。问题的根源,往往就出在 ONVIF 的设备发现和媒体描述这两块。
ONVIF 全称是开放型网络视频接口论坛,它定义了一套基于 Web Services 的标准,让不同厂商的摄像机、录像机、管理平台能互相"对话"。对一台相机来说,最核心的三件事是:能被发现(WS-Discovery)、能被描述(Device Service 和 Media Service)、能被取流(Media Profile 里给出 RTSP 地址)。NVR 添加相机的过程,本质上就是依次完成这三步握手。
这个项目标题里的 "onvif-c" 指的是一个用纯 C 语言写的 ONVIF 组件,专门适配 ESP-IDF 框架。所谓 "plain C 组件",意思是它不依赖 C++ 的 gSOAP 那套重型框架,而是用轻量的方式手写 SOAP 报文解析和生成,这对资源紧张的 ESP32 来说非常关键。gSOAP 在 PC 上跑没问题,但塞进 ESP32 那点 RAM 和 Flash 里,编译出来的固件体积和运行时内存占用都会让人头疼。
这篇文章适合谁看?如果你已经会用 ESP-IDF 建工程、烧固件,对 RTSP 和网络编程有基本概念,但被 ONVIF 的 SOAP 报文、鉴权、Profile 配置搞得一头雾水,那这篇就是给你写的。我会从工程搭建一路讲到 NVR 成功添加,把每个环节的坑都摊开说。如果你是完全的新手,建议先把 ESP-IDF 的 hello world 和 Wi-Fi 连接跑通再回来,不然会有点吃力。
需要提前说明的是,ONVIF 协议本身是个庞大的规范,完整实现涉及几十个服务。但对于"能被 NVR 添加并出流"这个目标,我们只需要实现其中最小可用的一个子集。这个思路很重要,别一上来就想做全,先把最小闭环打通,后面再按需扩展。
2. 整体方案设计与选型思路
2.1 为什么不用 gSOAP 而选纯 C 手写
ONVIF 的通信底层是 SOAP,也就是用 XML 封装远程调用。业界最常见的做法是用 gSOAP 工具,把 WSDL 文件自动生成一堆 C/C++ 代码。这套方案在服务器和 PC 上很成熟,但搬到 ESP32 上问题就来了。
第一个问题是体积。gSOAP 生成的代码加上运行时库,动辄几百 KB 到上 MB,而 ESP32 的 Flash 分区通常给应用留的空间有限,尤其是你还想同时塞进摄像头驱动、RTSP 服务、Wi-Fi 协议栈。第二个问题是内存。gSOAP 的运行时会在堆上分配大量临时对象,ESP32 的可用堆内存在几万到十几万字节量级,跑起来很容易触发内存分配失败。
纯 C 手写的思路是:我们只处理 NVR 实际会发来的那几种 SOAP 请求,用字符串匹配和简单的 XML 解析来提取关键字段,然后拼装固定格式的响应。这样做的好处是代码量小、内存可控、没有隐藏的动态分配。代价是灵活性差,遇到没实现的请求只能返回错误。但对于一个功能确定的相机来说,NVR 的请求模式是相对固定的,这个代价完全可以接受。
提示:手写 SOAP 解析时,千万不要用递归下降的通用 XML 解析器,那会引入不必要的复杂度。针对固定请求做定向字符串查找,反而更稳。
2.2 最小可用 ONVIF 服务集
要让 NVR 成功添加,至少要实现下面这几个服务接口。我把它们按调用顺序列出来,方便你理解整个握手流程。
| 服务 | 作用 | 触发时机 |
|---|---|---|
| WS-Discovery | 响应 NVR 的组播搜索 | NVR 扫描网段时 |
| GetSystemDateAndTime | 获取设备时间 | 添加流程早期 |
| GetCapabilities | 获取设备能力集 | 探测设备支持哪些服务 |
| GetDeviceInformation | 获取厂商、型号、固件版本 | 显示设备信息 |
| GetProfiles | 获取媒体配置列表 | 准备取流前 |
| GetStreamUri | 获取 RTSP 流地址 | 真正取流前 |
这六个接口构成了最小闭环。其中 WS-Discovery 走的是 UDP 组播,端口 3702,其余都走 HTTP,端口通常是 80 或 8000。很多新手卡在 Discovery 上,因为组播在 Wi-Fi 环境下容易丢包,需要特别注意。
2.3 鉴权策略的选择
ONVIF 支持 WS-Security 的 UsernameToken 鉴权,也就是在 SOAP 头里带上用户名、随机数、时间戳和密码摘要。摘要算法通常是 SHA1,把随机数、时间戳、密码拼接后做哈希。
这里有个关键决策:要不要强制鉴权。如果强制,NVR 添加时必须填对用户名密码,否则所有请求都返回 401。如果不强制,任何设备都能添加,方便调试但安全性差。我的建议是做成可配置的,调试阶段先关掉鉴权,等流程跑通了再打开,这样能把鉴权和协议问题分开排查,效率高很多。
摘要的计算逻辑是这样的:把 nonce(随机数)、created(时间戳)、password 依次拼接成字节串,做一次 SHA1,再 Base64 编码。注意 nonce 是 Base64 编码后的字符串,计算摘要前要先解码回原始字节。这个细节很容易搞错,导致鉴权一直失败。
3. 工程搭建与组件集成
3.1 创建 ESP-IDF 工程骨架
先建一个标准的 ESP-IDF 工程。假设你已经装好了 ESP-IDF 环境,命令行里能跑 idf.py。工程目录结构建议这样组织:
onvif_cam/ ├── CMakeLists.txt ├── sdkconfig.defaults ├── main/ │ ├── CMakeLists.txt │ └── main.c └── components/ └── onvif_c/ ├── CMakeLists.txt ├── include/ │ └── onvif_c.h └── onvif_c.c把 onvif-c 做成一个独立组件放在 components 目录下,这样主程序通过#include "onvif_c.h"就能调用,也方便以后复用到别的项目。组件的 CMakeLists.txt 里用idf_component_register注册源文件和头文件路径。
主程序的 CMakeLists.txt 里要声明依赖:
idf_component_register(SRCS "main.c" INCLUDE_DIRS "." REQUIRES onvif_c nvs_flash esp_wifi esp_netif)3.2 关键依赖与 sdkconfig 配置
ESP-IDF 默认的配置有些地方需要调整。首先是 lwIP 的组播支持,WS-Discovery 依赖它。在 sdkconfig 里确认CONFIG_LWIP_IGMP是打开的,否则收不到组播包。其次是 HTTP 服务器的最大连接数,ONVIF 请求可能并发,建议把CONFIG_LWIP_MAX_SOCKETS调到 16 以上。
内存方面,建议打开CONFIG_ESP32_SPIRAM_SUPPORT(如果你的模组带 PSRAM),把一些大缓冲区放到外部内存。ONVIF 的 SOAP 报文虽然不大,但 RTSP 和摄像头缓冲会吃掉不少内存,提前规划好。
Wi-Fi 配置建议用 station 模式连到和 NVR 同一个路由器。这里有个经验:尽量用有线或者 5GHz Wi-Fi,2.4GHz 在组播场景下丢包率明显更高,Discovery 经常要重试好几次才能被搜到。
3.3 组件接口设计
onvif-c 组件对外暴露的接口应该尽量简单。我一般设计成这样的形式:
// 初始化 ONVIF 服务,传入设备信息和回调 esp_err_t onvif_c_init(const onvif_config_t *config); // 启动 HTTP 服务器和 Discovery 监听 esp_err_t onvif_c_start(void); // 停止服务 esp_err_t onvif_c_stop(void);onvif_config_t里包含设备名、厂商、型号、固件版本、RTSP 地址、鉴权用户名密码等。把这些做成配置结构体,而不是硬编码在组件里,是为了让组件保持通用,不同项目改配置就行。
RTSP 地址这里要注意,ONVIF 返回的 StreamUri 必须是 NVR 能访问到的完整地址,格式类似rtsp://192.168.1.100:554/stream1。IP 地址不能写死成 127.0.0.1,要用设备实际的局域网 IP。可以在启动时通过esp_netif_get_ip_info动态获取。
4. 核心协议实现细节
4.1 WS-Discovery 的组播响应
WS-Discovery 是整个流程的第一关。NVR 会往组播地址 239.255.255.250 的 3702 端口发一个 Probe 报文,内容大概是"谁在这个网段里,是 NetworkVideoTransmitter 类型的设备,请回答"。相机收到后,要单播回一个 ProbeMatches 报文,里面带上自己的服务地址。
实现上有几个要点。第一,要加入组播组。用 lwIP 的 socket 接口,创建 UDP socket 后调用IP_ADD_MEMBERSHIP加入 239.255.255.250。第二,要能收到组播包,socket 必须绑定到 INADDR_ANY 的 3702 端口。第三,响应要单播回请求方的源地址和源端口,不能也走组播。
ProbeMatches 报文里的关键字段是d:EndpointReference和d:XAddrs。XAddrs 就是设备的 ONVIF 服务地址,比如http://192.168.1.100:8000/onvif/device_service。NVR 拿到这个地址后,后续所有 SOAP 请求都往这里发。
注意:组播响应里有个 MessageID 和 RelatesTo 字段,RelatesTo 要填成收到的 Probe 报文里的 MessageID,这样 NVR 才能把响应和请求对应起来。这个字段填错,NVR 会忽略你的响应。
实测下来,Wi-Fi 环境下 Probe 报文丢失是常态。我的做法是在收到 Probe 后延迟一个随机的小时间(比如 0 到 500 毫秒)再回复,避免多台设备同时回复造成碰撞。这个随机延迟在 ONVIF 规范里也有建议,叫 APP_MAX_DELAY。
4.2 SOAP 报文的解析与生成
SOAP 报文本质是 XML。解析时,我不用通用 XML 库,而是针对每种请求做定向提取。比如 GetStreamUri 请求里,我们需要提取 ProfileToken 字段,那就直接在报文里找<trt:ProfileToken>标签,取它和下一个<之间的内容。
这种做法的前提是 NVR 发来的报文格式相对固定。主流 NVR 的报文确实比较规范,但不同厂商可能在命名空间前缀上有差异,比如有的用trt:,有的用tns:。所以查找时最好只匹配标签名部分,忽略前缀。可以用strstr找ProfileToken>,然后往前找到<,往后找到</。
生成响应时,直接拼字符串。把固定的模板和动态字段拼接起来。这里要注意 XML 转义,如果设备名里有&或<这种字符,要转义成&和<,否则 NVR 解析会出错。虽然设备名一般不会用这些字符,但养成转义的习惯能避免很多诡异问题。
响应报文的命名空间声明必须完整。ONVIF 的 SOAP 响应通常要声明s(SOAP envelope)、tds(Device Service)、trt(Media Service)等命名空间。少声明一个,NVR 可能就解析失败。我建议把常用的命名空间都写在一个固定的头部模板里,每次响应直接复用。
4.3 时间同步与 GetSystemDateAndTime
GetSystemDateAndTime 这个接口看起来简单,但坑不少。NVR 在添加设备早期会调用它,如果返回的时间格式不对,或者时区处理有问题,可能导致后续鉴权的时间戳校验失败。
响应里要返回 UTC 时间和本地时间两部分,还有时区信息。UTC 时间用tt:UTCDateTime包裹,本地时间用tt:LocalDateTime。时区用tt:TZ表示,格式是GMT+08:00这种。
ESP32 上获取时间,如果连了网可以用 SNTP 同步。但即使没同步,也要返回一个合理的时间,不能返回 1970 年,否则 NVR 可能认为设备异常。我的做法是启动时先设一个编译时间作为初始值,联网后再用 SNTP 校准。
提示:鉴权用的 Created 时间戳和 GetSystemDateAndTime 返回的时间要一致。如果两者偏差太大,NVR 会认为请求过期而拒绝。所以时间同步这块一定要做对。
4.4 Profile 与 StreamUri 的构造
GetProfiles 返回的是媒体配置列表。对单路相机来说,返回一个 Profile 就够了。Profile 里要包含 ProfileToken(一个唯一标识符,比如 "profile_1")、视频编码配置、分辨率、帧率等信息。
GetStreamUri 请求里会带上 ProfileToken,我们要根据这个 token 返回对应的 RTSP 地址。如果只有一个 Profile,直接返回固定的 RTSP 地址即可。响应里的 Uri 字段就是 NVR 最终用来拉流的地址。
这里有个容易忽略的点:RTSP 地址的 IP 要和 ONVIF 服务的 IP 一致。如果 ONVIF 服务监听在 192.168.1.100,RTSP 地址也必须是这个 IP,不能是别的网卡地址。多网卡或者有 AP 模式的情况下,要确保返回的是 NVR 能访问到的那个地址。
Profile 里的编码信息要和实际 RTSP 流匹配。如果 Profile 里写的是 H.264,实际推的是 MJPEG,NVR 拉流后可能解码失败。虽然不影响添加,但会影响出流显示。建议 Profile 描述和实际编码保持一致。
5. 完整实操流程与验证
5.1 从零到能被搜索到的完整步骤
我把整个流程拆成可执行的步骤,你可以照着一步步来。
第一步,建工程并集成组件。按前面说的目录结构建好,把 onvif-c 组件放进去。先不接摄像头,只让 ONVIF 服务跑起来,确认能被 NVR 搜到。
第二步,配置 Wi-Fi 和网络。在 main.c 里初始化 NVS、netif、event loop,然后连 Wi-Fi。连上后打印出获取到的 IP,确认网络通。
第三步,初始化 ONVIF 组件。填入设备信息,比如厂商 "MyVendor"、型号 "ESP32-CAM"、固件版本 "1.0.0"。RTSP 地址先填一个占位符,等 RTSP 服务起来再改。
第四步,启动服务。调用 onvif_c_start,这时候 HTTP 服务器和 Discovery 监听都起来了。用另一台电脑 ping 一下设备 IP,确认能通。
第五步,用 ONVIF 测试工具验证。PC 上有很多 ONVIF 设备管理工具,可以扫描网段、查看设备、获取流地址。先用工具验证,比直接上 NVR 更容易定位问题。
第六步,接入 NVR。在 NVR 的通道管理里手动添加,输入设备 IP、ONVIF 端口、用户名密码。如果前面都对了,这时候应该能添加成功并出流。
5.2 用测试工具逐项验证接口
不要一上来就接 NVR,先用工具把每个接口单独测通。我常用的验证顺序是这样的:
先测 Discovery。用工具发 Probe,看能不能收到 ProbeMatches。收不到就检查组播配置和防火墙。然后测 GetSystemDateAndTime,看返回的时间格式对不对。接着测 GetCapabilities,确认返回的能力集里包含 Media 服务。再测 GetDeviceInformation,看设备信息是否正确。最后测 GetProfiles 和 GetStreamUri,确认能拿到 RTSP 地址。
每一项都单独验证的好处是,出问题时能快速定位到具体是哪个接口的问题,而不是面对 NVR 的"添加失败"干瞪眼。
| 验证项 | 预期结果 | 常见失败原因 |
|---|---|---|
| Discovery | 收到 ProbeMatches | 组播未加入、防火墙拦截 |
| GetSystemDateAndTime | 返回合法时间 | 时间格式错误、时区缺失 |
| GetCapabilities | 含 Media 服务地址 | 命名空间声明不全 |
| GetDeviceInformation | 返回厂商型号 | 字段拼写错误 |
| GetProfiles | 返回 Profile 列表 | ProfileToken 缺失 |
| GetStreamUri | 返回 RTSP 地址 | IP 地址错误 |
5.3 鉴权开启后的联调
前面建议先关鉴权调试,等接口都通了再打开。打开鉴权后,重点验证 WS-Security 的摘要计算。
先在工具里配置用户名密码,发一个带 UsernameToken 的请求,看设备能不能正确校验。如果返回 401,说明摘要算错了。排查顺序是:先确认 nonce 是否正确 Base64 解码,再确认 created 时间戳格式是否是 ISO8601,最后确认密码拼接顺序。
摘要计算的伪代码是这样的:
// nonce_b64 是收到的 Base64 字符串 // 先解码成原始字节 int nonce_len = base64_decode(nonce_b64, nonce_raw); // 拼接 nonce_raw + created + password memcpy(buf, nonce_raw, nonce_len); memcpy(buf + nonce_len, created, strlen(created)); memcpy(buf + nonce_len + strlen(created), password, strlen(password)); // SHA1 sha1(buf, total_len, digest); // Base64 编码 base64_encode(digest, 20, digest_b64);把算出来的 digest_b64 和请求里的 Digest 字段比对,一致就通过。这个逻辑看着简单,但拼接顺序、编码方式任何一处错了都会失败,建议把中间结果都打印出来对照。
5.4 接入 NVR 的实操记录
接口都验证通过后,接 NVR 通常就顺了。以常见的 NVR 添加流程为例:进入通道管理,选择"手动添加"或"自定义添加",协议选 ONVIF,填入设备 IP、ONVIF 端口(一般是 80 或 8000)、用户名密码。保存后 NVR 会依次发起前面说的那几个请求。
如果添加成功但不出流,问题多半在 RTSP 侧。检查 RTSP 服务是否真的在推流,地址是否和 GetStreamUri 返回的一致,编码格式是否匹配。我遇到过 Profile 里写 H.264 但实际推 MJPEG 的情况,NVR 添加成功但画面黑屏,改成一致就好了。
如果添加时提示"设备未响应",回到 Discovery 和 GetCapabilities 检查。如果提示"用户名密码错误",检查鉴权摘要。如果添加成功但过一会儿掉线,检查心跳和 keepalive 机制,ONVIF 没有强制心跳,但有些 NVR 会定期重新拉取设备信息,要保证服务一直稳定运行。
6. 常见问题与排查技巧实录
6.1 Discovery 搜不到设备的排查
这是最高频的问题。排查思路按这个顺序走:先确认设备真的加入了组播组,可以在代码里打印IP_ADD_MEMBERSHIP的返回值。再确认防火墙没拦截 3702 端口,ESP32 本身没有防火墙,但路由器可能有 AP 隔离,导致组播不通。然后确认 NVR 和相机在同一网段,跨网段组播默认是不通的。
如果都正常还是搜不到,用抓包工具在 PC 上看有没有收到 Probe 报文,以及设备有没有回 ProbeMatches。抓包是最直接的定位手段,能看到到底是请求没到还是响应没回。
还有一个隐蔽的坑:有些 NVR 的 Discovery 超时很短,如果设备响应慢了就认为不存在。前面提到的随机延迟如果设得太大(比如超过 1 秒),反而会导致搜不到。建议延迟控制在 500 毫秒以内。
6.2 鉴权失败的典型原因
鉴权失败基本集中在几个点上。最常见的是 nonce 处理错误,很多人直接把 Base64 字符串拿去参与摘要计算,忘了先解码。其次是时间戳格式,ONVIF 要求 ISO8601 格式,带毫秒和时区,比如2024-01-01T12:00:00.000Z,少个 Z 或者格式不对都会失败。
还有一个是密码编码问题。如果密码里有特殊字符,拼接时的字节处理要小心。建议统一按 UTF-8 字节处理,不要做额外的编码转换。
排查时最有效的办法是把服务端算出的 digest 和客户端发来的 digest 都打印出来,逐字节比对。如果长度都不一样,那肯定是编码环节出了问题。
6.3 内存不足与稳定性问题
ESP32 上跑 ONVIF 加 RTSP,内存是绕不开的坎。常见现象是运行一段时间后崩溃,或者请求多了就返回失败。排查时先看esp_get_free_heap_size的数值,如果低于 20KB 就要警惕了。
优化方向有几个。一是减少动态分配,SOAP 报文的缓冲区用静态数组而不是 malloc。二是复用缓冲区,不要每个请求都新申请。三是把大缓冲区放到 PSRAM。四是检查有没有内存泄漏,尤其是 socket 用完有没有关闭。
提示:HTTP 服务器的连接如果没正确关闭,socket 会一直占用,时间长了就耗尽。确保每个请求处理完都正确关闭连接。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| NVR 搜不到设备 | 组播不通、响应超时 | 检查组播配置、减小延迟 |
| 添加时提示未响应 | 接口未实现或报错 | 逐项验证 SOAP 接口 |
| 提示密码错误 | 鉴权摘要算错 | 检查 nonce 解码和时间格式 |
| 添加成功但黑屏 | RTSP 地址或编码不符 | 核对 StreamUri 和实际流 |
| 运行一段时间掉线 | 内存泄漏或 socket 耗尽 | 检查资源释放 |
| 时间显示异常 | 时区或格式错误 | 核对 ISO8601 格式 |
6.5 几个踩过的坑和经验
第一个坑是命名空间前缀。不同 NVR 发来的报文前缀不一样,我一开始按固定前缀匹配,结果换个 NVR 就解析失败。后来改成只匹配标签名,忽略前缀,兼容性好了很多。
第二个坑是 SOAPAction 头。有些 NVR 靠 HTTP 头里的 SOAPAction 来区分请求类型,如果不返回或者返回错误,NVR 会认为请求失败。虽然规范里 SOAPAction 不是必须的,但为了兼容性,最好按请求类型返回对应的值。
第三个坑是并发请求。NVR 有时会同时发多个请求,如果 HTTP 服务器是单线程处理,后面的请求会超时。建议用多任务或者至少保证请求处理足够快,别在请求处理里做耗时操作。
第四个坑是固件版本字符串。有些 NVR 会解析固件版本做兼容性判断,如果格式太奇怪可能被拒绝。建议用标准的主版本.次版本.修订号格式。
7. 后续可扩展的方向
最小闭环打通后,这个项目还有很多可以深挖的地方。比如加上 PTZ 控制,让 NVR 能控制云台转动,这需要实现 PTZ Service 的几个接口。再比如加上事件订阅,让相机能主动上报移动侦测等事件。还有多码流支持,返回多个 Profile,让 NVR 能选择主码流或子码流。
安全方面也可以加强,比如支持 HTTPS 的 ONVIF 服务,或者更严格的鉴权策略。性能方面可以优化 SOAP 解析,减少字符串操作的开销。
我个人在实际操作中的体会是,ONVIF 这个协议看着吓人,但真正需要的核心部分并不多。把最小闭环打通后,剩下的都是在这个框架上做加法。关键是别一开始就追求大而全,先把 Discovery、Device、Media 这三块做扎实,让 NVR 能稳定添加和出流,后面再按实际需求扩展。踩过的那些坑,说到底都是对协议细节理解不到位造成的,多抓包、多打印中间结果,问题总能定位到。