简介:面向Linux环境下基于ONVIF协议开发网络视频设备的工程师,这份编译好的C代码框架由gSOAP工具链生成,用于快速搭建与ONVIF设备交互的客户端程序,解决手动编写SOAP/XML消息的繁琐问题。压缩包共12个文件,包含5个C源文件、5个头文件、1个nsmap命名空间映射文件以及1个Makefile构建脚本,整体约1.31MB。其中C源文件与头文件分别为由WSDL自动生成的客户端接口和序列化辅助代码,nsmap文件负责XML命名空间绑定,Makefile支持一键编译。借助这些文件,开发者可直接调用设备发现、GetDeviceInformation、GetStreamUri等ONVIF服务方法,获取设备基本信息与RTSP拉流地址,还能利用gSOAP内建的错误处理机制排查通信异常。目前已有1687人学习/下载,适合具备C语言与Web Service基础、希望快速掌握Linux+gSOAP+ONVIF开发路径的工程师参考复用。
1. 从 ONVIF 接口对接到 gSOAP 生成的 C 代码:第一步怎么做
搞 ONVIF 对接的 C 工程,基本绕不开 gSOAP 这一套工具链。它做的事情很具体:把设备管理、媒体、PTZ 等一堆 WSDL 描述,直接变成能编译的 C 头文件和客户端/服务端骨架,省掉手写 SOAP 编解码、序列化和命名空间管理的体力活。我最早接触它是在某个芯片方案的模拟项目X里做 IPC 接入,最开始自己拼 XML 消息包,遇到设备不认就反复调接口。后来换成 gSOAP 生成框架,整个流程从一个星期压到两天。这篇笔记写给两类人:一类是刚接触 ONVIF 的嵌入式开发,另一类是手头已有设备端代码、想快速补一套标准接口对接的工程师。先把原理说透,再给完整命令和踩坑记录。
2. 理解 gSOAP 和 ONVIF 的匹配关系:SOAP 骨架到底给你省了什么
先讲道理再动手。多数人卡住不是因为命令记不住,而是不理解为什么要跑两条命令,以及生成出来的到底是一堆什么文件。下面把 ONVIF 的接口模型、gSOAP 的两步生成逻辑以及环境准备说清楚,这些搞明白了,再进第三章的命令环节会顺手很多。
2.1 ONVIF 的接口本质:SOAP 消息加标准功能点
ONVIF 规范把设备对外能力切成几个功能点:设备发现、设备管理、媒体配置、云台控制、事件告警、录像回放。每个功能点对应一组操作,比如设备管理里有 GetDeviceInformation、GetSystemDateAndTime、SetHostname;媒体服务里有 GetProfiles、GetStreamUri;PTZ 里有 ContinuousMove、AbsoluteMove。每个操作都是一对 SOAP 消息:请求和响应。
SOAP 消息说白了是一份带信封结构的 XML,外层有 Envelope、Header、Body,里面再塞具体的业务 XML。C 语言没有内建 SOAP 栈,通用 XML 库只能帮你生成或解析 XML 树,但协议层的命名空间、版本协商、错误处理、Action 绑定全要自己写。真按这个路子做,一个 GetProfiles 就得写两百行样板代码,后面每接一个设备还要根据响应差异改解析逻辑,非常罗兰。
gSOAP 把“操作、结构体、XML 报文”三者之间的转换关系固定下来。比如某个_tds__GetDeviceInformation请求结构体,序列化之后就是一段符合 ONVIF 规范的 SOAP XML;从设备收到的 XML 也能自动反序列化回结构体。这样业务代码里几乎看不到 XML 字符串,字段类型由编译器检查,排查问题只要看结构体内容就行。
2.2 gSOAP 两步生成:wsdl2h 与 soapcpp2 各自的活
gSOAP 拆成两个工具来用。第一个是wsdl2h,它的输入是 WSDL 文件,输出是一个 C 头文件。WSDL 里描述的消息类型和端口操作,会被翻译成struct定义与函数原型。第二个是soapcpp2,它的输入正是这个头文件,输出是一整套可编译的 C 源码:序列化函数、反序列化函数、客户端调用函数、服务端分发框架,还有运行时需要的头文件。
整个流程可以理解成两条流水线:
WSDL 文件 → wsdl2h → onvif.h(接口定义) → soapcpp2 → soapClient.c / soapServer.c / soapC.c / soapH.h
第一次接触时容易犯的错是直接拿别人仓库里的 onvif.h 来用,不跑 wsdl2h。不同 gSOAP 版本生成的头文件在类型别名、命名空间宏、回调签名上差异都很大,换库版本后很可能编译不过。我一般会在自己的环境里从头生成一遍,十次里有九次比修补旧文件快。生成出来的 onvif.h 就是你这台机器上的“接口契约”,后续所有业务代码都以它为准。
2.3 版本差异与前置依赖:动手前的环境检查清单
先确认本机的 gSOAP 工具在不在,版本是否统一。很多发行版把 wsdl2h、soapcpp2 和运行库拆成不同包,装的时候容易漏。我的检查顺序是这样:
which wsdl2h which soapcpp2 wsdl2h -h 2>&1 | head -n 20 pkg-config --modversion libxml-2.0 2>/dev/null pkg-config --modversion openssl 2>/dev/nullwsdl2h -h 的输出里通常能看到版本号,soapcpp2 的版本可以和它对照。两边的版本如果差了一个大版本,生成代码和运行库之间容易出符号不匹配,第五章第一个坑讲的就是这个现象。依赖方面重点看三样:
- libxml2:gSOAP 启用 DOM 解析或 WS-Security 时会用到,编译宏是 DWITH_DOM。
- openssl:做 Digest 认证、HTTPS 传输时必用,编译宏是 DWITH_OPENSSL。
- zlib:处理 SOAP 压缩传输,嵌入式环境里经常没装全。
检查完再开始生成。如果是在交叉编译环境,宿主机上的 gSOAP 工具只负责生成代码,生成出来的 C 文件拿到目标工具链里编译,但运行库 stdsoap2.c 必须和生成器同版本,不能拿发行版里旧的 .so 凑数。
3. 生成 ONVIF 的 C 框架:从 WSDL 到可编译工程
这一章是实操主流程。我按“整理 WSDL → wsdl2h → soapcpp2 → 编进工程”四步走。命令给的是常用形态,具体细节按你本机的 gSOAP 版本微调。每条命令后面都有参数说明,照着敲就行。
3.1 先整理 WSDL 文件:只挑你要的模块
ONVIF 的 WSDL 是按功能点拆分的,不是一个大文件包打天下。常见模块名:devicemgmt.wsdl(设备管理)、media.wsdl(媒体)、ptz.wsdl(云台)、event.wsdl(事件)、recording.wsdl(录像)。每个 WSDL 又会引用 common.xsd 这类公共 Schema。
常见做法是单独建一个目录,把需要用到的 WSDL 和 XSD 放平,再用 wsdl2h 一次引用多个文件。比如这次只做设备管理、媒体、PTZ:
mkdir -p onvif-wsdl && cd onvif-wsdl # 目录里放 devicemgmt.wsdl media.wsdl ptz.wsdl common.xsd目录整理这一步别省。WSDL 内部用相对路径互相引用,如果文件分散在不同目录,wsdl2h 找依赖的时候会多花时间排查,报错信息还不直观。只挑需要的模块还有一个好处:生成的代码体积小,交叉编译时间短。如果你不确定后面要不要 PTZ,建议一次加进来,免得后面重新生成时影响已经写好的业务头文件。
3.2 wsdl2h 生成 C 头文件:参数与常见输出问题
执行生成命令:
wsdl2h -c -s -o onvif.h \ -P http://www.onvif.org/ \ -t typemap.dat \ devicemgmt.wsdl media.wsdl ptz.wsdl参数说明:
-c:生成纯 C 代码而不是 C++,对应 onvif.h 里不会有 std::string 这类类型。-s:去掉 STL 依赖,C 工程强烈建议加,交叉编译时最省心。-o onvif.h:指定输出头文件名,后面 soapcpp2 用的就是它。-P http://www.onvif.org/:把命名空间前缀统一挂到 ONVIF 域名下,避免出现_ns1、_ns2这种随机前缀。-t typemap.dat:指定类型映射表。重要参数,不加它 xsd:string 可能映射成奇怪的结构体。
如果你的 WSDL 引用了不在当前目录的公共 Schema,需要加-I指定搜索目录:
wsdl2h -I ./onvif-wsdl -c -s -o onvif.h -t typemap.dat \ devicemgmt.wsdl media.wsdl ptz.wsdl常见的错误提示是Cannot open file ...。这时候优先查相对路径,而不是怀疑工具坏了。生成完先打开 onvif.h 头部,确认两件事:命名空间宏有没有生成、基础类型有没有变成char *。如果基础类型不对,回头看-t typemap.dat。
为什么-P参数值得单独说?因为它决定了后续生成的命名空间一致性。ONVIF 各模块的 targetNamespace 并不完全一样,有的挂在http://www.onvif.org/ver10/device/wsdl,有的挂在http://www.onvif.org/ver20/media/wsdl。不处理的话,序列化报文里会冒出一堆_ns1、_ns2,抓包看着乱,部分设备严格解析时会直接判命名空间错误。用-P统一根路径后,配合生成的 nsmap 表检查,能挡掉八成报文兼容问题。
3.3 soapcpp2 生成客户端与服务端骨架
wsdl2h 产出的是“接口定义”,还编译不了。接着用 soapcpp2 把它变成可编译 C 文件。生成客户端加服务端:
soapcpp2 -c -L -I /opt/gsoap/gsoap/import -x onvif.h只生成客户端,少一些用不到的服务端代码:
soapcpp2 -c -L -C -I /opt/gsoap/gsoap/import -x onvif.h参数含义:
-c:输出 C 代码。-L:不生成 soapClientLib.c / soapServerLib.c 这类聚合模板文件,需要的话也可以主动生成,看工程组织习惯。-C:只生成客户端代码。-S:只生成服务端代码。-I:指向 gSOAP 安装目录下的 import 目录,里面放着 xsd.h 等基础定义,路径改成你本机的实际路径。-x:不生成 XML 示例文件,省得目录里多出一堆测试报文。
执行完检查生成物,最核心的五个文件:soapH.h、soapStub.h、soapC.c、soapClient.c、soapServer.c。如果只想要客户端,soapServer.c 不会出现,这符合预期。
生成完建议顺手 grep 一下命名空间前缀和函数原型。比如你关心设备服务,就搜tds__GetDeviceInformation,看函数的参数列表是否和你手里的 WSDL 一致。这一步能提前拦截很多后面对接时才暴露的问题。
3.4 进入工程编译:链接库与 Makefile 写法
生成完代码,下一步是编进工程。假设工程结构是:
src/ main.c onvif.h soapH.h soapStub.h soapC.c soapClient.c soapServer.c stdsoap2.cMakefile 可以这样写:
CC = gcc CFLAGS = -I. -I/opt/gsoap/gsoap -O2 -DWITH_OPENSSL LDFLAGS = -lssl -lcrypto -lz -lm SRCS = main.c soapC.c soapClient.c soapServer.c stdsoap2.c OBJS = $(SRCS:.c=.o) all: onvif_demo onvif_demo: $(OBJS) $(CC) $(OBJS) -o $@ $(LDFLAGS) %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f $(OBJS) onvif_demo要点有三个。第一,-I/opt/gsoap/gsoap要指向 gSOAP 源码目录,因为 stdsoap2.c 编译时可能 include 同目录下的其他头文件。第二,链接-lssl -lcrypto是因为 OpenSSL 的加解密和时间函数;如果完全不用安全认证,可以把 CFLAGS 里的 DWITH_OPENSSL 去掉。第三,别把 stdsoap2.c 换成系统里的 libgsoap.so,除非你非常确定版本与生成器一致。嵌入式交叉编译时,同样把 stdsoap2.c 拿进目标平台一起编译,不要图省事链接宿主机的库。
4. 生成代码的骨架长什么样:文件清单与业务填充方法
生成完代码后,需要知道每个文件管什么、业务逻辑填在哪。很多人对着一堆 .c/.h 发懵,其实拆开看并不复杂。
4.1 六个关键文件,各自管哪一段
| 文件 | 作用 | 需要动的地方 |
|---|---|---|
| onvif.h | wsdl2h 输出的接口定义 | 通常不改,改完要重跑 soapcpp2 |
| soapStub.h | 所有结构体的完整定义 | 排查字段时看这里 |
| soapH.h | 编译总入口,声明函数和结构体 | 业务代码 include 它 |
| soapC.c | 所有序列化/反序列化实现 | 一般不动 |
| soapClient.c | 调用对端服务的函数实现 | 一般不动 |
| soapServer.c | 服务端分发框架与回调入口 | 回调函数指向自己的实现 |
还有一个常见文件 soapClientLib.c / soapServerLib.c,用了-L就不会生成。有些工程把 soapC.c、soapClient.c、soapServer.c 统称“生成代码”,建议单独放一个 gen/ 目录,下次重新生成时整体替换,不污染手写业务代码。
4.2 服务端:回调函数的注册与填充
如果做设备端,soapServer.c 已经为每个操作定义了回调函数。比如GetDeviceInformation的回调函数原型大致是这样:
int tds__GetDeviceInformation(struct soap *soap, struct _tds__GetDeviceInformation *req, struct _tds__GetDeviceInformationResponse *resp) { resp->Manufacturer = soap_strdup(soap, "DemoVendor"); resp->Model = soap_strdup(soap, "IPC-Demo"); resp->FirmwareVersion = soap_strdup(soap, "1.0.0"); return SOAP_OK; }逻辑说明:三个参数分别是 soap 运行环境、请求结构、响应结构。响应结构里每个字段名来自 WSDL 里的返回元素。这个函数不是你在 main 里直接调用的,而是被 soap_serve 或 soap_serve_request 在收到对应 SOAP 请求后自动调用。函数返回SOAP_OK表示处理成功;业务判断失败时,可以设置soap->error = SOAP_FAULT,框架自动生成 SOAP Fault 报文。
参数说明:soap_strdup是 gSOAP 的字符串复制函数,它把副本放进 soap 上下文管理的内存池,后续序列化阶段仍能访问。这里不能直接写resp->Manufacturer = "DemoVendor",因为 gSOAP 解析结构体时可能尝试释放这块内存,常量区指针会造成崩溃。响应字段多的时候,用soap_malloc先分配空间再逐字段填。
4.3 客户端:soap_call 系列接口的调用方式
如果做对接平台或上位机,客户端代码用 soap_call 系列函数。函数名格式是soap_call___开头,后面跟命名空间前缀和方法名。常见前缀对应关系:tds 是设备服务,trt 是媒体服务,ptz 是云台服务。调用设备时间接口的示意代码:
#include "soapH.h" #include "soapStub.h" struct soap *soap = soap_new(); struct _tds__GetSystemDateAndTime req; struct _tds__GetSystemDateAndTimeResponse resp; memset(&req, 0, sizeof(req)); memset(&resp, 0, sizeof(resp)); if (soap_call___tds__GetSystemDateAndTime( soap, "http://192.168.1.64/onvif/device_service", NULL, &req, &resp) != SOAP_OK) { soap_print_fault(soap, stderr); soap_end(soap); soap_free(soap); return -1; } printf("UTC time: %04d-%02d-%02d %02d:%02d:%02d\n", resp.UTCDateTime->Date.Year, resp.UTCDateTime->Date.Month, resp.UTCDateTime->Date.Day, resp.UTCDateTime->Time.Hour, resp.UTCDateTime->Time.Minute, resp.UTCDateTime->Time.Second); soap_end(soap); soap_free(soap);逻辑说明:soap_new 创建独立的 soap 调用环境;请求和响应结构体先清空,避免把栈里的随机值当字段用;soap_call 传入端点地址和 SOAPAction;调用后立刻判断返回值,出错时用 soap_print_fault 打印错误信息。
参数说明:端点地址是设备 SOAP 服务地址,通常是/onvif/device_service结尾;SOAPAction 传 NULL 是常见做法,ONVIF 设备大都忽略,但个别旧设备要求固定 Action,遇到 500/400 响应时回查一下这个参数。
要注意前缀对应关系。如果 onvif.h 里设备服务映射成了tds__,函数名才是这种形态。用了-P统一命名空间后,前缀和 WSDL 的 targetNamespace 一一对应,不要硬编码其他前缀。
4.4 内存管理边界:soap_malloc 与临时变量的坑
gSOAP 在 C 语言模式下有自己的内存管理规则。简单说,soap 上下文里分配的对象(soap_malloc、soap_strdup、soap_new_xxx 系列)生命周期跟着 soap_end / soap_free 走。业务代码如果需要把设备返回的数据存到更长的生命周期,一定要自己深拷贝:
static char g_model[64]; snprintf(g_model, sizeof(g_model), "%s", resp.Model ? resp.Model : "");这里用 snprintf 拷贝而不是g_model = resp.Model,正是为了断开与 soap 内存池的关联。响应结构体经过 soap_end 后,内部指针指向的内容会失效。这个问题的典型表现是“第一次调用正常,第二次调用崩溃”,排查时非常容易蒙。
还有一点线程相关:一个 soap 上下文不能同时在多个线程里调用 soap_call。常见做法是每个线程 soap_new 一个实例,线程退出时 soap_free,不共享上下文。我见过有人图省事把全局 soap 变量用在多线程里,结果同一个请求两个线程发出不同内容,查了很久才发现是上下文串了。
5. 生成 ONVIF 框架的五个常见坑:现象、原因与方案
这个坑清单是我实际跑过几轮后留下的记录,每条按“现象 → 原因 → 解决”来写,可以直接对照排查。
5.1 链接报错一堆 undefined reference:工具链版本错配
现象:代码编译都很顺利,但链接时报undefined reference to soap_begin_send、undefined reference to soap_getelement这类错误,而且数量极多。
原因:soapcpp2 根据 onvif.h 生成的源码里,调用的运行库函数版本和当前链接的 libgsoap 不一致。最常见的是 wsdl2h / soapcpp2 来自新装的 gSOAP 包,而系统里链接的是旧版 libgsoap.so,两个大版本内部接口改动很大,符号对不上。
解决:不要链接系统 libgsoap.so,改把生成器同目录下的 stdsoap2.c 直接编进工程。Makefile 里把 stdsoap2.c 加入 SRCS,去掉-lgsoap,保证生成器和运行库同源。这个做法在交叉编译环境里尤其重要,宿主机 .so 根本不能直接用。
5.2 生成的基本类型不对:typemap.dat 没加载
现象:生成的 onvif.h 里xsd__string没有变成char *,而是变成struct指针或std::string,后续代码用到字符串时编译报错。
原因:wsdl2h 处理 XML Schema 类型映射时,默认映射对纯 C 工程不友好,需要 typemap.dat 告诉它xsd__string映射成char *、xsd__int映射成int。命令里漏了-t typemap.dat,或者路径给错,映射没生效。
解决:确认 wsdl2h 命令行里有-t typemap.dat,且 typemap.dat 中格式类似xsd__string = | char *。gSOAP 安装目录自带一份 typemap.dat 可以直接沿用;要加 ONVIF 特有类型,就追加规则后放在工程目录里,用相对或绝对路径传给-t。生成完成后可以打开 onvif.h 搜索char *,确认映射已经落地。
5.3 SOAP 序列化失败:命名空间前缀不统一
现象:客户端调设备接口时,soap_print_fault 输出类似SOAP 1.1 fault: Invalid namespace或Wrong namespace,抓包看报文里前缀是_ns1、_ns2。
原因:wsdl2h 没有显式指定命名空间映射,ONVIF 各模块的 targetNamespace 各不相同,工具自动生成_ns1、_ns2这类随机前缀,和设备端严格解析的命名空间 URI 对不上。
解决:生成时用-P参数统一命名空间根路径,比如-P http://www.onvif.org/。生成完成后,打开 soapH.h 里的 SOAP_NAMESPACE 表,检查实际 URI 是否符合 ONVIF 规范。表里每一行是“前缀、URI、序号”的结构,发现重复或错位,手动调整后重新编译也能修。
5.4 WS-Security 链接错误:生成时没准备插件
现象:实现设备认证时调用了soap_wsse_add_UsernameTokenText之类的函数,链接时报undefined reference to soap_wsse_*。
原因:gSOAP 的 WS-Security 是以插件形式存在的,wsseapi.c 和 dom.c 不在 soapcpp2 默认生成的代码里。同时 WS-Security 依赖 DOM 解析,编译时需要 DWITH_DOM 宏,默认 Makefile 编不过。
解决:把 gSOAP 源码里 wsse 目录下的 wsseapi.c、wsseapi.h、dom.c 加入工程,编译选项加-DWITH_OPENSSL -DWITH_DOM,链接加-lssl -lcrypto。注意启用 WS-Security 后,生成的客户端函数签名会多出一个安全上下文参数,原有的 soap_call 调用都要跟着改。编译报错时会提示得很清楚,照着改即可。
5.5 回调返回后数据丢失或段错误:内存生命周期没管住
现象:服务端回调函数把响应字段指向局部数组,比如resp->Manufacturer = local_buf,函数返回时看似正常,但实际序列化响应时读到了悬垂指针,轻则返回空字符串,重则段错误。
原因:gSOAP 服务端流程是“收到请求 → 调用回调 → 序列化响应 → 发送”,回调返回并不是响应处理结束,局部数组在回调返回时已经失效。
解决:响应结构体里的所有指针字段,用soap_strdup或soap_malloc分配,生命周期交给 soap 上下文。需要复制一段字节时,用soap_memdup配合长度参数,不要直接赋栈地址。这个坑最容易出现在刚上手的人身上,我每次 review 生成代码第一眼看的就是响应字段的赋值方式。
6. 验证这套 ONVIF 代码能跑:回环自测与进阶习惯
6.1 用最小 main 跑通回环
生成完代码后最担心的就是“不知道跑没跑起来”。常见做法是先做一个回环测试:工程里同时保留 soapClient.c 和 soapServer.c,main 函数先启动本地服务,再调用客户端函数访问它,验证请求、回调、序列化、反序列化全链路是否正确:
int main(void) { struct soap *soap = soap_new(); /* 服务端初始化并监听本地端口 */ soap_bind(soap, "127.0.0.1", 18080, 100); if (soap_accept(soap) == SOAP_OK) { soap_serve(soap); } /* 客户端调用自己的服务 */ soap_call___tds__GetDeviceInformation(soap, "http://127.0.0.1:18080", NULL, &req, &resp); soap_free(soap); return 0; }这个方式不需要真设备,能把 gSOAP 生成层的问题过滤掉八成。回环通了,再连真实 IPC 验证地址、认证这些外围问题。
6.2 进阶习惯:每次生成都留一份环境快照
回头看我自己的习惯:每次换环境重跑这套流程,都会把 gSOAP 版本号、typemap.dat 内容、wsdl2h 和 soapcpp2 的完整命令复制进一份 build 日志,和生成的头文件一起存档。这样出现诡异问题时有退路,能对着版本差异快速定位。从那以后,我每次换一台新机器跑 gSOAP,都会强制走一遍“查版本 → 整理 WSDL → 生成头文件 → 生成源码 → 编译验证”的流程,确认五步全过才继续写业务。这套流程帮我挡掉了不少查错时间,希望帮到你。
本文还有配套的精品资源,点击获取