01 工业数据采集全景与架构

本篇定位:给已经会写 Java、但没系统摸过车间的工程师,建立一张"从传感器到云端"的完整地图——搞清采集什么、怎么采、采上来放在哪、以及为什么工业现场的采集比调用一个 HTTP 接口难十倍。本篇不展开具体协议的报文细节(那是 03/04 篇的事),也不展开字节序(详见 03-字节序与数据类型编码.md)。

本篇目录


1. 什么是设备数据采集:OT 与 IT 的边界

设备数据采集(Device Data Acquisition / OT Data Collection),指的是把工业现场设备内部的数据(寄存器值、状态位、报警、产量、工艺参数)以自动化方式读取出来,转换成 IT 系统可消费的结构化数据,并可靠地送到上位系统的过程。

它本质上是一次跨边界的翻译工作:

维度 OT 侧(运营技术) IT 侧(信息技术)
主体 电气工程师、工艺工程师、设备厂商 后端/平台工程师
关注点 实时性、确定性、安全(人身与设备)、可用性 吞吐量、一致性、可扩展、可观测
时间尺度 毫秒 ~ 秒,硬实时 秒 ~ 天,最终一致
协议 Modbus RTU/TCP、S7comm、MC、Profinet、CAN HTTP、MQTT、Kafka、gRPC
数据形态 寄存器、位、字节流 JSON、行/列式表、对象
变更态度 极度保守,"能跑就不要动" 快速迭代、灰度发布
故障代价 停机、废品、人身事故 服务降级、用户投诉
生命周期 10~20 年 3~5 年

这条边界带来三个结构性矛盾,几乎所有采集项目的麻烦都源于此:

  1. 语义落差:PLC 里只有 DB1.DBW20 这种"地址 + 字节",没有任何业务语义。把一个 16 位整数解释成"主轴温度"的知识,只存在于电气工程师的脑子里或者一份 Excel 点表里。
  2. 可靠性落差:IT 默认"网络终将恢复,重传即可";OT 现场会断网、会掉电、会被人拔网线、会被电焊机干扰。采集侧必须能自己兜住这些。
  3. 写操作的不对称性:读错了顶多是图表难看;写错了会让阀门动作、电机启动。所以采集平台必须把"读通道"和"写通道"当成两个安全等级完全不同的功能来设计。

一句话判断:采集系统的第一优先级不是"快",是**"不添乱"**——绝对不能因为采集平台的存在,让原本正常运行的产线多一个故障点。


2. ISA-95 五层模型:工业系统的分层语言

ISA-95(IEC 62264)把制造企业自动化系统划分为 5 层。这是你跟甲方/集成商沟通时最有效的共同语言。

  Level 4  企业层  ERP / SCM / CRM
           ├─ 关注:订单、成本、库存、财务
           ├─ 时间尺度:天 / 月
           └─ 典型系统:SAP、用友、金蝶

  Level 3  工厂/车间管理层  MES / WMS / QMS / EAM
           ├─ 关注:工单、报工、追溯、质量、OEE
           ├─ 时间尺度:分钟 / 小时 / 班
           └─ 典型系统:SIMATIC IT、达索 Apriso、国产 MES

  Level 2  过程控制层  SCADA / HMI / DCS 操作站 / 历史库
           ├─ 关注:工艺流程、报警、配方、趋势曲线
           ├─ 时间尺度:秒 / 分钟
           └─ 典型系统:WinCC、iFix、Intouch、组态王

  Level 1  基础自动化层  PLC / DCS 控制器 / CNC 数控 / 机器人控制器
           ├─ 关注:逻辑控制、闭环调节、联锁保护
           ├─ 时间尺度:毫秒 ~ 百毫秒
           └─ 典型设备:S7-1200/1500、汇川、三菱 Q/R、Fanuc、KUKA

  Level 0  实际生产过程  传感器 / 执行器 / 电机 / 阀门 / 仪表
           ├─ 关注:物理量
           └─ 时间尺度:微秒 ~ 毫秒(硬实时)

几个实用的判断:


3. 一条数据的完整旅程:从传感器到云端

以"注塑机料筒温度"为例,看一个物理量是怎么变成数据库里的一行记录的:

  [1] 物理量
      料筒实际温度 = 218.6 ℃

  [2] 传感器 / 变送器 (L0)
      热电偶 K 型 → 温度变送器 → 4~20mA 电流信号
      电流 = (218.6 / 300) * 16 + 4 = 15.66 mA

  [3] PLC 模拟量输入模块 (L1)
      A/D 转换,16 位有符号整数
      原始码值 RAW = (15.66 - 4) / 16 * 27648 = 20150
      → 存入 PLC 地址,例如 S7 的 DB3.DBW 10,或 Modbus 保持寄存器 400101

  [4] PLC 用户程序 (L1)
      可能做:滤波、量程标定、报警判断(>250℃ 置 M10.0)
      注意:此刻数据仍然只是一个 16 位整数,没有任何单位信息

  [5] 采集器读取 (边缘侧)
      通过 S7comm 或 Modbus TCP 读取该地址
      得到字节流:0x4E 0xB6(大端)
      → 按点表配置解释为 INT16,值 = 20150

  [6] 工程量转换 (边缘侧)
      EGU = RAW / 27648 * (300 - 0) + 0 = 218.6
      → 得到 { tag: "M01.BarrelTemp", value: 218.6, unit: "℃", ts: 1717... }

  [7] 边缘处理
      死区过滤(±0.5℃) → 变化才上报 → 本地环形缓存 → 打包

  [8] 上云传输
      MQTT PUBLISH → topic: iot/A01/M01/telemetry, QoS 1

  [9] 平台接入
      MQTT Broker → 规则引擎 → 解析 → 时序库写入

  [10] 存储
      TDengine 超级表 `sensor_telemetry`:
      (ts, device_id, tag, val_double, quality) VALUES (...)

  [11] 应用
      实时看板 / 报警 / OEE 分析 / 工艺优化模型

