☰
pSpace实时数据库SDK:C/C++批量读写、订阅回调与断线重连
2026/10/27 15:10:41 网站建设 项目流程

简介:这份psAPISDK6.0.1.9_2压缩包面向使用C#与.NET Framework 4.0开发工业监控程序的开发者,提供力控pspace 6.0实时数据库的接口开发工具包,可用于实时值读写、历史数据检索、点表维护、事件告警处理及用户权限控制等场景,帮助应用系统与实时库深度集成。包内共49个文件,以12个dll、12个头文件、12个pdb、10个cpp及2个lib、1个vcproj工程文件为主,涵盖运行库、接口声明、调试符号、调用示例与Win32/X64编译配置,压缩包约6.76MB,便于对照头文件与示例工程理解接口组织。当前已有241人学习,适合需要快速验证数据访问、搭建采集或报表原型的中初级.NET开发者,也可作为点表管理与历史查询的接口参考。

1. 上位机画面刷得再顺,隔壁系统要一个数还是得靠人抄

上位机画面上的温度曲线刷得很顺,可隔壁 MES 那边要一份昨天下午三点的炉膛压力,只能靠人截图抄数——这场景我见得太多了。pSpace 实时数据库 SDK(包名 psAPISDK6.0.1.9)就是堵这个口子的东西:它把实时库里的位号值、时间戳、质量戳和历史归档,用一组 C/C++ 接口暴露出来,让外部程序直接读写,不用再绕组态画面和手工导出。

适合两类人:要给实时库做外部采集、转发网关的工控开发者;以及需要把产线数据接进自研系统、又不想重新铺一套采集链路的团队。下面按「先能编译、再能连上、最后能跑稳」的顺序拆。

2. 搭一个能编译的调用环境:位数对齐、头文件与运行库依赖

拿到压缩包之后第一件事不是急着写代码,而是把包里的文件按「编译期用」和「运行期用」分开放。这两类东西混在同一个目录里,是后面 80% 链接错误和加载失败的源头。我一般的做法是解压到一个干净的英文路径下(路径里带空格和中文,某些版本的导入库在链接阶段会出问题,这一点属于血泪经验),然后按类型归档。

2.1 先分清包里的四类文件,各自的归属别搞混

一个标准的实时库 API 开发包,解开之后基本是这四类东西。判断标准很简单:报错发生在哪个阶段,就去找对应那一类。

类型典型扩展名生效阶段该放哪里
头文件.h.hpp编译期工程「附加包含目录」
导入库.lib链接期工程「附加库目录」+「附加依赖项」
动态库.dll运行期exe 同目录,或系统 PATH 内
文档与示例.chm.pdf.cpp参考独立目录,别混进源码树

最容易翻车的是第三类。很多人在 IDE 里配好了包含目录和库目录,编译链接一路绿灯,双击运行直接闪退——因为.dll还在压缩包解开的那层目录里躺着,exe 根本找不到它。运行期的问题永远不会在编译期暴露。

提示:把 dll 复制到输出目录这一步,最好写进工程的生成后事件里,别靠自己手动拷。手动拷这件事,你今天记得,三个月后接手的人一定不记得。

2.2 位数必须一致:32 位进程加载不了 64 位库

这是这个 SDK 最硬的一条约束。x86 工程配 x64 的导入库,链接阶段会直接甩LNK2019(无法解析的外部符号)或者LNK1112(模块计算机类型冲突);就算你侥幸用动态加载绕过了链接,运行期也会得到一个0xc000007b(应用程序无法正常启动)。

判断方法有两个:

# 方法一:看导入库的机器类型,x64 会显示 machine (x64) dumpbin /headers psAPI.lib | findstr machine # 方法二:看 dll 的依赖和位数 dumpbin /dependents psAPI.dll

第一条命令输出machine (x86)就只能配 Win32 平台,输出machine (x64)就配 x64 平台。如果包里同时给了两套 lib 和 dll,别偷懒只放一套到输出目录——32 位和 64 位的同名 dll 互相覆盖,是排查起来最费劲的一类问题,因为现象是「换台机器就好了」。

