
1. 为什么要在 ESP32 上跑 ONVIF从能出图到能被 NVR 认出来的鸿沟很多人第一次用 ESP32 做摄像头项目流程都差不多OV5640 接上esp32-camera 库拉起来浏览器打开http://192.168.x.x:81/stream能看到画面心里就觉得成了。结果拿到手边一台海康或者大华的 NVR 前面输入 IP、端口、账号密码点添加转圈半天最后弹一个设备连接失败或者未搜索到设备。这时候才意识到能出图和能被 NVR 添加完全是两码事。ONVIFOpen Network Video Interface Forum就是横在这两者之间的那道门槛。它不是某个具体的视频编码协议而是一整套基于 SOAP/XML 的设备发现、能力协商、媒体配置、流地址获取的交互规范。NVR 添加一台相机背后至少要走完这几步先通过 WS-Discovery 组播把设备喊出来再通过 ONVIF Device Service 拿到设备信息和媒体能力然后创建媒体配置、拿到 RTSP 地址最后用 RTSP 去拉流。任何一步对不上NVR 就会判定这台设备不是我要的相机。onvif-c这个组件就是把这套流程用纯 C 在 ESP-IDF 上实现出来让 ESP32 从一个能推流的玩具变成一台 NVR 眼里合规的 IP 相机。它依赖esp_http_server提供 HTTP/SOAP 服务端依赖 mDNS 或裸 UDP 组播实现 WS-Discovery再配合 RTSP 服务端把 H.264 或者 MJPEG 流吐出去。关键词里的esp_http_server、WS-Discovery、onvif-c、ESP-IDF、ESP32这五个词基本就勾勒出了整个技术栈的骨架。这篇文章面向的是已经能把 ESP32 跑起来、能烧固件、但卡在NVR 不认这一步的开发者。我会从建工程开始把 ONVIF 服务端的目录结构、SOAP 报文处理、WS-Discovery 应答、媒体配置、RTSP 地址生成这一整条链路拆开讲重点放在那些文档里不会写、但实际调试时一定会踩的坑上。读完你应该能自己搭出一个能被主流 NVR 添加、能出流的 ESP32 相机固件。2. 工程骨架搭建onvif-c 组件在 ESP-IDF 里的正确落位2.1 组件目录结构与 CMake 依赖关系onvif-c不是一个官方组件它更像是一个约定俗成的组件命名实际项目里你需要自己组织目录。我习惯的布局是这样的project/ ├── CMakeLists.txt ├── sdkconfig.defaults ├── main/ │ ├── CMakeLists.txt │ └── main.c └── components/ └── onvif-c/ ├── CMakeLists.txt ├── include/ │ ├── onvif_device.h │ ├── onvif_media.h │ └── onvif_discovery.h ├── onvif_device.c ├── onvif_media.c ├── onvif_discovery.c └── onvif_soap.c组件级的CMakeLists.txt里REQUIRES这一项决定了编译顺序和头文件可见性写错了会出现找不到 esp_http_server.h这种低级但烦人的错误idf_component_register( SRCS onvif_device.c onvif_media.c onvif_discovery.c onvif_soap.c INCLUDE_DIRS include REQUIRES esp_http_server esp_netif nvs_flash mdns )这里有个细节mdns组件在 ESP-IDF 里是可选的如果你打算用 mDNS 来做设备发现就加上如果只用裸 UDP 组播做 WS-Discovery可以去掉。但实测下来很多 NVR 的 WS-Discovery 探测包是发到239.255.255.250:3702的跟 mDNS 的224.0.0.251:5353不是一回事所以 mDNS 并不是必须的。我建议初期两个都留着方便对比调试。2.2 sdkconfig 里必须打开的几项配置sdkconfig.defaults是新手最容易忽略的地方。默认配置下esp_http_server的最大 URI 处理数、LWIP 的 socket 数量、TCP 接收窗口都可能不够用导致 ONVIF 请求处理到一半就断连。下面这几项是我反复验证过的底线配置CONFIG_ESP_HTTP_SERVER_ENABLEy CONFIG_HTTPD_MAX_REQ_HDR_LEN1024 CONFIG_HTTPD_MAX_URI_LEN512 CONFIG_LWIP_MAX_SOCKETS16 CONFIG_LWIP_TCP_SND_BUF_DEFAULT8192 CONFIG_LWIP_TCP_RCV_BUF_DEFAULT8192 CONFIG_ESP_NETIF_TCPIP_LWIPy CONFIG_MDNS_MAX_SERVICES8HTTPD_MAX_URI_LEN这一项特别关键。ONVIF 的 SOAP 请求 URI 通常是/onvif/device_service这种看起来不长但有些 NVR 会在 URI 后面带一堆查询参数默认 512 字节有时候会溢出导致返回 414。我遇到过某品牌 NVR 发的 URI 长达 700 多字节最后把这项调到 1024 才稳定。另外LWIP_MAX_SOCKETS默认是 10ONVIF 服务端要同时监听 HTTP、RTSP、WS-Discovery 三个端口再加上 NVR 可能开多个并发连接10 个 socket 很容易被占满。调到 16 是比较稳妥的值内存够的话可以上 24。2.3 网络初始化与多服务并存的端口规划ESP32 上同时跑 HTTP 服务端、RTSP 服务端、WS-Discovery 监听端口不能冲突。我的规划是服务端口协议用途ONVIF Device/Media80TCPSOAP 请求处理RTSP554TCP视频流拉取WS-Discovery3702UDP设备发现HTTP 快照8080TCP调试用 JPEG 快照端口 80 和 554 都是标准端口NVR 默认就认这两个。WS-Discovery 的 3702 是 ONVIF 规范里写死的不能改。8080 是我自己加的调试端口方便在浏览器里直接看画面确认摄像头本身没问题。网络初始化部分esp_netif和事件循环要先起来再启动各个服务。顺序错了会出现HTTP 服务起来了但网络还没通的情况ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); esp_netif_create_default_wifi_sta(); // wifi 初始化、连接、等待 IP 事件 // 拿到 IP 后再启动 httpd、rtsp、discovery这里有个经验不要在 WiFi 连接成功的回调里直接启动所有服务最好用一个xEventGroup或者信号量等 IP 真正拿到之后再统一启动。我早期在回调里直接httpd_start结果 DHCP 还没完成服务绑定的 IP 是 0.0.0.0NVR 根本连不上。3. WS-DiscoveryNVR 是怎么喊出你的相机的3.1 组播探测包的报文结构与应答时机WS-Discovery 是 ONVIF 设备发现的入口。NVR 上线后会往239.255.255.250:3702发一个 UDP 组播包内容是一段 SOAP XML核心是Probe动作里面带着它要找的设备类型通常是dn:NetworkVideoTransmitter。你的 ESP32 要做的就是监听这个组播地址和端口收到 Probe 后回一个ProbeMatches单播包给 NVR 的源地址。Probe 报文简化后长这样soap:Envelope xmlns:soaphttp://www.w3.org/2003/05/soap-envelope xmlns:wsahttp://schemas.xmlsoap.org/ws/2004/08/addressing xmlns:wsdhttp://schemas.xmlsoap.org/ws/2005/04/discovery soap:Header wsa:Actionhttp://schemas.xmlsoap.org/ws/2005/04/discovery/Probe/wsa:Action wsa:MessageIDuuid:xxx/wsa:MessageID wsa:Tourn:schemas-xmlsoap-org:ws:2005:04:discovery/wsa:To /soap:Header soap:Body wsd:Probe wsd:Typesdn:NetworkVideoTransmitter/wsd:Types /wsd:Probe /soap:Body /soap:Envelope应答的ProbeMatches里最关键的是XAddrs字段它告诉 NVR我的 ONVIF 服务在这个地址上wsd:ProbeMatches wsd:ProbeMatch wsa:EndpointReference wsa:Addressurn:uuid:你的设备UUID/wsa:Address /wsa:EndpointReference wsd:Typesdn:NetworkVideoTransmitter/wsd:Types wsd:Scopesonvif://www.onvif.org/type/video_encoder onvif://www.onvif.org/name/ESP32Cam/wsd:Scopes wsd:XAddrshttp://192.168.1.100/onvif/device_service/wsd:XAddrs wsd:MetadataVersion1/wsd:MetadataVersion /wsd:ProbeMatch /wsd:ProbeMatchesXAddrs里的 IP 必须是 ESP32 实际拿到的 IP不能写死。我见过有人图省事写192.168.1.100结果路由器分配的是192.168.1.101NVR 收到应答后去连100当然连不上。正确做法是在收到 Probe 时从recvfrom拿到的本地接口信息里取 IP或者用esp_netif_get_ip_info动态获取。3.2 组播 socket 的绑定陷阱与 IGMP 加入在 ESP-IDF 里创建 UDP 组播 socket有几个坑必须绕开。第一绑定地址要用INADDR_ANY不能绑具体 IP否则收不到组播第二要显式加入组播组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));IP_ADD_MEMBERSHIP这一步如果漏了socket 能 bind 成功但一个组播包都收不到现象就是NVR 搜索不到设备非常隐蔽。我当初排查这个问题花了整整一个下午最后用 Wireshark 抓包才发现 ESP32 根本没收到 Probe。还有一个细节SO_REUSEADDR建议打开方便调试时反复重启服务而不报地址已占用。另外接收缓冲区SO_RCVBUF可以适当调大NVR 有时候会连续发多个 Probe缓冲区小了会丢包。3.3 应答延迟与重复探测的处理策略ONVIF 规范里对 ProbeMatches 的应答时机有建议不要立即回随机延迟 0 到 500 毫秒。原因是如果网络里有多台设备同时应答会撞在一起。ESP32 上可以用esp_random()生成一个随机延迟uint32_t delay_ms esp_random() % 500; vTaskDelay(pdMS_TO_TICKS(delay_ms));但实测下来很多 NVR 的探测超时窗口只有 1 到 2 秒延迟太久反而会被判定为无响应。我的做法是把延迟控制在 50 到 200 毫秒之间既避免撞包又不会超时。另外NVR 通常会发多次 Probe比如间隔 1 秒发 3 次你的设备每次都要应答。有些实现只在第一次应答后面忽略结果 NVR 认为设备不稳定。正确做法是每次收到 Probe 都回 ProbeMatches但要注意MessageID要跟请求对应上RelatesTo字段填请求的MessageID。4. SOAP 服务端用 esp_http_server 扛住 ONVIF 的 XML 轰炸4.1 ONVIF Device Service 的必备接口清单NVR 添加设备时会按顺序调用一系列 ONVIF 接口。不是所有接口都必须实现但下面这几个是最小可用集缺一个 NVR 就可能添加失败接口SOAP Action作用GetDeviceInformationhttp://www.onvif.org/ver10/device/wsdl/GetDeviceInformation返回厂商、型号、固件版本GetCapabilitieshttp://www.onvif.org/ver10/device/wsdl/GetCapabilities声明支持的能力GetServiceshttp://www.onvif.org/ver10/device/wsdl/GetServices返回服务地址列表GetProfileshttp://www.onvif.org/ver10/media/wsdl/GetProfiles返回媒体配置GetStreamUrihttp://www.onvif.org/ver10/media/wsdl/GetStreamUri返回 RTSP 地址GetSystemDateAndTimehttp://www.onvif.org/ver10/device/wsdl/GetSystemDateAndTime返回系统时间GetSystemDateAndTime这个接口看起来无关紧要但很多 NVR 在添加设备的第一步就会调它如果返回错误后面全都不走了。我建议把它放在最优先实现的位置。4.2 esp_http_server 的 URI 注册与 SOAP Action 分发esp_http_server的 URI 注册是前缀匹配的所以你可以只注册一个/onvif前缀然后在 handler 里根据 SOAP Action 分发httpd_uri_t onvif_uri { .uri /onvif, .method HTTP_POST, .handler onvif_handler, .user_ctx NULL }; httpd_register_uri_handler(server, onvif_uri);在onvif_handler里先读Content-Length把 body 读出来然后从 XML 里提取Action字段用字符串匹配分发到不同的处理函数。这里有个性能陷阱ONVIF 的 XML 报文可能有好几 KB如果用一个固定大小的栈上 buffer 去接容易栈溢出。我一般用malloc动态分配读完再释放size_t content_len httpd_req_get_hdr_value_len(req, Content-Length); char *body malloc(content_len 1); httpd_req_recv(req, body, content_len); body[content_len] \0; // 解析 body分发 free(body);httpd_req_recv不保证一次读完要循环读直到读满content_len。我见过有人只调一次httpd_req_recv结果 body 只收到一半XML 解析失败返回 400NVR 直接放弃。4.3 XML 解析为什么我不推荐用完整的 XML 库ESP32 内存有限引入一个完整的 XML 解析库比如 expat 或者 mxml会吃掉不少 flash 和 RAM。ONVIF 的请求报文结构相对固定我倾向于用轻量的字符串查找来提取关键字段char *action_start strstr(body, wsa:Action); if (action_start) { action_start strlen(wsa:Action); char *action_end strstr(action_start, /wsa:Action); // 提取 action 字符串 }这种做法的前提是你对 ONVIF 报文格式足够熟悉知道每个字段长什么样。好处是代码量小、内存占用低、解析速度快。坏处是如果 NVR 发的报文格式有细微差异比如命名空间前缀不是wsa而是a就会匹配失败。我的折中方案是对关键字段用字符串查找但查找时同时匹配几种常见的命名空间前缀。比如找 Action同时找wsa:Action、a:Action、Action。这样兼容性会好很多。4.4 响应报文的命名空间与 SOAP 版本对齐ONVIF 用的是 SOAP 1.2命名空间是http://www.w3.org/2003/05/soap-envelope。如果你返回 SOAP 1.1 的命名空间http://schemas.xmlsoap.org/soap/envelope/有些 NVR 会直接拒绝。响应报文的骨架?xml version1.0 encodingUTF-8? soap:Envelope xmlns:soaphttp://www.w3.org/2003/05/soap-envelope xmlns:tdshttp://www.onvif.org/ver10/device/wsdl xmlns:trthttp://www.onvif.org/ver10/media/wsdl soap:Body tds:GetDeviceInformationResponse tds:ManufacturerESP32/tds:Manufacturer tds:ModelESP32-CAM/tds:Model tds:FirmwareVersion1.0.0/tds:FirmwareVersion tds:SerialNumberESP32CAM001/tds:SerialNumber tds:HardwareId1.0/tds:HardwareId /tds:GetDeviceInformationResponse /soap:Body /soap:EnvelopeContent-Type要设成application/soapxml; charsetutf-8这个头如果写错NVR 可能不解析 body。我遇到过某品牌 NVR 对Content-Type大小写敏感必须是小写的application/soapxml写成Application/SOAPXML就不认。5. 媒体配置与 RTSP 地址让 NVR 拿到能播的流5.1 GetProfiles 返回的媒体配置结构GetProfiles是 NVR 获取媒体配置的接口返回的 XML 里包含编码格式、分辨率、帧率、码率等信息。一个典型的 Profile 长这样trt:GetProfilesResponse trt:Profiles tokenprofile_1 fixedtrue trt:NamemainStream/trt:Name trt:VideoSourceConfiguration tokenvsc_1 trt:NameVideoSource/trt:Name trt:SourceTokenvs_1/trt:SourceToken trt:Bounds x0 y0 width640 height480/ /trt:VideoSourceConfiguration trt:VideoEncoderConfiguration tokenvec_1 trt:NameH264/trt:Name trt:EncodingH264/trt:Encoding trt:Resolution trt:Width640/trt:Width trt:Height480/trt:Height /trt:Resolution trt:RateControl trt:FrameRateLimit15/trt:FrameRateLimit trt:BitrateLimit1024/trt:BitrateLimit /trt:RateControl /trt:VideoEncoderConfiguration /trt:Profiles /trt:GetProfilesResponsetoken字段很重要NVR 后面调GetStreamUri时会把这个 token 传回来你要根据 token 返回对应的 RTSP 地址。如果 token 对不上NVR 会报配置不存在。ESP32 上做 H.264 编码OV5640 本身只出 JPEG 或 RGB需要软件编码或者用 ESP32-S3 的硬件编码器。如果只是做 MJPEG 流Encoding字段填JPEG很多 NVR 也支持 MJPEG 拉流但兼容性不如 H.264。我建议初期先用 MJPEG 跑通流程再考虑上 H.264。5.2 GetStreamUri 与 RTSP 地址的拼接规则GetStreamUri的请求里带着 ProfileToken响应里返回 RTSP 地址trt:GetStreamUriResponse trt:MediaUri tt:Urirtsp://192.168.1.100:554/stream1/tt:Uri tt:InvalidAfterConnectfalse/tt:InvalidAfterConnect tt:InvalidAfterRebootfalse/tt:InvalidAfterReboot tt:TimeoutPT0S/tt:Timeout /trt:MediaUri /trt:GetStreamUriResponseURI 里的 IP 同样要动态获取不能写死。RTSP 路径/stream1是你自己定的只要 RTSP 服务端注册了这个路径就行。有些 NVR 会要求 URI 里带用户名密码比如rtsp://admin:admin192.168.1.100:554/stream1这个看 NVR 的配置可以在 ONVIF 响应里带上也可以让 NVR 自己拼。5.3 RTSP 服务端与 ONVIF 的 token 一致性校验RTSP 服务端和 ONVIF 服务端是两个独立的模块但它们共享同一套 Profile 配置。我的做法是定义一个全局的 profile 结构体数组ONVIF 的GetProfiles和 RTSP 的路径注册都从这个数组里读typedef struct { char token[16]; char rtsp_path[32]; int width; int height; int fps; int bitrate; } onvif_profile_t; static onvif_profile_t profiles[] { {profile_1, /stream1, 640, 480, 15, 1024}, {profile_2, /stream2, 320, 240, 10, 512}, };这样GetStreamUri返回的 URI 里的路径跟 RTSP 服务端实际注册的路径一定是一致的。我见过有人两边各写各的ONVIF 返回/stream1RTSP 注册的是/liveNVR 拿到 URI 去拉流404添加失败。5.4 认证ONVIF 的 WS-UsernameToken 与 RTSP DigestONVIF 支持两种认证HTTP Digest 和 WS-UsernameToken。WS-UsernameToken 是在 SOAP Header 里带用户名、密码摘要、随机数和时间戳。实现起来比 Digest 麻烦但兼容性更好。摘要算法是 SHA1PasswordDigest Base64(SHA1(Nonce Created Password))ESP32 上用 mbedtls 的 SHA1 接口就能算。Nonce是随机数Created是 ISO8601 时间戳。这里有个坑Created的时间必须跟 NVR 的时间接近偏差超过 5 分钟NVR 会认为摘要过期。ESP32 如果没有 RTC开机时间是从 1970 年开始的需要先通过 NTP 同步或者在GetSystemDateAndTime里返回一个合理的时间让 NVR 来同步。我建议初期先把认证关掉让 NVR 用匿名方式添加跑通流程后再加认证。很多 NVR 添加设备时可以选择无认证这样调试起来快很多。6. 联调实录NVR 添加失败时我按这个顺序排查6.1 从抓包看 NVR 到底走到哪一步NVR 添加失败第一步不是改代码是抓包。在 ESP32 和 NVR 之间的交换机上做个端口镜像或者直接在 ESP32 侧用tcpdump抓如果固件里集成了。看 NVR 发了哪些包ESP32 回了哪些包卡在哪一步一目了然。常见的几种现象NVR 发了 ProbeESP32 没回 ProbeMatchesWS-Discovery 的组播 socket 没建好或者IP_ADD_MEMBERSHIP没加。NVR 没发 ProbeNVR 的搜索机制不是 WS-Discovery可能是 ONVIF 的GetServices主动探测或者需要手动输入 IP。NVR 发了GetDeviceInformationESP32 返回 400SOAP 报文格式不对或者Content-Type不对。NVR 发了GetStreamUriESP32 返回了 URI但 NVR 不去拉流URI 里的 IP 或端口不对或者 RTSP 服务端没起来。抓包工具我推荐 Wireshark过滤规则用udp.port 3702 || tcp.port 80 || tcp.port 554三个端口一起看。6.2 那些年我踩过的 SOAP 命名空间坑ONVIF 的命名空间前缀是可以自定义的NVR 发过来的报文里可能用tds、trt、ns1、a各种前缀。如果你的解析代码写死了tds:GetDeviceInformation遇到用ns1前缀的 NVR 就匹配不上。我的解决方案是解析时只看标签名不看前缀。比如找GetDeviceInformation用strstr(body, GetDeviceInformation)不管前面是什么前缀。返回报文里我自己用标准前缀tds、trt因为 NVR 解析响应时通常按标准前缀来。还有一个坑是命名空间的 URL。http://www.onvif.org/ver10/device/wsdl这个 URL 必须一字不差少个斜杠或者多个点NVR 都可能不认。我建议把这些 URL 定义成宏避免手写出错。6.3 时间同步为什么 GetSystemDateAndTime 是隐藏的关键前面提过GetSystemDateAndTime这里展开说。很多 NVR 在添加设备时会先调这个接口获取设备时间然后跟自己的时间对比。如果偏差太大NVR 会认为设备不可信直接拒绝添加。ESP32 没有电池供电的 RTC断电后时间归零开机后如果没同步 NTP返回的时间就是 1970 年。解决方案有两个一是开机后连 NTP 服务器同步时间二是GetSystemDateAndTime里返回一个跟 NVR 请求时间接近的时间比如直接用 NVR 请求里的Created时间戳。我倾向于第一种因为 NTP 同步后WS-UsernameToken 的摘要也能正常工作。NTP 同步用 ESP-IDF 的esp_sntp组件配置好服务器地址等SNTP_SYNC_STATUS_COMPLETED事件后再启动 ONVIF 服务。如果网络环境没有 NTP可以在GetSystemDateAndTime里返回一个固定但合理的时间比如 2024 年 1 月 1 日至少比 1970 年强。6.4 内存与并发多路 NVR 同时拉流时的稳定性一台 ESP32 同时被多台 NVR 或者客户端拉流时内存和 socket 会迅速吃紧。我实测过ESP32-S3 带 8MB PSRAM 的情况下同时支撑 3 路 640x480 MJPEG 流是极限第 4 路开始丢帧。H.264 因为码率低能多撑一两路。优化方向有几个一是把 RTSP 的发送缓冲区调大减少 TCP 重传二是限制最大并发连接数超过就拒绝三是用 PSRAM 存帧缓冲别占内部 RAM。esp_http_server的max_open_sockets配置项也要相应调整默认是 7可以调到 10 到 12。还有一个隐蔽的问题ONVIF 的 SOAP 请求处理是同步的如果某个请求处理时间过长比如 XML 解析卡住会阻塞整个 HTTP 服务端导致 NVR 的其他请求超时。我的做法是把 SOAP 处理放在独立的 task 里HTTP handler 只负责收包和回包中间用队列传递。这样即使某个请求处理慢也不会影响其他请求。7. 从能添加到好用几个提升兼容性的细节7.1 Scopes 字段的写法与 NVR 的过滤逻辑WS-Discovery 应答里的Scopes字段决定了 NVR 会不会把你归类为视频编码器。标准写法是onvif://www.onvif.org/type/video_encoder onvif://www.onvif.org/name/ESP32Cam onvif://www.onvif.org/hardware/ESP32-CAM onvif://www.onvif.org/location/officetype/video_encoder这一条必须有否则 NVR 可能认为你是网络摄像机以外的设备类型不显示在添加列表里。name和hardware是显示用的可以自定义。location是可选的有些 NVR 会按 location 分组。我遇到过某品牌 NVR 只认type/video_encoder不认type/NetworkVideoTransmitter虽然规范里后者才是标准。所以我的做法是两条都写上兼容性最好。7.2 EndpointReference 的 UUID 生成与持久化EndpointReference里的 UUID 是设备的唯一标识NVR 用它来区分不同设备。这个 UUID 必须每次开机都一样否则 NVR 会认为设备换了需要重新添加。ESP32 上可以用 MAC 地址生成一个稳定的 UUIDuint8_t mac[6]; esp_read_mac(mac, ESP_MAC_WIFI_STA); char uuid[37]; snprintf(uuid, sizeof(uuid), urn:uuid:%02x%02x%02x%02x-%02x%02x-..., ...);或者用nvs_flash存一个随机生成的 UUID第一次开机生成后面读出来。我倾向于用 MAC 生成简单可靠不用额外存储。7.3 固件版本号与厂商信息的拟真策略GetDeviceInformation返回的厂商、型号、固件版本NVR 有时候会用来做兼容性判断。比如某些 NVR 对未知厂商的设备会限制功能。我的做法是填一个通用的、NVR 友好的信息tds:ManufacturerONVIF/tds:Manufacturer tds:ModelIP Camera/tds:Model tds:FirmwareVersion1.0.0/tds:FirmwareVersion不要填太奇怪的名字比如ESP32Test有些 NVR 会过滤掉。填ONVIF或者Generic比较稳妥。固件版本号建议用x.y.z格式NVR 解析起来不会出错。7.4 日志与调试把 ONVIF 交互过程打到串口调试 ONVIF 最有效的手段是把每次请求和响应都打到串口。我在onvif_handler里加了一个开关打开后把收到的 body 和发出的 response 都ESP_LOGI出来ESP_LOGI(TAG, ONVIF Request: %s, body); ESP_LOGI(TAG, ONVIF Response: %s, response);这样 NVR 发了什么、你回了什么一目了然。注意日志级别别开太高ONVIF 报文很长全打出来会拖慢处理速度调试时开生产时关。另外esp_http_server本身有错误日志如果返回 400 或者 500串口会打出来。结合 Wireshark 抓包和串口日志基本能定位 90% 的问题。8. 写在最后一些个人体会onvif-c这个组件在 ESP-IDF 上跑通最难的不是写代码而是理解 ONVIF 这套协议的脾气。它不像 HTTP 那样宽松字段名、命名空间、时间格式、认证摘要任何一处不对NVR 就给你脸色看。我调试第一台能被 NVR 添加的 ESP32 相机花了差不多两周其中大部分时间是在抓包和对比报文。我的建议是先找一个能正常工作的 ONVIF 相机用 Wireshark 抓一遍它跟 NVR 的完整交互把每个请求和响应都存下来作为你实现的参考答案。然后照着这个参考答案一步步实现 ESP32 侧的响应。这样比对着 ONVIF 规范文档硬啃要快得多。另外别指望一次就能兼容所有 NVR。海康、大华、宇视、天地伟业每家的 ONVIF 实现都有细微差异。我的做法是先跑通一家把代码结构做成可配置的然后针对不同品牌做适配。onvif-c的价值就在于它提供了一个干净的 C 语言骨架你在这个骨架上做适配比从零开始要省力得多。最后提醒一句ESP32 的算力做 ONVIF 服务端够用但做 H.264 编码就有点吃力了。如果项目要求高分辨率、高帧率建议用 ESP32-S3 带硬件编码的型号或者外挂一个专用的编码芯片。MJPEG 虽然简单但带宽占用大多路并发时网络压力不小。这个取舍得根据你的实际场景来定。