☰
基于ESP-IDF与onvif-c实现ESP32-CAM接入NVR的完整指南
2026/10/6 11:40:40 网站建设 项目流程

手头正好有一块搭载OV2640的ESP32-CAM,之前做了一堆简单的“上电出图小玩具”,可一旦接到NVR上就全傻眼:海康搜不到,群晖也搜不到,只能打开网页看MJPEG。后来我把ONVIF服务端用plain C写进ESP-IDF工程里,配合onvif-c组件把WS-Discovery、设备管理、媒体配置这些层层补齐,终于让这块二十多块的开发板像模像样地出现在NVR列表里,能添加、能取流、能录像。

这篇文章就是把整套流程沉淀下来的参考手册:怎么从零建一个ESP-IDF项目,怎么把onvif-c组件组装进去,怎么让NVR发现设备、拿到配置文件、拉取RTSP流,以及我在真机上踩过的那些“搜不到”“加不上”“流断断续续”的坑。适合手头有ESP32或ESP32-S系列开发板、想低成本做IP摄像头、又不想只停留在“网页看画面”这层的同学参考。

1. 先把ONVIF这个“面试官”的套路聊清楚

1.1 NVR并不是靠“看IP地址”来找摄像头的

很多第一次做这个的人会误以为:我把RTSP流跑起来,然后把NVR里的“添加IP摄像头”界面填上IP和端口就能用。实际上NVR在做“自动发现”时,用的是ONVIF的WS-Discovery,也就是向局域网组播地址 239.255.255.250:3702 发一条Probe探测消息,凡是支持ONVIF的设备必须在一个限定的时间窗口内回一条ProbeMatch。

这条ProbeMatch里一般带设备地址、Scope、设备型号等。NVR收到之后,才会进一步用HTTP/SOAP去问你:你的设备信息是什么?你有哪几路摄像头?你能出什么编码的流?给我一个取流地址。这一连串动作,才是ONVIF真正的价值所在——不是某个私有SDK,而是规范化的一套“自我介绍+能力答复”协议。

所以我们常说的“让NVR接入ESP32相机”,本质上是两件事:第一,把RTSP流做出来;第二,在设备上实现ONVIF服务端所需的那几个核心接口,并且让它们跟实际流媒体服务对齐。onvif-c组件在ESP-IDF里的作用,就是替你处理SOAP报文、XML解析、WS-Discovery响应这些无聊但容易错的部分。

1.2 onvif-c到底解决了什么问题

如果你去看ONVIF的WSDL,你会发现接口多到吓人:Device、Media、Event、PTZ、Recording、Display、Analytics……几百个方法。但真实NVR接入一个最基础的网络摄像头,通常只用到这些:

  • WS-Discovery:响应Probe,让NVR在搜索列表里看到你。
  • DeviceManagement:GetDeviceInformation、GetCapabilities、GetSystemDateAndTime、SetSystemDateAndTime。
  • Media:GetProfiles、GetStreamUri、GetVideoEncoderConfiguration。
  • 最多再加一个Event里的PullMessages或GetEventProperties,但很多NVR即使没有Event服务也能硬加。

onvif-c在ESP-IDF里做的是把这套SOAP/XML处理抽象成C结构体和回调函数。你不用自己去拼一长串SOAP XML,只需要填充设备信息结构体、配置文件结构体,然后启动HTTP服务端和组播探测监听。这就是我把这个组件放进项目里的第一原因:它能让我把精力集中在“摄像头到底能不能出流”上,而不是死磕XML namespace。

1.3 为什么是ESP-IDF而不是Arduino

Arduino跑一个RTSP服务不难,但要把ONVIF服务端、多线程、socket、HTTP解析、Wi-Fi重连这些杂事整在一起,维护起来会非常痛苦。ESP-IDF本身就是为这种“多种通信协议”共存的场景设计的,它提供了lwIP TCP/IP协议栈、mbedTLS、事件循环、以及基于FreeRTOS的多任务模型。

onvif-c本身就是plain C实现,放在ESP-IDF里几乎零移植成本。相比之下,如果用C++库反而还得处理编译器和STL兼容性。这就是我推荐ESP-IDF + plain C方案的理由:不是因为它时髦,而是因为它贴近底层、依赖干净,出问题时候你还能套上Wireshark抓包一层一层排查。

