前两年接了一条农产品加工产线的数据采集项目,客户现场用的上位机是一套老牌商用组态软件。功能确实全,但每年的授权费相当可观,点位一超就要再买授权,通信协议是个黑盒,想接自己的算法模块根本无从下手。当时我就下决心,与其被商用方案绑死,不如自己动手做一套真正够用的轻量级监控系统。于是就有了SuperSCADA这个项目,配套的人机界面叫SuperHMI。后来这套技术底板继续演化,又拆出了面向标准化交付的TopSCADA和面向轻量组态场景的TopHMI两个产品形态。
说实话,自研SCADA这件事,在工业自动化圈子里争议挺大。多数人觉得“为什么不直接用现成的”,但真到了项目现场,你才会明白商用方案那些看不见的成本有多高。这篇文章就把我在这套系统上踩过、填过、沉淀下来的东西完整写一遍,从通信采集、实时库、历史存储,到前端组态引擎、报警引擎,再到上线压测的坑。内容偏实操,适合正在考虑自研监控平台、或者对SCADA内部机制感兴趣的工程师参考。
1. 为什么要自研一套轻量级SCADA:商用方案的痛点与我的取舍
1.1 商用组态软件用起来到底哪里难受
很多年长的工程师习惯了WinCC、组态王、iFix这类软件,觉得它们成熟、稳定、有问题有人兜底。我承认大厂的SCADA平台上限很高,特别是做大型DCS系统、几千上万点位的场景,它们有完整生态。但中小型项目里,商用方案的问题非常具体。
首先是授权费用。商用SCADA普遍按点位收费,几百个点位的项目授权费就是一笔不小的开销,加上冗余备份、Web发布、历史报表这些模块通常还要单独付费。其次是实施效率,这类软件配置一个画面往往要在编辑器里反复点鼠标,图元绑定、动画关联、脚本事件,一套流程下来并不比写代码快。最后是跨平台和二次开发的问题,很多老牌软件的运行环境锁定Windows,想要在Linux服务器上部署就很别扭;想做业务集成、想接自己的算法、想和MES/ERP做深度数据交互,走官方SDK的话又是一轮学习成本。
开源SCADA类项目也存在问题。大多数开源项目的核心开发者只有一两个人,文档不全,协议栈老旧,系统架构十多年没什么大变化。真正出问题时,只能自己啃源码,那和自研有什么区别。
1.2 技术选型:全栈用哪些东西搭起来的
SuperSCADA的技术栈我基本上是按“部署简单、资源占用低、二次开发顺手”这三个原则选的。
后端用的Go。理由很直接:编译出来就是一个静态二进制,扔到工控机上直接跑,不需要装运行时,内存占用比Java系低一大截,而且goroutine模型天然适合处理大量设备连接和并发轮询。C++性能确实更好,但开发效率、第三方生态、团队招人都有点跟不上。
数据这块分成实时库和历史存储两层。实时值保存在进程内存中,用哈希表加读写锁实现,保证毫秒级访问;历史数据先写内存环形缓冲,再批量落盘到SQLite。后来点位增加、报表需求变多之后,我在新版本里加了PostgreSQL和TDengine的适配接口,满足不同现场的需求。
前端HMI选择了Vue 3加TypeScript,画面渲染用了SVG和Canvas组合的方案。通信用WebSocket做实时推送,HTTP REST做配置管理和历史查询。Mobile端其实就是同一个Web应用,只是做了响应式适配,方便现场人员在平板上看画面和确认报警。
1.3 Super、Top两条产品线的定位差异
这套系统我取了四个名称:SuperSCADA、SuperHMI、TopSCADA、TopHMI。很多人第一次看到会懵,觉得是不是做了两套重复的系统。实际上不是,这里有一条明确的产品线逻辑。
SuperSCADA是整个平台的后端核心代号,包含采集、存储、报警、接口等全部服务端能力。SuperHMI是配套的Web组态与运行界面,负责画面编辑、数据可视化、操作控制。两者合在一起,是一套完整的自研SCADA解决方案。
TopSCADA和TopHMI是在Super这套底座上做的两种交付形态。TopSCADA强调标准化,把轮询、存储、报警等能力固化成模板,适合代理集成商按标准模式快速交付。TopHMI则主打轻量组态,不要求使用者写任何脚本,用预置的模板和组件库组合出监控画面,适合产线局部改造、单机设备监控这类轻应用场景。
一句话总结:Super是技术内核,Top是面向市场的封装。内核只有一套,但交付路径可以按项目档次灵活选择。
2. 通信采集层:Modbus这关过不了,其他都是扯淡
2.1 为什么通信层决定了SCADA的天花板
SCADA系统做的是“现场设备数据上来、控制指令下去”这件事,通信采集层就是数据的水龙头。水龙头没接好,后面实时库再快、HMI画面再炫,全部白搭。
工业现场协议种类非常多。通用一点的有Modbus RTU/TCP、OPC UA、MQTT、S7comm、PROFINET,行业专用的还有DLT645(电表)、CJ/T188(水表)、BACnet(楼宇)等。SuperSCADA第一版先实现了Modbus RTU/TCP和MQTT,后续按项目需要逐步加了OPC UA和S7comm。
选择Modbus优先没有任何悬念:它是工业自动化领域事实上的通用语言。PLC、仪表、变频器、智能电表,几乎每个设备都会提供Modbus接口,只要把Modbus吃透,系统就能覆盖大多数采集需求。
2.2 驱动管理器:通道、设备、点位三层模型
这套采集架构我把模型设计成了三层:通道(Channel)、设备(Device)、点位(Tag)。
通道代表一条物理或逻辑链路。一个Modbus TCP通道对应一个IP端口;一个Modbus RTU通道对应一个串口和波特率参数。通道的职责是管理连接生命周期、超时、重试和并发限制。设备挂在通道下,Modbus里就是从站地址(Slave ID)。一个通道下面可以挂多个设备,只要它们的地址参数配置正确。点位是最终的数据项,有寄存器地址、数据量类型、读写属性、缩放系数这些属性标签。
这样三层模型的好处是配置直观,和现场物理拓扑完全对应。排查问题的时候,看一眼配置就知道数据链路走的是哪条通道、哪个设备,不用猜。
2.3 轮询调度与合并读:减少报文就是减少故障
刚开始做采集驱动时,我用的是最简单的顺序轮询:每个点位依次发送读取请求。点位数量少的时候没什么感觉,但点位一多,比如一台设备有两三百个寄存器要采集,按5秒周期轮询,报文数量就变得非常大,现场总线负荷高不说,还容易触发设备通信超时。
后来我改了轮询调度算法,现在是按“连续读区间合并”来规划报文。也就是说,一台设备的点位表里那些地址连续的寄存器,会合并成一次“批量读”请求。比如某个从站的保持寄存器从0x0000到0x0050整段需要采集,我就直接用0x03功能码,发送起始地址0x0000、长度0x0051的请求,一次报文把整段都读回来,再在本地按点位表拆分。
合并之后报文数量大幅下降。以前一台200点位的设备需要发出200次请求,合并后大概只需要30到40次,整体轮询速度提升非常明显。需要注意的一点是,有的老PLC对单次请求的寄存器数量有限制(比如Modbus标准建议单次不超过125个寄存器),我在驱动里做了可配置的最大合并长度,默认120,遇到奇怪的设备可以调小。
代码层面,轮询调度的核心逻辑大概是这样:
type PollSchedule struct { Device *Device ReadPlans []ReadPlan Interval time.Duration lastPoll time.Time inProgress bool } type ReadPlan struct { FuncCode byte StartAddr uint16 Quantity uint16 Tags []*Tag // 映射回点位 } func (s *PollSchedule) BuildPlans() { // 将点位按功能码分组,再按地址排序 // 遍历排序后的点位,将连续地址区间合并为一个ReadPlan // 限制每个ReadPlan的Quantity不超过MaxReadQuantity }每个通道有独立的goroutine循环,调度器根据各设备的PollInterval决定何时发送请求,响应回来后通过回调把数据分发给实时库。这样不同通道之间天然并发,不会因为某一台设备慢而拖垮整个系统。
2.4 数据解析的几个大坑:字节序、缩放、无符号数
通信协议里最容易出错的地方不在通信,而在数据解析。Modbus返回的报文是原始字节流,怎么解释成直观的工程值,是每个SCADA工程师都绕不过去的坎。
首先是字节序(Byte Order)。Modbus协议规定寄存器的高字节在前(big-endian),但不同PLC在组合32位浮点数或32位整数时,寄存器之间的顺序差别很大。西门子PLC和施耐德PLC处理同样的浮点数,在Modbus上呈现的寄存器高低字顺序可能是反的。也就是说,同样读4个字节,有的设备是按“字1高字节、字1低字节、字2高字节、字2低字节”排列,有的则是“字2高字节、字2低字节、字1高字节、字1低字节”。我在点位属性里加了字节序配置项,支持ABCD、BADC、CDAB、DCBA四种排列模式,遇到任何品牌的设备都能适配。
其次是缩放系数。很多仪表内部是整数存储,工程值是毫安、摄氏度还是MPa,需要乘一个系数。比如一个压力变送器的量程是0到1.6MPa,对应数据0到16000,那么缩放系数就是0.0001。这个也是点位属性里必须有的配置项。
再有就是无符号数和溢出。有些电表的功率值会超过32767,如果按有符号数来解析,读出来就变成负数了。我统一规定所有Modbus点位默认按无符号读,需要符号解析时显式配置。
为了把这些解析逻辑做干净,我在驱动层定义了一个Transform管道:原始字节流进入后,依次经过字节序转换、类型转换(Int16/UInt16/Int32/UInt32/Float32)、缩放计算,最终形成标准化数值交给上层。每个环节都是独立函数,方便单测。
3. 实时数据库与历史存储:纯内存环形缓冲和时序落盘的结构设计
3.1 实时库为什么不能用MySQL
第一次做SCADA的人容易犯一个错误:把所有采集数据直接写关系型数据库,用SELECT查最新值来刷页面。理论上这是可行的,但一旦点位数量上千、采集周期到秒级,MySQL这类通用关系型库的读写瓶颈立刻暴露,而且HMI页面要拿实时值,走SQL链路延迟非常高,画面一卡就是几百毫秒。
SCADA的实时数据访问有几个特点:高频写入、按TagKey随机读取、几乎不做事务操作。这正好是内存哈希表最擅长的场景。
SuperSCADA的实时库结构很简单,核心就是一张大哈希表。为了减少锁竞争,我没有用一个大锁保护整张表,而是采用了分段锁的思路,把哈希表按Key哈希值分成256个桶,每个桶有自己的读写锁。写值的时候,只锁对应的桶,其他桶完全不受影响。实测下来,在10000点位的规模下,单机写入吞吐轻松到十万级每秒,HMI页面读取周期值几乎零延迟。
3.2 实时值的状态管理:质量戳和时间戳
实时库里除了数值本身,应用程序还常关心这个值“可不可信”。现场经常遇到设备断线、点位超时、传感器异常这些情况,如果系统把这些异常值照单全收,HMI上就会出现离谱的数据和误报警。
为此,我为每个点位增加了Quality状态概念,值分为Good、Bad、Uncertain、Stale四种。通信层成功更新点位值时标记Good;轮询超时且超过重试次数后标记Bad;设备返回的数据超过了组态时设置的高低量程,则标记Uncertain;系统重启后、首轮采集尚未完成时,历史点位标记为Stale。HMI画面上的颜色着色、趋势曲线的阴影区域、报警引擎能否触发,全部参考Quality状态。这样数据是真是假,界面上一眼就能看出来。
时间戳归通信层管,每次数据包到达时,采集驱动打上服务器接收时间。这里特别注意,时间戳用的是服务器本地时间,不是设备侧时间,因为很多现场设备根本不校时,设备时钟慢十几分钟的事情很常见。历史数据的时间轴,我统一以服务器时间为准。
3.3 历史存储:环形缓冲、批量写、聚合降采样
历史数据存储设计的目标是“写得快、占得小、查得动”。
点位实时值从采集层到实时库之后,并不会立刻落盘。每个点位前面有一个环形缓冲(Ring Buffer),默认容量是120个秒级快照。历史归档线程每5秒扫描一次,把缓冲里的数据批量写入存储层。这样设计的一个好处是,当现场网络短暂断开又恢复时,断网期间的数据不会立刻丢失,缓冲能暂存一部分,恢复后再补写。
批量写入是性能关键。一条条INSERT的数据库性能非常差,但改成事务批量提交后,几千条记录一次提交,IO效率能提高一两个数量级。存储选型上,SQLite在处理十万到百万级的历史记录上表现完全够用,文件单机部署还方便备份。更大的数据处理我后面接了TDengine,列式存储加时序压缩,查询性能比SQLite强很多。
降采样这块也值得提一下。趋势曲线要显示一年内的历史数据时,原始秒级数据量太大,直接把全部记录拉到浏览器肯定卡死。我的做法是查询接口支持降采样参数,按天、小时、分钟自动聚合,返回MAX、MIN、AVG值,前端曲线用聚合值绘图,浏览器负载小,曲线轮廓也完整。
历史表的核心结构大致是:
CREATE TABLE IF NOT EXISTS history_data ( tag_key TEXT NOT NULL, ts INTEGER NOT NULL, value REAL NOT NULL, quality INTEGER NOT NULL, PRIMARY KEY (tag_key, ts) ); CREATE TABLE IF NOT EXISTS agg_hour ( tag_key TEXT NOT NULL, bucket INTEGER NOT NULL, avg_value REAL NOT NULL, min_value REAL NOT NULL, max_value REAL NOT NULL, sample_count INTEGER NOT NULL, PRIMARY KEY (tag_key, bucket) );实际查询时,按时间范围和tag_key走索引,性能没问题。我用一台树莓派4B做过测试:模拟3000点、5秒周期上报,连续跑48小时,SQLite文件增长约400MB,查询一周的曲线数据响应时间在2秒以内。这个数字在中小型项目里完全可以接受。
3.4 重启恢复:从文件回放历史与实时库预热
SCADA系统最怕的就是服务器重启,一旦内存里的实时值全没了,HMI画面上所有数据变成空白或Stale,要等下一轮采集周期才能恢复,这个恢复过程看起来非常业余。
我在实时库里做了快照机制,每30秒将当前所有点位的最新值、质量戳、时间戳异步写到内存映射文件中。系统启动时先加载这个快照,把点位预热到上一轮的状态,再启动采集驱动。这样HMI页面一打开,看到的不是空白数据,而是几秒前的现场值,采集恢复后数值自然刷新。这个细节在现场演示效果很加分,客户看到“上位机重启了数据还在”,信任感完全不一样。
4. SuperHMI组态引擎:为什么我没用现成Web组态,而是自己画了一套渲染层
4.1 市面上的Web组态和自研之间的账,怎么算都不亏
做HMI前端之前,我认真考察过市面上的Web组态产品。一类是和商用SCADA绑定的Web发布模块,功能强,但只能配合自己的平台用,而且价格不低。另一类是国产的通用Web组态框架,比如乐吾乐这类,拖拽式编辑做得确实好,但问题是它们的核心套件授权费用对几个中小项目来说还是偏高,并且二次开发和私有化部署受制于框架本身的API边界。
SuperHMI自研渲染引擎的出发点是“所有渲染行为都可控”。点位刷新、动画绑定、事件脚本、报警闪烁这些核心交互,我在原生Canvas和SVG层面自己实现,不依赖某个框架封装的黑盒。前期开发周期确实长了点,但后续迭代的自由度非常大,新项目加需求时不会被上游框架卡住。
4.2 SVG和Canvas的分工:静态基元用SVG,动态大数据用Canvas
SuperHMI在渲染方案上没有二选一,而是让SVG和Canvas各司其职。
画面里的阀门、泵、管道、储罐、仪表盘这类静态设备图元,用SVG绘制。SVG本身就是DOM节点,天然支持CSS样式、事件绑定和动画,而且矢量缩放不丢细节。像阀门的开到位绿、关到位红这种状态切换,直接用样式绑定就能实现,开发效率很高。工业现场的画面不会像游戏界面那样极端复杂,几百个SVG图元在浏览器里渲染毫无压力。
实时趋势曲线、历史曲线、大数据量表盘这类需要频繁重绘的动态元素,用Canvas来画。Canvas没有DOM节点,一帧就是一次绘制,处理几千个数据点的曲线刷新非常丝滑。SuperHMI里的趋势图组件是纯Canvas实现的,支持多曲线叠加、游标取值、缩放区间、量程自动匹配,实际体验下来流畅度比SVG方案高一个档次。
为了管理两类元素的混合渲染,我设计了一套统一的图元接口,每个图元都向上层暴露render、update、hitTest三个方法。SVG图元的update内部就是更新DOM属性,Canvas图元的update则把更新请求加入重绘画布列表。这样组态编辑器拖一个图元到画布上时,引擎根据图元类型自动决定走哪套渲染路径,使用者完全无感知。
4.3 数据订阅与动画刷新:WebSocket推送加帧合并
HMI页面刷新的设计是整个前端的关键。实时数据如果走HTTP轮询,几百毫秒间隔请求服务器,页面上几千个点位的情况下,浏览器和服务器都会很吃力。我用的是WebSocket长连接加增量推送。
SuperHMI有一个订阅管理器,保存当前页面所有活动点位集合,并建立“点位 → 订阅者”的映射。采集驱动每轮更新点位后,实时库会把变动点位的时间戳标记为脏(Dirty)。服务器端推送器每秒扫描一次脏点位表,把新增变动的数据压缩成一条消息推送给对应客户端。
推送频率固定为一秒一次,也就是说客户端页面最多一秒刷新一次数据。这里有个优化细节:每次画面切换时,订阅管理器会计算当前画面涉及的点位集合,只向服务器注册这些点位,离开画面就自动取消订阅。这样切到大画面时页面不会收到一堆用不上的点位推送,内存和CPU占用都稳得住。
动画这块处理上,我做得比较克制。阀门切换状态的变色动画,用了CSS transition来做过渡,时间设置200毫秒,太短看不见效果,太长显得拖沓。流量流动、液位波动这类连续动画则用requestAnimationFrame驱动Canvas绘制。对于数字跳动的画面,我不做滚动数字特效,直接一刀切更新数值,工业画面贵在清晰准确,动画特效只服务于状态表达,不纯粹为了炫。
4.4 组态编辑器:拖拽、图元属性、事件脚本的最小闭环
SuperHMI的编辑器和运行器是同一个渲染内核。打开组态编辑器,画布左边是图元面板,右边是属性面板,底部是事件脚本编辑器。
图元面板把常用设备图元分成基础、电气、工艺、仪表、管线五类,全部是SVG模板,拖拽就能放到画布上。每个图元的属性面板包含位置尺寸、填充颜色、可见性、旋转角度这些基础样式,最关键的是数据绑定配置。给一个阀门图元绑定点位之后,点位值变化时,图元会根据配置的映射规则变化样式,比如数值大于0.5时阀门开到位变绿。
事件脚本用的不是JavaScript而是我设计的一套轻量级公式语言HMIScript,支持简单的IF条件、算术表达式、字符串拼接和价值比较。这样做的原因是安全:完全开放JavaScript会让组态工程像自由发挥的程序,很容易写出不可控的代码,限制成一门小型DSL后,表达力对监控场景足够,安全性却大幅提升。
TopHMI形态其实是基于这套编辑器做的模板化封装。我在编辑器里内置了一套预置工程模板,用户只需要替换点位表和背景图,画面上绑定的点位自动映射,不需要从头搭画面,半小时就能交付一个设备监控页面。很多集成商就是冲着这个模板化能力选的TopHMI,项目进场快了很多。
5. 报警与事件引擎:分级、去抖、确认流,一条都不能少
5.1 Basic报警流程里的两个管理重心
报警是SCADA系统最“重”的功能之一,它直接关系现场安全,设计草率会有很大的隐患。
报警管理有两个核心命题:一是避免误漏报,二是建立闭环流程。
先说误漏报。现场经常遇到这种情况:一个液位信号在阈值边界上抖动,采集周期内数值来回穿越阈值,就会产生一连串的报警产生、恢复消息。我在报警引擎里做了去抖(Debounce)机制:报警条件成立后,必须连续持续设定的时间(默认2秒,可配置)才正式触发报警;同样,恢复条件也要持续一段时间才宣布恢复。这个去抖时间设置要谨慎,太长会延迟真实报警,太短则过滤不掉抖动,我一般建议设置在采集周期的2到3倍。
报警处理还常涉及一个“滤波”概念,通常和去抖一起用。滤波是先判断数值的死区(Deadband),对变化小于死区的波动不产生事件,比如温度在49.8℃和50.2℃之间波动,死区设置0.5℃就能把这种频繁切换抑制掉。滤波和去抖配合,现场的误报率能降低很显著。
5.2 报警分级、多状态流转与确认
报警分级我参考了国际电工委员会的惯例,划分为四个等级:Critical、Major、Minor、Warning。Critical对应设备停机和人员安全风险,必须立即处理;Major对应影响生产的故障,需要尽快响应;Minor对应需要关注的异常,可以在计划内处理;Warning对应提示性信息。每一个等级配置不同的颜色、声音、短信通知策略。
报警状态是一个有限状态机,核心状态包括:Active(未经确认)、Acknowledged(已确认)、Recovered(已恢复)、Cleared(已关闭)。当报警条件第一次满足时,状态置为Active,系统推送报警通知;操作员在HMI上确认后,状态变为Acknowledged,确认动作记录操作员ID和时间;条件恢复后,状态变为Recovered,但事件仍然保留在报警列表中供追溯;明确处理后关闭记录,状态为Cleared。
这里有个细节:已恢复但未确认的报警不能直接消失,必须保留在报警列表中。现场很多安全规范要求操作员必须对每一个报警逐一确认,哪怕这一刻报警已经恢复了,也要确认一下“我看到了,我知道发生了这件事”。SuperSCADA的报警列表在这方面严格按流程来。
报警通知这一块,我在服务端做了通知器接口。默认支持钉钉机器人、企业微信机器人、Webhook和邮件。Critical级别报警触发时,还会直接调用短信网关接口,把报警内容推给值班工程师和车间负责人。通知频次做了限流,同一个报警点10分钟内不会重复推送超过3次,防止半夜被同一报警轰炸。
5.3 报警风暴的抑制机制
报警风暴是工业现场的经典灾难场景。电网闪断、通信电缆被挖断这类事件会导致大量设备同时离线,瞬间产生几百条甚至上千条报警。如果直接把所有报警全部推送,操作员后台会被信息淹没,真正的关键报警反而被刷掉了。
SuperSCADA的报警引擎里加了两层抑制机制。第一层是关联抑制:配置报警关联关系后,当一个父级设备处于离线状态时,子设备引发的报警自动降级为影子报警,不推送通知,只在“隐藏报警”查询里可见。第二层是限流抑制:当系统检测到每分钟报警数超过设定阈值(比如120条),自动进入风暴模式,后续报警只写入存储,推送改为汇总合并告知,等到报警速率回落后再恢复正常模式。
这个抑制设计在电网闪断场景下非常有效。闪断那几秒钟,几百台仪表的通信全部中断,但系统不会刷屏几百条“设备离线”,而是推一条“检测到多处设备离线,已进入报警风暴抑制模式”汇总,之后陆续恢复时也只推汇总信息。现场管理人员实战反馈说,有了这个功能,值班压力小多了,至少能看清发生了什么。
5.4 报警事件表设计与追溯能力
报警记录和趋势曲线、操作日志是SCADA三个最重要的审计数据。报警事件表的数据模型我在设计时单独拆了出来,和普通历史数据表分开,因为报警数据有明显的电视结构特征:产生、确认、恢复,每次状态变化都有独立时间戳,查询模式也不同。
报警事件表核心字段包括:点位Key、报警等级、报警内容、触发值、限值、产生时间、确认操作员、确认时间、恢复时间、恢复值、状态。版本设计确认后,我补充了两张辅助表:报警配置表和报警确认记录表。报警配置表存储了每个点位的报警上下限、死区、去抖时间、等级、是否启用,确认记录表则存操作审计信息。
有了这套数据结构,追溯一个报警的完整生命周期非常轻松。从第一次超限到操作员确认,再到现场恢复正常,全过程可查,而且操作员是谁、何时点的确认、当时触发值是多少都清清楚楚。这在应对客户审计、事故复盘时是不可或缺的底牌。
6. 上线压测与踩坑实录:断线重连风暴、页面卡死、时间戳错乱
6.1 现场网络闪断引发的重连风暴
SuperSCADA上线后遇到的第一个大坑就是断线重连风暴。当时现场有200多台设备连着同一台工业交换机,某天施工队的电焊机意外把市电搞跳闸了,交换机断电,200台设备同时离线。等交换机恢复后,我的采集程序里每个设备的goroutine都在专心重连,200个连接同时撞向设备和Modbus TCP端口,结果导致那台老PLC的通信模块卡死,部分设备恢复了又被踢下线,反复了很长时间才稳定下来。
这个问题的根源是重试策略太简单。打了个补丁后,我实行了指数退避加抖动:设备连接失败后,第1次重试等待500毫秒,第2次1秒,第3次2秒,依次递增到上限30秒,并在这基础上加入随机抖动,避免所有设备退避步调一致。同时,我把同一个通道下的设备重连请求做了串行化,一台设备重连过程中,同通道其他设备的连接请求排队等待。改完之后,再遇到交换机断电恢复,整个平台可以在几十秒内安静地恢复状态,不再出现设备踢来踢去的情况。
6.2 Modbus TCP的粘包、半包和处理时序
开发Modbus TCP驱动时,报文解析也踩了很经典的坑。TCP是字节流协议,不是按报文边界传输的,应用层自己需要处理粘包(一个TCP包包含多帧响应)和半包(一帧响应被拆分成多个TCP包)。
这个问题的标准解法是用状态机解析,在套接字读取循环里维护一个接收缓冲区,先解析MBAP报文头,根据报文头里的长度字段来确定本帧报文的总字节数,再判断缓冲区内数据是否达到这个长度。数据不足就继续等待,数据超出就截取一帧,剩余数据继续参与下一帧解析。
func (c *ModbusClient) readFrame() ([]byte, error) { header := make([]byte, 6) if _, err := io.ReadFull(c.conn, header); err != nil { return nil, err } // header[4], header[5] 是长度字段(高、低字节) length := int(header[4])<<8 | int(header[5]) frame := make([]byte, 6+length-1) // 继续读取剩余字节 if _, err := io.ReadFull(c.conn, frame[6:]); err != nil { return nil, err } copy(frame[:6], header) return frame, nil }事务ID的匹配也是一个容易出问题的点。SuperSCADA的Modbus TCP客户端支持并发请求,也就是同一设备可以同时发出多个读请求,响应返回后根据事务ID匹配对应的请求。最初我图省事,把事务ID固定为0,导致并发请求时响应错乱。后来实现的是一张自增ID表,每个请求分配唯一ID,收到响应后查表匹配。事务ID达到上限时自动翻转,同时哈希表清理超时未响应的旧请求。
6.3 32位浮点的字节序差点让整套数据报废
我遇到过规格很小的坑:一批温度变送器,用Modbus读回来的数值,有的通道显示正常,有的通道数值大得离谱。排查了很久,最后定位到是字节序配置问题。
这批变送器的Modbus寄存器排序方式不同于我先前接触的设备。我的驱动默认按大端方式解析,而它们的32位浮点实际是按小端存放的。修正方法其实很简单,点位上配置字节序改为小端后,数据立刻恢复正常。
这里我想强调:任何Modbus设备的点位,拿到手后都先用Modbus Poll这类调试工具读一下原始寄存器值,手动验证字节序,再配置进系统。摸清设备的寄存器分布、字节序和数据格式,比写好代码本身更花时间。不要嫌烦,这一步省了,后续在数据解析上耗的时间会成倍返还。
6.4 前端页面越用越卡的元凶
还有一次现场反馈说HMI页面运行半小时后开始变卡,刷新页面能好一阵,但过一会儿又不行了。这种问题十有八九是前端内存泄漏。
排查发现,泄漏点出在WebSocket消息处理里。我的订阅管理器每次收到推送消息后,会构建一个点位更新对象通知图元刷新,但有些图元销毁时没有注销自己的监听回调,时间一长,订阅管理器里积累了大量僵尸回调,每帧推送数据都要遍历一遍,浏览器内存不断膨胀。
修复方式有两块。第一块是规范生命周期管理,所有图元销毁时强制调用unsubscribe方法,必须在绑定时登记一个释放函数,防止回调泄漏。第二块是给推送器加了增量快照机制:同一秒内同一个点位有两轮更新,只推最后一次值,不推历史中间值。加上这个改动之后,现场页面连续跑了好多天内存都很稳定。
6.5 时钟不同步导致的历史数据时间轴错乱
最后一个坑来自现场工控机的系统时钟。某天客户说趋势曲线的数据点“跑到未来去了”,打开数据库一看,最近几个小时的历史表时间戳全部有问题,有的记录时间戳比实际时间大了好几个小时。
原因是现场那台工控机上跑着其他软件调整了系统时间,而我的采集程序打的是本地时间戳,系统时钟一调,新数据按错误时钟写入,旧数据就被覆盖了。调整时间把时钟同步拨正后,数据才恢复正常。
这个教训让我做了一个决定:SuperSCADA服务端启动时,如果检测到系统时间和上一次运行记录的时间差超过30秒(比如系统重启或者被动调整时间),会明确记录一条系统事件日志,并校验所有点位是否进入Stale状态。如果后台配置了NTP服务器,服务端还会定期触发系统校时。历史时间戳的产出逻辑不变,始终以服务器当前时间为准,但启动时检测时间跳变可以及时发现问题,至少工程师能知道时间被修改过,而不是在数据错乱之后才意识到。
写在最后
SuperSCADA和SuperHMI从最开始的一个采集小工具,慢慢长成一整套轻量级SCADA平台,中途又拆出了TopSCADA、TopHMI两条产品线。我把这段经历完整分享出来,核心是想说明一件事:在工业自动化这个领域,稳定可靠的系统不是靠买某个万能软件堆出来的,而是靠对通信协议、数据结构、前端渲染、报警逻辑每一个细节的较真和打磨。回头再看那个“要不要自研SCADA”的问题,我的答案依然是肯定的。商用方案自有它的价值和场景,但当你真正掌握整套系统的每个环节,能根据现场需求灵活调整时,那种主动权是任何现成软件都给不了的。后续我还会继续迭代这个系统,把边缘计算、预测性维护这些方向一步步加进去。