1. 为什么要在 ESP32 上折腾 ONVIF 相机
先说清楚这个项目到底在干什么。ONVIF 是一套网络视频设备的通信标准,核心价值在于互操作性——你做的相机只要符合这套标准,市面上主流的 NVR(网络录像机)就能自动发现它、添加它、拉取它的 RTSP 视频流,不需要为每个品牌的 NVR 单独做适配。而onvif-c是一个用纯 C 写的、跑在 ESP-IDF 框架下的 ONVIF 协议组件,专门服务于 ESP32 这类资源受限的芯片。
这件事解决的核心痛点是:让一块十几块钱的 ESP32 模组,变成一个能被专业安防系统识别的标准 IP 相机。传统做法要么用厂商现成的 IPC 方案(贵、不灵活),要么自己从零实现 ONVIF 协议栈(工作量大、容易踩坑)。onvif-c把 WS-Discovery 设备发现、设备信息服务(Device Service)、媒体服务(Media Service)、RTSP 地址协商这些必备环节都封装好了,你只需要把它集成进自己的工程,接上摄像头驱动,就能跑起来。
适合谁来参考?三类人最对口:一是做 ESP32 摄像头项目的嵌入式开发者,想把手里的板子接入现有安防系统;二是学习 ONVIF 协议栈的工程师,想找一个轻量级实现来读源码;三是做智能家居、边缘视觉产品的团队,需要一个低成本、可定制的相机节点。前提是你得会用 ESP-IDF,至少能编译烧录一个 hello world,纯 Arduino 框架的朋友需要先补一下 IDF 的基础。
我实测下来,整个链路从建工程到 NVR 成功添加,顺利的话一个下午能跑通,但坑主要集中在网络发现和 RTSP 协商这两块。下面我把完整过程拆开讲,包括每一步为什么这么做、参数怎么算、哪里容易翻车。
2. 整体架构设计与方案选型思路
2.1 为什么选纯 C 组件而不是现成框架
ESP32 上做 ONVIF,市面上能想到的路子有几条:用 Linux 平台的完整 ONVIF 实现移植(比如 gSOAP 生成的代码)、用某个 Arduino 库魔改、或者用onvif-c这种为嵌入式裁剪过的纯 C 组件。我选onvif-c的理由很直接——依赖少、内存占用可控、和 ESP-IDF 的esp_http_server天然契合。
gSOAP 那套东西生成的代码动辄几百 KB,栈开销大,ESP32 的 RAM 本来就紧张,跑起来很容易栈溢出。而onvif-c走的是精简路线,它不依赖重量级 SOAP 框架,而是自己解析和处理 ONVIF 需要的 SOAP 报文,配合esp_http_server处理 HTTP 层。这样整个组件的 flash 占用能压到几十 KB 级别,对 ESP32 来说非常友好。
另一个关键考量是协议覆盖范围。ONVIF 规范非常庞大,但一个能被 NVR 添加的相机,实际只需要实现一个最小子集:WS-Discovery 负责被搜到,GetDeviceInformation 和 GetCapabilities 负责被识别,GetProfiles 和 GetStreamUri 负责给出视频流地址。onvif-c恰好覆盖了这个最小闭环,没有过度设计。
2.2 组件在系统中的位置
整个系统的数据流是这样的:ESP32 上跑一个 HTTP 服务器(esp_http_server),监听一个端口(通常是 80),onvif-c把 ONVIF 的 SOAP 请求注册成这个服务器上的 URI handler。同时,WS-Discovery 需要一个独立的 UDP 组播监听,因为设备发现走的是组播协议,不是 HTTP。
视频流这块是独立的,通常用esp_rtsp或者自己实现的 RTSP 服务器,把摄像头(比如 OV2640/OV5640)的 JPEG 或 H264 帧推出去。ONVIF 的 Media Service 只负责告诉 NVR "视频流在这个 RTSP 地址",它本身不传视频数据。这个分工一定要搞清楚,很多人一开始会混淆,以为 ONVIF 组件要负责推流,其实不是。
提示:ONVIF 是控制面协议,RTSP 是数据面协议。ONVIF 负责"告诉对方去哪拿流",RTSP 负责"真正把流送出去"。两者解耦,各自独立实现。
2.3 内存与任务划分的取舍
ESP32 的内存分内部 SRAM 和外部 PSRAM。ONVIF 的 SOAP 报文解析、HTTP 缓冲这些用内部 SRAM 就够,但视频帧缓冲必须放 PSRAM,否则分辨率一上去就 OOM。任务划分上,我建议把 HTTP 服务器任务、WS-Discovery 监听任务、RTSP 推流任务分成三个独立任务,优先级上 RTSP 推流最高(保证视频流畅),HTTP 和 Discovery 次之。
栈大小这块有个经验值:esp_http_server的 handler 任务栈建议给到 8KB 以上,因为 SOAP 报文解析会用到不少局部变量。WS-Discovery 任务 4KB 够用。RTSP 推流任务看分辨率,VGA 给 4KB,UXGA 建议 8KB。
3. 环境搭建与工程创建实操
3.1 ESP-IDF 安装与版本选择
先说版本。onvif-c对 IDF 版本有要求,我实测在IDF v5.1 和 v5.2上最稳,v4.x 因为esp_http_server的 API 有差异,需要改代码。安装方式我推荐用官方安装器,Windows 上直接下esp-idf-tools-setup,Linux/macOS 用 git clone 加install.sh。
安装完第一件事是验证环境:
idf.py --version能打印出版本号就说明环境变量配好了。如果提示找不到命令,Windows 上要跑一下export.bat(或安装器生成的快捷方式),Linux 上source export.sh。
注意:Windows 上编译 ESP32 慢是老大难问题,主要卡在文件系统 IO 和杀毒软件扫描。实测把工程目录加到 Windows Defender 的排除列表,编译速度能快 30% 以上。另外用 SSD 而不是机械盘,效果立竿见影。
3.2 创建工程骨架
不要用idf.py create-project生成空工程再手动加,我建议直接用examples里的http_server示例做底子,因为 ONVIF 本质就是一堆 HTTP handler,这样省去配esp_http_server的功夫。
idf.py create-project --path ./esp32_onvif_cam 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/ └── src/主工程的CMakeLists.txt里要确保EXTRA_COMPONENT_DIRS指向components,这样 IDF 构建系统才能找到onvif-c。
3.3 关键配置项(menuconfig)
进idf.py menuconfig,有几项必须改:
| 配置项 | 路径 | 建议值 | 原因 |
|---|---|---|---|
| HTTP 服务器最大 URI handler | Component config → ESP HTTP Server | 16 | ONVIF 要注册多个 handler |
| LWIP 组播支持 | Component config → LWIP | 开启 | WS-Discovery 依赖组播 |
| PSRAM | Component config → ESP32-specific | 开启(若板子有) | 视频帧缓冲 |
| 主任务栈大小 | Component config → FreeRTOS | 8192 | SOAP 解析吃栈 |
| WiFi 缓冲区 | Component config → WiFi | 默认或调大 | 视频流吞吐 |
组播这一项特别容易漏。WS-Discovery 用的是239.255.255.250:3702这个组播地址,如果 LWIP 没开组播,设备根本发不出发现响应,NVR 自然搜不到你。
4. ONVIF 核心服务实现细节
4.1 WS-Discovery 设备发现机制
WS-Discovery 是整个链路的第一环。NVR 上线后会往组播地址发一个Probe报文,问"局域网里有哪些 ONVIF 设备"。你的 ESP32 要监听这个组播端口,收到 Probe 后回一个ProbeMatch,里面带上自己的设备类型、UUID、服务地址。
实现上有两个关键点。第一是组播 socket 的创建,要用setsockopt加入组播组:
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));第二是UUID 的生成和持久化。ONVIF 要求每个设备有唯一的 UUID,格式是urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx。这个 UUID 不能每次重启都变,否则 NVR 会认为是新设备。我的做法是从 ESP32 的 efuse MAC 地址派生一个稳定的 UUID,存到 NVS 里,首次生成后就不再变。
ProbeMatch 报文里的XAddrs字段是设备服务地址,格式是http://<ip>:<port>/onvif/device_service。这里有个坑:IP 地址必须是设备当前的实际 IP,不能写死。要在响应时动态获取 WiFi 的 IP 填进去。
4.2 Device Service 必备接口
Device Service 是 NVR 识别设备身份的地方。最小实现需要这几个接口:
GetDeviceInformation:返回厂商、型号、固件版本、序列号、硬件 ID。这些字段 NVR 会显示在设备列表里,随便填也能过,但建议填真实信息方便调试。GetCapabilities:告诉 NVR 这个设备支持哪些服务(Device、Media、Events 等)。这里必须声明 Media 服务的地址,否则 NVR 不知道去哪拿视频流。GetServices:列出所有服务的版本和地址。GetSystemDateAndTime:返回系统时间。有些 NVR 在添加设备时会先校时,这个接口不实现可能导致添加失败。
GetCapabilities的响应里,Media 服务的XAddr要指向http://<ip>:<port>/onvif/media_service,这个地址必须和后面注册的 handler 路径一致,否则 NVR 拿到地址后请求会 404。
4.3 Media Service 与 RTSP 地址协商
Media Service 负责视频流的元信息和地址。核心接口是GetProfiles和GetStreamUri。
GetProfiles返回一个或多个 Profile,每个 Profile 描述一路视频的编码格式、分辨率、帧率等。最简单的做法是只返回一个 Profile,token 设为profile_1。响应里要包含VideoEncoderConfiguration,声明编码是 H264 还是 JPEG。
GetStreamUri是 NVR 真正要拿流地址的地方。请求里会带 ProfileToken,你根据这个 token 返回对应的 RTSP 地址:
<trt:StreamUri> <trt:Uri>rtsp://192.168.1.100:554/stream1</trt:Uri> </trt:StreamUri>这个 RTSP 地址必须和你实际跑的 RTSP 服务器地址完全一致。我踩过的坑是:ONVIF 返回的地址用了 WiFi 的 IP,但 RTSP 服务器绑定的是0.0.0.0,理论上没问题,但如果 ESP32 同时有 AP 和 STA 两个接口,NVR 可能拿到错误的网段地址。解决办法是明确指定用 STA 接口的 IP。
4.4 SOAP 报文解析的注意事项
ONVIF 所有请求都是 SOAP over HTTP,Content-Type 是application/soap+xml。esp_http_server收到请求后,onvif-c需要解析 XML 提取参数。这里有几个实操要点:
第一,报文可能很大,尤其是带命名空间的完整 SOAP 信封。接收缓冲要给够,建议 4KB 以上。第二,命名空间前缀不固定,有的 NVR 用tds:,有的用ns1:,解析时不能硬编码前缀,要按命名空间 URI 匹配。第三,响应必须带正确的 SOAP Action,HTTP 头里的Content-Type要包含action="...",否则严格的 NVR 会拒绝。
提示:调试 ONVIF 时,用 Wireshark 抓包看 SOAP 报文是最直接的办法。过滤条件用
http or udp.port == 3702,能同时看到 HTTP 和 Discovery 的交互。
5. 从零到被 NVR 添加的完整流程
5.1 网络初始化与 IP 获取
先跑通 WiFi 连接。用esp_wifi的 STA 模式连上路由器,拿到 IP 后再启动 ONVIF 服务。顺序很重要——如果 ONVIF 服务先起来但还没 IP,Discovery 响应里的 XAddr 就是空的,NVR 会添加失败。
// 等待获取 IP 的事件 xEventGroupWaitBits(wifi_event_group, WIFI_CONNECTED_BIT, pdFALSE, pdTRUE, portMAX_DELAY); esp_netif_ip_info_t ip_info; esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"), &ip_info); // 此时 ip_info.ip 就是可用地址拿到 IP 后,把它存到一个全局变量,ONVIF 的各个 handler 在拼 XAddr 和 StreamUri 时都从这里取。
5.2 启动 HTTP 服务器并注册 handler
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.max_uri_handlers = 16; config.stack_size = 8192; httpd_handle_t server = NULL; httpd_start(&server, &config); // 注册 ONVIF 各服务的 handler httpd_register_uri_handler(server, &device_service_post); httpd_register_uri_handler(server, &media_service_post);每个 handler 内部根据 SOAP Action 分发到具体的处理函数。比如 Device Service 的 handler 收到请求后,看 Action 是GetDeviceInformation还是GetCapabilities,分别处理。
5.3 启动 WS-Discovery 监听任务
Discovery 是独立任务,循环收组播报文:
void discovery_task(void *arg) { int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); // 绑定 3702 端口,加入组播组 // 循环 recvfrom,收到 Probe 就回 ProbeMatch }这个任务的优先级不用太高,但不能阻塞太久,否则 NVR 的 Probe 超时了还没收到响应,就搜不到设备。收到 Probe 后要尽快回,实测响应时间控制在 100ms 内比较稳。
5.4 RTSP 服务器对接
RTSP 服务器这块,可以用现成的esp_rtsp组件,也可以自己写一个精简版。核心是监听 554 端口,处理DESCRIBE、SETUP、PLAY这几个方法。DESCRIBE返回 SDP 描述,告诉客户端视频编码和轨道信息;SETUP建立传输通道;PLAY开始推流。
关键点:RTSP 的 URL 路径要和 ONVIF 返回的 StreamUri 一致。比如 ONVIF 返回rtsp://ip:554/stream1,那 RTSP 服务器就要能处理/stream1这个路径的请求。
5.5 NVR 添加实测与验证
全部跑起来后,在 NVR 上点"搜索设备",应该能看到你的 ESP32 相机出现在列表里。选中添加,输入 ONVIF 用户名密码(如果实现了鉴权),然后看能不能出画面。
验证顺序建议这样:先用 ONVIF Device Manager(Windows 上的免费工具)搜设备,能搜到说明 Discovery 通了;再用工具点"Live Video",能出画面说明 Media Service 和 RTSP 都通了;最后才上真实 NVR。这样分层排查,出问题容易定位。
6. 常见问题排查与避坑经验
6.1 设备搜不到怎么办
这是最高频的问题。排查顺序:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| NVR 完全搜不到 | 组播没开 | 检查 menuconfig 的 LWIP 组播选项 |
| 搜到但添加失败 | XAddr 地址错误 | 抓包看 ProbeMatch 里的地址 |
| 时有时无 | 响应超时 | 看 Discovery 任务是否被阻塞 |
| 只有部分 NVR 能搜到 | 报文格式不标准 | 对比标准 ProbeMatch 报文 |
我遇到过一次搜不到,查了半天发现是路由器开了"AP 隔离",组播报文被拦了。这种网络层的问题最容易被忽略,排查时先确认设备之间能互相 ping 通。
6.2 添加成功但看不到画面
这通常是 RTSP 的问题。先用 VLC 直接拉流测试:vlc rtsp://<ip>:554/stream1。VLC 能出画面说明 RTSP 没问题,那就是 ONVIF 返回的地址和实际不符;VLC 也拉不出来,就是 RTSP 服务器本身的问题。
常见原因包括:SDP 描述里的编码格式和实际推流不一致、RTSP 的SETUP响应缺少Transport头、防火墙拦了 554 端口。
6.3 内存不足与栈溢出
ESP32 跑 ONVIF 加 RTSP,内存压力不小。典型症状是运行一段时间后重启,或者 handler 处理到一半崩溃。排查手段:
- 用
esp_get_free_heap_size()定期打印剩余内存,看是否有泄漏。 - 用
uxTaskGetStackHighWaterMark()检查各任务栈的剩余水位,低于 512 字节就要警惕。 - SOAP 解析的临时缓冲用完立即释放,不要用全局大数组。
注意:视频帧缓冲一定要放 PSRAM,用
heap_caps_malloc(size, MALLOC_CAP_SPIRAM)分配。如果板子没有 PSRAM,分辨率要降到 QVGA 以下,否则必崩。
6.4 时间同步导致的鉴权失败
如果实现了 ONVIF 的 WS-Security 鉴权(用户名密码用 digest 方式),时间戳偏差太大会导致鉴权失败。ESP32 启动后要先 SNTP 同步时间,再启动 ONVIF 服务。GetSystemDateAndTime接口返回的时间也要和实际一致。
6.5 多网段与 IP 变化问题
ESP32 如果同时开了 AP 和 STA,或者 IP 是 DHCP 动态分配的,IP 变化后 ONVIF 返回的地址就失效了。解决办法是监听 IP 变化事件,变化后更新全局 IP 变量,并重新生成 Discovery 的 XAddr。更稳妥的做法是给 ESP32 配静态 IP。
7. 性能优化与进阶扩展方向
7.1 降低 Discovery 响应延迟
Discovery 响应慢会直接影响 NVR 的搜索体验。优化点:把 Discovery 任务优先级提到中等,收到 Probe 后不要做任何耗时操作,直接拼报文回。报文可以预先拼好模板,只替换 IP 和 UUID 字段,减少字符串拼接开销。
7.2 视频流参数调优
ONVIF 的 Profile 里声明的分辨率和帧率,要和 RTSP 实际推流一致。JPEG 编码在 ESP32 上比 H264 省 CPU,但带宽占用大。局域网内 VGA@15fps 的 JPEG 流,实测延迟能控制在 200ms 以内,够用。如果要上 H264,建议用 ESP32-S3 带硬件编码的型号。
7.3 扩展事件服务与 PTZ
基础闭环跑通后,可以扩展 ONVIF 的 Events 服务,让相机上报移动侦测等事件。如果做云台相机,还可以实现 PTZ 服务,让 NVR 控制转动。这两个服务onvif-c有预留接口,按需实现即可。
7.4 多相机与边缘 AI 结合
ESP32-S3 带向量指令,可以跑轻量的人形检测模型。把检测结果通过 ONVIF Events 上报,NVR 就能做智能报警。这是低成本边缘视觉的一个实用方向,onvif-c的事件通道正好能承载这类元数据。
我在实际项目里发现,ONVIF 这套协议虽然老,但生态成熟,只要把最小闭环跑通,兼容性比想象中好。真正花时间的不是协议本身,而是网络配置和 RTSP 的细节打磨。建议先用 ONVIF Device Manager 把每个接口单独验证通过,再整体联调,能省下大量排查时间。