关键观察:步骤 [5]→[6] 之间的转换知识(地址、数据类型、字节序、量程)不在设备里,也不在协议里,它必须由人配置出来,并且被持久化管理。这就是点表存在的理由,也是采集平台最核心的资产。

反过来说:没有点表的采集器只是个协议转换器,不是采集平台。


4. 采集对象分类:你要面对的到底是什么设备

不同设备的"可采集性"差异巨大。下面这张表决定了一个项目的工作量与风险。

设备类别 典型代表 常用接口/协议 数据特征 采集难度 主要坑点
PLC(小型) 西门子 S7-200 SMART / S7-1200、三菱 FX5U、欧姆龙 CP1H、汇川 H3U Modbus RTU/TCP、S7comm、MC、FINS、HostLink 开关量、模拟量、产量计数 ★★☆☆☆ 连接数上限、老固件不支持以太网
PLC(中大型) S7-300/400/1500、三菱 Q/iQ-R、AB ControlLogix、施耐德 M580 S7comm、MC/SLMP、EtherNet/IP CIP、Modbus TCP 大规模 DB 块、工艺参数、报警区 ★★★☆☆ 优化块(Optimized DB)地址不可读、需要符号寻址
DCS 霍尼韦尔 TPS/Experion、横河 CENTUM、浙大中控 ECS OPC UA/DA、Modbus、专有网关 海量位号(万级)、高可靠要求 ★★★★☆ 几乎不开放直连,必须走 OPC;安全审计严格
CNC 数控机床 Fanuc、西门子 840D、三菱 M70/M80、广数、华中 Focas(Fanuc)、OPC UA(840D)、Modbus、MTConnect 主轴负载、进给、坐标、程序号、报警号 ★★★★☆ 厂商 SDK 授权费用、老机床只有 RS232 串口输出
机器人 KUKA、ABB、Fanuc、安川、埃斯顿 KUKA VarProxy/EKI、ABB PC SDK/RWS、OPC UA 关节角、TCP 位姿、电流、报警、程序状态 ★★★★☆ 需厂商选件包(如 KUKA EKI 是付费选项)
智能仪表 电表(DL/T645、Modbus)、水表、流量计、温控表、称重仪 Modbus RTU/TCP、DL/T645、CJ/T188 累积量、瞬时量、报警状态 ★★☆☆☆ 地址表各家自定义、BCD 码、浮点字序五花八门
传感器(独立联网) 温湿度、振动、压力、光电 Modbus、CAN、LoRa、NB-IoT、4-20mA 单值或三轴时序 ★☆☆☆☆ 供电与布线比协议更难
条码 / RFID 扫码枪、固定式读码器、UHF RFID 读写器 TCP Socket / 串口 / OPC UA / HTTP 事件型字符串 ★★☆☆☆ 触发时机、去重、与产线节拍对齐
视觉系统 基恩士、康耐视、海康/大华工业相机 TCP/UDP 私有协议、GigE Vision、Modbus TCP 判定结果(OK/NG)、尺寸值、图片 ★★★☆☆ 结果走协议、图片走文件系统,两条链路要对齐
变频 / 伺服 / 执行器 西门子 V90、ABB ACS、台达、汇川 Modbus RTU、CANopen、Profinet、EtherCAT 频率、电流、转矩、故障码 ★★★☆☆ 参数号 ≠ 寄存器地址,需要查参数手册

判断采集难度的经验法则:

  1. 越靠近 L0,越依赖硬件改造(加传感器、加网关、布线)。
  2. 越靠近 L2,越依赖厂商配合(要授权、要 SDK、要开放接口)。
  3. 越老的设备,越可能只有串口,而且手册可能已经找不到了。
  4. 有 PLC 程序源码 = 难度减半;没有源码只能靠猜和抓包反推(这是常态,做好心理准备)。

5. 采集方式五选一:直采 / OPC / 网关 / 硬接线 / 相机

采集方式 原理 优点 缺点 适用场景
协议直采 采集器直接发 Modbus/S7/MC 报文读 PLC 无中间环节、延迟最低、成本最低、可控性强 协议碎片化;需要懂地址与字节序;直连 PLC 有安全与负载争议 中小型项目、PLC 型号可控、自研平台(FastBee 的 Modbus/S7/MC 驱动即此类)
OPC UA 通过 OPC UA Server 统一暴露,客户端标准订阅 语义化(有 NodeId、有数据类型、有单位);跨厂商统一;支持订阅与安全加密 需要部署 Server(KepServer/KEPServerEX、Matrikon、Prosys);授权费;性能受 Server 限制;配置工作量大 多品牌 PLC 混合、大型项目、已有 OPC 基础设施
OPC DA(Classic) 基于 Windows DCOM 的老标准 老系统普遍支持 DCOM 配置地狱、跨机跨域几乎不可用、无安全模型、已被淘汰 新项目不要用;只在必须接入老 SCADA 时被动兼容
网关透传/协议转换 串口服务器、DTU、边缘网关把 RS485 转成以太网或 MQTT 解决布线问题;屏蔽底层协议;可脱网缓存;可跨公网 多一跳延迟;多一个故障点;网关本身要配置;透传模式下报文分析困难 老设备联网、远程站点、串口设备集中上云
硬接线 IO 把无源干接点/继电器信号接到采集模块的 DI 通道 完全不依赖设备协议、电气隔离、绝对可靠 需要布线与停电施工;点位成本高;只能取开关量 关键状态(运行/停机/故障)、安全相关信号、无通信接口的老设备
工业相机 / 视觉 相机拍照 + 算法识别(读码、OCR、缺陷、计数) 能采到"没有数据接口"的信息(表盘读数、指示灯、仪表数字) 算法成本高、受光照影响、结果非确定性 老式仪表读数、无接口设备、质检工位