工程配置里必须改的四个地方,按顺序来:

  1. 平台:解决方案平台选 Win32 或 x64,与 lib 位数对齐;
  2. 附加包含目录:指向头文件所在目录,用相对路径${SolutionDir}开头;
  3. 附加库目录 + 附加依赖项:库目录指向 lib 所在目录,依赖项里写 lib 文件名;
  4. 运行库:如果 SDK 是用/MT编的而你工程用/MD,某些版本会出现堆分配跨模块释放的问题,表现为退出时崩溃。这个只能试,先用/MD,崩了再换/MT验证。

2.3 先跑一个只验证加载的空壳程序

不要一上来就写完整的采集逻辑。先写一个只做一件事的程序:把 dll 加载起来,打印一句成功,然后退出。这一步过了,说明运行环境没问题,后面所有报错都可以归因到「调用方式」而不是「环境」。

// probe.cpp —— 只验证运行库能否被加载,不碰任何业务接口 #include <windows.h> #include <cstdio> int main() { // 文件名换成你包里实际的运行库名,注意大小写不敏感但别写错扩展名 HMODULE h = LoadLibraryA("psAPI.dll"); if (h == nullptr) { DWORD err = GetLastError(); // 126 = 找不到模块(多半是缺依赖的 VC 运行库) // 193 = 不是有效的 Win32 应用程序(位数不对) printf("LoadLibrary failed, GetLastError=%lu\n", err); return 1; } printf("dll loaded at %p\n", reinterpret_cast<void*>(h)); FreeLibrary(h); return 0; }

代码逻辑很直白:LoadLibraryA走的是系统标准的模块查找顺序,先找 exe 同目录,再找系统目录,最后找 PATH。它的报错码信息量比链接器的报错大得多。126基本可以锁定为依赖缺失,用dumpbin /dependents psAPI.dll看它还依赖哪些 dll,逐个补齐;193就是位数不匹配,回到 2.2 节。

参数上只有一个需要注意:如果你用LoadLibraryW,路径要传宽字符,混用A/W版本在中文路径下会静默失败。这个坑我在一个客户现场踩过一次,程序返回空句柄但错误码是 0,查了两小时才发现是字符集的问题。

这一步通过之后再往下走。如果这一步都过不了,后面的连接、读写、订阅全都是空中楼阁。

3. 连接会话与位号模型:把第一条带质量戳的数据读上来

实时库和普通数据库最大的差别在于:它不是一个「连接—执行 SQL—拿结果」的模型,而是「建立一个长会话—按位号寻址—拿回值+时间戳+质量戳」的模型。理解这一点,后面所有接口的调用顺序就顺了。位号(Tag)是实时库里的最小寻址单位,一个位号对应一个测点,带自己的数据类型、工程单位、量程和采集周期。

3.1 连接三要素:服务地址、库实例名、认证信息

连接参数看起来简单,但每一个都有坑。常见的连接入参是这样的结构体:

参数含义常见错误
服务地址实时库服务所在节点的 IP 或主机名填了组态客户端地址,不是数据服务地址
端口数据服务监听端口与组态服务端口混淆,两者通常不同
库实例名要连的实时库实例现场有多套库(测试/生产),连错不报错
用户名/密码认证凭据用了只读账号却要做写操作
超时连接与请求超时(毫秒)设成 0 或极小值,弱网下必然失败
#include "psAPI.h" // 头文件名以包内实际文件为准 // 连接参数按字段填,别用位置参数,字段顺序在版本间变过 PsConnParam stParam; memset(&stParam, 0, sizeof(stParam)); strncpy_s(stParam.szHost, sizeof(stParam.szHost), "10.20.30.40", _TRUNCATE); stParam.wPort = 0; // 0 表示用默认端口,显式指定更稳妥 strncpy_s(stParam.szDbName, sizeof(stParam.szDbName), "PSPACE_PROD", _TRUNCATE); strncpy_s(stParam.szUser, sizeof(stParam.szUser), "app_reader", _TRUNCATE); strncpy_s(stParam.szPwd, sizeof(stParam.szPwd), "******", _TRUNCATE); stParam.dwTimeoutMs = 3000; // 连接超时 3 秒,别设 300 毫秒 PSHANDLE hConn = nullptr; int rc = psConnect(&stParam, &hConn); if (rc != PS_OK || hConn == nullptr) { // 连接失败要打印 rc,不要只打印「连接失败」 printf("connect failed, rc=%d\n", rc); return -1; }

