☰
C#上位机PLC通讯从轮询到异步架构重构实战
2026/10/17 4:42:28 网站建设 项目流程

凌晨两点被一通电话叫醒,产线上十台PLC的通讯周期突然全部拉满。当时上位机用的还是最朴素的轮询方案:每台PLC分配一个后台线程,线程里死循环读点位,读完Sleep几十毫秒再来一轮。小规模验证时这套方案一点问题没有,两台设备、一百个点位,响应飞快;可点位堆到两千多个、设备加到十台之后,一轮完整读取的时间从300毫秒涨到2.4秒。操作员在HMI上点一下启动,设备要等两秒多才有反馈,这已经不是上位机,而是带界面的批处理脚本。

后来我把整套C#上位机通讯层重构成了异步通讯架构,S7协议、Modbus TCP、MC协议都接进了同一套调度框架,稳定跑了一年多没再出过通讯性的打摆子问题。这篇文章把整个落地过程拆开讲:轮询为什么卡、异步架构怎么设计、多协议怎么统一接入、实测数据是什么水平,以及一路上踩过的坑。适合正用.NET做上位机开发、做工业通讯的工程师,尤其是设备越加越多、点位越来越密、轮询已经明显卡壳但还没想好怎么迈出第一步的朋友。

1. 轮询为什么会成为瓶颈——一次产线改造的真实教训

1.1 最初的实现:一个设备一个线程,循环读到天黑

先把最原始的架构交代清楚,因为很多团队现在还在用这个模式。当时通讯层是一个静态类,里面放了一个List,每个元素是一个工作线程。线程体打开Socket连接PLC,连接成功之后进入while循环,按预先定义的点位列表用ReadMultipleRegisters之类的方法去读连续地址块,读完后触发事件,UI层订阅事件刷新控件,然后Thread.Sleep(interval)再读下一轮。

这个方案有两个地方看起来合理,实际是隐患。第一,它用了批量读,很多人觉得已经算是优化过——一次把MB地址的连续区间拉回来,总比逐点位读强。第二,它把通讯逻辑放在独立线程里,UI不会因为Socket阻塞直接卡死,表面上也说得过去。但线程模型和IO模型两处都埋了雷:线程模型上,每台设备独占一个线程,设备一多线程数就跟着涨;IO模型上,读操作是同步阻塞的,设备断线的时候线程会卡在Read调用里等TCP超时,这段时间里这个设备的所有点位全部处于失联状态。

更麻烦的是共享资源。多个线程之间共享日志对象、共享连接状态缓存、共享点位缓存,为了防止并发写错数据就得加锁。点位数少时锁竞争不明显,点位一多,线程切换加上锁等待,真实通讯时间被稀释得很厉害。现场看到的典型症状是:加第4台设备后UI开始轻微一顿一顿,加到第8台时操作员频繁抱怨按按钮响应慢,加到第10台时轮询周期直接崩到秒级。这个时候很多人第一反应是换台更高配的工控机,但问题根本不在硬件上。

1.2 轮询架构的四笔技术债

我把那段时间轮询架构暴露出来的问题整理成四类,后面所有设计决策都和还债有关。

第一笔是跨线程刷新UI的债。每个轮询线程读完数据都要把结果送到UI线程,WinForms里就得BeginInvoke。点位多的时候,主线程的消息队列里塞满了刷新请求,界面绘图反而成了最重的任务。有些控件刷新逻辑还挺重,比如DataGridView整行更新,一秒钟来几十次,主线程根本没有喘息机会。

第二笔是IO阻塞的债。同步Socket在设备无响应时只能靠超时兜底,而断线重连期间该设备的所有读写请求全部排队等待,这个等待又会污染下一个轮询周期。典型的故障链是:一台PLC断电,它的工作线程卡在读超时里,其他PLC的线程本来没事,但共享锁把大家串起来了,结果全线通讯延迟都跟着飙升。

第三笔是读写耦合的债。轮询循环里读操作占用绝大部分时间,写操作只能插在轮询间隙里执行。现场操作员突然要改一个参数,得等当前这一轮读完,操作就会延迟一拍。在轮询架构下,读和写天然互相牵制,而且随着点位增加,延迟只会更严重。