选型建议(有观点)

五种方式的链路对比

  直采:       [PLC] ──以太网── [采集器] ──MQTT── [平台]
              延迟 1~20ms     跳数 1

  OPC UA:     [PLC] ──S7comm── [OPC UA Server] ──UA── [采集器] ──MQTT── [平台]
              延迟 50~500ms   跳数 2

  网关透传:   [仪表] ──RS485── [串口服务器] ──TCP── [采集器] ──4G/MQTT── [平台]
              延迟 30~500ms   跳数 2(透传模式下采集器仍要自己解 Modbus RTU)

  硬接线:     [继电器触点] ──导线── [IO 模块] ──Modbus── [采集器] ──MQTT── [平台]
              延迟 5~50ms     跳数 2(但可靠性最高)

  视觉:       [相机] ──GigE── [视觉控制器/算法] ──TCP/Modbus── [采集器] ──MQTT── [平台]
              延迟 100ms~数秒 跳数 2(图片另走文件通道)

6. 采集架构:边缘采集器 → 消息总线 → 时序库 → 应用层

这是一套经过大量项目验证的主流架构。FastBee、ThingsBoard、以及大多数国产工业互联网平台基本都是这个形状。

┌─────────────────────────────────────────────────────────────────────────────────┐
│  L0/L1  现场设备层                                                                │
│                                                                                   │
│   ┌────────┐  ┌────────┐  ┌────────┐  ┌────────┐  ┌────────┐  ┌──────────────┐   │
│   │ S7-1200│  │S7-1500 │  │ 三菱Q  │  │ 汇川PLC│  │ 仪表   │  │  CNC / 机器人│   │
│   │ (S7)   │  │ (S7)   │  │ (MC)   │  │(Modbus)│  │(RTU)   │  │  (OPC UA)    │   │
│   └───┬────┘  └───┬────┘  └───┬────┘  └───┬────┘  └───┬────┘  └──────┬───────┘   │
│       │S7comm     │S7comm     │MC/TCP     │MB/TCP     │RS485         │ UA        │
└───────┼───────────┼───────────┼───────────┼───────────┼──────────────┼───────────┘
        │           │           │           │           │              │
        │           │           │           │        ┌──┴───┐          │
        │           │           │           │        │串口  │          │
        │           │           │           │        │服务器│          │
        │           │           │           │        └──┬───┘          │
        └───────────┴───────────┴───────────┴───────────┴──────────────┘
                                │  工业交换机(VLAN 隔离)
┌───────────────────────────────┼─────────────────────────────────────────────────┐
│  L2.5  边缘层(Edge)          ▼                                                  │
│   ┌────────────────────────────────────────────────────────────────────┐         │
│   │                        边缘采集器 (Edge Collector)                   │         │
│   │  ┌──────────────────────────────────────────────────────────────┐  │         │
│   │  │ 1. 驱动层  Driver Manager                                     │  │         │
│   │  │    ModbusTcpDriver | S7Driver | MCDriver | OpcUaDriver        │  │         │
│   │  │    ─ 连接复用(一个 PLC 一条长连接,批量读)                    │  │         │
│   │  │    ─ 自动重连 + 指数退避                                       │  │         │
│   │  └──────────────────────────────────────────────────────────────┘  │         │
│   │  ┌──────────────────────────────────────────────────────────────┐  │         │
│   │  │ 2. 点表层  Tag Table(本地缓存,支持离线加载)                  │  │         │
│   │  │    设备 → 测点 → {地址, 数据类型, 字节序, 采集频率, 死区, 量程} │  │         │
│   │  └──────────────────────────────────────────────────────────────┘  │         │
│   │  ┌──────────────────────────────────────────────────────────────┐  │         │
│   │  │ 3. 调度层  Polling Scheduler                                  │  │         │
│   │  │    按频率分组:100ms组 / 1s组 / 10s组 / 60s组                  │  │         │
│   │  │    分组批量合并读(把连续地址合成一次报文)                     │  │         │
│   │  └──────────────────────────────────────────────────────────────┘  │         │
│   │  ┌──────────────────────────────────────────────────────────────┐  │         │
│   │  │ 4. 解码层  Decoder                                            │  │         │
│   │  │    字节 → 类型 → 工程量(RAW→EGU) → 质量码(GOOD/BAD)            │  │         │
│   │  │    (字节序处理详见 03-字节序与数据类型编码.md)                │  │         │
│   │  └──────────────────────────────────────────────────────────────┘  │         │
│   │  ┌──────────────────────────────────────────────────────────────┐  │         │
│   │  │ 5. 缓冲层  Local Store(断网续传的关键)                       │  │         │
│   │  │    内存环形队列 + 磁盘持久化(SQLite/LevelDB/文件)              │  │         │
│   │  └──────────────────────────────────────────────────────────────┘  │         │
│   │  ┌──────────────────────────────────────────────────────────────┐  │         │
│   │  │ 6. 上行层  Publisher(MQTT Client, QoS1, 自动重连, Clean=F)   │  │         │
│   │  └──────────────────────────────────────────────────────────────┘  │         │
│   │  ┌──────────────────────────────────────────────────────────────┐  │         │
│   │  │ 7. 写通道  Write Channel(独立鉴权 + 白名单 + 二次确认 + 审计)│  │         │
│   │  └──────────────────────────────────────────────────────────────┘  │         │
│   └────────────────────────────────────────────────────────────────────┘         │
└───────────────────────────────────┬─────────────────────────────────────────────┘
                                    │ MQTT over TLS (8883)
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│  L3  平台层(Cloud / 私有云)                                                     │
│                                                                                   │
│   ┌──────────────┐   ┌──────────────────┐   ┌───────────────────────────────┐    │
│   │ MQTT Broker  │──▶│   规则引擎/接入   │──▶│      时序数据库                 │    │
│   │ EMQX/Mosquitto│   │  解析 · 校验 ·    │   │  TDengine / InfluxDB /         │    │
│   │              │   │  告警判定 · 转发   │   │  IoTDB / ClickHouse            │    │
│   └──────────────┘   └──────────────────┘   └───────────────┬───────────────┘    │
│          ▲                     │                            │                    │
│          │                     ▼                            ▼                    │
│          │            ┌──────────────────┐      ┌──────────────────────────┐    │
│          │            │   业务关系库      │      │  数据服务 API             │    │
│          └────────────│ MySQL/PostgreSQL │      │  最新值 / 历史 / 聚合     │    │
│        下行指令        │ 设备·产品·点表·用户│      └────────────┬─────────────┘    │
│                       └──────────────────┘                     │                 │
│                                                                ▼                 │
│   ┌──────────────────────────────────────────────────────────────────────────┐  │
│   │ 应用层:实时看板 | 报警中心 | OEE 分析 | 能耗管理 | 工艺追溯 | 预测性维护  │  │
│   └──────────────────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────────────────┘