这段代码的关键不在调用本身,在错误处理。很多示例代码只判断hConn != nullptr,但连接失败时句柄可能是非空的垃圾值,必须同时判断rc。返回码的具体含义去翻包里的错误码表,各个版本的编码规则不一致,照抄网上的代码很容易把「认证失败」误判成「网络不通」。

还有一个现实问题:现场往往有多套库,测试库和生产库的实例名不一样,但连接接口对不存在的实例有时不报错,而是返回一个可用句柄,然后所有读写都返回「点不存在」。所以在连接成功后,我一般会立刻做一次「存活探测」——读一个已知必然存在的系统点或者统计点,探测失败就直接断开重来。

3.2 位号名、数据类型与质量戳,这三样必须同时对齐

位号名在实时库里通常是分层的,比如装置.单元.位号或者带命名空间前缀。这里有两个尺寸必须记住:

第一,名字大小写敏感。有些版本的实时库对位号名做了统一大写处理,有些保留原样。你在组态工具里看到的和接口里能用的,未必是同一个字符串。稳妥做法是从组态工具的点表里直接复制,别手打。

第二,数据类型必须严格匹配。位号在库里是 float 还是 int32,决定了你传进去的接收缓冲区用哪个结构体。用double去接int32的位号,很多 SDK 不会报错,而是给你一个经过隐式转换的值——这个值看着像对的,实际上是错的,属于最危险的一类静默失败。

读接口返回的不只是一个值,而是「值 + 时间戳 + 质量戳」三元组。质量戳(Quality)是工业数据里最容易被忽略、又最不能忽略的字段:它告诉你这个值是不是可信的。采集通道断了、传感器超量程、点被手工置值,都会体现在质量戳上,而不是体现在数值上。

PsTagValue v; memset(&v, 0, sizeof(v)); // 最后一个参数是超时毫秒,同步读一定要给超时,否则网络抖动时会一直挂住 int rc = psReadTag(hConn, "SITE1.BOILER.TEMP_PV", &v, 3000); if (rc != PS_OK) { printf("read failed, rc=%d\n", rc); return -1; } if (v.quality != PS_QUALITY_GOOD) { // 质量戳不好:值可能还在刷新,但不可信,别写进历史表 printf("bad quality: %u\n", v.quality); } // 时间戳一般是库侧的时间,注意与本地时间的时区差异 printf("value=%.3f ts=%lld quality=%u\n", v.dValue, (long long)v.timestamp, v.quality);

三个字段的用途要分清:v.dValue是数值本体,v.timestamp是这个值在库里的采集时刻(不是你的读取时刻),v.quality是可信度。历史归档、报表统计这类下游应用,必须把质量戳一路带下去,否则你会得到一条曲线很平滑、实际上全是断线期间保持值的假数据。这种数据进到报表里,比没有数据更糟。

3.3 写入的三种语义,别默认「写进去就等于生效」

写接口看起来比读简单,实际上语义更微妙。常见的三种写模式:

  • 直接写值:把值送进实时库,采集通道下一次刷新时会把它覆盖掉。适合瞬时下发的设定值。
  • 写值并置质量:连质量戳一起写,适合模拟量仿真或者通道故障时的保持。
  • 写值到采集设备:经由实时库下装到现场设备,这条路径涉及下装权限和设备响应,返回成功只代表「已下发」,不代表「设备已接受」。

新手最容易犯的错是把第三种当第一种用,然后困惑于「写成功了但设备没动」。区分方法看返回值结构里有没有单独的下装状态字段,有就一定要检查它。另外,写操作的账号权限通常和读账号不一样,用一个只读账号去调写接口,有些版本返回的是通用错误码,看上去像参数错误,实际上是权限问题。

