1. 从零理解 onvif-c:为什么要在 ESP32 上跑 ONVIF
搞安防监控的朋友大概率都碰过这样的场景:手头有一块 ESP32 模组,接了个摄像头传感器,想把它做成一个能被海康、大华、TP-LINK 这些 NVR 直接识别的网络摄像机。最省事的路径当然是走 RTSP 推流,但问题在于——NVR 添加设备时,第一步不是拉流,而是通过 ONVIF 协议去“发现”设备、获取设备能力、拿到 RTSP 地址。如果设备不响应 ONVIF 的 WS-Discovery 广播,也不提供 Device Service 的 SOAP 接口,那 NVR 的“自动发现”列表里根本不会出现你的设备,只能手动填 URL,体验差一大截。
onvif-c这个组件就是来解决这个问题的。它是一个用纯 C 写的、跑在 ESP-IDF 上的 ONVIF 协议实现,核心目标很明确:让一块 ESP32 相机在局域网里被标准 NVR 当成一台“正经”的 ONVIF 设备添加进去。它不依赖任何 C++ 运行时,不依赖 gSOAP 这种重量级框架,而是基于esp_http_server手写 SOAP 解析与响应,配合 UDP 组播实现 WS-Discovery。整个组件体积小、依赖少,非常适合资源受限的嵌入式场景。
这篇文章适合三类人看:第一类是做 ESP32 摄像头项目、卡在 NVR 对接环节的嵌入式工程师;第二类是想了解 ONVIF 协议最小实现集合、不想啃几百页规范文档的开发者;第三类是想拿一个真实可跑的 plain C 组件来学习 ESP-IDF 组件化开发的人。我会从工程搭建一路讲到 NVR 成功添加设备,把中间踩过的坑、参数怎么算、代码为什么这么写都摊开说。
需要先明确一个认知:ONVIF 不是一个单一协议,而是一整套基于 SOAP over HTTP 的服务集合。一台设备要被 NVR 接受,最少要实现三块内容——WS-Discovery(让 NVR 发现你)、Device Service(让 NVR 查询你的能力)、Media Service(让 NVR 拿到你的流地址)。onvif-c组件围绕这三块做了最小可用实现,其余如 PTZ、事件、录像回放等一概不碰,这也是它能在 ESP32 上跑起来的关键取舍。
2. 工程搭建与组件集成:把 onvif-c 塞进 ESP-IDF 项目
2.1 环境准备与 ESP-IDF 版本选择
先把地基打牢。onvif-c依赖esp_http_server,这个组件在 ESP-IDF v4.4 之后趋于稳定,建议用ESP-IDF v5.0 或 v5.1。我实测过 v4.4 也能跑,但 v5.x 的httpd在并发连接和 header 处理上更省心。安装方式用官方 installer 最稳,Windows 下编译慢是老问题,后面会讲加速办法。
装完之后先验证环境:
idf.py --version能打印出版本号就说明工具链没问题。接着创建一个空白工程:
idf.py create-project esp32_onvif_cam cd esp32_onvif_cam2.2 组件目录结构与 CMakeLists 配置
onvif-c作为一个 ESP-IDF 组件,标准做法是放在工程的components/目录下。目录结构大概长这样:
esp32_onvif_cam/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ └── main.c └── components/ └── onvif-c/ ├── CMakeLists.txt ├── include/ │ └── onvif_c.h └── src/ ├── onvif_discovery.c ├── onvif_device.c ├── onvif_media.c └── onvif_soap.c组件自己的CMakeLists.txt关键内容是注册源文件和依赖:
idf_component_register( SRCS "src/onvif_discovery.c" "src/onvif_device.c" "src/onvif_media.c" "src/onvif_soap.c" INCLUDE_DIRS "include" REQUIRES esp_http_server nvs_flash lwip )这里有个容易翻车的点:REQUIRES里必须显式写上esp_http_server,否则链接阶段会报httpd_start未定义。很多人第一次集成时只写了lwip,结果编译过了链接挂掉,排查半天。
主工程的main/CMakeLists.txt则要引用这个组件:
idf_component_register( SRCS "main.c" INCLUDE_DIRS "." REQUIRES onvif-c nvs_flash esp_wifi )2.3 网络初始化与组件启动顺序
ONVIF 依赖网络,所以启动顺序不能乱。正确顺序是:NVS 初始化 → 网络栈初始化 → Wi-Fi 连接并获得 IP → 启动 HTTP 服务器 → 启动 WS-Discovery 组播监听。如果顺序颠倒,比如在拿到 IP 之前就启动 Discovery,组播 socket 绑定会失败或者绑到错误的网卡上。
void app_main(void) { ESP_ERROR_CHECK(nvs_flash_init()); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); wifi_init_sta(); // 连接路由器,阻塞直到拿到 IP onvif_http_server_start(); // 启动 SOAP over HTTP 服务 onvif_discovery_start(); // 启动 WS-Discovery 组播 }注意:
onvif_discovery_start()内部会创建 UDP socket 并加入组播组239.255.255.250:3702。如果你的设备同时开了 AP 和 STA 两个网口,务必指定绑定到 STA 网口的 IP,否则组播包可能从 AP 口发出去,NVR 永远收不到。
2.4 编译加速与国内源配置
Windows 下 ESP-IDF 编译慢是公认的痛点,尤其是第一次全量编译。几个实测有效的加速手段:把工程放在 SSD 上、关闭杀毒软件对 build 目录的实时扫描、使用ccache。在idf.py menuconfig里打开Compiler options -> Enable ccache,二次编译能快 40% 以上。
另外组件依赖下载慢的话,可以配置国内镜像源。在~/.gitconfig里把 github 的 URL 替换规则配好,或者直接用乐鑫的组件镜像。这一步不是必须,但能省不少等待时间。
3. WS-Discovery 实现细节:让 NVR 在列表里看到你
3.1 WS-Discovery 的工作原理
WS-Discovery 是 ONVIF 设备被发现的入口。它的机制其实很朴素:NVR 往组播地址239.255.255.250的3702端口发一个Probe消息,所有在线的 ONVIF 设备收到后,单播回一个ProbeMatch消息,里面带上自己的设备服务地址(XAddr)。NVR 拿到这个地址后,才会去请求 Device Service 的详细信息。
用生活化的类比:NVR 在小区里拿喇叭喊“谁是摄像头”,每个摄像头听到后单独打电话回复“我是,我的联系方式是 xxx”。onvif-c要做的就是监听这个喇叭、然后回电话。
3.2 组播 socket 的创建与绑定
关键代码在于 socket 的创建和组播组加入:
int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); 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在单网口设备上没问题,但双网口设备必须填具体网口的 IP。我踩过一次坑:设备同时开了 STA 和 AP,结果组播包从 AP 口出去,NVR 在 STA 网段里死活发现不了设备,查了两小时才定位到。
3.3 Probe 消息解析与 ProbeMatch 构造
收到 Probe 后,需要解析出消息里的MessageID,因为ProbeMatch必须引用同一个 ID,NVR 才能把回复和请求对应上。onvif-c用简单的字符串查找来提取 UUID,而不是上完整的 XML 解析器——这是资源受限场景下的合理取舍。
ProbeMatch的响应体核心结构如下:
<soap:Envelope> <soap:Header> <wsa:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/ProbeMatches</wsa:Action> <wsa:RelatesTo>urn:uuid:请求里的ID</wsa:RelatesTo> </soap:Header> <soap:Body> <ProbeMatches> <ProbeMatch> <EndpointReference><Address>urn:uuid:设备UUID</Address></EndpointReference> <Types>dn:NetworkVideoTransmitter</Types> <XAddrs>http://设备IP/onvif/device_service</XAddrs> </ProbeMatch> </ProbeMatches> </soap:Body> </soap:Envelope>Types必须是dn:NetworkVideoTransmitter,这是 ONVIF 对网络摄像机的标准类型标识。写错了 NVR 会认为你不是摄像头,直接忽略。XAddrs里的地址必须和 NVR 能访问到的 IP 一致,如果设备有多个 IP,这里要填对。
3.4 设备 UUID 的生成与持久化
EndpointReference里的 UUID 是设备的唯一标识,NVR 靠它区分不同设备。这个 UUID 必须持久化,不能每次重启都变,否则 NVR 会认为是新设备,反复添加。onvif-c的做法是首次启动时生成一个 UUID 存进 NVS,之后每次读取。
char uuid[37]; if (nvs_get_str(handle, "dev_uuid", uuid, &len) != ESP_OK) { generate_uuid(uuid); // 基于 MAC 地址生成 nvs_set_str(handle, "dev_uuid", uuid); nvs_commit(handle); }实操心得:UUID 生成建议基于芯片 MAC 地址做哈希,这样即使 NVS 被擦除,重新生成的 UUID 也一致,避免 NVR 里出现重复设备条目。
4. Device Service 与 Media Service:NVR 真正要的数据
4.1 SOAP 请求的分发机制
esp_http_server收到 POST 请求后,onvif-c根据 SOAP Body 里的第一个元素名来判断是哪个服务请求。比如 Body 里是GetDeviceInformation,就分发到设备信息服务;是GetProfiles,就分发到媒体服务。这种基于字符串匹配的分发方式虽然土,但在只有十来个接口的场景下足够用,而且比引入 XML 解析库省几百 KB 内存。
分发逻辑大致是:
if (strstr(body, "GetDeviceInformation")) { handle_get_device_info(req); } else if (strstr(body, "GetCapabilities")) { handle_get_capabilities(req); } else if (strstr(body, "GetProfiles")) { handle_get_profiles(req); } else if (strstr(body, "GetStreamUri")) { handle_get_stream_uri(req); }4.2 GetDeviceInformation 与 GetCapabilities
GetDeviceInformation返回厂商、型号、固件版本、序列号这些信息。NVR 添加设备时会显示这些内容,填得规范一点体验更好。GetCapabilities则告诉 NVR 这台设备支持哪些能力,比如是否支持媒体、是否支持 PTZ、是否支持事件。
<tds:GetDeviceInformationResponse> <tds:Manufacturer>MyVendor</tds:Manufacturer> <tds:Model>ESP32-CAM-ONVIF</tds:Model> <tds:FirmwareVersion>1.0.0</tds:FirmwareVersion> <tds:SerialNumber>ESP32A1B2C3</tds:SerialNumber> <tds:HardwareId>1.0</tds:HardwareId> </tds:GetDeviceInformationResponse>GetCapabilities的响应里,Media节点的XAddr必须指向媒体服务地址,通常是http://设备IP/onvif/media_service。如果这个地址填错,NVR 后续的GetProfiles请求会打到错误的路径上,直接 404。
4.3 GetProfiles 与 GetStreamUri 的配合
这是整个对接流程里最关键的一环。NVR 通过GetProfiles获取设备的媒体配置档(Profile),每个 Profile 里包含视频编码器配置、分辨率、帧率等信息。然后 NVR 拿 Profile 的 token 去调GetStreamUri,换取真正的 RTSP 地址。
GetProfiles响应示例:
<trt:GetProfilesResponse> <trt:Profiles token="profile_1" fixed="true"> <tt:Name>mainStream</tt:Name> <tt:VideoEncoderConfiguration> <tt:Encoding>H264</tt:Encoding> <tt:Resolution><tt:Width>1280</tt:Width><tt:Height>720</tt:Height></tt:Resolution> <tt:RateControl><tt:FrameRateLimit>25</tt:FrameRateLimit></tt:RateControl> </tt:VideoEncoderConfiguration> </trt:Profiles> </trt:GetProfilesResponse>GetStreamUri响应里返回的就是 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> </trt:MediaUri> </trt:GetStreamUriResponse>注意:
Uri里的 IP 必须是 NVR 能访问到的地址,不能填127.0.0.1或0.0.0.0。我见过有人图省事写死0.0.0.0,结果 NVR 拉流一直失败,排查半天才发现是地址问题。正确做法是运行时动态获取本机 IP 拼进去。
4.4 编码配置与 NVR 兼容性
不同 NVR 对 Profile 的解析严格程度不一样。海康的 NVR 相对宽容,大华的会校验VideoEncoderConfiguration里的必填字段。实测下来,Encoding、Resolution、RateControl这三块必须完整,缺一个字段某些 NVR 就会报“不支持的媒体配置”。
分辨率建议填设备实际能推流的分辨率。ESP32 用 OV2640 的话,720p 是上限,填 1080p 会导致 NVR 按 1080p 协商但实际流是 720p,画面可能被拉伸或黑边。老老实实填 1280x720 最稳。
5. 常见问题与排查技巧实录
5.1 NVR 发现不了设备
这是最高频的问题。排查顺序建议这样走:先确认设备是否真的发出了 ProbeMatch。用 Wireshark 抓包,过滤udp.port == 3702,看设备有没有回复。如果没有回复,检查组播 socket 是否绑定成功、是否加入了正确的组播组。如果有回复但 NVR 还是发现不了,检查XAddrs里的 IP 是否可达,以及Types是否为dn:NetworkVideoTransmitter。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 抓包无 ProbeMatch | 组播 socket 未绑定成功 | 检查 bind 返回值 |
| 有 ProbeMatch 但 NVR 无反应 | XAddrs IP 不可达 | ping 该 IP |
| NVR 显示设备但添加失败 | Device Service 返回异常 | 用 ONVIF Device Manager 测试 |
| 添加成功但无画面 | GetStreamUri 地址错误 | 手动用 VLC 拉流验证 |
5.2 SOAP 响应格式错误导致解析失败
ONVIF 对 XML 命名空间很敏感。tds、trt、tt这些前缀必须和 NVR 期望的命名空间 URI 对应。如果命名空间写错,NVR 的 XML 解析器会直接报错,表现为“设备不支持该操作”。建议直接参考 ONVIF 官方 WSDL 里的命名空间定义,不要自己臆造。
另一个坑是 SOAP Envelope 的encodingStyle属性。有些 NVR 要求这个属性存在且为空字符串,有些则要求不存在。onvif-c默认不写这个属性,实测对主流 NVR 兼容性最好。
5.3 内存不足与任务栈溢出
ESP32 的内存有限,esp_http_server默认给每个连接分配的任务栈是 4KB 左右。如果 SOAP 响应体比较大(比如 GetProfiles 返回多个 Profile),拼接字符串时可能栈溢出。解决办法是把响应缓冲区放到堆上,或者调大httpd_config_t里的stack_size。
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = 8192; // 默认 4096,调大一倍 config.max_open_sockets = 4; // 按需调整实操心得:调试阶段把
max_open_sockets设小一点(比如 2),能更快暴露连接泄漏问题。如果 NVR 反复请求后设备卡死,大概率是 socket 没释放。
5.4 设备重启后 NVR 重复添加
前面提过,UUID 必须持久化。如果每次重启 UUID 都变,NVR 会认为是新设备。除了 UUID,GetDeviceInformation里的SerialNumber也建议持久化,有些 NVR 用序列号做去重。两个都存 NVS,双保险。
5.5 编译报错与依赖缺失
最常见的编译错误是undefined reference to httpd_start,原因是组件CMakeLists.txt里没写REQUIRES esp_http_server。另一个是lwip/sockets.h找不到,需要确认REQUIRES lwip已添加。如果用了nvs_flash但没在REQUIRES里声明,链接阶段同样会报错。
6. 从能发现到能出画面:完整联调流程
6.1 用 ONVIF Device Manager 做中间验证
在把设备交给 NVR 之前,强烈建议先用 ONVIF Device Manager(ODM)这类工具验证。ODM 能自动发现设备、展示 Device Service 返回的所有信息、列出 Profiles、甚至能直接拉流预览。如果 ODM 能正常识别和预览,说明 ONVIF 实现基本没问题,剩下的就是 NVR 兼容性微调。
ODM 里重点看三个地方:设备信息页是否正常显示厂商型号、媒体页是否能列出 Profile、预览页是否能出画面。三个都通过,NVR 对接成功率在 90% 以上。
6.2 NVR 添加设备的实际操作
以海康 NVR 为例,进入“配置 -> 系统 -> 设备管理 -> 在线设备”,正常情况下应该能看到你的 ESP32 设备。勾选后点“添加”,NVR 会自动填充协议为 ONVIF、端口为 80、用户名密码为空(如果设备没做鉴权)。添加成功后状态变成“在线”,点“预览”就能看到画面。
如果在线设备列表里没有,点“刷新”再等几秒。WS-Discovery 是周期性的,NVR 可能每隔几秒才发一次 Probe。如果一直不出现,回到 5.1 节排查。
6.3 RTSP 流地址的对接
GetStreamUri返回的 RTSP 地址必须和实际推流服务一致。onvif-c本身不负责推流,它只负责告诉 NVR 流地址在哪。推流部分需要另外实现,可以用 ESP32 的 RTSP 服务器组件,或者用其他方案。关键是地址要匹配,端口要一致。
如果 NVR 添加成功但预览黑屏,先用 VLC 手动打开rtsp://设备IP:554/stream1,确认流本身是通的。VLC 能播但 NVR 不能播,通常是编码格式问题——NVR 可能只支持 H.264 Baseline Profile,而推流用的是 Main Profile。把编码配置改成 Baseline 再试。
6.4 鉴权与安全配置
生产环境建议开启 ONVIF 鉴权。onvif-c支持 HTTP Digest 鉴权,在GetDeviceInformation等接口上校验用户名密码。NVR 添加设备时填上对应的凭据即可。不开鉴权的话,局域网内任何人都能访问设备接口,存在风险。
鉴权实现上,esp_http_server本身不直接支持 Digest,需要在 handler 里手动解析Authorization头并计算摘要。onvif-c把这部分封装成了独立函数,调用时传入期望的用户名密码即可。
7. 性能优化与扩展方向
7.1 减少内存占用
onvif-c的响应字符串拼接如果全用snprintf到固定缓冲区,会占用不少栈空间。优化做法是预估最大响应长度,用malloc分配堆缓冲区,拼完发送后立即free。实测这样能把峰值栈占用从 6KB 降到 2KB 左右。
另一个优化点是复用缓冲区。多个请求处理函数可以共用一个全局缓冲区,加锁保护即可。但要注意esp_http_server是多任务并发处理请求的,共享缓冲区必须加互斥锁,否则会出现响应内容错乱。
7.2 支持多 Profile 与子码流
单 Profile 够用,但有些 NVR 会请求子码流用于多画面预览。扩展方式是GetProfiles返回两个 Profile,GetStreamUri根据传入的 token 返回不同的 RTSP 地址。主码流 720p,子码流 480p 或更低,能显著降低 NVR 多画面时的解码压力。
7.3 事件与 PTZ 的扩展思路
onvif-c目前不实现事件和 PTZ,但架构上留了扩展点。事件服务需要实现CreatePullPointSubscription和PullMessages,PTZ 需要实现ContinuousMove和Stop。如果只是做固定摄像头,这两块可以完全不碰。如果要做云台控制,建议单独开一个组件,不要塞进onvif-c里,保持职责单一。
7.4 固件 OTA 与配置持久化
设备部署后难免要升级固件。ESP-IDF 自带 OTA 机制,配合onvif-c的配置持久化,可以做到升级后 UUID、序列号、网络配置都不丢。关键是把这些配置存在 NVS 的独立命名空间里,OTA 不会擦除 NVS 数据。
配置持久化建议存这几项:设备 UUID、序列号、ONVIF 用户名密码、RTSP 端口、分辨率配置。这样即使换固件版本,NVR 那边也不需要重新添加设备。
8. 我踩过的几个真实坑
第一个坑是组播绑定。前面提过,双网口设备必须指定网口 IP,这个坑我花了两个小时。后来养成习惯,任何涉及组播的代码,第一件事就是确认绑定网口。
第二个坑是 SOAP 命名空间。我一开始自己编了个前缀onvif,结果 ODM 能识别但海康 NVR 不认。后来老老实实按 WSDL 里的tds、trt、tt来写,问题消失。ONVIF 的命名空间是硬约定,不要自作聪明。
第三个坑是响应里的 IP 地址。我图省事在代码里写死了192.168.1.100,测试环境没问题,换到客户现场网段变了,NVR 拉流全失败。后来改成运行时通过esp_netif_get_ip_info动态获取,再拼进响应里,彻底解决。
第四个坑是httpd的 socket 数量。默认配置下并发连接数有限,NVR 在添加设备时会连续发好几个请求,如果 socket 不够用,后面的请求会被拒绝,表现为“添加设备超时”。把max_open_sockets调到 4 或 6 就稳了。
第五个坑是 UUID 持久化。早期版本没存 NVS,每次重启 NVR 里就多一台设备,列表越来越长。加上 NVS 存储后,这个问题再没出现过。
这几个坑的共同点是:都不是代码逻辑错误,而是对协议约定和运行环境的理解不到位。ONVIF 对接这件事,代码只占一半,另一半是对 NVR 行为的理解和适配。多抓包、多用 ODM 验证、多换几台 NVR 测试,比闷头改代码有效得多。