各层的职责边界(重要)

层 必须做的事 绝对不要做的事
设备 提供数据 被采集平台写崩
驱动 建连、报文收发、超时重试、解码 理解业务语义(它只该知道"读 20 号寄存器 10 个字")
点表 把地址翻译成业务语义 放在采集器进程的内存里(必须持久化)
调度 控频、合批、错峰 无节制并发(会把 PLC 打死)
缓冲 断网时兜住数据 无限增长(必须有限额和落盘策略)
Broker 传输 做业务计算
时序库 存时序、做降采样 存设备主数据(那是关系库的事)

7. 点表(Tag)与测点模型设计

7.1 什么是点表

**点表(Tag Table / 测点表 / 位号表)**是"设备内部地址"与"业务语义"之间的映射字典。它是采集系统里最核心、也最容易被低估的设计。

一个测点至少要说清楚这 9 件事:

要素 说明 示例
设备 属于哪台物理设备 INJ-01(1 号注塑机)
测点标识 平台内唯一标识 INJ-01.BarrelTemp.Zone1
字段/地址 协议相关的定位信息 S7: DB3.DBD10;Modbus: HR:400101;MC: D100
数据类型 决定读几个字节、怎么解释 FLOAT32
字节序 决定多字节怎么拼 CDAB(详见 03 篇)
采集频率 多久读一次 1000ms
读写属性 RO / WO / RW RO
量程与缩放 RAW → EGU 的线性变换 RAW 0~27648 → 0~300.0 ℃
死区与精度 变化多少才上报、保留几位小数 死区 0.5,精度 1 位
质量与单位 单位、质量码 ℃, GOOD

7.2 测点表结构设计(关系库)

-- 设备表
CREATE TABLE device (
    id            BIGINT PRIMARY KEY,
    device_key    VARCHAR(64)  NOT NULL COMMENT '平台内唯一标识',
    device_name   VARCHAR(128) NOT NULL,
    product_key   VARCHAR(64)  NOT NULL COMMENT '所属产品/型号',
    protocol      VARCHAR(32)  NOT NULL COMMENT 'MODBUS_TCP / S7 / MC / OPCUA',
    conn_params   TEXT         COMMENT 'JSON: host,port,slot,rack,unitId,timeout...',
    poll_enabled  TINYINT      DEFAULT 1,
    status        VARCHAR(16)  DEFAULT 'OFFLINE',
    created_at    DATETIME,
    UNIQUE KEY uk_device_key (device_key)
);

-- 测点定义表(核心)
CREATE TABLE tag (
    id             BIGINT PRIMARY KEY,
    device_id      BIGINT       NOT NULL,
    tag_key        VARCHAR(64)  NOT NULL COMMENT '测点标识,如 BarrelTemp.Zone1',
    tag_name       VARCHAR(128) COMMENT '中文名:料筒1区温度',
    address        VARCHAR(128) NOT NULL COMMENT '协议地址: DB3.DBD10 / 400101 / D100',
    data_type      VARCHAR(32)  NOT NULL COMMENT 'BOOL/INT16/UINT16/INT32/UINT32/FLOAT32/DOUBLE64/STRING/BCD',
    byte_order     VARCHAR(8)   NOT NULL DEFAULT 'ABCD' COMMENT 'ABCD/BADC/CDAB/DCBA',
    bit_index      TINYINT      DEFAULT NULL COMMENT 'BOOL 类型时在字内的位号 0-15',
    access_mode    VARCHAR(8)   NOT NULL DEFAULT 'RO' COMMENT 'RO/WO/RW',
    -- 采集策略
    collect_mode   VARCHAR(16)  NOT NULL DEFAULT 'POLL' COMMENT 'POLL/RBE/SUBSCRIBE/TRIGGER',
    poll_interval  INT          NOT NULL DEFAULT 1000 COMMENT '采集周期 ms',
    deadband       DOUBLE       DEFAULT NULL COMMENT '死区,变化超过该值才上报',
    -- 工程量转换
    raw_min        DOUBLE       DEFAULT NULL COMMENT '原始值下限',
    raw_max        DOUBLE       DEFAULT NULL,
    eng_min        DOUBLE       DEFAULT NULL COMMENT '工程量下限',
    eng_max        DOUBLE       DEFAULT NULL,
    scale_factor   DOUBLE       DEFAULT 1 COMMENT '线性系数 a (EGU = a*RAW + b)',
    scale_offset   DOUBLE       DEFAULT 0 COMMENT '线性偏移 b',
    -- 展示与校验
    unit           VARCHAR(16)  COMMENT '单位 ℃/MPa/kW',
    precision_num  TINYINT      DEFAULT 2 COMMENT '小数位数',
    value_min      DOUBLE       DEFAULT NULL COMMENT '合理范围下限(越界判 BAD)',
    value_max      DOUBLE       DEFAULT NULL,
    enum_map       TEXT         COMMENT 'JSON: {"0":"停止","1":"运行","2":"故障"}',
    -- 扩展
    group_name     VARCHAR(64)  COMMENT '分组,便于批量管理',
    enabled        TINYINT      DEFAULT 1,
    remark         VARCHAR(256),
    UNIQUE KEY uk_device_tag (device_id, tag_key),
    INDEX idx_device (device_id)
);