第四笔是扩容和新协议接入的债。想加一台设备就要多开一个线程;想接一种新协议,就要在同一个线程体里再塞一套分支判断。我见过有项目把Modbus、S7的解析代码都堆在同一个while循环里,每加一种协议,后来者改代码的心理阴影面积就大一倍。

1.3 决定重构前的三个判断信号

不是说轮询一无是处。只有几个点位、一两台设备、数据变化不频繁的场景,轮询反而是最简单可靠的方案。我们最终决定重构,是因为三个信号同时出现。

第一个信号是增长曲线:点位数量和PLC数量都在按季度翻番,现有代码已经明显追不上需求增长速度。第二个信号是现场排查结果:通讯线程占用的CPU时间接近30%,但其中一大半消耗在线程切换和锁等待上,真正的协议收发占比低得可怜。第三个信号是业务侧提出接入另一种协议,而现有轮询代码改起来差不多等于重写。三个信号碰在一起,轮询就不再是简单方案,而是必须还的技术债。

2. 异步通讯骨架——用Channel替换线程轮询

2.1 核心思路:从“定时扫表”转向“按需驱动”

异步重构并不等于丢掉周期读的变量。PLC数据获取本质上有三种模式,我先重新梳理了它们的关系。

第一种是周期快照,比如温度、压力这类连续变化的变量,仍然需要定期刷新,但关键在于这个定期刷新由调度器统一触发,而不是每台设备自己闷头循环。第二种是请求响应,读写某个点位,尤其是写操作和少量读操作,都不该等周期到来,而是即发即等。第三种是主动上报,有些PLC支持事件式数据上送,上位机只需要维持心跳并接收。

实现按需驱动的核心机制是生产者消费者模型。所有对PLC的读写请求都被封装成消息,投递到一个全局请求队列,后台的消费者线程从队列取消息、发报文、收响应。这样带来的直接变化是:没有请求的时候链路就是空闲的,不需要空转轮询。设备数量增加也不怕,消费者线程数量只和通讯端口、协议栈的并发能力相关,和PLC台数没有线性关系。

2.2 用Channel承载请求队列

.NET里做生产者消费者队列,首选System.Threading.Channels。相比自己加锁维护ConcurrentQueue,Channel在读写两端都有异步操作支持,WriteAsync和ReadAsync用起来自然,吞吐也更稳。

我先定义一个请求消息:

public sealed class PlcRequest { public long RequestId { get; init; } public string DeviceId { get; init; } = string.Empty; public TagMeta Tag { get; init; } = null!; public CancellationToken CancellationToken { get; init; } public TaskCompletionSource<object?> Completion { get; init; } = null!; }

RequestId是全局递增的序号,用来匹配响应;Completion是TaskCompletionSource,调用方等它拿到结果,就实现了await异步调用。再定义一个调度器,内部维护一个全局Channel:

public sealed class PlcDispatcher { private readonly Channel<PlcRequest> _channel = Channel.CreateUnbounded<PlcRequest>(); private readonly List<Task> _consumers = new(); public void Start(int consumerCount) { for (int i = 0; i < consumerCount; i++) { _consumers.Add(Task.Run(() => ConsumerLoopAsync())); } } private async Task ConsumerLoopAsync() { await foreach (var request in _channel.Reader.ReadAllAsync()) { var driver = _driverFactory.Get(request.DeviceId); try { var value = await driver.ReadAsync(request.Tag, request.CancellationToken); request.Completion.TrySetResult(value); } catch (Exception ex) { request.Completion.TrySetException(ex); } } } }

这个示例把问题简化了,真实实现里ConsumerLoop还需要做按设备分组、地址排序、批量聚合、并发控制,这些可以在取到请求之后、真正驱动协议之前加一个预处理阶段完成。Channel选型上我用了无界Channel,因为内部对每个请求都做了超时控制,不会出现无限堆积;如果请求量可能暴涨而协议栈完全跟不上,建议改用有界Channel加Wait策略,防止内存被撑爆。

2.3 等待响应必须系好安全带:超时与取消

