做倍福上位机的朋友应该都有印象,TcHmi 这个基于 Web 的 HMI 平台,优点一大把——界面漂亮、控件丰富、能跑在任意浏览器里;但真到用 ADS 读 PLC 数据来刷新界面的时候,稍不留神,页面就能给你表演一个当场假死。这问题我在几个项目上都踩过,后来把调用方式统一改成异步子线程 + 事件驱动的模式才彻底消停。这篇就专门聊聊倍福上位机 TcHmi 里,异步子线程调用 ADS 并更新 UI 界面的完整套路:为什么不能直接在界面逻辑里同步读,怎么设计一条可靠的数据链路,以及那些文档里不会写但实操中一定会撞上的坑。
1. 需求拆解:TcHmi 为什么会在 ADS 通讯面前栽跟头
1.1 TcHmi 与 ADS 的通讯链路到底长什么样
先说基础。TcHmi 虽然是倍福家的 HMI 方案,但它不是传统的桌面组态软件,而是基于浏览器渲染的。你的界面跑在浏览器里,JS 环境去跟 PLC 交换数据,走的是 ADS 协议。ADS(Automation Device Specification)是 TwinCAT 系统的设备间通讯规范,三层架构大概是这样:
- 浏览器里的 TcHmi 页面发起请求
- 请求到 TcHmi Server,由 Server 作为网关转发
- Server 通过 ADS Router 找到目标 PLC 的 AMS NetId
- 最终落到 PLC 里的某个符号变量上
这个链路本身没问题,问题是它每一步都有耗时。别小看这一步,ADS 请求虽然快,但也不是零延迟。一次读取操作,从页面发出去到拿到值返回,常见情况是 5~20 毫秒。如果一台设备上有几十个变量要刷,而你的代码是同步循环逐个读,那完蛋了,一次刷新就要几百毫秒。人眼对 100 毫秒以上的卡顿就有感知,操作工点按钮点不动、拖拽拖动不了,第一反应就是死机重启,最后一句“这破系统不行”就把你打发了。
1.2 同步调 ADS 的典型事故现场
我见过最典型的错误写法是这样的:某个同事写页面初始化逻辑,在事件处理里直接循环读取符号值,用的是同步接口。
// 反面教材:同步逐个读取 const symbols = ['PLC1:Main.Temperature', 'PLC1:Main.Pressure', 'PLC1:Main.Flow']; const values: number[] = []; for (const path of symbols) { const sym = TcHmi.Symbols.createSymbol(path); values.push(sym.read()); // 同步调用,主线程阻塞 }这段代码跑起来后,页面在读取完成前整个是冻结的。一开始还好,变量少感觉不出来,等设备联调时监控画面加了一堆变量,操作工一打开页面,鼠标转圈转三秒,点哪儿都没反应。大家第一反应是网太差、PLC 太慢,其实根子就是同步调用把 UI 主线程堵死了。
在实际工程里,这个问题比“卡顿”更隐蔽的地方在于:它不稳定。ADS 通讯延迟受 PLC 扫描周期影响,受网络负载影响,同步调用一旦碰上 PLC 那边恰好有周期性任务在跑,一次读取超时,整个 UI 线程就跟着倒霉。你没法预估什么时候卡、卡多久,这才是最难受的。
1.3 “异步子线程”在 JavaScript 世界里到底是什么
很多工控背景的工程师,第一反应是“那我开个子线程去干这件事”。这个思路对,但到了 TcHmi 的 JavaScript 环境里要稍微转个弯:JavaScript 本身是单线程的,没有传统意义上的多线程。它靠事件循环(Event Loop)实现异步——把耗时的 I/O 操作丢给底层,JS 主线继续干别的,等 I/O 完成了,通过回调或者 Promise 把结果送回来。对我们写代码的人来说,效果就类似于开了个子线程去跟 PLC 通讯,UI 主线程不被阻塞。
这里要给两个实用结论:
- 你不需要真的去创建线程,用 Promise + async/await 就能达到“异步子线程”的效果
- 如果数据后处理特别重,比如做 FFT、大数据量统计,那才需要真正的并行线程,TcHmi 里可以用 Web Worker,这块后面单独说
理解了这个,后面的所有设计就通了。核心就一句话:凡是跟 ADS 通讯相关的操作,全部走异步;凡是能用事件驱动替代轮询的,优先事件驱动。
2. ADS 异步调用的两种主流姿势:订阅与轮询怎么选
2.1 姿势一:符号订阅,让 PLC 主动告诉你数据变了
TcHmi 提供了一套符号(Symbol)机制,你可以监听某个 PLC 变量的变化事件。做法不复杂:
const temperatureSymbol = TcHmi.Symbols.createSymbol('PLC1:Main.Temperature'); // 订阅符号值变化,回调里拿到的就是最新值 temperatureSymbol.subscribe((value) => { // 在这里更新 UI,或者把数据存到状态管理器里 TcHmi.Controls.get('TextBox_Temp').setValue(value.toFixed(1)); });这种方式的好处非常明显:数据一变,界面马上跟着变,实时性最好。而且你在回调里只做轻量更新,主线程的压力非常小。我最早用这种方式做了一条产线的温度趋势监控,十几个温度点,每个点单独订阅,页面流畅得很。
但订阅方式也有它的前提:你得盯着“变化”本身。有些变量长时间不变(比如液位到了设定值就一直停在那儿),那就没有回调触发,下游逻辑就感知不到。如果你需要的是周期性心跳或者持续刷新,订阅就不够用了。另外,如果你要同一时间读一批变量做逻辑判断(比如要判断温度、压力、流量三个都正常才能启动),靠各自独立的订阅回调,数据是碎片化的,你还得自己做聚合。这时候轮询方案更顺手。
2.2 姿势二:定时异步轮询 + Promise 批量读取
所谓的异步轮询,就是用一个定时器,每隔一段时间把所有需要的变量统一读一遍。关键点是:读取本身要用异步接口,并且多个变量用Promise.all并发执行,而不是串行。
export class PlcDataService { private symbols: TcHmi.Symbol[]; constructor(private deviceName: string = 'PLC1') { // 把要采集的变量路径集中管理 this.symbols = [ TcHmi.Symbols.createSymbol(`${deviceName}:Main.Temperature`), TcHmi.Symbols.createSymbol(`${deviceName}:Main.Pressure`), TcHmi.Symbols.createSymbol(`${deviceName}:Main.Flow`), TcHmi.Symbols.createSymbol(`${deviceName}:Main.RotSpeed`), ]; } public async readAll(): Promise<Record<string, number>> { // 并发异步读取,一个请求卡住不影响其他请求返回 const values = await Promise.all( this.symbols.map(sym => sym.readAsync() as Promise<number>) ); return { temperature: values[0], pressure: values[1], flow: values[2], rotSpeed: values[3], }; } }用Promise.all去并发读,是我建议的标配做法。原因很简单:一次异步读取是一个网络/通讯往返,串行读 N 个变量就是 N 次往返,耗时线性增长;并发读 N 个变量,总耗时约等于最慢的那一个。实际项目里,我测过 20 个变量并发读,比串行读快 6~8 倍都是正常的。PLC 那边通讯负载也没怎么增加,因为请求基本是同一批出去的。
2.3 两种方案怎么选,一张表说清楚
| 对比维度 | 符号订阅方式 | 定时异步轮询方式 |
|---|---|---|
| 数据实时性 | 极高,变量一变立即触发 | 取决于轮询间隔,有延迟 |
| 对频繁变化变量的压力 | 回调频繁,要节流处理 UI | 刷新频率可控,压力可预估 |
| 批量判断与聚合逻辑 | 需要自己攒数据、做对齐 | 天然整批读取,适合做判断 |
| 实现复杂度 | 低,单变量监听简单 | 中,需要管理定时器和状态 |
| 典型场景 | 单点/少量点实时监控 | 多变量汇总表、工艺状态机判断 |
我的习惯是:如果一个画面上需要同时关注超过 10 个变量,或者要做跨变量的联动逻辑,统一走轮询。如果只是个别关键信号(比如急停、报警、某个轴的当前位置)要求秒级响应,用订阅。两个混着用也完全没问题。
3. 完整示例:磨床数据监控页从零实现
3.1 工程准备与符号路径排查
开始写代码之前,先在 TcHmi 工程里把 ADS 连接配置好。这里我碰到过一个特别容易踩的坑:符号路径里的PLC1不是你随便起的名字,它是 TcHmi 工程里 ADS 连接(ADS Device)的 Name,有人说那我就写 PLC1 那是配置里的,配置里叫什么这里就得写什么。如果路径不对,报错一般是0x752之类的错误码,很多人以为是通讯断了,其实只是名字没对应上。
我的建议:先在界面设计器里拖一个测试控件,手动绑定一个符号,如果绑定能正常出值,就说明路径格式没问题,然后你再在代码里用同一个路径串。
3.2 编码实现:一个带自动重连的异步读取服务
下面这套代码是我在一个磨床监控项目里实际用过的简化版。它干的事情是:每 500ms 采集一次温度、压力、流量和主轴转速,更新到页面上的表格和几个文本框里。
这里有个很重要的设计点:不要用setInterval去启动轮询,而是用setTimeout链式调用。
为什么?setInterval是固定时间间隔触发,它不管上一次任务有没有结束。如果某次 ADS 读取因为网络抖动花了 800ms,而你的间隔是 500ms,那下一次触发就会跟上一次叠在一起,请求堆积,页面越来越卡。用setTimeout链式调用的意思是:每次读取完成后,等 500ms,再进行下一次读取。时间轴的起点是读取完成时刻,而不是读取开始时刻,这样永远不会有并发堆积。
export class MonitorScheduler { private timer: number | null = null; private readonly intervalMs = 500; private readonly plcService: PlcDataService; private readonly onData: (data: Record<string, number>) => void; private readonly onError: (err: Error) => void; constructor(plcService: PlcDataService, onData: (data: Record<string, number>) => void, onError: (err: Error) => void) { this.plcService = plcService; this.onData = onData; this.onError = onError; } public start() { this.scheduleNext(); } public stop() { if (this.timer) { window.clearTimeout(this.timer); this.timer = null; } } private scheduleNext() { this.timer = window.setTimeout(() => { this.readOnce() .finally(() => { // 无论成功失败,都进入下一轮 this.scheduleNext(); }); }, this.intervalMs); } private async readOnce(): Promise<void> { try { const data = await this.plcService.readAll(); // 转给 UI 层更新 this.onData(data); } catch (err) { this.onError(err as Error); } } }这段代码在多个项目里跑下来很稳。你可能会注意到一个细节:即使读取失败了,它也不会停下来,而是继续下一轮。这其实是有意的设计——现场通讯偶发抖动很常见,如果一次失败就中止整个监控,那界面就会彻底停摆,操作工看到的就是一片死数据。你要做的不是死等一次成功,而是保证数据链路永远在尝试。
3.3 UI 更新:如何避免高频刷新把界面拖垮
ADS 数据进来了,接下来就是更新控件。这里最常见的毛病是——每次都把所有控件全量刷新一遍。表格还好说,如果页面上有几十个文本框、几个趋势图,全量刷新在低配工控机上很快就顶不住了。
我的做法是加一层“变化检测”:每次读取到的值和上一次比较,只有变化超过阈值的才更新控件。比如温度波动 0.1 度,对于工艺监控来说根本没意义,但如果你每次都写,UI 就得跟着重绘,纯属浪费。
export class UiUpdater { private lastValues: Record<string, number> = {}; private readonly threshold: Record<string, number> = { temperature: 0.1, pressure: 0.05, flow: 0.5, rotSpeed: 1, }; public update(data: Record<string, number>) { for (const key of Object.keys(data)) { const newVal = data[key]; const oldVal = this.lastValues[key]; if (oldVal === undefined || Math.abs(newVal - oldVal) >= this.threshold[key]) { this.refreshControl(key, newVal); this.lastValues[key] = newVal; } } } private refreshControl(key: string, value: number) { // 根据 key 找到对应的 TcHmi 控件并更新 const controlNameMap: Record<string, string> = { temperature: 'TextBox_Temp', pressure: 'TextBox_Press', flow: 'TextBox_Flow', rotSpeed: 'TextBox_Speed', }; const control = TcHmi.Controls.get(controlNameMap[key]); if (control) { control.setValue(value.toFixed(1)); } } }你再想想,如果趋势图控件也在同一页面上,最好不要每次数据点都追加。趋势图控件内部有缓冲区,不停追加最终内存会涨到你怀疑人生。我一般会限制缓存长度,只保留最近 1000 个点,或者干脆让趋势图控件只消费最新一个值,走滚动显示模式。
3.4 断线自恢复:指数退避重连策略
现场最让人头疼的是用户操作工重启了 PLC,或者网线被叉车压断了。ADS 连接一断,页面数据就全停掉,而且你稍微没处理到位,重连时会疯狂报错刷屏。
我的处理套路是四段式:
- 监听连接状态变化,断开后停止正常的轮询读取
- 启动重连定时器,重连间隔按 300ms、500ms、1s、2s、5s、10s 递增,最多到 10 秒封顶
- 每次重连成功后,重置间隔回到 300ms,并恢复轮询
- 界面上给一个明确的状态指示,红色表示“通讯失败”,绿色恢复,黄色表示“重连中”
代码如下,注意看定时器重置逻辑:
export class ConnectionManager { private reconnectAttempt = 0; private reconnectTimer: number | null = null; private readonly maxIntervalMs = 10000; constructor( private readonly checkConnection: () => Promise<boolean>, private readonly onLost: () => void, private readonly onRestored: () => void ) {} public start() { this.periodicCheck(); } private periodicCheck() { this.reconnectTimer = window.setTimeout(() => { this.checkConnection() .then(online => { if (online) { this.reconnectAttempt = 0; this.onRestored(); } else { this.onLost(); this.reconnectAttempt++; } this.periodicCheck(); }) .catch(() => { this.onLost(); this.reconnectAttempt++; this.periodicCheck(); }); }, this.getNextInterval()); } private getNextInterval(): number { // 指数退避:300ms 起步,翻倍增长,10s 封顶 const interval = Math.min(300 * Math.pow(2, this.reconnectAttempt), this.maxIntervalMs); return interval; } }这里面最核心的体会是:重连逻辑必须独立于业务轮询逻辑。我见过有人把重连塞在业务轮询的finally里,业务一停重连也停了,一旦 PLC 长时间重启,页面就永远卡在断线状态,必须手动刷新页面才能恢复。独立之后,重连检查一直在跑,PLC 一恢复,业务轮询自动接上,操作工根本不用重启浏览器。
4. 高频踩坑与性能调优实录
4.1 症状一:界面还是卡,到底卡在哪儿
代码全改成异步了,界面还卡,这时候别慌,先定位瓶颈。我给你一个最笨但最有效的排查方法:在页面上加两个时间戳显示,一个是“数据读取耗时”,一个是“UI 更新耗时”。
- 如果读取耗时长,问题在 ADS 通讯链路。看看是不是网络丢包、PLC 负载高,或者是不是并发数设得过大,把 PLC 通讯模块压垮了
- 如果读取很快但 UI 更新耗时高,那就是渲染的问题。检查是不是控件刷得太频繁,或者有大量 DOM 操作没有合并
我遇到过一次诡异的情况:读取耗时只有 10ms,但整个页面操作起来就是一顿一顿的。最后查出来是某个趋势图控件在后台不停重绘,把每秒的帧率吃干净了。那个控件我没用到,但它在页面上是默认存在的。这种问题你不去量化耗时,靠感觉排查一辈子也查不出来。
4.2 症状二:数值在界面上闪烁跳动
数据源是稳定的,但界面上数值一会儿高一会儿低,像接触不良。大概率不是 ADS 通讯的问题,而是你 UI 更新的顺序和 PLC 数据更新的时序对不上。比如你订阅了多个符号,每个符号独立回调,某个瞬间温度回调先到、压力回调后到,但界面上显示的是一个组合状态值,那就相当于读到半个状态的“快照”,自然不稳定。
解决办法有两个:
- 要么不用订阅,改轮询,保证每一次界面刷新读到的都是一组完整的、时间对齐的数据
- 要么订阅回调里只存原始值,在 UI 绘制前做一次聚合对齐,以统一的时钟片为准
我在磨床项目里用的是后者:每个订阅回调把值打到缓存对象里,然后每 100ms 做一次界面统一刷魂。数据虽然晚了几十毫秒显示,但界面上看起来永远是稳定的一帧,操作工舒服多了。
4.3 症状三:内存持续增长,跑几个小时页面就卡死
这个问题的根源通常是两个:一是定时器泄漏,二是订阅未释放。
定时器泄漏的典型场景:你每次启动轮询都调一次start(),但忘了调stop(),导致旧的定时器没有被清除,多个定时器同时在跑。两个定时器互相叠加,先是数据刷新翻倍,然后是内存翻倍,最后页面直接崩。我在代码里给start()这么写:
public start() { // 先停掉旧逻辑,再启动新逻辑,彻底避免重复 this.stop(); this.scheduleNext(); }这个“先停再启”的习惯,我建议你养成。它成本几乎为零,但能挡住一整个类别的坑。
订阅未释放就比较隐蔽了。TcHmi 的符号订阅默认跟着页面生命周期走,但如果你的页面做了 Tab 切换、关闭弹窗等操作,而订阅还留在那儿,它就会一直接收 PLC 的推送,回调里还在更新一个已经不显示的控件。这类泄漏积累起来,内存增长非常快。规范做法是:页面关闭或组件销毁时,主动调用unsubscribe()。
// 页面卸载时的清理逻辑 this.symbols.forEach(sym => sym.unsubscribe());4.4 数据量大到一定程度:把重计算扔给 Web Worker
如果你做的是高速数据处理——比如主轴振动信号采样率很高,每秒几千个点——那得正视一个问题:JavaScript 主线程的处理能力和渲染能力是有限的。异步能解决“不阻塞 UI”,但解决不了“主线程计算量太大”这个物理瓶颈。
这时候就要上 Web Worker 了。TcHmi 的工程结构支持放 Web Worker 文件,主线程负责 ADS 读取和 UI 更新,拿到原始数据后通过postMessage发给 Worker,Worker 里做滤波、FFT、统计计算,算完再postMessage回主线程。
这里有个红线要记住:Worker 线程里不能访问 TcHmi 的任何对象,不能操作控件,不能订阅符号。你只能传数值进去、传数值出来。所以要把“数据采集”和“数据处理”彻底分开——采集在主线程,处理放 Worker,结果回主线程再渲染。这个边界一开始就划清楚,后面就不会写出“Worker 里调用 TcHmi.Controls”这种必然运行失败、还没地方查错的代码了。
5. 一些用过都说值得的经验与细节补充
5.1 轮询间隔不是越小越好
有个朋友跟我分享过他的惨痛教训:为了追求“实时”,把轮询间隔压到 50ms。结果 PLC 的通讯负载飙升,反倒把原来好好的 20ms 扫描周期拖慢到 40ms,整个产线逻辑全乱套了。这是个非常经典的教训——HMI 刷新频率和 PLC 控制周期是两回事,你读得再快,显示的也是上一次扫描周期的旧值。
我的经验值:普通监控变量 500ms 完全够用,操作工看一眼界面正常就完事了;高速计数、轴位置这类需要跟踪的,100ms 是合理下限;要人眼看到“实时动”的曲线,也不建议低于 100ms。真正到了毫秒级监控,那就得靠专门的示波器方案,别指望 HMI 页面扛这个活。
5.2 在 UI 更新里避免重复控制控件
接数据进界面的方式,我推荐用一个壳:把 TcHmi 控件、框架绑定、样式逻辑都收敛到一个函数里,业务逻辑层只往外抛数据对象。这样之后做控件改名、样式调整,甚至从 TcHmi 原生控件换成 React/Vue 组件,都不需要改动数据采集层的代码。
我在两个项目里试过这个分层方式,效果很好。数据服务层只关心怎么从 PLC 拿数据,UI 层只关心怎么把数据画出来,中间用回调传递。对于需要长期维护的上位机项目,这种解耦等于给你未来的自己省时间。
5.3 先把异常框架搭好,再谈业务逻辑
ADS 通讯在现场就是不稳定,你不把它当常态来设计,早晚会被它坑。所以我强烈建议:每个项目开工第一件事,不是先拖控件,而是先搭一套简单的异常记录机制。读数据失败记录一条日志,带时间戳、带错误码、带失败的具体符号路径。等你在现场被操作工拉住说“数据不对”,这套日志就是你唯一能依靠的真相来源。
经验之谈,很多“数据异常”根本不是程序 bug,而是地址算错、PLC 变量类型不匹配、某个工艺参数临时改掉了。没有日志,你只能蹲在现场瞎猜,效率低到怀疑人生。有日志,大概率几分钟就能定位到是哪条路径的问题。
5.4 最后分享一个小技巧:开发阶段把失败表现调到最明显
我在开发联调阶段,故意让错误处理逻辑把问题放大——比如读取失败时,UI 上的数值控件直接显示红色大叉,而不是保留旧值。这样任何一次通讯抖动我都能第一时间看到,方便暴露问题。正式上线再改回旧值保留 + 状态区小提示,不让操作工被满屏错误吓到。
这一招帮我提前发现了好几个隐藏的通讯风暴问题。你可以试试,成本极低,但联调效率提升是实打实的。(全文完)