-- 测点最新值表(热表,供实时看板秒级读取)
CREATE TABLE tag_latest (
    device_id    BIGINT,
    tag_key      VARCHAR(64),
    raw_value    VARCHAR(64)   COMMENT '原始值(保留便于排查)',
    eng_value    DOUBLE        COMMENT '工程量值',
    str_value    VARCHAR(256)  COMMENT '字符串/枚举值',
    quality      VARCHAR(16)   DEFAULT 'GOOD' COMMENT 'GOOD/BAD/STALE/UNCERTAIN',
    ts_device    DATETIME(3)   COMMENT '设备侧时间戳(若设备提供)',
    ts_collect   DATETIME(3)   COMMENT '采集时刻(边缘)',
    ts_server    DATETIME(3)   COMMENT '平台入库时刻',
    PRIMARY KEY (device_id, tag_key)
);

7.3 Java 侧测点模型

package com.fastbee.iot.model;

import java.io.Serializable;

/**
 * 测点定义(点表条目)。
 *
 * 设计原则:
 * 1) 地址(address)、数据类型、字节序这三项是"协议相关"的,其余都是"业务相关"的;
 * 2) 采集器只依赖这个对象,不依赖任何具体协议实现;
 * 3) 这个对象必须可序列化 —— 它要能下发到边缘采集器并持久化。
 */
public class TagDefinition implements Serializable {

    private static final long serialVersionUID = 1L;

    /** 数据类型枚举 */
    public enum DataType {
        BOOL, BYTE, INT16, UINT16, INT32, UINT32, INT64,
        FLOAT32, DOUBLE64, STRING, BCD16, BCD32, DATE_TIME
    }

    /** 字节序:详见 03-字节序与数据类型编码.md */
    public enum ByteOrder { ABCD, BADC, CDAB, DCBA }

    /** 采集模式 */
    public enum CollectMode { POLL, RBE, SUBSCRIBE, TRIGGER }

    /** 读写属性 */
    public enum AccessMode { RO, WO, RW }

    private String     tagKey;        // 测点唯一标识
    private String     tagName;       // 中文名
    private String     address;       // 协议地址,如 "DB3.DBD10" / "400101" / "D100"
    private DataType   dataType;      // 数据类型
    private ByteOrder  byteOrder = ByteOrder.ABCD;
    private Integer    bitIndex;      // BOOL 位号
    private Integer    length;        // 字符串长度 / 读取字(寄存器)数
    private AccessMode accessMode = AccessMode.RO;
    private CollectMode collectMode = CollectMode.POLL;
    private int        pollIntervalMs = 1000;
    private Double     deadband;      // 死区
    private Double     rawMin;        // RAW 下限
    private Double     rawMax;        // RAW 上限
    private Double     engMin;        // EGU 下限
    private Double     engMax;        // EGU 上限
    private double     scaleFactor = 1.0;
    private double     scaleOffset = 0.0;
    private String     unit;
    private int        precision = 2;
    private Double     valueMin;      // 合理值下限(越界 → BAD)
    private Double     valueMax;
    private String     groupName;

    /**
     * RAW → EGU 线性变换。
     * 优先使用 rawMin/rawMax/engMin/engMax 的两点标定;
     * 未配置量程时退化为 EGU = a * RAW + b。
     */
    public double toEng(double raw) {
        if (rawMin != null && rawMax != null && engMin != null && engMax != null
                && Math.abs(rawMax - rawMin) > 1e-9) {
            return engMin + (raw - rawMin) * (engMax - engMin) / (rawMax - rawMin);
        }
        return scaleFactor * raw + scaleOffset;
    }

    /** EGU → RAW 反向变换(写操作前必须调用,把工程量还原成设备码值) */
    public double toRaw(double eng) {
        if (rawMin != null && rawMax != null && engMin != null && engMax != null
                && Math.abs(rawMax - rawMin) > 1e-9) {
            return rawMin + (eng - engMin) * (rawMax - rawMin) / (engMax - engMin);
        }
        if (Math.abs(scaleFactor) < 1e-9) {
            throw new IllegalStateException("scaleFactor 为 0,无法反算: " + tagKey);
        }
        return (eng - scaleOffset) / scaleFactor;
    }

    /** 值是否在合理范围内(超出判为 BAD 质量码,而不是直接丢弃) */
    public boolean inRange(double eng) {
        return (valueMin == null || eng >= valueMin)
            && (valueMax == null || eng <= valueMax);
    }

    // ===== getter / setter 省略,实际项目用 Lombok 或 IDE 生成 =====
    public String getTagKey() { return tagKey; }
    public void setTagKey(String tagKey) { this.tagKey = tagKey; }
    public String getAddress() { return address; }
    public void setAddress(String address) { this.address = address; }
    public DataType getDataType() { return dataType; }
    public void setDataType(DataType dataType) { this.dataType = dataType; }
    public ByteOrder getByteOrder() { return byteOrder; }
    public void setByteOrder(ByteOrder byteOrder) { this.byteOrder = byteOrder; }
    public CollectMode getCollectMode() { return collectMode; }
    public void setCollectMode(CollectMode collectMode) { this.collectMode = collectMode; }
    public int getPollIntervalMs() { return pollIntervalMs; }
    public void setPollIntervalMs(int pollIntervalMs) { this.pollIntervalMs = pollIntervalMs; }
}