异步通讯最容易被忽略的一点是:await一个永远不会完成的Task。PLC断线、报文丢失、协议栈异常,任何一环出问题,调用方如果傻等,整个业务流程就被挂住。所以每个请求必须带超时,最好还支持外部取消。

我封装了一个统一的发送方法:

public async Task<object?> RequestAsync(TagMeta tag, CancellationToken externalCancellation) { var cts = CancellationTokenSource.CreateLinkedTokenSource(externalCancellation); cts.CancelAfter(_timeout); var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously); var request = new PlcRequest { RequestId = _requestId.Next(), DeviceId = tag.DeviceId, Tag = tag, CancellationToken = cts.Token, Completion = tcs }; await _channel.Writer.WriteAsync(request, cts.Token); await using var registration = cts.Token.Register(() => tcs.TrySetCanceled()); try { return await tcs.Task.ConfigureAwait(false); } catch (OperationCanceledException) { if (cts.IsCancellationRequested && !externalCancellation.IsCancellationRequested) { throw new TimeoutException($"读取设备 {tag.DeviceId} 点位 {tag.Name} 超时"); } throw; } finally { cts.Dispose(); } }

有几个细节值得强调。TaskCreationOptions.RunContinuationsAsynchronously一定要带,否则响应到达后回调会在响应解析线程上同步执行,拖慢通讯线程。超时之后要及时把tcs置为取消状态,否则响应真的来了也不会有任何线程在等它,但状态还是应该清理干净,避免资源泄露。

还有一个容易踩的坑是请求与响应的匹配。TCP链路是长连接,多个请求可能同时在途,响应顺序和请求顺序完全可能不一致。我的做法是给每个请求分配自增RequestId,放进报文里,响应回来之后按RequestId找到对应的TaskCompletionSource。这一步如果做错,异步架构会变成事故现场,数据错乱还不自知。

2.4 并发控制:别把设备协议栈冲垮

异步架构解决了线程膨胀,却带来一个反方向的隐患:请求吞吐变高之后,协议栈如果吃不消,会触发PLC端通讯故障。所以要给每台设备设置最大并发数。我用SemaphoreSlim做按设备的并发闸门:

private readonly ConcurrentDictionary<string, SemaphoreSlim> _gates = new(); public async Task<object?> ReadWithConcurrencyLimitAsync(TagMeta tag, CancellationToken ct) { var gate = _gates.GetOrAdd(tag.DeviceId, _ => new SemaphoreSlim(4)); await gate.WaitAsync(ct); try { return await RequestAsync(tag, ct); } finally { gate.Release(); } }

并发数怎么定?看协议栈。串口设备基本只能是1,因为物理链路是半双工的;Modbus TCP一般可以并发2到4个请求,但要实测PLC端的表现;S7协议通过PDU协商机制决定窗口大小,我们保守起见设置4。经验是从较小值开始,压测时观察PLC通讯错误率,再逐步上调。

3. 多协议怎么统一——S7、Modbus TCP、MC协议的接入实践

3.1 三套协议的差异先摆在一起看

标题既然说了多协议,就不能只写一套。我把实际项目里用得最多的三套协议做个横向对比,后续抽象设计才有依据。

协议传输层常用端口报文头部特点批量读取能力数据模型
S7协议TCP102内嵌TPKT与COTP头,有PDU协商很强,可连续读多个DB区DB块、M区、I/O区
Modbus TCPTCP5026字节MBAP头,功能码简洁支持连续寄存器块读取保持寄存器、线圈
MC协议TCP自定义端口固定帧头加子命令支持按地址区间批量读D区、M区、W区

选这三套来统一,因为它们覆盖了德系PLC、通用以太网设备、日系PLC的典型结构。协议细节差别很大,但抽象之后,上层根本不用关心这些差异。

S7协议的头部结构分三层,初次连接还要做COTP广播协商,意思是驱动层在Connect阶段就要完成握手流程。Modbus TCP最简单,MBAP头加功能码就能读写,但数据解析要小心寄存器字序和字节序。MC协议则是固定帧头加子命令,批量读写支持完整,错位、长帧、补位这类细节特别多。

3.2 协议适配层接口设计

不管底层是什么协议,上层只关心三件事:连接、读、写。先定一个最小接口:

