1. 项目缘起与整体设计思路1.1 这个组件到底解决什么问题做过安防监控的朋友都知道市面上大部分网络摄像机IP Camera出厂时都会带一个 ONVIF 协议接口用来让 NVR网络录像机或者第三方客户端自动发现设备、拉取 RTSP 视频流、控制云台、查询设备信息。ONVIF 本质上是一套基于 SOAP/XML 的 Web Service 规范它规定了设备发现WS-Discovery、设备管理Device Management、媒体配置Media、事件Events等多个服务模块的接口格式。问题在于如果你拿一块 ESP32 想自己做一个能被 NVR 添加的相机官方 ESP-IDF 里并没有现成的 ONVIF 组件。你要么自己从零手写 SOAP 解析和 WS-Discovery 组播响应要么去网上找零散的示例代码但那些代码往往只覆盖了“设备发现”这一小段真正要让 NVR 完整添加并出图还差设备信息服务GetDeviceInformation、能力集GetCapabilities、媒体配置GetProfiles、GetStreamUri等一大堆接口。onvif-c这个组件就是冲着这个痛点来的。它是一个纯 C 语言写的 ESP-IDF 组件不依赖 C、不依赖额外的 XML 库内部用轻量级解析把 ONVIF 设备端最核心的几个服务接口都实现了目标很明确让你在 ESP32 上跑起一个能被主流 NVR 识别、添加、并正常拉流的相机固件。它适合谁适合做安防类硬件产品的嵌入式工程师、做 ESP32 音视频项目的开发者以及想搞清楚 ONVIF 协议到底怎么在资源受限设备上落地的人。1.2 为什么选择纯 C ESP-IDF 组件形态这里要先解释一个关键选型为什么是纯 C而不是 C原因很实际。ESP32 上跑 ONVIF 服务端本身就是一个内存敏感的场景。C 的异常处理、RTTI、STL 容器在嵌入式环境里会带来额外的 flash 和 RAM 开销而且很多做安防固件的团队代码基线就是 C引入 C 会打乱整个构建体系。纯 C 实现意味着你可以把它当成一个普通的 ESP-IDF 组件往components/目录一放CMakeLists.txt里注册一下就能用不需要改编译器标准也不需要链接 libstdc。另一个选型点是“组件形态”而非“独立工程”。ESP-IDF 的组件机制允许你把 ONVIF 功能做成一个可复用的模块通过idf_component_register声明依赖比如esp_http_server、lwip、nvs_flash然后在主工程里#include onvif.h调用初始化接口。这样做的好处是你的相机固件可以同时包含摄像头驱动、RTSP 服务、ONVIF 服务三者解耦各自独立调试。如果 ONVIF 部分出问题你可以单独关掉它不影响 RTSP 出流这对排查问题非常关键。从协议实现角度看ONVIF 的设备发现依赖 WS-Discovery也就是 UDP 组播239.255.255.250:3702。ESP32 的 lwip 协议栈支持组播但需要正确配置 IGMP 和网络接口。组件内部把组播 socket 的创建、加入组播组、接收 Probe 消息、回复 ProbeMatch 这一整套流程封装好了你只需要在初始化时传入设备的基本信息UUID、名称、IP 等。这里有个容易踩的坑很多新手直接用单播 socket 去收组播包结果 NVR 根本发现不了设备。组件内部强制使用IP_ADD_MEMBERSHIP加入组播组并且绑定到INADDR_ANY这是经过实测能稳定被 NVR 发现的配置。1.3 整体架构分层把onvif-c拆开看它大致分为四层。最底层是网络传输层负责 UDP 组播收发WS-Discovery和 TCP HTTP 服务SOAP over HTTP。往上一层是 SOAP 消息层负责解析 NVR 发来的 XML 请求提取 Action 和参数同时构造符合 ONVIF 规范的 XML 响应。再往上是服务实现层包括 Device Service、Media Service、Event Service 等每个服务对应一组 Action 处理函数。最上层是应用接口层暴露给用户的只有几个函数初始化、启动、停止、设置回调。这种分层的好处是当你想扩展一个新的 ONVIF 接口时只需要在服务实现层加一个 Action 处理函数然后在 SOAP 消息层注册这个 Action 的路由即可不需要动网络层。组件内部用一个简单的“Action 字符串到处理函数指针”的映射表来实现路由比用一堆 if-else 清晰得多。实测下来这种结构在 ESP32 上跑起来单个 SOAP 请求的处理时间在 10ms 以内完全能满足 NVR 的轮询节奏。2. 核心细节解析与实操要点2.1 WS-Discovery 的设备发现机制NVR 添加相机的第一步是发一个 WS-Discovery 的 Probe 消息到组播地址。这个消息里通常包含一个Types过滤条件比如dn:NetworkVideoTransmitter表示“我要找网络视频发送设备”。onvif-c收到这个 Probe 后需要判断自己的设备类型是否匹配如果匹配就回复一个 ProbeMatch里面包含设备的 Endpoint Reference也就是 UUID和 XAddrs设备服务地址通常是http://ip/onvif/device_service。这里的关键细节是 UUID 的生成和持久化。ONVIF 要求每个设备有唯一的 UUIDNVR 用它来区分不同设备。如果你每次重启都生成新的 UUIDNVR 会认为这是一个新设备之前添加的记录就失效了。组件的做法是首次启动时用esp_random()生成一个 UUID然后存到 NVS非易失存储里后续启动直接读取。这个逻辑看似简单但很多 DIY 项目就是栽在这里导致 NVR 里出现一堆重复设备。另一个细节是 XAddrs 里的 IP 地址。设备必须告诉 NVR“我的服务在这个 IP 上”。如果 ESP32 用的是 DHCPIP 可能会变所以组件在回复 ProbeMatch 时会实时获取当前网络接口的 IP 地址而不是用启动时缓存的旧地址。实测中如果 IP 变了但 XAddrs 没更新NVR 会尝试连接旧 IP导致添加失败。组件内部通过esp_netif_get_ip_info动态获取确保地址始终正确。注意WS-Discovery 的回复必须在一个很短的时间内发出通常 NVR 等待 3 秒左右所以组件的 Probe 处理函数里不能做耗时操作比如读文件、等锁。所有需要的数据UUID、名称、IP都应该在初始化阶段准备好Probe 来了直接拼 XML 回复。2.2 SOAP 请求解析与 Action 路由NVR 发现设备后会通过 HTTP POST 向http://ip/onvif/device_service发送 SOAP 请求。一个典型的请求体长这样soap:Envelope soap:Body GetDeviceInformation xmlnshttp://www.onvif.org/ver10/device/wsdl/ /soap:Body /soap:Envelopeonvif-c的 HTTP 处理函数收到请求后首先要从 XML 里提取出 Action 名称。这里没有用完整的 XML 解析库而是用一个轻量级的字符串扫描函数定位soap:Body后面的第一个元素标签名。这样做的好处是代码体积小、速度快缺点是要求 NVR 发来的 XML 格式比较规范主流 NVR 都符合。提取到 Action 后去路由表里查找对应的处理函数。路由表是一个静态数组每个条目包含 Action 字符串和函数指针。比如GetDeviceInformation对应handle_get_device_informationGetProfiles对应handle_get_profiles。查找时用strcmp逐个比较虽然效率不是最高但 Action 数量有限通常十几个实测对性能没有影响。如果找不到匹配的 Action组件会返回一个 SOAP Fault告诉 NVR“这个操作不支持”。这里有个经验有些 NVR 会先发一个GetServices来查询设备支持哪些服务组件需要正确回复这个请求列出 Device 和 Media 服务的地址否则 NVR 可能直接放弃添加。2.3 媒体配置与 RTSP 地址返回NVR 添加设备的最后一步通常是调用GetProfiles获取媒体配置然后调用GetStreamUri拿到 RTSP 流地址。GetProfiles的响应里需要包含 Profile 的 token、名称、编码格式、分辨率、帧率等信息。onvif-c内部维护一个 Profile 结构体初始化时由用户传入比如MainStream对应 1080pSubStream对应 720p然后在响应里按 ONVIF 的 XML 格式拼出来。GetStreamUri的响应就是一个 RTSP URL比如rtsp://192.168.1.100:554/stream1。这里的关键是这个 URL 必须和你的 RTSP 服务实际监听的地址一致。很多开发者 ONVIF 部分配的是/stream1但 RTSP 服务实际用的是/live结果 NVR 拿到 URL 后拉流失败提示“无法连接”。组件的做法是在初始化时让用户传入 RTSP 的路径和端口然后统一拼装避免不一致。提示如果你的 RTSP 服务需要鉴权GetStreamUri的响应里可以带上用户名密码格式是rtsp://user:passip:port/path。但要注意有些 NVR 不支持 URL 里带鉴权信息这时候需要在 ONVIF 的GetStreamUri响应里设置StreamSetup的Transport为RTSP并让 NVR 通过 ONVIF 的鉴权机制来获取凭据。实测中大部分 NVR 对 URL 带鉴权的支持是 OK 的但海康和大华的某些型号需要额外配置。2.4 内存与性能的平衡ESP32 的 RAM 有限跑 ONVIF 服务端时最大的内存开销来自 XML 响应的拼装。一个GetProfiles的响应可能有 1-2KB如果每次请求都动态分配大块内存容易造成碎片。组件的做法是使用一个静态的发送缓冲区比如 4KB所有响应都在这个缓冲区里拼装然后通过 HTTP 发送。这样做的前提是响应内容不能超过缓冲区大小所以组件对 Profile 的数量做了限制通常不超过 4 个。另一个性能点是 HTTP 连接的处理。ESP-IDF 的esp_http_server默认支持多连接但 ONVIF 的请求通常是短连接NVR 发完请求就关。组件配置了max_open_sockets为 4stack_size为 8192实测在 NVR 轮询时 CPU 占用率在 15% 左右240MHz 主频。如果同时有多个 NVR 连接建议把max_open_sockets调到 7但要注意每个 socket 都会消耗内存需要根据实际可用 RAM 权衡。3. 实操过程与核心环节实现3.1 工程创建与组件集成假设你已经装好了 ESP-IDF建议 v5.0 以上第一步是创建一个新工程idf.py create-project esp32_onvif_cam cd esp32_onvif_cam然后把onvif-c组件放到components/目录下。目录结构应该是这样的esp32_onvif_cam/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ └── main.c └── components/ └── onvif-c/ ├── CMakeLists.txt ├── include/ │ └── onvif.h └── src/ ├── onvif_device.c ├── onvif_media.c └── onvif_discovery.c在main/CMakeLists.txt里声明依赖idf_component_register(SRCS main.c INCLUDE_DIRS . REQUIRES onvif-c nvs_flash esp_http_server)这里REQUIRES是关键它告诉构建系统main 组件依赖 onvif-c链接时会自动把 onvif-c 的源文件编进去。如果漏了这一步编译时会报“undefined reference to onvif_init”。3.2 初始化参数配置在main.c里初始化 ONVIF 服务需要填一个配置结构体。这个结构体定义了设备的基本信息和媒体配置#include onvif.h static const onvif_config_t onvif_cfg { .device_name ESP32-CAM, .manufacturer DIY-Lab, .model ESP32-ONVIF-01, .firmware_version 1.0.0, .serial_number SN-12345678, .hardware_id HW-ESP32, .rtsp_port 554, .rtsp_path /stream1, .profiles { { .token MainStream, .name Main Stream, .width 1920, .height 1080, .fps 25, .bitrate 2048, .encoding H264 }, { .token SubStream, .name Sub Stream, .width 640, .height 480, .fps 15, .bitrate 512, .encoding H264 } }, .profile_count 2 };这里每个字段都有讲究。device_name会出现在 NVR 的设备列表里建议用英文避免中文编码问题。serial_number和hardware_id虽然 NVR 不一定显示但 ONVIF 规范要求必须返回所以不能为空。profiles数组里token是 NVR 用来引用这个 Profile 的标识必须唯一且不含空格。encoding目前主流是 H264如果你的 ESP32 用的是 MJPEG这里要改成MJPEG但要注意不是所有 NVR 都支持 MJPEG 的 ONVIF 设备。3.3 启动流程与网络准备初始化 ONVIF 之前必须先确保网络已经连上。典型的启动顺序是初始化 NVSONVIF 组件需要存 UUID连接 Wi-Fi 或以太网等待获取 IP 地址初始化 ONVIF 组件启动 RTSP 服务如果还没启动代码大致如下void app_main(void) { esp_err_t ret nvs_flash_init(); if (ret ESP_ERR_NVS_NO_FREE_PAGES || ret ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); nvs_flash_init(); } wifi_init_sta(); wait_for_ip(); onvif_init(onvif_cfg); onvif_start(); rtsp_server_start(); }这里wait_for_ip是一个自定义函数用事件组等待IP_EVENT_STA_GOT_IP。为什么要等 IP因为 ONVIF 初始化时需要把 IP 地址写进 XAddrs如果 IP 还没拿到XAddrs 就是空的NVR 发现设备后无法连接。实测中如果跳过这一步NVR 能发现设备但添加失败提示“设备无响应”。注意onvif_start()会启动两个任务一个处理 UDP 组播一个处理 HTTP 服务。这两个任务默认优先级是 5栈大小分别是 4096 和 8192。如果你的应用里还有其他高优先级任务建议把 ONVIF 任务的优先级调到 4 或更低避免抢占摄像头驱动的 CPU 时间。3.4 验证设备能否被 NVR 发现固件烧录后先别急着开 NVR。用一台电脑装一个 ONVIF Device Manager比如开源的 ONVIF Device Manager在同一网段里搜索设备。如果能看到你的 ESP32 设备说明 WS-Discovery 工作正常。如果搜不到按以下顺序排查确认 ESP32 和电脑在同一子网且没有防火墙阻止 UDP 3702 端口确认 ESP32 的组播功能已启用CONFIG_LWIP_IGMP要打开用 Wireshark 抓包看 ESP32 有没有回复 ProbeMatch如果 ONVIF Device Manager 能发现设备但点进去看设备信息时报错那多半是 SOAP 响应格式有问题。这时候可以打开组件的调试日志把ONVIF_DEBUG宏定义为 1重新编译串口会打印出收到的请求和发出的响应 XML对照 ONVIF 规范检查标签名和命名空间是否正确。3.5 在 NVR 上添加设备以某主流 NVR 为例添加流程是进入“通道管理”-“添加设备”-选择“ONVIF”协议-输入 ESP32 的 IP 地址、ONVIF 端口默认 80、用户名密码如果组件没启用鉴权随便填。点击“添加”后NVR 会依次执行发现、获取设备信息、获取 Profile、获取 StreamUri、拉流。如果一切正常通道状态会变成“在线”预览画面出现。这里有个实测经验有些 NVR 在添加时会先发一个GetSystemDateAndTime请求如果设备不回复或回复格式不对NVR 会认为设备不支持 ONVIF直接添加失败。onvif-c内部实现了这个接口返回一个固定的时间比如 2024-01-01T00:00:00Z因为 ESP32 通常没有 RTC返回真实时间反而可能因为时区问题导致 NVR 校验失败。这个细节在官方文档里不会写但实际调试中非常关键。4. 常见问题与排查技巧实录4.1 NVR 发现不了设备这是最常见的问题表现是 NVR 搜索不到 ESP32或者搜索到了但添加时提示“设备未响应”。排查思路如下表现象可能原因排查方法解决方案NVR 完全搜不到组播未加入用 Wireshark 看 ESP32 是否发 IGMP Join确认CONFIG_LWIP_IGMP已启用NVR 搜到但添加失败XAddrs IP 错误抓包看 ProbeMatch 里的 XAddrs确保初始化时 IP 已获取NVR 搜到但添加失败UUID 冲突检查 NVS 里是否有旧 UUID擦除 NVS 重新生成NVR 搜到但添加失败HTTP 端口不对确认 ONVIF 服务监听 80 端口检查esp_http_server配置这里重点说 UUID 冲突。如果你之前烧过其他 ONVIF 固件NVS 里可能存了旧的 UUID新固件启动后读到旧 UUIDNVR 会认为这是之前添加过的设备但设备信息对不上导致添加失败。解决方法是擦除 NVSidf.py erase-flash然后重新烧录。这个坑我踩过两次第一次以为是协议问题查了半天才发现是 NVS 没清。4.2 添加成功但不出图NVR 显示通道在线但预览黑屏或者提示“码流连接失败”。这通常是 RTSP 流的问题而不是 ONVIF 的问题。排查步骤用 VLC 直接拉 RTSP 流看能不能出图。如果 VLC 也拉不到说明 RTSP 服务本身有问题跟 ONVIF 无关。如果 VLC 能拉流但 NVR 不行检查GetStreamUri返回的 URL 是否和 VLC 用的完全一致。特别注意端口号和路径。检查 NVR 的“传输协议”设置有些 NVR 默认用 UDP 拉流但 ESP32 的 RTSP 服务可能只支持 TCP。在 NVR 里把传输协议改成 TCP 试试。提示ESP32 的 RTSP 服务通常基于esp_rtsp或自己实现的轻量级 RTSP。如果用的是 UDP 传输丢包会导致花屏建议强制 TCP。在GetStreamUri的响应里可以把StreamSetup的Transport设为RTSPProtocol设为TCP这样 NVR 会优先用 TCP。4.3 SOAP 响应被 NVR 拒绝有时候 NVR 能发现设备也能发请求但收到响应后报“XML 解析错误”。这通常是命名空间或标签大小写问题。ONVIF 的 XML 是大小写敏感的比如GetDeviceInformationResponse不能写成GetDeviceInformationResponse少个 s 或者大小写不对。组件的调试日志会打印出完整的响应 XML你可以复制出来用在线 XML 校验工具检查格式。另一个常见问题是 SOAP Envelope 的命名空间。ONVIF 要求soap:Envelope的命名空间是http://www.w3.org/2003/05/soap-envelope而有些老版本 NVR 用的是http://schemas.xmlsoap.org/soap/envelope/。组件默认用前者如果遇到老 NVR可以在配置里切换。这个兼容性问题在混合品牌 NVR 环境里很常见建议在初始化时提供一个soap_version参数。4.4 内存不足导致重启ESP32 跑 ONVIF RTSP 摄像头驱动内存很容易紧张。如果串口日志里出现malloc failed或者task watchdog说明内存不够了。优化方向减少 Profile 数量从 2 个减到 1 个降低 HTTP 任务的栈大小从 8192 降到 6144关闭不必要的日志输出CONFIG_LOG_DEFAULT_LEVEL设为WARN如果用的是 ESP32-S3可以启用 PSRAM把大缓冲区放到 PSRAM 里实测中一个 1080p H264 的 RTSP 流加上 ONVIF 服务在 ESP32-S38MB PSRAM上跑内存占用大约 60%还有余量。如果是 ESP32 普通版没有 PSRAM建议只跑 720p并且把 Profile 减到 1 个。4.5 多 NVR 同时连接的问题有些场景下你可能希望多个 NVR 或客户端同时连接同一个 ESP32 相机。ONVIF 本身支持多客户端但 ESP32 的资源有限。组件的 HTTP 服务默认max_open_sockets是 4如果同时有 3 个 NVR 在轮询加上 RTSP 的连接可能会达到上限。这时候新的 ONVIF 请求会被拒绝NVR 显示“设备离线”。解决方案是把max_open_sockets调到 7同时把lru_purge_enable设为true这样当 socket 用完时会自动关闭最久未使用的连接。但要注意每个 socket 大约消耗 2KB 内存7 个就是 14KB需要确认剩余 RAM 够用。如果还是不够可以考虑把 ONVIF 的 HTTP 服务端口从 80 改成其他端口减少被扫描的概率但 NVR 添加时需要手动指定端口。4.6 调试工具与抓包技巧最后分享几个调试 ONVIF 的实用工具。Wireshark 是必须的过滤条件用udp.port 3702看 WS-Discovery用http看 SOAP 请求。如果 Wireshark 抓不到组播包检查电脑的防火墙或者用tcpdump在路由器上抓。另一个工具是onvif-cli一个命令行工具可以手动发 ONVIF 请求方便测试单个接口。比如onvif-cli -u http://192.168.1.100/onvif/device_service GetDeviceInformation这个命令会直接打印出设备的响应 XML比通过 NVR 调试快得多。如果onvif-cli能拿到正确响应但 NVR 不行那问题就在 NVR 的兼容性上而不是设备端。注意调试时建议把 ESP32 的日志级别调到DEBUG但正式发布固件时要调回WARN否则日志输出会占用大量 CPU 和串口带宽影响视频流的稳定性。这个细节在开发阶段容易被忽略等到量产时才发现性能不达标。5. 扩展方向与个人经验5.1 增加事件推送与云台控制onvif-c目前实现了设备发现、设备信息、媒体配置这几个核心服务但 ONVIF 还有事件Events和云台控制PTZ两个常用模块。如果你的相机需要支持移动侦测报警就需要实现CreatePullPointSubscription和PullMessages接口让 NVR 能主动拉取事件。这个扩展的难点在于事件队列的管理ESP32 内存有限不能缓存太多事件建议用一个环形缓冲区只保留最近 10 条。PTZ 控制相对简单主要是ContinuousMove、Stop、AbsoluteMove几个接口。如果你的云台是用舵机做的收到ContinuousMove后启动一个定时器按速度参数驱动 PWM 即可。这里要注意ONVIF 的 PTZ 速度范围是 -1.0 到 1.0需要映射到舵机的 PWM 占空比。实测中用 50Hz 的 PWM 驱动舵机映射关系是duty 0.5 speed * 0.4这样速度 1.0 对应 90% 占空比速度 -1.0 对应 10%。5.2 鉴权与安全加固默认情况下onvif-c可能没有启用鉴权任何客户端都能访问。这在局域网里问题不大但如果设备暴露在公网就有风险。ONVIF 支持 WS-Security 的 UsernameToken 鉴权实现方式是在 SOAP Header 里加一个Security元素包含用户名、密码和 Nonce。组件可以配置一个用户名密码收到请求后校验 Header 里的凭据。如果校验失败返回 401 或者 SOAP Fault。这里有个坑WS-Security 的密码不是明文传输而是用 SHA1 哈希后 Base64 编码。具体算法是Base64(SHA1(nonce created password))。组件内部需要实现这个哈希计算ESP32 的mbedtls库提供了 SHA1 接口直接调用即可。实测中启用鉴权后NVR 添加设备时需要填写正确的用户名密码否则会提示“认证失败”。这个功能建议在量产固件里默认开启。5.3 我个人的一些实操体会做了几个基于 ESP32 的 ONVIF 相机项目后我最大的体会是ONVIF 的坑不在协议本身而在兼容性。不同品牌的 NVR 对 ONVIF 规范的实现程度不一样有的严格按规范来有的会发一些非标准的请求。onvif-c的设计思路是“宽进严出”——对收到的请求尽量宽容能解析就解析对发出的响应严格按规范确保主流 NVR 都能识别。另一个体会是调试 ONVIF 一定要有耐心。有时候一个问题查了半天最后发现是网线接触不良或者 NVR 的固件版本太老。建议手头备一个已知能正常工作的 ONVIF 相机作为对照当你的 ESP32 出问题时用同样的 NVR 添加那个相机如果也失败说明是 NVR 的问题不是你的固件问题。这个对照法能省下大量排查时间。最后分享一个小技巧在onvif-c的初始化代码里加一个“模拟模式”启动时不真正初始化网络而是用预设的 IP 和 UUID 回复请求。这样你可以在没有 NVR 的情况下用onvif-cli测试各个接口的响应格式快速验证 XML 拼装逻辑是否正确。等格式都对了再开真实网络效率会高很多。这个模式我在几个项目里都用了尤其是当 NVR 不在手边的时候非常实用。
阅读完成 · 觉得有帮助?