4. 批量读写与订阅回调:500 个点位怎么在 100ms 内拉完

单点读写跑通之后,几乎所有人都会立刻撞上性能墙:500 个点,一个一个读,每个往返 5 到 20 毫秒,一轮下来好几秒,曲线全是台阶。这不是 SDK 慢,是调用方式用错了。实时库的接口设计里,批量接口和订阅接口才是给生产环境用的,单点接口只适合调试和低频配置读取。

4.1 批量读:一次往返拿回 N 个点

批量接口的核心是「一次网络往返处理多个位号」。写法上是传一个位号名数组和一个等长的结果数组,返回后逐元素校验。

const int N = 500; const char* pNames[N]; PsTagValue results[N]; // 位号名列表一般从点表配置文件加载,别硬编码在代码里 for (int i = 0; i < N; ++i) pNames[i] = g_tagNames[i]; int rc = psReadTags(hConn, pNames, N, results, 5000); if (rc != PS_OK) { printf("batch read failed, rc=%d\n", rc); return -1; } // 关键:批量接口返回的整体 rc 只代表「请求处理了」, // 每个点的成败要看元素级的返回字段 int badCount = 0; for (int i = 0; i < N; ++i) { if (results[i].rc != PS_OK || results[i].quality != PS_QUALITY_GOOD) { ++badCount; // 记录位号名 + 子返回码,便于事后定位是哪个点的问题 } } printf("ok=%d bad=%d\n", N - badCount, badCount);

两个参数最值得调:批量大小和超时。批量大小不是越大越好,一次塞几千个位号,服务端要一次性组包,反而拉长单次响应时间,而且中间有一个点失败整批都得重来。我的经验值是一次 200 到 500 个,具体多少个要看位号的数据类型分布——字符串型位号比数值型重得多,混在一起时要往小调。超时则建议设成「正常响应时间的 3 到 5 倍」,太紧会在负载高峰误判失败,太松会让程序在真正断线时卡很久。

还有一个容易忽略的点:批量读拿回来的时间戳不是同一个时刻。服务端是按位号各自的采集周期填的,所以做曲线对齐时要按时间戳插值,不能假设它们同时采样。

4.2 订阅回调与轮询的取舍

如果你的程序需要「变化时立刻知道」,而不是「每隔一秒问一次」,那就要用订阅接口。订阅的模型是:注册一批位号 + 一个回调函数,库侧检测到值变化或质量变化时主动推给你。

// 回调运行在 SDK 内部的线程上,函数里不要做耗时操作 void OnTagChanged(const PsTagValue* pItems, int nCount, void* pUser) { for (int i = 0; i < nCount; ++i) { // 只做入队,真正的处理交给业务线程,避免阻塞推送线程 QueuePush(pUser, pItems[i]); } } // 注册订阅:位号列表 + 回调 + 用户上下文 int subId = psSubscribe(hConn, g_tagNames, 200, OnTagChanged, g_queue, 100 /* 死区百分比 */); if (subId < 0) { printf("subscribe failed, %d\n", subId); }

100那个参数是死区(deadband),单位通常是位号量程的百分比。设置死区是为了过滤噪声——现场一个温度点可能在 25.01 和 25.02 之间反复跳,不设死区就是每秒几百次回调,把程序打满。这个值要根据测点的实际波动幅度调,设太大丢失真实变化,设太小起不到过滤作用。温度、压力这类慢变量,死区设量程的 0.5% 到 1% 比较常见;流量、振动这类快变量,要么把死区设得很小,要么干脆用轮询加本地判断。

订阅和轮询怎么选,一句话:数据量小、要求实时、波动有规律,用订阅;数据量大、要求定时快照、下游是批处理,用轮询批量读。两者也可以混用,关键位号走订阅,趋势归档走定时批量。

4.3 历史查询:时间参数决定你拿到的是真数据还是插值

历史数据接口的入参基本是:位号、起止时间、采样间隔、聚合方式、最大返回条数。