public interface IPlcDriver : IAsyncDisposable { string DeviceId { get; } string ProtocolName { get; } Task ConnectAsync(ConnectionConfig config, CancellationToken ct); Task<byte[]> ReadRawAsync(string protocolAddress, int length, CancellationToken ct); Task WriteRawAsync(string protocolAddress, byte[] data, CancellationToken ct); }

ReadRawAsync和WriteRawAsync都是原始字节级读写,协议差异被压在实现内部。上层只需要传协议地址字符串和长度,比如S7驱动解析“DB100.DBD4”,Modbus驱动解析“40001”,MC驱动解析“D100”。

这里有个设计取舍:为什么不直接返回解析好的数值?因为不同点位的数据类型差别太大,有bool、short、int、float、double、string以及各种字节序。原始字节层保持纯粹,上层按Tag元数据统一解析,这样新增一种数据类型时不需要改驱动接口。每种协议一个实现类,按协议规范实现收发,再由驱动工厂根据设备配置里的ProtocolName返回实例。

3.3 Tag元数据:让上层只认点位名

点位信息必须抽象成一份独立元数据结构。我在上层把所有点位统一为TagMeta,包含名称、设备、协议类型、协议地址、数据类型、字节序、缩放系数、单位等:

public sealed class TagMeta { public string Name { get; init; } = string.Empty; public string DeviceId { get; init; } = string.Empty; public string ProtocolAddress { get; init; } = string.Empty; public PlcDataType DataType { get; init; } public Endianness Endianness { get; init; } public double Scale { get; init; } = 1.0; public string Unit { get; init; } = string.Empty; }

这样在UI层配置点位、在数据库存点位结构、在新设备接入时批量导入,都围绕TagMeta转。协议地址差异只影响适配层,点位层的读写和展示完全统一。解析逻辑也集中在一个地方:驱动返回原始字节后,按DataType和Endianness转成数值,乘以Scale,就是界面上看到的工程值;写操作则先把工程值除以Scale再转字节发出。

3.4 心跳与重连:跨协议统一状态管理

多协议统一后,链路状态管理也必须统一,否则每个驱动各写一套重连逻辑,很快又会膨胀。我在驱动层之上加了一个DeviceSession,负责心跳、重连、状态上报。

心跳频次我一般设为通讯空闲时每3秒发一次,连续三次无响应判断链路失效,触发重连。重连采用指数退避:第一次失败等1秒,第二次2秒,最多30秒。期间对外暴露状态是Disconnected,上层界面显示灰色,不参与业务读写。

要特别提醒的是:TCP是长连接,但应用层心跳不能省。只靠TCP的KeepAlive默认参数根本不靠谱,默认探测周期太长,对产线来说感知太慢。应用层心跳虽然增加了一点报文量,但换来接近实时的断线感知,非常值得。

4. 实测对比——吞吐量、响应延迟与资源占用

4.1 测试条件和口径

为了不拍脑袋,我在同一台工控机和同一个网络环境里分别跑了旧版轮询和新版异步的对比测试。设备规模:10台PLC,1350个点位,其中高频刷新点位300个、低频点位1050个。通讯构成为读操作占80%,写操作占20%,持续运行10分钟,取稳定段数据。

测试项包括:一轮完整数据刷新时间、单项读写平均延迟、最大延迟抖动、CPU占用率、内存占用、线程数、UI交互响应延迟。UI交互响应延迟通过一个定时器模拟按钮点击后到控件刷新的间隔。

4.2 关键数字

指标轮询架构异步架构
一轮全量刷新时间2.1秒820毫秒
单项读写平均延迟受轮询周期影响,数百毫秒级12毫秒
最大延迟抖动超过1500ms38ms
CPU占用27%9%
内存占用480MB360MB
线程总数176
UI点击到控件刷新320-900ms40-80ms

需要说明的是,绝对数值和点位构成、PLC型号强相关,但趋势有参考性。异步架构最明显的收益其实不是CPU,而是延迟抖动被压平了。操作员体感上的最大变化是按钮点击后反馈变得跟手了。项目后面又加过3台PLC和400个点位,异步架构只调整了通道数量,通讯层代码一行没改。