2. 从建工程到把组件编译进固件

2.1 准备ESP-IDF环境并创建项目

如果你还没装ESP-IDF,先去乐鑫官网按常规流程装好,支持Linux、macOS和Windows。装完以后打开终端,先建一个空白项目:

idf.py create-project esp32_onvif_cam cd esp32_onvif_cam

这里会生成一个默认的main目录,里面只有个hello world。接下来就是把这个工程改成“摄像头固件”项目。目标芯片我用的ESP32,你也可以顺手把它编译到ESP32-S3,后面只要把摄像头驱动引脚配置改一下就行。

然后是依赖。我做这个项目时用到的组件有:

  • esp32-camera:驱动OV2640/OV5640,获取帧数据。
  • rtsp-server:用于起一路RTSP Server,把帧编码成流推出去。
  • onvif-c:核心ONVIF服务栈。
  • 系统自带的esp_wifi、esp_netif、nvs_flash、lwip。

这些第三方组件统一放在工程的components目录下。onvif-c我习惯直接用git管理,因为后续我自己会改它的回调,直接用源码比预编译库好维护得多。

2.2 组件目录与CMake配置

典型目录结构是这样:

esp32_onvif_cam/ ├── main/ │ ├── CMakeLists.txt │ └── main.c ├── components/ │ ├── esp32-camera/ │ ├── rtsp-server/ │ └── onvif-c/ ├── sdkconfig.defaults └── CMakeLists.txt

onvif-c组件本身的CMakeLists.txt一般只需把自己编译进来,并声明依赖lwip和mbedtls:

idf_component_register( SRCS "src/onvif.c" "src/ws_discovery.c" "src/device_service.c" "src/media_service.c" INCLUDE_DIRS "include" "src" REQUIRES lwip mbedtls esp_wifi esp_netif)

如果你的onvif-c源码目录结构和这个不一样,以实际代码为准。关键是你要确保它是作为一个IDF组件被编译,而不是放在main里被include,否则后续idf.py menuconfig里某些网络相关的宏会绕开lwIP的开关,调试时特别容易出莫名其妙的问题。

2.3 工程级配置里必须打开的开关

编译前,我习惯先设置sdkconfig.defaults,里面固定写几项:

CONFIG_ESPTOOLPY_FLASHMODE_QIO=y CONFIG_ESPTOOLPY_FLASHFREQ_80M=y CONFIG_LWIP_MULTICAST_TX_OPTIONS=y CONFIG_LWIP_IPV4=y CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y

其中CONFIG_LWIP_MULTICAST_TX_OPTIONS和WS-Discovery关系很大。如果这一项没有,ESP32在往239.255.255.250发组播时可能会因为网卡路由表问题丢包。还有一些旧版本ESP-IDF默认关闭了对多播报文的监听,需要确认esp_netif配置里打开了多播,否则NVR的Probe到了板子也进不了Wifi收包回调。

曾经最折腾的一次:onvif-c一切都正常,服务也在监听,但NVR就是搜不到设备,最后发现是NVS里Wi-Fi配置残留导致连接了另一台路由器的AP。这种“明明局域网却不在一个广播域”的问题是排查时的第一优先级。

3. 相机侧的三根支柱:时间、画面、RTSP

3.1 先校准时间,不要小看NTP

ONVIF和RTSP这两个协议都非常依赖正确的系统时间。WS-Security的UsernameToken会用到时间戳,RTSP的RTP会话也要在SDP里带时间基准。ESP32刚上电时RTC时间是1970年甚至乱七八糟,如果不先做NTP校准,后面NVR做认证时大概率报“时间偏差过大”。

我在main初始化里加了一个简单的NTP任务:

  • 使用esp_netif_sntp_start或直接创建一个UDP socket去访问NTP服务器。
  • 等待SNTP同步完成再启动ONVIF服务,避免在错误时间戳下被人访问。
  • 如果局域网没有外网,可以让主控设备通过ONVIF的SetSystemDateAndTime接口手动校时,但这个更多是兜底方案。

时间对准后,还要在代码里把时区设置对,不然录像回放的时间戳会比实际钟点差8小时,虽然设备“能添加成功”,但这个细节用久了极难受。