7.4 Modbus 地址模型(最容易搞错的一块)

Modbus 用"区号 + 偏移"定位数据,而且协议报文里的地址是 0 基偏移,人类书写的地址是 1 基。这个 1 的差值是新手第一大坑。

区 人类写法 含义 读写 功能码 报文内偏移(0 基)
0x 00001 ~ 09999 线圈 Coil(位) 读写 01 读 / 05 写单 / 0F 写多 00001 → 0
1x 10001 ~ 19999 离散输入 Discrete Input(位) 只读 02 10001 → 0
3x 30001 ~ 39999 输入寄存器 Input Register(16 位字) 只读 04 30001 → 0
4x 40001 ~ 49999 保持寄存器 Holding Register(16 位字) 读写 03 读 / 06 写单 / 10(0x10) 写多 40001 → 0
  例:点表上写 "400101",实际报文里要填 100(十进制)或 0x0064
  换算:offset = 人类地址 - 40001 = 400101 - 400001 = 100
        (注意五位数与六位数的基数不同:40001 系列基数 40001;
          400001 系列基数 400001,两种写法都存在于现场,必须确认)

  常用功能码:
    0x01 读线圈            0x02 读离散输入
    0x03 读保持寄存器      0x04 读输入寄存器
    0x05 写单个线圈        0x06 写单个保持寄存器
    0x0F 写多个线圈        0x10 写多个保持寄存器
    0x17 读/写多个寄存器(少见)
    异常响应:功能码 | 0x80,后续 1 字节异常码
             常见异常码 0x01 非法功能 / 0x02 非法数据地址 / 0x03 非法数据值

强烈建议:点表里存的永远是**"报文偏移量(0 基)+ 区号"**,而不是 400101 这种字符串。字符串地址在各家设备上歧义太大(S7 的 DB3.DBW10、MC 的 D100、Modbus 的 400101 完全不同),让点表存结构化字段,UI 层再负责渲染成人类可读格式。


8. 采集模式:轮询 / 变化上报 / 订阅 / 触发

模式 原理 实时性 带宽占用 实现复杂度 适用协议/场景
轮询 (Polling) 采集器按固定周期主动发读请求 取决于周期,≤周期延迟 高(大量无效读) 低 Modbus RTU/TCP、S7comm、MC 的默认模式;绝大多数现场
变化上报 (RBE, Report by Exception) 设备/采集器只在值变化(或超死区)时上报 高(变化时立即) 低 中(需死区配置 + 缓存最新值) 适用于慢变量(温度、液位、累计量);也可在采集器侧"轮询 + 死区过滤"模拟
订阅 (Subscribe) 建立会话后由服务端主动推送变化 高(10~100ms 级) 低(只推变化) 高(会话管理、存活检测、重订阅) OPC UA Subscription、EtherNet/IP implicit、Profinet cyclic
触发采集 (Trigger) 外部事件触发一次性采集 事件驱动,延迟最低 极低 中(需事件源) 条码扫描后读 PLC、工单下达时抓快照、报警时抓上下文

带宽与实时性量化对比

假设 1000 个测点,平均每个值变化周期 10 秒,单条上报负载 60 字节:

模式 采集周期 每秒请求数 每秒上行流量 平均延迟 评价
纯轮询 1s 1000ms 1000 次(合批后约 50~200 报文) ~60 KB/s(全量上报) ≤1s 简单,但 90% 数据是重复的
纯轮询 10s 10s 100 次 ~6 KB/s ≤10s 慢变量够用,快变量丢细节
轮询 1s + 死区上报 1000ms 1000 次读,~100 条上报 ~6 KB/s ≤1s 推荐折中:读得勤、传得省
订阅 (OPC UA) 由服务端决定 0(服务端推) ~6 KB/s 10~500ms 最优,但依赖设备支持与 Server 性能
触发 事件驱动 不定 极低 事件后立即 特殊场景补充

工程实践结论:在 Modbus/S7/MC 这类"只能轮询"的协议上,"高频轮询 + 边缘死区过滤 + 变化上报"几乎总是最优解。原因:读一次的边际成本很低(一条报文能读 100 多个寄存器),而上云的边际成本高(流量、存储、数据库写入)。所以把判断放在边缘,把传输留给变化。

合批(Batching)是轮询的生命线

  错误做法:100 个测点,每个点一条读报文
    → 100 次 TCP 往返,PLC 通信负载 100 倍,延迟 = 100 × RTT

  正确做法:按"设备 + 区 + 地址连续性"合并
    地址 400101~400110 连续 → 1 条报文读 10 个寄存器
    地址 400150~400155 连续 → 1 条报文读 6 个寄存器
    → 2 条报文搞定,延迟 = 2 × RTT

  合批约束(务必注意):
    · Modbus 单次最多读 125 个保持寄存器(0x7D),超过必须拆分
    · S7comm 单次 PDU 大小受协商限制(S7-1200 常见 240 字节,需实测确认)
    · MC 协议单次读取点数也有限制(按位/按字单位不同,需实测确认)
    · 跨"不连续地址"不要硬合:读 1000 个寄存器只为拿其中 2 个是浪费
    · 不同采集频率的点不要放进同一批

9. 采集质量指标:怎么衡量"采得好不好"

没有指标就没法验收。交付项目时,下面这张表应该写进技术协议。

