ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

工业数据采集网关架构设计

工业数据采集网关架构设计

工业数据采集网关架构设计

一套 C++17 插件化工业数据采集网关的设计思路与关键实现
版本 v1.0


一、为什么用插件化架构

工业现场的设备协议极多:Modbus TCP、OPC UA、串口私有协议、UDP 报文、HTTP/JSON……
如果每接一种协议就改一次内核,项目会迅速失控。

本网关的做法是:内核只负责"点位数据"这件事,协议全部下沉为插件。

内核定义了一套稳定的接口(IPlugin/IDriver/IChannel/IDevice),
任何新协议只要实现这套接口、编译成一个.so,放进plugins/目录即可被加载,
不需要重新编译内核。

带来的直接好处:

好处说明
隔离某个驱动崩溃不影响内核与其他驱动
可裁剪现场不需要的驱动直接不部署,减少攻击面与体积
可扩展私有协议对接只写一个插件,不动主干代码
可替换同一份点位表可以在不同协议驱动间迁移

二、整体架构

系统按职责分为四层:

┌─────────────────────────────────────────────────────────────┐ │ 表现层 Web 组态 / 上位机(浏览器访问) │ │ 实时画面 · 设备配置 · 点位配置 · 历史查询 · 权限 │ └───────────────────────────┬─────────────────────────────────┘ │ REST / WebSocket ┌───────────────────────────┴─────────────────────────────────┐ │ 服务层 shuweb(Web 服务)· shuservice(工程管理服务) │ └───────────────────────────┬─────────────────────────────────┘ │ RPC(自定义二进制协议) ┌───────────────────────────┴─────────────────────────────────┐ │ 内核层 shucore │ │ · 点位管理(DataManager) · 设备管理(DeviceManager) │ │ · 工程加载(ProjectManager)· 事件(Event)· 搜索(DataSearch)│ │ · 冗余(Redundency)· 授权(License)· 日志(Logger) │ └───────────────────────────┬─────────────────────────────────┘ │ 统一接口 IChannel / IDevice ┌───────────────────────────┴─────────────────────────────────┐ │ 驱动层 采集插件(全部以 .so 形式动态加载) │ │ Modbus TCP · OPC UA · 串口 · UDP · JSON · … │ └─────────────────────────────────────────────────────────────┘

另有守护进程shudaemon负责内核异常退出后的拉起,
shuupdate负责版本升级,shuhwinfo负责读取硬件指纹(授权用)。


三、插件模型

3.1 三种容器

插件管理器把插件按ContainerType分为三类,加载顺序与生命周期不同:

容器枚举值典型插件说明
内核容器CONTAINER_COREDataManager、DeviceManager、Logger系统必需,最先加载
扩展容器CONTAINER_EXTENDEDEvent、DataSearch、Redundency增强功能,可裁剪
驱动容器CONTAINER_DRIVERModbus、OPC UA、串口按现场需要加载

3.2 插件生命周期

每个插件都实现统一的生命周期钩子:

classIPlugin{public:virtualconstchar*pluginName()=0;// 唯一名称,如 "c.modbus.tcp"virtualintinstall(intargc,char**argv)=0;// 安装(生成属性定义文件)virtualintuninstall()=0;// 卸载virtualintinit(intargc,char**argv)=0;// 初始化:读取配置、注册能力virtualintuninit()=0;// 反初始化:释放资源virtualintupdate()=0;// 周期回调virtualintgetPluginId()const=0;// 内核内固定 IDvirtualintgetContainerType()const=0;};

关键设计:init只做注册、不做阻塞工作。真正的采集线程由设备在start()时拉起,
避免某个慢设备拖垮整个启动流程。

3.3 插件的获取方式

插件间不直接互相持有指针,而是通过内核的"单例注册表"按类型取用:

ILogger*logger=IPluginManager::getSingleInstance<ILogger,1>();IDataManager*data=IPluginManager::getSingleInstance<IDataManager,8>();IDeviceManager*devices=IPluginManager::getSingleInstance<IDeviceManager,12>();

模板参数中的数字是内核约定的插件 ID。这样驱动插件只依赖Interfaces.h,
不依赖任何具体实现,编译期完全解耦。


四、核心接口设计

4.1 驱动三件套:IDriver / IChannel / IDevice

这是最关键的抽象,三级对应"协议 → 通道 → 设备":

// 一个协议 = 一个 IDriver 插件classIDriver:publicIPlugin{public:virtualstd::shared_ptr<IChannel>createChannel(intchannelId)=0;virtualstd::stringgetChannelUniqueId(intchannelId)=0;virtualstd::vector<std::string>getAllProperties()=0;// 从在线设备反向导出点位表(用于"扫点")virtualintexportPointsFromDevice(conststd::map<std::string,Variant>&params,std::vector<PointProperty>*properties,std::list<std::vector<Variant>>*values,std::string*error)=0;};// 一个通道 = 一类连接(如同一网段的 Modbus TCP 站)classIChannel{public:virtualstd::stringgetType()const=0;// "c.modbus.tcp"virtualintcreateDevice(intdeviceId,IDevice*&out)=0;virtualvoidupdate100ms(unsignedlongtick)=0;// 100ms 心跳virtualintrequestDataByRedun(unsignedlongseq,conststd::vector<unsignedchar>&data)=0;virtualintrespondDataByRedun(unsignedlongseq,conststd::vector<unsignedchar>&data)=0;};// 一个设备 = 一个通信端点(IP:Port / COM 口 / Endpoint URL)classIDevice{public:virtualintloadPoints(constint*pointIds,unsignedlongcount)=0;virtualintstart()=0;// 拉起独立采集线程virtualintstop()=0;virtualvoidsetState(DeviceState state)=0;virtualvoidonValueChanged()=0;// 点位值变化回调virtualvoidonCommandChanged()=0;// 下发命令回调};

为什么要有 Channel 这一层?
因为真实现场里,"一个 TCP 连接"往往承载同一台设备(或同一网关下的多个站)。
把连接复用逻辑放在 Channel,把点位轮询逻辑放在 Device,
既避免了重复建连,又让每个设备可以独立启停、独立重连。

4.2 点位数据总线:IDataManager

所有采集到的数据都汇聚到IDataManager,它是全系统的数据中枢:

classIDataManager:publicIPlugin{public:// 按名称批量取点位 ID(避免运行期字符串比较)virtualintpointNameToId(constchar*const*names,unsignedlongcount,int*ids)=0;// 写入点位值,reason 用于区分来源(采集 / 命令 / 冗余同步)virtualintsetPointValue(intpointId,constVqt&value,ChangeReason reason)=0;// 批量读取,cache 参数决定走缓存还是强制刷新virtualintgetPointsValue(constint*ids,constbool*cache,unsignedlongcount,Vqt*values,Result*results)=0;// 订阅:点位变化时回调,这是 Web 实时刷新的基础virtualvoidaddPointWatcher(intpointId,boolall,PointWatcherSlot&&slot)=0;virtualvoidaddAllPointWatcher(PointWatcherSlot&&slot)=0;};

设计要点:

  • 点位一律用intID 寻址,名称到 ID 的转换只在配置加载期做一次。
    运行期热路径上没有任何字符串操作。
  • Vqt(值 + 质量 + 时间戳)而不是裸值。质量码让上层能区分
    “值真的是 0” 和 “通信断了所以是 0”。
  • change reason让数据源头可追溯,避免冗余同步回环。

五、数据流

一次完整的采集到展示:

设备轮询线程 内核 Web 服务 ───────────── ──────────── ───────────── 读寄存器/订阅回调 │ ▼ Devices->submitCollectValues(deviceId, pointIds, values, reason) │ ▼ DataManager::setPointValue ──► 值变化判定 │ │ │ ├─► 写历史库 │ ├─► 触发 Event(报警/事件) │ └─► 通知 Watcher │ │ │ ▼ │ WebSocket 推送到浏览器 │ └─► 冗余同步(若配置为主机)

上行(控制)方向对称:Web 下发命令 →IDeviceManager::setCommand
→ 设备的onCommandChanged()→ 驱动把写请求发到设备。


六、关键技术实现

6.1 点位聚合:把 N 个请求合并成 1 个

Modbus 逐点读取性能极差。核心做法是块合并:
把同一功能码、地址相近的点位合并成连续块,一次请求读回。

// 排序后贪心合并:相邻块之间的小空洞直接读掉,换取请求数下降if(block.func==spec.func&&spec.start<=blockEnd+8&&std::max(blockEnd,specEnd)-block.start<=limit){block.count=std::max(blockEnd,specEnd)-block.start;// 扩展块}

"容许 8 个地址空洞"这个阈值是关键权衡:
多读几个无关寄存器,换掉一次 TCP 往返,在工业现场几乎总是划算的。

6.2 字节序处理

Modbus 只有 16 位寄存器,32/64 位值如何拼接各家厂商不统一。
工程上用ABCD / CDAB / BADC / DCBA四种布局描述,点位地址里可指定:

FUNC:ADDR[.BIT]|TYPE[|ORDER] 例:3:100|REAL32|CDAB 保持寄存器 3x 区,地址 100,32 位浮点,CDAB 布局 1:20.3|BOOL 线圈区地址 20 的第 3 位

四种布局互为逆变换,所以同一段代码既能编码也能解码。

6.3 断线重连状态机

每个设备一个独立线程,状态机非常克制:

┌──────────┐ 连接失败 ┌─────────┐ │ STARTING │────────────►│ FAULT │ └────┬─────┘ └────┬────┘ │ 连接成功 │ 等待 reconnectMs ▼ │ 并上报坏质量 ┌──────────┐◄─────────────────┘ │ RUNNING │ 读失败 → 关闭连接 → FAULT └──────────┘

两个细节值得注意:

  • 失败必须上报坏质量,否则上位机看到的是"最后一帧的旧值",会误判现场状态。
  • 等待重连时按 100ms 切片检查退出标志,保证stop()能及时返回,
    不会卡在sleep(reconnectMs)上。

6.4 OPC UA 订阅分组

OPC UA 用订阅(Subscription)而不是轮询。但一个会话能创建的
MonitoredItem 数量有上限,因此需要把点位分成多个SubscriptionGroup,
每组建一个 Subscription,再批量创建 MonitoredItem:

UA_CreateMonitoredItemsRequest request;UA_CreateMonitoredItemsRequest_init(&request);request.subscriptionId=group->subscriptionId;// 批量创建:一次服务调用挂载整组点位UA_Client_MonitoredItems_createDataChanges(client,request,contexts.data(),callbacks.data(),deleteCallbacks.data());

数据变化时由回调s_valueChanged触发,直接把值提交给IDeviceManager,
不需要任何轮询。这是 OPC UA 相比 Modbus 在点位规模大时性能优势的来源。

6.5 双机冗余

冗余主机之间通过 RPC 同步状态:

  • 主机周期把点位变化下发给备机(sendDataToRedun)
  • 备机保持热备,只接收不同步输出
  • 主机故障时备机切换为MASTER,接管对外通信

同步数据带sequence序号,用于去重和丢包检测。


七、工程结构

shu-gate/ ├── include/ │ ├── shu/ # 内核公共头文件 │ │ ├── Interfaces.h # ★ 所有插件接口定义 │ │ ├── Variant.h # 通用值类型 │ │ ├── DataTypes.h # Vqt / 质量码 / 枚举 │ │ └── PluginManager.h │ └── plugins/ # 各驱动对外头文件 ├── src/ │ ├── shu/ # 基础库(配置、字符串、动态库加载) │ ├── shucore/ # 内核主程序 │ ├── shuweb/ (Go) # Web 后端 │ ├── libDataManager/ # 点位数据中枢 │ ├── libDeviceManager/ # 设备管理 │ ├── libProjectManager/ # 工程管理 │ ├── libc.modbus.tcp/ # Modbus TCP 驱动 │ ├── libc.opc.ua/ # OPC UA 驱动 │ ├── libc.serial/ # 串口驱动 │ ├── libc.udp/ libf.json/ # UDP / JSON 驱动 │ └── libRedundency/ libevent/ … ├── etc/ # 配置、工程、授权 ├── plugins/ # 编译产物(.so) └── web/ # Web 前端资源

八、构建与运行

mkdir-pbuild&&cdbuild cmake..make-j$(nproc)

依赖:CMake ≥ 3.16、支持 C++17 的编译器、Boost、SQLite3、OpenSSL、Protobuf、
open62541(OPC UA 驱动)。Web 后端需要 Go 1.20+。

产物:内核shucore、服务shuservice/shuweb、守护shudaemon,
以及plugins/下各驱动.so。

启动:

./bin/shucore-petc/projects/default

九、可扩展点小结

想做的事需要改的地方
接一种新协议新写一个IDriver插件,不动内核
增加点位属性在驱动getAllProperties()中声明,配置界面自动出现
自定义报警逻辑订阅IDataManager的 PointWatcher,或扩展 Event 插件
数据转第三方平台扩展一个"北向"插件,订阅全量点位后转发
更换存储IProjectConfig已支持 File / SQLite 双实现,可再加一种

附录:术语表

术语含义
点位(Point)一个可采集/可写入的变量,有值、质量、时间戳
VqtValue + Quality + Timestamp,点位值的统一载体
通道(Channel)一类通信连接的集合,如一个网段
设备(Device)一个通信端点,承载若干点位
块(Block)一次 Modbus 请求读取的连续寄存器区间
质量码标识当前值是否可信(Good / Bad)
冗余双机热备,主机故障时备机接管
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进