3.2 摄像头帧源要怎么喂给RTSP

ESP32-CAM的OV2640通过DVP接口接在主控上,esp32-camera组件把整条链路封装好了,初始化时只需配置引脚和分辨率:

camera_config_t config = { .pin_pwdn = GPIO_NUM_32, .pin_reset = GPIO_NUM_NUM -1, .pin_xclk = GPIO_NUM_0, .pin_sccb_sda = GPIO_NUM_26, .pin_sccb_scl = GPIO_NUM_27, .pin_d7 = 35, .pin_d6 = 34, // ...省略其他引脚配置 .frame_size = FRAMESIZE_VGA, .jpeg_quality = 10, .fb_count = 2, }; esp_camera_init(&config);

帧源出来后,RTSP server负责把它封装成RTP包发出去。要注意OV2640硬编码JPEG,所以最省事的方式是输出PIXFORMAT_JPEG,RTSP服务端MIME type写video/JPEG即可。如果非要H.264,ESP32只能靠软件编码器,VGA分辨率下勉强能跑,但码率和CPU占用都会比较高。我在这个项目里先用JPEG起步,把NVR接入跑通后才慢慢优化编码。

3.3 RTSP地址、编码格式要和ONVIF“对表”

ONVIF的Media服务在GetStreamUri时要返回一个实际可用的RTSP地址,比如:

rtsp://192.168.1.50:554/mjpeg

NVR拿到这个地址后会主动建立RTSP会话。这里最容易犯的错是:ONVIF里配置好的分辨率是1920x1080,但RTSP实际推送的是VGA,NVR在协商阶段就会断开。所以设计时你要维护一张表,让配置文件和实际编码参数严格一致。

我的做法是先定义两个Profile:一个叫“MainStream”对应较高分辨率,另一个叫“SecondStream”对应较低分辨率。每个Profile都绑定独立的编码参数,RTSP也起两个端口或两个路径,这样NVR在拉流时选择哪个Profile就能拿到对应质量的流,两头不乱。

4. 让NVR主动“看得见、加得上”的ONVIF服务实现

4.1 服务端地址和端口怎么定

ONVIF说到底是走HTTP + SOAP,所以ESP32上必须先开一个HTTP服务。我通常让HTTP服务监听80端口,然后ONVIF服务挂在固定路径上:

  • http://<ESP32-IP>:80/onvif/device_service
  • http://<ESP32-IP>:80/onvif/media_service
  • http://<ESP32-IP>:80/onvif/events_service

WS-Discovery的ProbeMatch报文里,我会把这些XAddr都写清楚。NVR在做正式请求时,其实就是在访问这些URL。onvif-c组件启动时会自动注册这些路径的处理函数,但我们自己必须维护好IP地址和端口,不能写死成192.168.1.10,不然换个网络环境设备就直接失联。

4.2 ProbeMatch报文怎么写才不会被忽略

可以做一个最简化的ProbeMatch验证思路:用电脑上的ONVIF Device Tool发一条搜索,再用Wireshark抓ESP32回的组播包。一个可以被绝大多数NVR识别的ProbeMatch,至少包含这几个标签:

<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"> <soap:Body> <d:ProbeMatch xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery"> <d:EndpointReference> <wsa:Address>uuid:your-device-uuid</wsa:Address> </d:EndpointReference> <d:Types>dn:NetworkVideoTransmitter</d:Types> <d:Scopes>onvif://www.onvif.org/name/ESP32Camera onvif://www.onvif.org/type/video_encoder</d:Scopes> <d:XAddrs>http://192.168.1.50:80/onvif/device_service</d:XAddrs> </d:ProbeMatch> </soap:Body> </soap:Envelope>

这里有几个坑:

  • UUID要稳定。如果每次上电都随机生成,NVR会把同一台设备当成好几台,特别是你在做录像回看时,记录会变得很乱。
  • XAddrs要能被NVR反向访问。有些板子同时有AP模式和Station模式,如果你把AP网段的地址写进去,NVR在局域网里根本路由不到。
  • Types和Scopes要与实际能力一致。如果Scopes里写了PTZ,那GetCapabilities里就必须有PTZ相关服务,不然NVR会在后续能力探索阶段给你判不合格。

4.3 GetDeviceInformation与Media服务的关键回复

NVR搜索到设备后,会立刻调用GetDeviceInformation读取厂商和型号,很多协议栈对这里的字段非常敏感。我的回复填法是:

<DeviceInformation> <Manufacturer>Espressif</Manufacturer> <Model>ESP32-CAM-ONVIF</Model> <FirmwareVersion>1.0.0</FirmwareVersion> <SerialNumber>ESP32CAM12345678</SerialNumber> <HardwareId>ESP32-CAM-REV1</HardwareId> </DeviceInformation>

NVR添加设备前,还会问GetProfiles。每个Profile必须有唯一的token,同时包含一个VideoEncoderConfiguration。举一个简化响应:

<Profiles token="MainStream"> <Name>MainStream</Name> <VideoEncoderConfiguration token="MainEncoder"> <Encoding>JPEG</Encoding> <Resolution> <Width>640</Width> <Height>480</Height> </Resolution> <Quality>5</Quality> <RateControl> <FrameRateLimit>15</FrameRateLimit> <BitrateLimit>4096</BitrateLimit> </RateControl> </VideoEncoderConfiguration> </Profiles>

然后GetStreamUri返回的RTSP地址里,要带Profile token。我在这里最容易出的问题是把两个Profile的token写到同一个RTSP路径,导致NVR分不清哪一路是哪一路。建议一个Profile对应一个专属路径,宁可多占内存,也不要偷懒混用。

4.4 认证怎么配才能兼容海康、大华之类的NVR

ONVIF认证最常用的是WS-UsernameToken,也就是把用户名、密码、随机数、时间戳组合后做摘要。onvif-c组件里这块已经实现了,你需要做的就是设置好用户名和密码,并且保证设备时间同步。否则NVR发来的nonce和timestamp,你用本地时间校验永远失败。

我曾经遇到一个现象:设备在Wireshark里能看到,NVR也能获取设备信息,但一到真正验证密码时就提示“用户名或密码错误”。排查到最后发现是我在配置里写死了密码,但代码里实际用的是组件默认值。这种低级错误常常比协议问题更费时间,所以建议把账号配置统一抽成宏定义,并用git提交前强制检查。

另外有些NVR只支持最基础的HTTP摘要认证,不支持WS-Security的Digest。如果碰到这种设备,要么在onvif-c组件里把两种认证方式都打开,要么升级组件版本。我在工程里同时保留了简单认证和WS-Security认证两套回调,让NVR来什么我接什么。

4.5 NVR下添加设备的三种姿势

不同的NVR操作方式不太一样,但总结下来就是三种:

  • 自动搜索:NVR点“搜索设备”,依赖WS-Discovery,要求ESP32能收到组播并回复单播/组播。
  • 手动添加ONVIF协议:填IP地址,填端口和ONVIF账号密码,不依赖Discovery,只要HTTP服务端和认证正常即可。
  • 手动添加RTSP流:这种方式其实已经不叫ONVIF接入,NVR不探索能力,直接把流拉过来。适合排查RTSP是否正常,而不是验证ONVIF。

从我的经验看,先把“手动RTSP”跑通,再调“自动搜索”是最快的路径。因为RTSP通了至少证明摄像头画面、编码、RTSP服务是好的,剩下的问题都集中在ONVIF服务层,抓包排查范围会小很多。

5. 常见问题与排查技巧实录

5.1 现象和原因对照表

下面这组问题是我在实际调式过程中以及和几个同好交流时收集到的,命中率最高的场景:

现象最可能的原因解决办法
NVR自动搜索完全看不到设备WS-Discovery组播没发出去或被路由器隔离确认都在同一网段,检查lwIP多播选项,先关AP模式再测试
能看到设备但添加失败XAddr返回了错误的IP/端口确认XAddrs是NVR可达地址,不能是AP网段
添加时提示认证失败系统时间未同步,或用户名密码配置不一致校准NTP,统一账号配置,验证WS-Security摘要算法
ONVIF能通但取流黑屏RTSP地址或编码类型与Profile不匹配用VLC直接手动拉RTSP验证,再回看Profile配置
RTSP花屏/画面卡顿JPEG过大、帧率过高、lwIP网络缓冲区不够降低分辨率或降低JPEG质量,把RTP分包缓冲区调大
换个路由器就失灵设备IP漂移或组播被AP隔离给ESP32固定静态IP,或在路由上开启组播转发

5.2 用抓包定位的最实用四板斧

第一板斧,先看组播。NVR发Probe后,ESP32网卡上有没有收到?如果没收到,问题大概率在Wi-Fi路由器/交换机的组播设置,而不是协议栈。如果收到了没回复,问题在onvif-c的服务启动顺序。

第二板斧,再看HTTP状态。NVR访问/onvif/device_service时,ESP32返回的HTTP状态码是不是200?如果返回401,去看认证逻辑;如果返回404,去看URL路径是否注册正确;如果返回500,十有八九是SOAP解析异常。

第三板斧,看RTSP DESCRIBE。NVR发出RTSP DESCRIBE请求后,ESP32有没有回SDP?SDP里的编码名称和Profile是否一致?我遇到过一次NVR报“无法预览”,本质就是SDP里写了video/JPEG但服务端实际推的是video/H264,封装都反了。

第四板斧,查时间戳。RTSP包里的RTP时间戳如果前后跳跃不连续,NVR端就会判定画面不稳定,严重时直接断开。可以抓RTP包看时间戳增量,确认你从摄像头取帧后没有在高开销路径上丢掉帧。

5.3 一些写代码时可以提前避开的坑

ESP32的Wi-Fi连接不稳定是常态,尤其是食堂、车间之类Wi-Fi信道拥挤的环境。我的做法是做一个连接监控任务:如果Wi-Fi断开超过10秒,不急着重连,先主动关闭ONVIF和RTSP服务,等Wi-Fi恢复后再按顺序重新拉起服务。这样做了一件事:避免NVR在设备离线期间不断重试,导致恢复后仍然认为设备不可用。

内存方面,ONVIF的SOAP报文虽然不算大,但RTSP服务的RTP发送缓冲区会吃不少内存。我用的是两个fb_count的双缓冲,再加上一个发送Queue,实测VGA分辨率、15帧率、JPEG质量10的情况下,剩余内存还能剩二十多KB。如果发现添加成功但预览断断续续,先别怀疑协议,去看heap_caps_get_free_size,排查内存是否在频繁分配。

另外一个容易忽视的是HTTP端口的并发短板。NVR添加设备后会同时开好几个连接:设备信息、时间同步、拉流、事件订阅。有些简化的onvif-c示例只支持单线程响应,导致两个请求同时进来时一个就会卡死。我的方案是开了系统任务里单独的ONVIF worker,并把HTTP keep-alive设置得短一些,避免被NVR的并发连接拖住。

6. 后续还能往哪些方向扩展

如果你只想要一块能接入NVR的板子,这套东西已经够用了。但如果你想把成本做到极致,或者想做得更“专业”,有几个方向是值得接着往下走的:

一是把Event服务完整实现出来。很多NVR在添加完设备后会订阅Motion Alarm之类的事件,如果事件服务为空,也不影响基础使用,但操作界面里就会少不少选项。实现PullMessages可以在ESP32上都行,在触发GPIO后自动推送报警事件,这对做门禁或联动特别有价值。

二是做多路虚拟摄像头。ESP32-S3性能更强,可以同时跑两个分辨率等级,用软件把两路MJPEG编码出来;也可以把多个ESP32组成分布式摄像头群,由主节点统一做ONVIF代理。这种玩法比较进阶,但onvif-c的接口抽象得很好,改一改回调就能实现。

三是考虑安全加固。公网访问时,HTTP的ONVIF服务最好走mbedTLS启用TLS,RTSP也建议用TCP加认证。ESP32的算力做不成太高强度的加密,但对个人密码类应用来说已经比裸奔强得多。

最后也算是我个人的一个经验总结:别一开始就追求把ONVIF所有接口都实现完整。先跑通“发现-取设备信息-取Profile-取流”这条主链路,后续功能再逐个补。NVR的排查手段并不神秘,用Wireshark盯住UDP 3702和HTTP 80端口,基本能看到它每一步在干什么。Emmbedded摄像头这条路并不一定要上Linux,用ESP-IDF把plain C栈一丝不苟地抠通,反而会让你对协议本身理解得更深。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询