指标 定义 典型目标值 测量方法
采集周期 (Cycle) 同一测点两次成功采集的间隔 配置值 ±10% 统计相邻两条数据时间戳差值的分布
周期抖动 (Jitter) 实际周期的标准差 / P99 偏差 P99 ≤ 周期 × 1.5 对周期序列求 P50/P95/P99
采集成功率 成功读数次数 / 总尝试次数 ≥ 99.9% 驱动层埋点计数
丢包率 未成功上报到平台的数据条数 / 总采集条数 ≤ 0.1%(断网续传后趋近 0) 边缘计数 vs 平台入库计数对比
断线重连时间 从检测到断线到恢复采集的耗时 ≤ 30s(TCP 场景通常 ≤10s) 模拟断网,记录恢复时刻
数据完整率 指定时间窗内应有数据点数 / 实际点数 ≥ 99% 按测点、按小时统计空洞
端到端延迟 设备值变化 → 平台可见 ≤ 3 × 采集周期 打时间戳对比(需时钟同步)
时钟偏差 采集器与平台时钟差 ≤ 1s(用 NTP 后可 ≤100ms) NTP 对时,定期巡检
PLC 通信负载 采集占用的 PLC 通信资源占比 通信负载 ≤ 30% PLC 侧诊断(西门子 CPU 的通信负载率)

时间戳:这是最容易被忽略、事后最难追的坑

一条数据至少要带 3 个时间戳,而且必须分开存:

  ts_device    设备侧时间戳  设备自己打的时间(如 PLC 的 DATE_AND_TIME)
               ├─ 优点:反映"这个值是设备什么时候产生的"
               └─ 缺点:设备时钟可能不准、可能没对时、可能为 0

  ts_collect   采集时刻      边缘采集器读到这个值的本地时刻
               ├─ 优点:一定有,反映采集行为的真实节奏
               └─ 缺点:包含通信延迟,不是物理量发生的精确时刻

  ts_server    入库时刻      平台收到并落库的时刻
               ├─ 优点:一定有,用于排查链路延迟
               └─ 缺点:受网络与消息队列积压影响,抖动最大

判断原则:


10. 采集难点清单:现场踩坑总纲

这一节是本篇最值钱的部分。下面每条都对应真实项目里会遇到的麻烦。

# 难点 具体表现 应对思路
1 协议碎片化 同一个"读寄存器",Modbus/S7/MC 的地址格式、报文、错误码完全不同;同品牌不同系列还不一样 抽象统一 Driver 接口;协议差异收在驱动内部;点表用结构化字段而非字符串
2 字节序与字序 32 位值读出来是 1.2e-38 这种天文数字 点表显式配置 ABCD/BADC/CDAB/DCBA;详见 03-字节序与数据类型编码.md
3 位操作 一个 16 位寄存器打包 16 个 bool;一个字节里 8 个报警位 点表支持 bitIndex;位序(MSB/LSB)要单独配置
4 寄存器地址映射 400101 是 0 基还是 1 基?DB3.DBW10 的 DB 是不是优化块? 统一为"0 基偏移 + 区号";S7 优化块需关闭优化或走符号寻址
5 网络不稳 现场交换机老化、网线被叉车压断、4G 信号波动、WiFi 干扰 TCP 长连接 + 心跳 + 指数退避重连 + 边缘缓存
6 连接数上限 PLC 同时能承载的 TCP 连接数非常有限(S7-1200 的通信连接资源受型号/固件限制,工程上常按 8 路左右估算,需实测确认);一个 PLC 被 HMI + SCADA + 采集器 + 调试 PG 抢连接 每个 PLC 只开一条连接并复用;采集器做连接池;不要为每个测点建连接
7 写入安全 写错地址 → 设备误动作;写错值 → 工艺事故;写得太快 → 覆盖人工操作 写通道独立鉴权;地址白名单;量程校验;二次确认;写前读回校验;全量审计日志
8 脏数据 设备掉电瞬间寄存器是随机值;通信中断期间值是旧的 质量码机制(GOOD/BAD/STALE);合理范围校验;断线期间标记 STALE 而非沿用旧值
9 数据语义缺失 拿到 20150 但不知道是温度还是压力 点表是唯一解,必须有
10 时钟不同步 设备、边缘、平台三个时钟各走各的 NTP 强制对时;三个时间戳分开存
11 老设备无文档 手册丢失、厂家倒闭、程序无源码 抓包反推(详见 02-协议栈与工业网络.md);Wireshark + 穷举地址
12 干扰与接地 RS485 通信时好时坏,跟天气/开机有关 屏蔽双绞线、单点接地、终端电阻 120Ω、远离动力线
13 PLC 扫描周期 采得快没用,PLC 本身 20ms 才刷新一次数据 采集周期 ≥ PLC 扫描周期 × 2;理解"数据新鲜度"上限
14 授权与合规 甲方要求不连生产网、要求等保、要求数据不出厂 单向网闸/工业防火墙;DMZ 区部署;数据脱敏

关于"写操作安全"的专门说明

写通道必须和读通道是两个独立的东西,至少要做到:

  1. 权限隔离:读写权限分开授予,默认所有点只读
  2. 地址白名单:只能写点表里 accessMode 含 W 的地址
  3. 值域校验:先过 engMin/engMax,再过 valueMin/valueMax
  4. 类型校验:FLOAT32 的点不能写 100000 这种溢出值
  5. 二次确认:关键写操作(启停、设定值)要求带 ticket/token
  6. 写前读回:写入后立即读回比对,不一致要报警
  7. 限速:同一地址单位时间内的写次数上限(防程序 BUG 疯狂写)
  8. 审计:谁、什么时候、写了哪个点、从什么值到什么值、来源 IP
  9. 会话绑定:写操作必须能追溯到具体用户会话,禁止匿名写
 10. 物理联锁:真正危险的动作(启动电机、开阀)应由 PLC 程序做最终判断,
     采集平台只下发"请求",不直接驱动执行器

11. 数据上云:MQTT 主题、QoS、断网续传与压缩