4.3 压测中发现的两个额外优化

测试过程中加的两个优化收益很大,单独说。

第一个是相邻地址聚合。多个请求投递进来后,如果地址落在同一个连续区间,消费者合并成一个批量读请求,一次报文读回一大块再按TagMeta切分。轮询架构也能做批量,但轮询的批量是刻板固定的;异步里的聚合是动态的,这轮高频变量只有10个相邻就合10个,下轮变成20个就合20个,聚合的粒度跟着实时请求走。

第二个是动态刷新分组。点位按刷新需求分三档:高频档100ms一次,中频档500ms一次,低频档5秒一次,每档独立调度。旧轮询方案所有点位统一一个周期,高频变量为了实时性,不得不拖着低频变量一起被高频扫描,浪费很大。

5. 异步架构落地过程中的那些坑

5.1 老设备的协议栈不是无限并行

看起来异步架构能力上来了,设备端却未必接得住。现场有一台老式PLC,Modbus TCP并发数只要超过1,就会出现偶发通讯错误,报文超时率明显上升。排查到最后确认是设备侧协议栈实现较弱,单连接窗口非常小。

解决方法是给那台设备在配置表里设DeviceConcurrency=1,单独一个闸门,其他现代设备保持并发4。所以并发数不要写死成全局常量,做成设备级配置,让现场能根据设备表现单独调整。

5.2 半开连接:最难发现的故障

异步架构有一个轮询架构不容易遇到的坑:半开连接。设备断电或网线断开但没有正常发送RST时,TCP连接两端已经不一致,本端还认为连接活着。你发报文,报文进入虚空,然后等超时;TCP层可能要很久才能发现链路已死。

解决办法分三层。第一层是缩短Socket读写超时,发送确认和接收都设5秒左右;第二层是应用层心跳,这是兜底;第三层是重连时主动关闭旧Socket并新建连接,而不是复用半死状态的连接。重连逻辑先Close再Connect只是第一步,别忘了把旧的Channel分区清理掉,否则旧请求会挂着继续超时。

5.3 日志和报文追踪不能省

异步架构把请求分发到多个环节,一旦出问题,排查难度比轮询大得多。我的习惯是给每个请求串同一个RequestId,从UI发起到协议报文发出再到响应返回,所有日志都带这个ID。配合结构化日志,筛选一条请求的完整链路只需要一条查询语句。

报文级追踪我建议在驱动层设置一个实验开关,打开后把每个读写报文的前64字节按十六进制写进日志。平时不开,等现场怀疑协议栈行为异常或第三方设备兼容问题时再开,能省下大量连抓包软件都来不及准备的时间。抓包也同样要会用,用抓包工具过滤IP和端口,确认请求和响应是否匹配,这是最终裁决手段。

5.4 新旧架构切换:灰度比推倒重建安全

重构最大的风险不是设计,而是切换过程。我们没有某一天直接停掉旧版,而是双跑策略:新旧两套通讯逻辑同时运行一段时间,都连接同一批PLC,但不让UI层切换到新数据源。等到新架构连续72小时没有异常、数据对比也没有差异,再把UI的数据源指针切过来。

这个阶段有一个要注意的点:PLC端对同一时刻的并发连接数是有限制的,双跑等于两个客户端在同时连设备,所以双跑期要控制新架构对同一设备的连接和请求频率,别把PLC挤爆。切换后再保留一段观察期的回滚开关,现场一旦发现异常可以一键切回旧通讯层。很多时候这个开关不是给开发看的,是给现场值班人员兜底的。

其实异步架构最值的部分还不是性能数字,而是代码结构变清晰了。写操作不会被周期读卡住,新增协议只需要写一个驱动实现类,设备扩容只是加配置行。项目组后来接新设备、调参数、排障,再也没人对通讯层动手就出一身冷汗。

最后分享一个小经验:异步通讯里所有对外暴露的await方法,一定要把ConfigureAwait(false)写到位。上位机项目里UI线程和通讯线程到处交叉,漏一处两个线程切换不算什么,数量一多主线程照样被拖垮。这一行不起眼的代码,在异步架构里值回重构票价。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询