工业数据采集网关架构设计
一套 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_CORE | DataManager、DeviceManager、Logger | 系统必需,最先加载 |
| 扩展容器 | CONTAINER_EXTENDED | Event、DataSearch、Redundency | 增强功能,可裁剪 |
| 驱动容器 | CONTAINER_DRIVER | Modbus、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>¶ms,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) | 一个可采集/可写入的变量,有值、质量、时间戳 |
| Vqt | Value + Quality + Timestamp,点位值的统一载体 |
| 通道(Channel) | 一类通信连接的集合,如一个网段 |
| 设备(Device) | 一个通信端点,承载若干点位 |
| 块(Block) | 一次 Modbus 请求读取的连续寄存器区间 |
| 质量码 | 标识当前值是否可信(Good / Bad) |
| 冗余 | 双机热备,主机故障时备机接管 |