
干安防或者自己做相机的朋友应该都有过这种经历拿 ESP32 加一个摄像头模组费了半个月劲把 RTSP 推流调通了VLC 里拖进去秒出画面成就感刚上来拿着去接客户的 NVR海康、大华的录像机一测设备搜索列表里空空如也。这不是你的 H.264 编码有问题也不是 RTSP 拉流写错了而是压根没走 ONVIF 这套“自我介绍”协议。NVR 不认识你你就只能靠手动输 IP 和 RTSP 地址碰运气。这篇手册就是干这个的讲清楚怎么用 onvif-c 这个 ESP-IDF 下的 plain C 组件从零建工程到最后写出一台能被 NVR 自动发现、添加、拉流的 ESP32 相机。1. 为什么必须上 ONVIFNVR 到底在“搜索”什么1.1 RTSP 裸流与 NVR 之间缺的那一层很多人觉得 RTSP 就是流媒体的一切URL 给出来、端口开着VLC 能放就等于大功告成。但 NVR 的工作逻辑完全不同。NVR 拿到一台新设备时第一步不是猜 RTSP 地址而是先通过多播发出一个网络探测请求等设备回应“我是谁、我是干什么的、我在哪个位置、我支持哪些服务”。这一整套“设备发现 能力自述 服务协商”的流程就是 ONVIF 定义的东西。你只跑一个 RTSP 服务器相当于一个人站在会场里却不递名片不自我介绍对方自然默认你是个路人。所以在 ESP32 上做摄像头如果目标是能被主流 NVR 直接添加ONVIF 不是可选项而是准入门槛。它解决的是“让 NVR 在不知道你任何参数的情况下也能通过标准流程把你找出来并完成添加”的问题。1.2 onvif-c 组件到底是什么为什么选 plain Convif-c 是一个用纯 C 写的 ONVIF 协议栈实现专门面向 ESP-IDF 这种嵌入式环境。它跟你在网上随处可见的那种跑在 Linux 上、依赖一大堆动态库的 ONVIF SDK 不一样核心诉求就俩字轻、省。ESP32 的 RAM 以 KB 计Flash 就算外扩也经不起动不动几 MB 的开销因此用纯 C 实现、按需裁剪功能比拉一整个 C 库进来要现实得多。选 plain C 还有个很现实的原因ESP-IDF 本身虽然是 C 写的但很多摄像头驱动、RTSP server 组件是分属于不同作者的独立项目接口风格五花八门。你用 C 写一套跟 C 语言的驱动对接时到处都要套 extern C麻烦不说还容易在 C 和 C 的 ABI 边界上踩坑。纯 C 实现意味着我可以把所有 onvif 相关逻辑都收敛在几个 .c 文件里组件边界清爽编译时序也简单这在 wrover 这类 flash 不太富裕的模组上优势很明显。注意这里的 onvif-c 不是官方标准组织发布的代码它是社区整理的嵌入式友好型实现。你用的时候要把它当成一个“半成品协议骨架”来对待很多细节响应还是需要自己补全的这在后面会详细说。2. 从空目录到编译通过工程搭建与组件化设计2.1 环境准备ESP-IDF 版本怎么选先说版本。ESP-IDF 的 master 分支改动很频繁onvif-c 这类社区组件往往没有余力追每一个新 API。我用的是 ESP-IDF v5.1 系列原因有两个一是它跟大部分摄像头驱动esp32-camera的默认分支兼容得比较好二是 FreeRTOS 和 lwIP 的接口稳定不像 v5.2 之后在 Wi-Fi 和 netif 上动过不少东西。如果你手头项目已经上了 v5.3 也没关系但要有心理准备可能需要手动修几处 netconn 或者 socket 相关的编译错误。安装方式就按官方文档走Windows 上用 ESP-IDF Tools InstallerLinux 上直接克隆 esp-idf 仓库再跑 install.sh。如果你是在内网环境工作装完工具链之后记得把 GitHub 的下载源替换成国内源否则下载 toolchain 的时候会让你怀疑人生。这里有一步很多人忽略装完 ESP-IDF 之后一定要跑一遍 export.shWindows 上是 export.bat来设置环境变量之后再打开新的终端窗口执行 idf.py 才找得到命令。相比之下Arduino IDE 装 esp32 内核的方式要简单得多但 ONVIF 这种需要精细控制 socket、多线程和内存布局的活儿用 ESP-IDF 才说得上顺手所以别图省事跑回 Arduino 环境。2.2 组件目录结构如何把 onvif-c 放进工程工程我建议直接建一个空白模板然后手动搭目录比用 idf.py create-project 再删一堆默认代码更清爽。最终结构大致是这样my_camera_app/ ├── CMakeLists.txt ├── sdkconfig.defaults ├── main/ │ ├── CMakeLists.txt │ ├── app_main.c # 初始化网络、摄像头、RTSP、ONVIF │ └── camera_rtsp.c # 你的 RTSP 推流逻辑 ├── components/ │ ├── onvif-c/ # onvif 协议栈组件 │ │ ├── CMakeLists.txt │ │ ├── include/ │ │ │ └── onvif_server.h │ │ └── src/ │ │ ├── onvif_server.c │ │ ├── onvif_discovery.c │ │ ├── onvif_media.c │ │ ├── soap_parser.c │ │ └── ... │ ├── minixml/ # XML 解析组件 │ └── cjson/ # 通常 esp-idf 自带但为了锁版本也可以单独拉onvif-c 组件内部我习惯拆成三个核心模块以 onvif_server.c 为核心的请求分发模块负责接收 SOAP 请求并调用对应handler以 onvif_discovery.c 为核心的 WS-Discovery 模块负责监听组播和回复探测以及以 onvif_media.c 为核心的媒体配置模块负责 GetProfiles、GetStreamUri 这类业务响应。剩下的 soap_parser.c 是公共基础负责解析收到的 SOAP/XML 包提取 Action 和参数。2.3 CMakeLists 怎么写组件依赖和构建参数每个组件都要有自己的 CMakeLists.txt引入 onvif-c 之后最关键的是要让编译器找到 minixml 的头文件并且把需要参与的源文件列全。这里给出我工程里实际能用的组件 CMakeLists# components/onvif-c/CMakeLists.txt idf_component_register( SRCS src/onvif_server.c src/onvif_discovery.c src/onvif_media.c src/soap_parser.c INCLUDE_DIRS include REQUIRES cjson minixml )顶层 CMakeLists 要做什么呢其实 ESP-IDF 工程顶层 CMakeLists 基本是模板改动非常少cmake_minimum_required(VERSION 3.16) include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(my_camera_app)真正需要仔细配置的是 main/CMakeLists.txt因为 main 要同时依赖 onvif-c、esp32-camera 和你的 RTSP 组件。这里有个小坑某些版本的 esp32-camera 组件会把跳线检测和帧缓冲分配的逻辑跟 main 绑得很紧如果你一编译就报 undefined reference优先检查 REQUIRES 是不是漏了 driver 或者 esp_wifi。以下是我的 main 组件配置idf_component_register( SRCS app_main.c camera_rtsp.c INCLUDE_DIRS . REQUIRES onvif-c esp32-camera esp_wifi nvs_flash driver )注意REQUIRES 里的组件顺序原则上不影响编译但若同时出现 cjson 和 minixml建议把 minixml 放前面因为 onvif-c 的 XML 解析对顺序不敏感但某些老版本 esp32-camera 会在初始化时踩到 cjson 的全局内存配置先编 minixml 能少碰几个 due to static init 的怪问题。2.4 sdkconfig 关键项mbedtls 和内存ONVIF 的认证走的是 WS-Security UsernameToken 摘要认证底层依赖 mbedtls 的 SHA1 和 Base64 编解码。ESP-IDF 默认配置里 mbedtls 的许多功能是裁剪过的如果你在编译阶段没看到报错但在运行到认证时反复握手失败十有八九是 mbedtls 的 X509 和 SHA1 相关宏没开。我在 sdkconfig.defaults 里会固定加上这几项CONFIG_MBEDTLS_HARDWARE_SHA1y CONFIG_MBEDTLS_HARDWARE_SHA2y CONFIG_MBEDTLS_PKCS1_V15y CONFIG_MBEDTLS_X509_CRL0注意 CONFIG_MBEDTLS_X509_CRL 不是必选项但关闭它可以显著减少 ROM 占用。另外如果你计划同时跑 Wi-Fi、RTSP 和 ONVIF 服务内存压力不小。我建议至少用 ESP32-WROVER 这类带 8MB PSRAM 的模组并且在 sdkconfig 里把 PSRAM malloc 优先级调到高于普通 heapCONFIG_SPIRAMy CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL4096这样帧缓冲和 RTSP 的发送缓冲就能尽量落到 PSRAM不会把内部 SRAM 挤爆。3. 核心协议拆解SOAP、WS-Discovery 和媒体配置3.1 WS-Discovery让 NVR 能在列表里看到你的关键NVR 添加设备的第一步是网内搜索。它向组播地址发送一个 Probe 消息目标端口是 3702消息里会带上它要找的设备类型ONVIF 规定摄像头必须声明为dn:NetworkVideoTransmitter。你的 ESP32 收到这条 Probe 之后得回一个 ProbeMatch 消息里面要带上自身的地址、端口还有 Scopes 信息比如硬件标识、MAC 地址或地理位置。注意WS-Discovery 是走 UDP 的回复消息要发回到探测包里标记的 UDP 源地址要处理多播和单播两种模式。实现里有个容易翻车的地方Probe 消息通常不止一条NVR/ONVIF Device Test Tool 会连续发送多次探测且每次用的 MessageID 不同。如果你实现的回复逻辑只能在工程启动后回复一次那大概率会在 NVR 的重试机制面前直接“隐身”。我的做法是把 discovery 处理放到一个独立 UDP 任务里循环 recvfrom每次都做完整的 XML 解析和响应组包不做“已响应过”标记。这样无论 NVR 什么时候发起搜索都能可靠响应。void onvif_discovery_task(void *arg) { int sock socket(AF_INET, SOCK_DGRAM, 0); // bind 到 0.0.0.0:3702并加入组播组 239.255.255.250 while (1) { ssize_t len recvfrom(sock, buf, sizeof(buf), 0, addr, addrlen); if (len 0) { continue; } if (soap_parse_action(buf, len, ws-discovery/Probe)) { onvif_send_probe_match(sock, addr, buf, len); } } }3.2 设备信息与能力自述GetDeviceInformation 和 GetCapabilities设备被发现之后NVR 会进一步向设备发送 SOAP 请求索取更详细的信息。这其中一个典型请求就是 GetDeviceInformation它要求设备返回厂商、型号、固件版本、序列号、MAC 地址等。NVR 拿这些信息去填充设备列表界面所以这一块更多地是关于响应的规范性。你必须保证用户名和密码字段不空否则大多数 NVR 会把设备判定为“未认证”直接跳过。在以 明文存储配置的块设备或 NVS 里不要明文保存密码至少做一层简单加固这是所有做设备端的人都应该牢记的基本卫生习惯。GetCapabilities 也是这个阶段的常客。NVR 会确认设备范能力集比如是否支持媒体服务、是否支持 PTZ、事件服务等。onvif-c 里通常会把能力集简化只需要在媒体服务部分向 NVR 保证“我是支持 RTSP 的”即可像 PTZ 这种不需要的功能直接回 false 或者不返回相关服务地址NVR 就不会往那个方向去尝试。3.3 媒体配置GetProfiles 和 GetStreamUri 是 NVR 拉流的入口NVR 添加完设备之后会向媒体服务发 GetProfiles 请求目的是拿到设备的编码预设列表。这个响应里最核心的内容是 Profile 的 token 和这个 Profile 对应的编码格式。例如一个 Profile 可以定义成 H264 主档、分辨率 1280x720、码率控制 VBR、帧率 15fps。每个 Profile 都要有唯一的 token字符串内容随意但建议用一个稳定的常量而不是每次生成的随机串原因后面讲。拿到 Profile 列表后NVR 会在用户确认画面的时候发 GetStreamUri 请求参数里带着之前拿到的 ProfileToken。GetStreamUri 的响应要返回 RTSP 拉流地址最常见的是返回rtsp://192.168.1.100:554/live0这样。这里的地址字符串看起来简单但其实是大坑的重灾区如果返回的 URL 里 IP 是内网正确地址但端口不匹配你启动的 RTSP 服务器监听端口NVR 就会直接跳过这一路导致你看着 NVR 明明显示了设备却永远黑屏。!-- GetStreamUri 必须返回的 RTSP 配置示例 -- GetStreamUriResponse MediaUri Urirtsp://192.168.1.100:554/live0/Uri InvalidAfterConnectfalse/InvalidAfterConnect InvalidAfterRebootfalse/InvalidAfterReboot TimeoutPT30S/Timeout /MediaUri /GetStreamUriResponse3.4 Digest 认证别在这种地方被 NVR 卡住ONVIF 1.2 及以后版本强制要求 WS-Security UsernameToken 的密码摘要认证。简单解释一下它的流程设备端管理用户名和密码当 NVR 发来一个带认证信息的 SOAP 请求时服务器端拿到 Nonce、Created 时间戳和 PasswordDigest先在本地用同样的算法Base64(SHA1(Nonce Created Password))计算摘要然后对比两个摘要是否一致。在嵌入式实现里常见错误有两个一个是通信双方的系统时间不同步。ESPD32 如果没接 RTC 也没有 NTP默认时间停留在 1970 年而 NVR 上的是真实时间这会导致 Created 字段永远对不上认证一直失败。另一个是 Nonce 随机数生成问题如果你每一次都返回相同的 Nonce某些严格检测的 NVR 会认为你在重放攻击。我的经验是至少用 esp_random() 生成一个 16 字节随机数并且把 Nonce 生成逻辑放在每次握手的前一步。4. 实操现场把 ESP32 相机接到 NVR 的完整流程4.1 先跑通 RTSP 再把 ONVIF 叠上去说句实在话ONVIF 和 RTSP 是两个独立服务但 NVR 添加设备的成功与否要同时看两者配合得好不好。我先建议你把 RTSP 服务器独立跑通用 VLC 确认输入rtsp://设备IP:554/live0能出画面然后再把 ONVIF 组件挂进去。为什么这么排序因为如果 ONVIF 调试时画面黑屏你会不知道问题出在拉流协商还是推流本身定位起来非常痛苦。在 ESP32 上跑 RTSP 服务器常见的选择是参考开源项目里用 POSIX socket 自己撸一个简易 RTP 会话。只做单路 H.264 的话不需要太复杂监听 TCP 554 端口处理 OPTIONS、DESCRIBE、SETUP、PLAY 四个方法就够了。关键是 SETUP 处理的 Transport 头要正确解析否则 VLC 能放、NVR 可能不放因为 NVR 会在 Transport 头里附带自己的客户端端口和接收模式你必须原样尊重这些参数。4.2 配置网络和 ONVIF 端口NVR 的搜索范围基本都在同一局域网所以给 ESP32 一个静态 IP 会比 DHCP 省心得多。我在 app_main.c 里固定用 DHCP 先启动等拿到 IP 之后打印出来然后你再手动改成静态 IP 做生产测试。无论是 DHCP 还是静态 IPIP 必须能跟 NVR 互通这是所有调试的前提。ONVIF 默认走 HTTP 80 端口ESP32 上同时跑摄像头驱动和 ONVIF HTTP 服务建议你把 80 端口留给 ONVIFRTSP 用 554。如果这两个端口在路由器或交换机的 VLAN 配置里被隔离了NVR 即使发现了设备也拉不了流。这里的排查要点是用电脑连到同一网段访问http://设备IP/onvif/device_service看是否能拿到 SOAP 响应。4.3 使用 ONVIF Device Test Tool 做标准验证实际生产前我强烈建议先用 ONVIF Device Test Tool官方测试工具跑一遍。它会模拟 NVR 去做网络搜索、设备信息读取、视频流地址获取等一系列流程还会给出每个项目的 PASS/FAIL 提示。我在开发时就是一边改代码一边用这个工具轮询效率比直接抱着一台真实 NVR 反复试要高得多。具体操作顺序是打开工具选择网卡并搜索设备找到你的 ESP32 之后它会显示设备信息然后再进入媒体配置选择 Profile点击“获取流地址”验证工具会弹出一个 RTSP 地址并可以预览画面。这里的 PASS 与 FAIL 信息极其有用。如果你遇到 GetProfiles 返回空列表先检查 SOAP 响应的命名空间是否严格遵循 ONVIF 规范缺一个前缀都可能被判定失败。4.4 真实 NVR 添加过程与关键步骤测试工具通过之后真机添加就是水到渠成的事。以常见 NVR 为例在添加设备时选择“一键添加/自动搜索”NVR 会发起 ONVIF 探测这时你的 WS-Discovery 任务已经在循环监听必然能触发响应。设备列表里会出现你设置的厂商、型号、IP 等信息。点击添加时一般要求输出 ONVIF 用户名密码这里的用户名和密码就是你在 onvif-c 组件里配置的账号最好把用户名固定成 admin 以外的一个自定义值比如 device_user这样能避开不少 OEM 固件的默认账号检查策略。添加成功后还会有一个“激活”或“初始化”过程需要设置设备管理密码。有些 NVR 会先向设备发送 SetUser 命令来更新密码如果你的 onvif-c 没有实现 SetUser 接口部分老款 NVR 会直接卡在初始化阶段。解决办法有两个一是自动跳过 NVR 的激活流程把设备状态设置为“已激活”二是实现一个无操作的 SetUser 响应返回空成功。5. 我踩过的坑问题排查与避坑技巧实录5.1 问题速查表现象排查方向可能原因与解决方案NVR 搜索不到设备组播是否监听成功确认已加入 239.255.255.250 组播组且 binds 0.0.0.0:3702能发现但添加失败GetDeviceInformation 响应是否规范检查用户名/密码字段是否为空、命名空间是否正确添加成功但黑屏GetStreamUri 返回地址错误确认 RTSP URL 端口与 RTSP 监听端口一致、编码格式是 NVR 支持的 H264Digest 认证失败系统时间是否同步开启 SNTP 客户端或手动同步到当前时间检查 Nonce 随机性GetProfiles 返回空命名空间、ProfileToken 是否稳定不要把 token 设为 NULL 或每次随机生成使用固定字符串“0”也可以响应包被 NVR 丢弃SOAP 报文格式是否合法用测试工具抓包对比标准报文检查SOAP-ENV:Envelope前缀和 Header/Action 字段5.2 独家经验从底层细节谈稳定运行不要以为 ONVIF 交互谈完了就完事了稳定运行同样重要。我在做这个项目时曾遇到 NVR 每隔几秒就向设备轮询一次 GetSystemDateAndTime 和 GetDeviceInformation导致 onvif 任务占用过高 CPU直接拖累了 RTSP 推流线程。解决办法是把频繁调用的 handler 设置成轻量缓存路径首次读取时缓存数据后续直接返回缓存。摄像头的信息在设备运行期间基本不变这个缓存策略完全适用。另一个值得说的事是线程栈大小。ESP-IDF 默认任务栈给到 2048 字word有时会溢出在 SOAP 解析时尤其容易崩。我试过把 discovery 任务栈配置为 4096 wordHTTP 解析任务栈 5120 word从此再没出现过莫名其妙的复位。排查栈溢出有个笨但有效的办法在关键函数入口和出口打印 xTaskGetStackHighWaterMark如果剩余低于 200 字就该调大栈了。最后一个提醒测试时手上至少要有一台能随便用网线插的交换机和一个支持抓包的笔记本。ONVIF 底层是 SOAP over HTTP报文结构不算复杂但协议交互时序非常讲究。有一次我排查一个“设备被部分NVR发现但Hikvision找不到”的诡异问题最后就是用 Wireshark 抓组播包才定位到是 Probe 响应报文里的 Scopes 多了个非法 URI 前缀。说实话ONVIF 这种标准协议现场调试时能少走 80% 的弯路全靠抓包看得懂报文。如果你还没装 Wireshark建议立刻装一个并学会只看两个过滤条件udp.port 3702和http ip.addr 你的设备IP。我把这套流程跑下来之后最大的体会是ONVIF 本身并不神秘它不会把你从零写的 RTSP 推流变成更高级的东西但它是你进入“通用安防生态”的入场券。让 ESP32 能被人家的 NVR 添加比音视频编码上的打磨更能说明你这台相机的完整性。后面如果你想把功能做深还可以在这个组件上继续扩展 PTZ 控制、事件报警、运动检测上报等接口骨架不变只是往 Profile 里多塞几个能力声明的事。