资讯动态

ESP32摄像头通过onvif-c接入NVR完整实战

发布时间:2026/10/27 9:13:02 来源:尧图企业网站定制
如果你做过带摄像头的嵌入式项目大概也经历过这个场景ESP32 采集画面跑得很顺App 预览也正常但客户一句话“能加到我们的 NVR 里吗”就把项目从快乐编码变成痛苦联调。NVR 一般不认私有协议它只认 ONVIF。onvif-c 这个专门给 ESP-IDF 用的 plain C 组件把 ONVIF 里最核心的 WS-Discovery、Device、Media 服务都封装好了不需要 gSOAP 那类重型依赖编译完就是几个 c 文件对 ESP32 这种资源吃紧的芯片非常友好。这篇文章是我从空工程开始一路做到 ESP32 相机能被 NVR 自动搜索并添加成功的完整过程适合所有想把摄像头接进标准监控体系的开发者尤其是做低成本 IP Camera、门禁、远程看护类项目的人。1. NVR 添加摄像头背后的 ONVIF 逻辑为什么 onvif-c 能帮你省掉一半开发1.1 ONVIF 不是协议是一套“标准登记表”很多人第一次接触 ONVIF会以为它是一种流媒体协议。其实不是视频流走的还是 RTSP/RTPONVIF 管的是设备能力、配置和发现等“元数据”接口。你可以把它理解成NVR 像物业前台ONVIF 是入住登记表RTSP 是房间钥匙。前台不关心你房间里的细节但它必须能登记设备、拿到钥匙、开门。ONVIF 标准里其实包含好几类服务Device Management 负责设备信息、时间、网络和用户管理Media 负责视频 Profile、编码参数和取流地址PTZ 负责云台控制Event 负责报警事件。对一台只做视频预览的 ESP32 相机来说绝大多数时候不需要把整套标准做完只需要实现最小服务集WS-Discovery、Device、Media 三块NVR 就能把你当标准摄像头添加。1.2 NVR 添加摄像头的最小服务集NVR 添加一台 ONVIF 相机时后台会走一条固定的调用链先通过 WS-Discovery 发组播探测找到设备后在设备返回的 XAddr 地址上调用 GetCapabilities、GetDeviceInformation、GetSystemDateAndTime接着调用 Media 服务的 GetProfiles、GetVideoEncoderConfigurationOptions、GetStreamUri最后用 GetStreamUri 拿到的地址去发起 RTSP 拉流。这条链路里每一步都有严格的 XML 格式要求。我最早在项目里只用 RTSP 手动添加结果 NVR 确实能显示画面但设备型号、时间同步、码流切换这些能力全部不可用等于一台“哑摄像头”。后来补上 ONVIF 服务NVR 的设备管理页才能正确识别名称、型号而且可以像配置大厂摄像头一样修改码流参数。1.3 onvif-c 组件的定位与架构onvif-c 的价值就是把这套调用链里最繁琐的 SOAP/XML 封装、WS-Discovery 组播监听、UsernameToken 认证都做成现成的 C 代码。它不像 gSOAP 那样需要预生成一堆骨架代码也不像 C 库那样拖进一堆运行时依赖。从工程角度说它就是一个普通 ESP-IDF 组件components/onvif-c 目录下放着 CMakeLists.txt、Kconfig、include 和 src你的工程只需要把它挂进构建系统再注册几个回调函数即可。有一点要提前说清楚onvif-c 负责的是 ONVIF 服务层摄像头驱动和 RTSP 推流这部分通常还是要你自己提供。组件里一般会留一个帧采集回调你把 esp_camera 拿到的 JPEG buffer 塞给它onvif-c 内部的 RTSP 模块再去发包。我实际用下来的整体数据流是NVR 发 WS-Discovery Probe → 设备回 ProbeMatch → NVR 拿到 XAddr 后访问 HTTP 服务 → 设备通过 Media 服务返回 RTSP 地址 → NVR 访问 RTSP 端口拉流 → 设备从摄像头采集 JPEG 帧并封装成 RTP 发送。2. 建工程与组件装载ESP-IDF 里把 onvif-c 装成普通组件2.1 创建工程和设置目标芯片我用的是 ESP-IDF 5.x创建工程比早期版本省事很多直接执行idf.py create-project esp32_onvif_camera cd esp32_onvif_camera idf.py set-target esp32如果手上是 ESP32-S3 或者其他带更多内存的芯片就把 set-target 改成对应型号。这里有个选型建议跑 ONVIF 服务 WS-Discovery RTSP 摄像头采集至少需要 PSRAM尤其是使用 OV2640 的 JPEG 模式时framebuffer 会直接分配在 PSRAM 里。我用的板子是带 8MB PSRAM 的 ESP32-CAM工程里把CONFIG_ESP32_SPIRAM_SUPPORTy打开即可。没装 PSRAM 的模组不是不能跑但分辨率一上去就很容易出现内存申请失败后面排查起来非常痛苦。2.2 获取并挂载 onvif-c 组件onvif-c 的获取方式取决于你拿到的版本。如果它已经收录进 Espressif 组件库可以直接用idf.py add-dependency onvif-c如果没有收录就把它作为本地组件放进工程。我自己的习惯是放在components/onvif-c这样idf.py build会自动识别。目录结构大概是这样的components/onvif-c ├── CMakeLists.txt ├── Kconfig ├── include │ ├── onvif.h │ ├── onvif_discovery.h │ ├── onvif_device.h │ └── onvif_media.h ├── src │ ├── onvif_soap.c │ ├── onvif_xml.c │ ├── onvif_discovery.c │ └── onvif_service.c └── examples └── esp32_camera挂载之后记得看一下CMakeLists.txt里的REQUIRES。ONVIF 服务依赖 TCP/UDP socket、lwIP 和 WiFi 接口信息所以通常会写成REQUIRES esp_wifi lwip esp_netif。如果你的工程还用了 esp32-camera也要把它加进依赖否则链接时找不到esp_camera_fb_get等符号。2.3 组件依赖与关键配置项我会在main组件的idf_component.yml里加入摄像头驱动依赖dependencies: espressif/esp32-camera: ^2.0.0ONVIF 服务本身一般没有第三方依赖但有几个 sdkconfig 配置项值得提前改掉。首先是把 lwIP 的 socket 数量调大一点默认 10 个经常不够用ONVIF 的 HTTP 服务占一个WS-Discovery 的 UDP 监听占一个RTSP 控制连接占一个如果 NVR 还会并发探测媒体能力很容易就触顶。我通常把CONFIG_LWIP_MAX_SOCKETS调到 16。然后是主任务栈和配置项。onvif-c 组件的 Kconfig 里一般会有这些开关CONFIG_ONVIF_USERNAME、CONFIG_ONVIF_PASSWORD、CONFIG_ONVIF_PORT、CONFIG_ONVIF_RTSP_PORT、CONFIG_ONVIF_STREAM_PATH、CONFIG_ONVIF_DEVICE_NAME。账号密码一定要认真配置最好不要留空我遇到好几个品牌的 NVR 对空密码同样会发起摘要认证密码长度为 0 时摘要计算逻辑很容易出问题。测试阶段我用的是admin / change_me_2024实测兼容性比admin / 123456好某些 NVR 会拒绝过短的密码。另外因为认证里的时间戳依赖系统时间工程里必须启动 SNTP。也就是在app_main里调用configTime(0, 0, pool.ntp.org)如果局域网没有外网可以指向 NVR 所在网络的 NTP 服务器或者至少用串口工具在开机时手动校时。这一步不做后面 401 会排到你怀疑人生。3. 让 NVR 找到你WS-Discovery、Device、Media 三个核心服务的实现要点3.1 WS-Discovery组播广播和 ProbeMatch 响应WS-Discovery 本质是 SOAP over UDP固定使用组播地址239.255.255.250和端口3702。NVR 向这个组播地址发 Probe所有监听 3702 端口的设备都要回 ProbeMatch。onvif-c 内部一般会按这个流程处理创建一个 UDP socket绑定 3702加入组播组然后循环接收消息并解析。加入组播组时有一个容易翻车的点ESP32 可能同时使能了 WiFi STA 和 AP或者有多个网卡接口这时必须指定 IP_ADD_MEMBERSHIP 作用在哪个网卡上。核心代码类似struct ip_mreqn mreq {0}; mreq.imr_multiaddr.s_addr inet_addr(239.255.255.250); mreq.imr_address.s_addr inet_addr(sta_ip); // 必须是本机 STA 口 IP mreq.imr_ifindex if_nametoindex(sta); setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, mreq, sizeof(mreq));如果imr_address写成 0.0.0.0 或者直接复用recvfrom收到的源 IP组播包很可能发出去了但收不到任何请求。我调试时踩过这个坑后面专门在 Socket 创建后把当前接口 IP 取出来填进去问题才消失。ProbeMatch 的 XML 里有两个关键字段。第一个是d:Types必须包含dn:NetworkVideoTransmitter这是 NVR 判断“这是摄像头”的重要依据。第二个是d:XAddrsNVR 后续所有 ONVIF 请求都会发到这个地址比如d:XAddrshttp://192.168.1.50:8080/onvif/device_service/d:XAddrs注意这里必须是 NVR 能从它的网络访问到的 IP。不要图省事写成localhost也不要写成 AP 口 IP否则添加设备时会显示“连接超时”。3.2 Device 服务身份信息、能力声明和时间认证NVR 在拿到 XAddr 后会立刻访问 Device 服务按顺序调用 GetCapabilities、GetDeviceInformation、GetSystemDateAndTime。onvif-c 里这套逻辑会映射到一组回调你把信息填进去就行。GetDeviceInformation 的返回内容直接决定了 NVR 设备管理页上显示的厂商和型号。我习惯填成ManufacturerEspressif/Manufacturer ModelESP32-CAM/Model FirmwareVersiononvif-c v0.1/FirmwareVersion SerialNumber7C9EBD123456/SerialNumber HardwareIdESP32/HardwareIdSerialNumber 我直接用 ESP32 的 MAC 地址可以省掉额外烧录序列号的逻辑。GetCapabilities 是 NVR 判断“你能做什么”的关键。对一款没有云台的简单相机只需要声明 Device 和 Media 两类能力其中 Media 的 XAddr 很关键。如果你和我的做法一样让 onvif-c 把所有 ONVIF 请求都收敛到同一个 HTTP endpoint那么这里也要填同一个地址Media XAddrhttp://192.168.1.50:8080/onvif/device_service/XAddr /Media很多初学者会专门为 Media 再开一个端口其实没有必要只要 HTTP server 能根据 SOAP Body 的动作元素分发即可。NVR 根本不在乎你是不是独立端口它只在乎拿到的 XAddr 能通。认证这一块是 ESP32 端最容易出 bug 的地方。ONVIF 使用 WS-Security UsernameToken核心摘要公式是PasswordDigest Base64(SHA1(Nonce Created Password))Nonce 是客户端发来的随机数Created 是客户端声称的 UTC 时间。这里有三个坑一是 SHA1 计算的输入是字节拼接不是字符串拼接二是 Created 必须是 UTC 时间不能带本地时区偏移三是 NVR 通常会允许 600 秒的时钟偏差超过就会 401。ESP32 如果没有正确同步 NTP哪怕其他全部正确也会认证失败。3.3 Media 服务Profile 和 GetStreamUri 的正确姿势NVR 确认设备能力后会调用 GetProfiles 获取视频 Profile。每个 Profile 对应一组编码参数、分辨率和码流控制。我维护一个固定 Profile 即可token 命名为MainStream编码类型写JPEG。注意这里必须写JPEG不是MJPEGONVIF 的枚举定义里没有MJPEG这个值。Profiles tokenMainStream NameMainStream/Name VideoEncoderConfiguration tokenvideo_enc_main Namemain/Name EncodingJPEG/Encoding Resolution Width1280/Width Height720/Height /Resolution Quality5/Quality RateControl FrameRateLimit15/FrameRateLimit EncodingInterval1/EncodingInterval /RateControl /VideoEncoderConfiguration /ProfilesProfile 里声明的分辨率、帧率必须和 ESP32 摄像头实际输出一致否则有些 NVR 会比较严格地核验出现“添加成功但黑屏”的情况。我用 1280x720 时 OV2640 在 15fps 下 CPU 占用已经偏高所以后来默认 Profile 改成了 640x480把 CPU 留下来做 RTP 分包和网络发送。GetStreamUri 返回的是 NVR 用来拉流的地址。最简单的实现是忽略请求里的 ProfileToken固定返回MediaUri Urirtsp://192.168.1.50:8554/stream/Uri /MediaUri这里同样要求 IP 是 NVR 可达的 IP。另外要不要在 URL 里带用户名密码NVR 行为有差异。我测试的多数 NVR 在添加设备时填了 ONVIF 账号RTSP 拉流时会自动带上认证信息但也有部分设备管理软件要求 URL 里直接写明账号密码。如果 onvif-c 的 RTSP 服务没有做认证建议先用不带凭据的 URL 跑通后续再根据目标 NVR 调整。3.4 可选 PTZ 的声明边界如果你只是做固定摄像头就不要在 GetCapabilities 里声明 PTZ。很多开发者觉得“多声明一个能力显得更完整”结果 NVR 调用 PTZ 服务时收到 SOAP Fault轻则在设备管理页显示“云台异常”重则影响添加流程。嵌入式系统第一原则是能力声明要克制没实现的功能坚决不写。如果确实接了云台电机至少要实现 GetNodes、GetConfigurations、GetConfiguration、ContinuousMove 和 Stop。onvif-c 里通常会有 PTZ 回调你把水平/垂直速度映射到 PWM 占空比即可原理不复杂但协议字段比 Media 更啰嗦需要额外花时间测试。4. ESP32 侧的 Camera 采集与 RTSP 推流ONVIF 的“面子”和“里子”4.1 硬件选型与 esp32-camera 配置我用的硬件是最常见的 Ai-Thinker ESP32-CAM 加 OV2640另外在 S3 核心板上也验证过 OV5640。无论哪块板子gpio 配置必须和模组原理图一致推荐用 esp32-camera 组件源码里的 camera_pins.h 模板而不是凭记忆写引脚表。初始化配置里建议开启 PSRAM 并指定 JPEG 输出camera_config_t config { .xclk_freq_hz 20000000, .pixel_format PIXFORMAT_JPEG, .frame_size FRAMESIZE_HD, .jpeg_quality 12, .fb_count 2, .fb_location CAMERA_FB_IN_PSRAM, .grab_mode CAMERA_GRAB_WHEN_EMPTY, }; esp_err_t err esp_camera_init(config);grab_mode用CAMERA_GRAB_WHEN_EMPTY可以避免采集线程阻塞时丢失新帧。fb_count我建议至少 2这样 RTSP 发送线程拿一帧的同时摄像头还能继续采下一帧但不要盲目调到 4PSRAM 虽然够但 espat 的内存带宽有限多缓冲反而增加延迟。4.2 一个最小可用的 RTSP 服务实现RTSP 服务其实不复杂核心是维护每一个 TCP 连接上的会话状态。NVR 拉流时会按 OPTIONS → DESCRIBE → SETUP → PLAY 的顺序交互有些设备还会先发一个 TEARDOWN 试探。OPTIONS 响应需要返回支持的公共方法RTSP/1.0 200 OK CSeq: 1 Public: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWNDESCRIBE 响应需要返回 SDPSDP 里最容易被 NVR 挑刺的是连接地址。很多现成例程写cIN IP4 0.0.0.0VLC 能播但部分 NVR 会坚持用c行的地址去连流媒体端口结果出现“控制连接成功、UDP 收不到包”。我后来一律把c行写成设备当前 STA IPv0 o- 123456 1 IN IP4 192.168.1.50 sESP32 ONVIF Camera cIN IP4 192.168.1.50 mvideo 0 RTP/AVP 26 acontrol:track1SETUP 阶段要注意 Transport 头。如果客户端请求的是 UDP服务端响应时要把客户端端口原样带回去比如Transport: RTP/AVP/UDP;unicast;client_port5000-5001;server_port8000-8001如果请求的是 TCP interleaved就要在后续发送 RTP 时加上$通道标识字节否则 NVR 会认为是非法数据。4.3 RTP 封装与 NVR 的编码兼容边界RTP 封装是另一个大坑。JPEG 在 RTP 里的载荷类型是 26对应 RFC 2435需要在 JPEG 帧前面加一个 JPEG payload header包含类型、缩放、质量表偏移等信息。很多网上的 ESP32 RTSP 示例直接把整个 JPEG buffer 塞进 RTP payloadVLC 偶尔能解但 NVR 会直接报“不支持的媒体格式”。onvif-c 的 RTSP 模块如果已经实现了 RFC 2435 分包这一层就不用你操心但你一定要确认它确实做了而不是让 RTSP 模块只负责传裸流。编码兼容是 ESP32 做 ONVIF 相机绕不开的边界ESP32 没有硬件 H.264 编码器所以实际推流只能走 MJPEG。ONVIF 标准里 JPEG 是合法的编码类型理论上标准 NVR 都该支持但现实是很多商业 NVR 默认只愿意收 H.264 主码流对 JPEG 摄像头要么显示“编码不支持”要么添加成功但预览黑屏。我在项目里做了两手准备对外部的 ONVIF Device Manager、开源 NVR 和 VLCMJPEG 完全没问题对客户指定的品牌 NVR提前确认它的编码支持列表。如果客户明确要求 H.264就不要硬在 ESP32 上折腾软件编码换带硬件编码器的方案更靠谱onvif-c 层代码基本可以原样迁移。5. 端到端验证从串口日志、Wireshark 到真 NVR 添加成功5.1 验证链路的五个节点我从不在没有分步验证的情况下直接拿 NVR 测因为 NVR 的报错信息过于笼统很难定位是 ONVIF 问题还是 RTSP 问题。我的标准链路是五步串口日志确认 ONVIF HTTP 服务和 WS-Discovery 已启动确认监听的端口是 8080 和 3702。电脑上装 VLC直接打开rtsp://设备IP:8554/stream先排除视频采集和 RTSP 基础链路的问题。用 Wireshark 在电脑同网段抓包过滤udp.port 3702确认能收到自己设备的 ProbeMatch。用 ONVIF Device Manager 扫描局域网确认设备能在管理工具里发现并显示型号。最后再用真 NVR 添加减少变量干扰。第 4 步非常值得做。ONVIF Device Manager 是很多厂家设备管理员的常用工具它的实现比较规范如果它能发现并预览说明你的 ONVIF 服务基本是标准可用的。它给出的错误信息也远比 NVR 的“密码错误”或“网络超时”更有指向性。5.2 常见异常现象与排查方法联调过程中我整理了一张排查表遇到问题直接按表查比反复重启设备高效得多现象可能原因排查方向NVR 搜不到设备组播没出网卡、跨 VLAN、防火墙拦截抓udp.port3702确认设备 IP 和 NVR 同网段搜索到但添加失败认证时间戳偏差、SOAP 命名空间错误看串口日志抓 HTTP 请求检查密码摘要和响应状态码添加成功但预览黑屏RTSP 地址不可达、RTP 负载类型不匹配、SDP c 行错误用 VLC 测流核对 SDP 的 payload type 和实际帧类型预览卡顿、马赛克JPEG 帧过大被 IP 分片、编码质量太低降低分辨率、提高 JPEG 质量或调小 RTP 分片大小其中“添加成功但预览黑屏”最隐蔽因为它意味着 ONVIF 服务完全正常问题出在流媒体负载上。我曾经把 Profile 里的 Encoding 写成H264但实际推的是 JPEG 流NVR 添加设备时没有任何报错真正拉流后播放器一直黑屏。这个错误不抓包很难看出来因为 ONVIF 阶段全部通过了。5.3 真机 NVR 添加的完整操作顺序在主流 NVR 上添加设备时登录设备管理页后选择“添加设备”或“IP Camera”通常有三种方式自动发现、按 IP 添加、按域名添加。自动发现成功后NVR 会带出设备名称和 IP接下来要手动选协议为 ONVIF并输入你在 onvif-c 里配置的用户名密码。这里有一个端口细节很多 NVR 的添加页面里让填“端口”默认显示 80 或 8000这个端口通常指 ONVIF HTTP 端口不是 RTSP 端口。我在测试时把 onvif-c 的 HTTP 端口从 8080 改成 80因为部分 NVR 自动发现完以后会拿 80 端口去探测8080 会被识别成“非标准设备”。如果你不想改端口手动添加时把端口填成 8080 也能通但自动发现流程的兼容性要自己多测几轮。6. 我实际踩过的五个坑以及对应的修复方案6.1 401 大量出现时间戳和摘要认证顺序第一次联调时NVR 一直提示“用户名或密码错误”但我用 ONVIF Device Manager 却完全正常。后来抓包发现 NVR 发来的 Created 时间比 ESP32 系统时间大了将近 8 个小时显然是时区问题。ESP32 的time()返回的是本地时间而 WS-Security 要求 UTC我直接用localtime格式化时间戳当然对不上。修复方法是格式化时调用gmtime_r并且让 SNTP 正常同步。另一个低级错误是 SHA1 拼接顺序ONVIF 要求的是Nonce Created Password不是Created Nonce Password顺序错了也会 401。6.2 抓包能收到 Probe 却收不到 ProbeMatchWireshark 里能看到 NVR 发出的 Probe 报文到达了设备但局域网里就是没有 ProbeMatch。排查一遍发现是组播组成员关系的锅。我最初把ip_mreqn.imr_address.s_addr设成了INADDR_ANY内核不知道该把组播数据投递给哪个网卡结果 UDP socket 永远收不到组播包。改成用esp_netif_get_ip_info取当前 STA 接口的 IP 填进去后ProbeMatch 立刻就能抓到。这个坑在单网卡设备上不存在但在双网卡、APSTA 场景下非常典型。6.3 RTSP 返回的 URL 是 localhost 或错误网卡 IPGetStreamUri 返回的地址是localhost这个问题出在早期我“图方便”用了gethostname。嵌入式设备上 hostname 解析总是倾向于回环地址NVR 拿到localhost:8554会尝试连它自己当然失败。正确做法是从esp_netif_get_ip_info(ESP_IF_WIFI_STA, ip_info)取字符串格式化进 URL。另外如果设备同时开着 AP 和 STA要优先用 STA IP因为 NVR 通常连接在同一个路由器下它访问不到 AP 口的网段。6.4 内存不足导致 ESP32 反复重启跑通 ONVIF 后发现一个尴尬问题添加设备后 ESP32 看门狗重启。原因是 RTSP 发送线程和 ONVIF HTTP 服务同时工作加上 esp_camera 的 framebuffer堆内存被吃到只剩几十 KB。我把fb_count从 3 降到 2分辨率从 1280x720 降到 640x480并且调大了CONFIG_LWIP_MAX_SOCKETS内存压力立刻缓解。排查时可以用heap_caps_print_heap_info(MALLOC_CAP_SPIRAM)在串口打印各段内存占用比猜变量准得多。6.5 MJPEG 编码标成 H.264NVR 拉流后立刻黑屏这个坑前面已经提到过。把 Encoding 写成H264的初衷是想让 NVR 更愿意接受设备结果反而适得其反。NVR 一旦发现 Profile 声明是 H.264就会用 H.264 的解码器去等数据而 ESP32 实际发过来的是 RFC 2435 JPEG 包解码器直接罢工。正确的做法是老老实实把 Encoding 写成JPEGSDP 里用 payload type 26。哪怕这样会导致部分商业 NVR 不兼容也不能用错误的声明去骗 NVR最终还是要靠提前确认编码支持列表来做产品决策。整个项目跑通之后最开心的一刻是第一次在真机 NVR 的设备列表里看到自己 ESP32 的设备名那一刻真的会觉得前面抓包、调时间戳、改组播参数字段的日子没白过。后来我再做任何相机类项目第一件事就是先把 onvif-c 塞进去让设备先成为一台“标准摄像头”再谈私有功能和业务逻辑。最后分享一个小习惯联调时把设备名里加上自己的项目代号和日期比如ESP32_CAM_V0204这样 NVR 扫描列表里出现异常设备时你能立刻知道是哪一台开发板在捣乱。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