11.1 MQTT 主题设计

主题(Topic)是 MQTT 的路由键,设计得好能省掉大量后端逻辑。推荐的分层结构:

  {租户}/{产品}/{设备}/{消息类型}

  遥测(时序数据,量最大)
    iot/{tenantId}/{productKey}/{deviceKey}/telemetry
  属性(设备静态/半静态属性)
    iot/{tenantId}/{productKey}/{deviceKey}/attributes
  事件(报警、状态变化,低频但重要)
    iot/{tenantId}/{productKey}/{deviceKey}/event
  命令下发响应
    iot/{tenantId}/{productKey}/{deviceKey}/cmd/reply/+ 
  生命周期(上线/下线/心跳)
    iot/{tenantId}/{productKey}/{deviceKey}/lifecycle

  下行(平台 → 设备)
    iot/{tenantId}/{productKey}/{deviceKey}/cmd/down
    iot/{tenantId}/{productKey}/{deviceKey}/config/down   # 点表下发

设计要点:

11.2 Payload 结构

{
  "msgId": "9f2c1a80-7b11-4e2a-9c31-001122334455",
  "deviceKey": "INJ-01",
  "ts": 1717171717000,
  "type": "telemetry",
  "values": [
    { "tag": "BarrelTemp.Zone1", "v": 218.6, "q": 0, "ts": 1717171716980 },
    { "tag": "BarrelTemp.Zone2", "v": 221.1, "q": 0, "ts": 1717171716980 },
    { "tag": "MotorCurrent",     "v": 32.45, "q": 0, "ts": 1717171716980 },
    { "tag": "RunStatus",        "s": "RUNNING", "q": 0, "ts": 1717171716980 },
    { "tag": "AlarmCode",        "v": 0, "q": 1, "ts": 1717171716980 }
  ]
}

字段说明:

11.3 QoS 选择

QoS 语义 报文开销 适用场景 工业采集建议
0 至多一次,fire and forget 1 个报文 高频可丢数据 100ms 级振动数据、视频流元数据
1 至少一次,可能重复 2 个报文(PUBLISH + PUBACK) 绝大多数遥测 推荐默认
2 恰好一次 4 个报文(PUBLISH/PUBREC/PUBREL/PUBCOMP) 不允许重复的关键消息 写指令响应、计费数据

判断:遥测用 QoS 1 是最优解。QoS 2 的四次握手在弱网下会显著增加延迟和重传风暴,而"重复"的问题在时序库侧更好解决——让 payload 带唯一 msgId,入库时做幂等去重(或利用时序库对相同时间戳的覆盖语义)。

11.4 断网续传(边缘缓存)

这是边缘采集器必须具备的能力,也是区分"玩具"和"产品"的分水岭。

  设计要点:

  1. 分层缓冲
     内存环形队列(快,断电即失)
        ↓ 队列水位 > 80%
     磁盘持久化(SQLite / LevelDB / 分片文件)
        ↓ 磁盘配额 > 阈值
     丢弃策略(优先丢最老的,或优先丢高频低价值点)

  2. 配额管理
     按磁盘剩余空间动态计算配额(如最多 1 GB 或 100 万条,取小值)
     关键报警事件单独队列,永不被遥测挤掉

  3. 恢复策略
     重连后限速补传(避免瞬间打爆 Broker 和数据库)
     补传与实时数据分流:实时数据优先,补传走低优先级队列
     补传数据打 late 标记,写入时用 ts_collect 作为时间戳

  4. 一致性
     本地队列必须持久化 msgId 与写入位点,防止重复补传
     上报成功(收到 PUBACK)后才从队列删除

  5. 简单实现示例(Java 伪结构,可直接落为代码)
     ┌──────────────────────────────────────────────┐
     │ class OfflineBuffer {                        │
     │   ArrayBlockingQueue<Packet> memQueue;       │
     │   DiskQueue diskQueue;      // 溢出落盘       │
     │   AtomicBoolean online;                      │
     │   void offer(Packet p) {                     │
     │     if (online.get() && memQueue.offer(p)) { │
     │       publishAsync(p);                       │
     │     } else {                                 │
     │       diskQueue.append(p);                   │
     │     }                                        │
     │   }                                          │
     │   void onReconnect() {                       │
     │     while (diskQueue.hasMore() && online) {  │
     │       publishSync(diskQueue.peek());         │
     │       diskQueue.remove();                    │
     │       rateLimit();                           │
     │     }                                        │
     │   }                                          │
     │ }                                            │
     └──────────────────────────────────────────────┘

11.5 时序数据压缩

方法 原理 压缩比 适用场景
死区过滤 变化 < 阈值不上报 5~50× 慢变量(温度、液位),最实用
变化率过滤 变化速率 < 阈值不上报 2~10× 缓慢漂移的量
批量打包 多条合并成一个 payload 2~5×(省协议头) 通用
数值精度裁剪 218.6345678 → 218.6 1.5~3× 展示类数据
JSON → Protobuf/CBOR 二进制编码代替文本 3~8× 带宽紧张(4G 按流量计费)
时间戳差分 (Delta) 存时间差而非绝对时间 2~5× 固定周期采集
列式压缩(库侧) TDengine/IoTDB 内置 5~20× 存储侧,与传输无关
Delta-of-Delta + XOR Facebook Gorilla / InfluxDB 浮点压缩 10×+ 库侧自动完成

实践建议:不要一开始就把压缩做得太复杂。先做死区过滤 + 批量打包这两件,通常就能解决 80% 的问题。Protobuf 改造收益大但会让调试变痛苦(抓包看不懂),只在 4G/NB-IoT 按流量计费的场景才值得。


小结


常见误区


本文是《工业通信与数据采集》栏目第 1/22 篇 · 栏目总览 | 下一篇:协议栈与工业网络