参数作用常见误设
起始时间查询窗口起点用本地时间直接传,未做时区换算
结束时间查询窗口终点设成「当前时刻」,边界那条记录不稳定
采样间隔返回数据的等间隔步长设得比归档周期还小,拿回一堆插值
聚合方式间隔内取首值/末值/平均/最大最小默认值不是你要的语义
最大条数防止单次返回过大不设上限,大窗口直接超时

最要命的是第三行。实时库的历史归档是有存储周期的,比如 1 秒或 5 秒一个点。你按 100 毫秒去查,服务端不会报错,它会给你插值补出一堆看起来平滑、实际上从未存在过的点。这种数据拿去算能耗、算极值,结论一定是错的。查询间隔的下限,先问清楚归档周期再定。

时间字段还有一层:SDK 的时间戳通常是库侧时间,可能是 UTC 也可能是本地时间,取决于部署配置。做跨时区或者有夏令时的场景,一定要在客户端统一换算,别把两种时间混在一张表里比较。

5. 避坑排查:五个把新手卡一整天的现场问题

下面这五条是我在不同项目里真实遇到过的,按「现象 → 原因 → 解决」写。新手照着这个顺序排,能省掉大量瞎猜的时间。

5.1 开发机跑得好好的,拷到现场一启动就退

现象:在自己机器上调试通过,程序拷到现场工控机上双击没反应,或者弹一个系统错误框就消失。

原因:九成是运行期依赖没跟过去。要么是.dll没和 exe 放同目录,要么是缺 VC 运行库(msvcr120.dll这类),要么是现场装的是 32 位系统而你编的是 64 位程序。这三件事在开发机上都不会暴露,因为开发机什么都有。

解决:先用dumpbin /dependents列出 dll 的全部依赖,逐个在现场机上确认存在。然后写一个check_env.bat,在启动主程序前先跑一遍依赖检查,缺什么就打印什么。再进一步,把 dll 直接嵌入资源、启动时释放到临时目录加载,能彻底消除「文件没拷全」这个变量。位数问题用任务管理器看进程有没有带(32 位)后缀就能确认。

5.2 连接返回成功,读任何点都是「点不存在」

现象:psConnect返回成功,句柄有效,但随便读哪个位号都失败,错误码指向点不存在。

原因:三种可能。一是位号名拼错或大小写不对;二是连到了错误的库实例(现场有测试库和生产库,实例名只差一个后缀);三是这个位号在库里存在但被停用了,采集通道没启动。

解决:第一步,在组态工具里打开点表,把位号名复制出来,别用键盘敲。第二步,调用枚举接口拉前 20 个位号名打印出来,和你手里的字符串逐字符比对——我遇到过位号名中间有一个不可见空格的情况,肉眼完全看不出来。第三步,确认库实例名,这个在连接参数里,很多人复制了上一版配置就忘了改。第四步,去组态侧看这个点的采集状态是不是「已停用」。

5.3 批量写偶尔丢点,日志里一条报错都没有

现象:批量写 500 个点,跑一天下来下游发现个别点没更新,但程序日志干干净净。

原因:只判断了批量接口的整体返回码,没判断元素级的返回码。批量接口的语义是「请求被受理」,某个具体位号因为类型不匹配、权限不足、值超量程而失败,不会体现在整体返回码上。另外,写入频率超过服务端处理能力时,服务端会静默丢弃部分请求,也不报错。

解决:循环校验每个元素的子返回码,失败的点单独记日志(带位号名)。把一次 500 个大包拆成 5 个 100 的小包,包之间加 10 到 20 毫秒间隔做节流。这两步做完,「丢点」基本就变成「可控的、有日志的失败」了,运维能定位,程序能重试。

5.4 跑十几个小时内存和句柄一起涨

现象:程序连续运行,任务管理器里内存持续上涨,句柄数也一路上涨,最后在某次读写时崩溃。

原因:两类泄漏。一类是连接泄漏——在循环里反复连接不复用,或者异常分支里return之前没释放句柄。另一类是结果对象泄漏——某些接口返回的字符串或数组指针需要按文档单独释放,不释放就每次都漏一点。

解决:连接只建立一次,全局复用,配一个心跳探测来发现失效连接(见第 6 章)。所有句柄用 RAII 包一层,构造时连接、析构时释放,异常路径自动覆盖。查泄漏用最土的办法:跑 1000 次循环,看句柄数涨不涨,涨了就二分定位是哪个接口。这个办法比任何工具都快。

5.5 历史数据查出来时间对不上,中间还少一段

现象:查昨天的数据,曲线开头和结尾的时间跟预期差几个小时,中间还有一段是空的。

原因:时区没统一(库侧时间和本地时间混用),或者查询窗口跨越了归档文件的切换点,或者查询间隔设得比归档周期小导致插值异常。少一段则多半是那段时间采集服务重启过,归档有缺口。

解决:客户端统一按 UTC 存储和比较,只在展示层做本地化转换。查询前先确认归档周期,间隔不小于它。对于缺口,程序要能识别「时间戳跳变超过 N 倍间隔」并标记为断档,而不是拿去插值填补——把缺口填平是最危险的处理方式,报表上完全看不出来。

6. 进阶验证:断线重连、并发安全与一版压测脚本

上面跑通之后,最后一道关是「能不能连续跑三十天」。这一关靠的不是功能测试,而是故障注入。

断线重连:不要指望 SDK 自动重连。我一般在自己的封装层加一个心跳线程,每 5 秒读一个已知存在的系统点,连续三次失败就判定连接失效,主动断开、释放句柄、按 1s/2s/5s/10s 的退避间隔重连。重连成功后要重新注册订阅,很多人漏了这一步,结果是程序重连成功了,但推送数据再也没有来过。

// 心跳探测:只读一个必然存在的点,失败三次就触发重连 int failCount = 0; while (g_running) { PsTagValue v; int rc = psReadTag(g_hConn, g_heartbeatTag, &v, 1000); failCount = (rc == PS_OK) ? 0 : failCount + 1; if (failCount >= 3) { ReconnectWithBackoff(); // 内部要包含:断开、释放、退避重连、重注册订阅 failCount = 0; } Sleep(5000); }

并发安全:先确认 SDK 是否线程安全——这个答案只能从包内文档里找,找不到就按「不线程安全」处理。安全的做法是每个线程持有自己的连接,不要跨线程共用句柄。如果位号数量大、连接数受限,就做单连接 + 一个请求队列 + 一个工作线程,所有调用串行化。这样牺牲一点吞吐,换来的是不会出现那种几周才复现一次的随机崩溃。

压测脚本:验证不能靠「感觉挺快的」,要出数。用一段脚本按固定频率打批量读,记录 P50 / P95 / P99 延迟和错误率,跑满 24 小时看这些指标有没有漂移。

#!/bin/bash # 按 10Hz 频率压测 30 分钟,输出延迟分位数和失败计数 ./ps_bench --host 10.20.30.40 --db PSPACE_PROD \ --tags tags_500.txt \ --batch 200 \ --interval-ms 100 \ --duration 1800 \ --out bench_result.csv # 事后看 P99 和失败数,比看平均值有用得多 awk -F, 'NR>1{n++; sum+=$2; if($2>max)max=$2} END{print "n="n, "avg="sum/n, "max="max}' bench_result.csv

三个指标里我最看 P99 和错误率,平均值最没用——它会把偶发的 2 秒卡顿藏起来,而现场下游系统恰恰是被这种偶发卡顿拖垮的。跑满 24 小时再看一次,如果 P99 从 30 毫秒漂到 300 毫秒,那说明有资源在慢速泄漏,回头查第 5.4 节。

最后说一个我自己的习惯:任何涉及实时库的项目,上线前我一定强制走一遍「拔网线」流程——运行中直接把网线拔掉,看程序多久发现、多久重连、重连后数据是不是连续的、有没有产生重复数据。这个动作五分钟,能提前暴露掉后面一整个月的现场投诉。别嫌土,管用。

希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询