☰
C# WPF上位机ModbusTCP通信类第三版:事务匹配、粘包处理与超时控制实践
2026/10/1 7:09:08 网站建设 项目流程

工业上位机项目做到一定阶段,真正卡住进度的往往不是业务逻辑,而是底层通信类的健壮性。手里这套C# WPF Mvvm Toolkit上位机项目,ModbusTCP通信类已经迭代到第三个版本,前面两版解决了阻塞、卡顿的基础问题,但真正上产线跑起来,才发现工业现场的残酷:多设备轮询、网络抖动、PLC断电重启、粘包半包,任何一个小概率事件都会变成线上事故。这篇文章就把第三次改进的几个关键点完整拆开,包括架构调整、代码实现、Smart 200等设备联机时的坑,以及我自己在WPF大屏项目里踩过的真实雷区。

1. 为什么还要做第三次改进:前两版的痛点复盘

1.1 从第一版到第三版:一次需求驱动的进化过程

先说一个前提:我在这个系列里把上位机通信类写成闭环案例,不是要证明“我写的ModbusTCP类多完美”,而是想梳理一条对大多数做设备数采、MES对接、能源监控的开发者都有参考价值的演进路径。

第一版是最朴素的做法:TcpClient连上PLC,发请求之后用Thread.Sleep等固定延时再收响应,同步方法一把梭。这套逻辑在小工具里能用,放到WPF界面里就暴露问题:读写寄存器会卡住UI线程,点按钮像死机;设备一多,整个采集循环全部串行,扫描周期长到无法接受。

第二版用async/await把读写方法改成异步,配合MVVM Toolkit的AsyncRelayCommand消除UI卡顿,又做了简单的重试机制。这套方案在实验室里看起来是好的,但拿到现场测试,暴露的第二个问题是:多个页面和定时器同时发请求时,响应帧可能被串线——读A地址返回的数据其实是上一个请求的。本质原因是TCP流式传输,多请求并发时没有做事务匹配,收包逻辑只认“来了数据就收”,来不及校验这条帧对应哪条请求。

所以第三版的核心目标很明确:引入事务标识符,构建请求-响应匹配模型;处理半包粘包问题;建立超时取消链路;把连接状态变成MVVM可绑定的数据源。这三个版本说白了就是一条线:先能用,再不卡,最后稳。

1.2 第三版重点解决的四类硬伤

第一类硬伤是并发请求的响应错配。ModbusTCP的报文头里有事务标识符(Transaction ID),但很多人复用同一个TcpClient时懒得管它,结果自嗨式地把所有请求塞进同一个连接,响应回来时根本不知道属于谁。第二类是粘包半包:因为TCP是流协议,一次Read可能读到半个响应,也可能一次读到两个完整响应,按“收到几个字节就解析”的思路必然出错。

第三类是超时假死:PLC断网或从站没应答时,ReadAsync如果没配超时,线程会挂在那里一直等,界面看着是“通信中”,其实已经原地死锁。第四类是UI状态不同步:连接断开、重连中、数据刷新这些状态没有数据绑定通道,界面上只能靠定时器反复刷线程安全的布尔变量,既不优雅也容易出并发问题。

第三版针对这四类问题做了几项关键设计:TCP连接封装为一个内部有锁、有事务表、有状态通知的服务;接收循环独立运行,按MBAP头长度字段精确拆帧;每个请求挂一个TaskCompletionSource,配合CancellationTokenSource实现超时取消;所有状态属性通过[ObservableProperty]交给界面自动刷新。后面几章会逐个拆开讲实现。

2. 新架构的整体设计思路与MVVM分工

2.1 通信服务类与ViewModel的边界划分

到了第三个版本,我最大的感受是:ViewModel千万别直接操作Socket。哪怕你只是想在按钮里读一个寄存器,一旦后续加了重试、日志、离线缓存,ViewModel就会膨胀成谁都不敢动的巨型类。所以这次我把通信层独立成ModbusTcpService,它是整个优化方案的核心载体。

这个类有四块职责:管理TcpClient生命周期(包括连接、断开、重连、释放资源);维护请求事务表(Dictionary<ushort, TaskCompletionSource<byte[]>>);提供线程安全的读写方法;对外发布连接状态和最近错误信息。而ViewModel只依赖这个服务类的公开方法,例如Task<ushort[]> ReadHoldRegistersAsync(ushort start, ushort count),内部怎么拆包、怎么超时重试,对上层完全透明。

这样划分之后,MVVM的边界极其清晰:服务类负责“数据能通”;ViewModel负责“数据怎么变成界面状态”;View的DataContext只绑ViewModel的属性。以后换PLC品牌也从ModbusTCP换成Profinet,不需要动界面层,只需要替换服务类的实现。

2.2 请求合并与“异步锁”设计

做上位机大屏项目时,多少会碰到十几个页面同时轮询同一个PLC的情况。如果每个页面各自连一个TcpClient,PLC大概率会被连接风暴打趴,现场交换机的连接数也会爆。第三版里我采用单连接+请求合并策略:所有请求复用同一个TcpClient,内部用一把信号量控制同一时刻只允许一个物理请求在网络上传输。

有人会问:ModbusTCP不是支持并发事务ID吗?协议确实支持多事务并行,但很多PLC从站(特别是西门子S7-200 SMART这类入门级设备)本质上还是串行处理Modbus请求,你并行发十个,它可能直接忽略后面的。所以现场求稳,我选择“应用层单飞”策略:请求队列顺序发送,收到对应响应后再发下一个。代价是轮询速度受限,但换来的是极高的兼容性。

实现上就是两个关键成员:一个SemaphoreSlim _sendLock控制发送顺序,一个事务表记录每个请求的等待者。发送前加锁,发送后把TaskCompletionSource放进事务表,然后释放锁等待响应;接收线程拆出完整帧之后,通过帧里的事务ID找到对应的TaskCompletionSource,把数据SetResult交还。这样一个请求永远只对应一个响应,天然不会串线。

2.3 MVVM Toolkit源生成器在这个项目里的实战技巧

CommunityToolkit.Mvvm从8.0开始大面积使用源生成器,这个版本正好匹配WPF项目的需求。这次改进里用得最多的是两个特性:[ObservableProperty]和[RelayCommand]。

先说[ObservableProperty]。它把一个私有字段自动转成公开属性并实现INotifyPropertyChanged,例如:

public partial class ModbusTcpService : ObservableObject { [ObservableProperty] private bool _isConnected; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanOperate))] private string _connectionState = "未连接"; }

这里有个细节:源生成器要求类必须是partial class,字段命名必须是_isConnected这种下划线小驼峰风格,生成出来的属性就叫IsConnected。我踩过一次坑是忘了partial关键字,编译一直报字段找不到,排查半天才反应过来。

[RelayCommand]生成命令更实用,异步方法直接声明成Task返回类型即可:

[RelayCommand] private async Task ConnectDeviceAsync() { await _service.ConnectAsync("192.168.1.10", 502); }

然后界面上直接Command="{Binding ConnectDeviceCommand}"绑定。配合MVVM Toolkit的AsyncRelayCommand,执行期间命令会自动置灰,UI不会重入,这个特性在连接/断开按钮上特别有用。

3. 核心代码实现与关键逻辑拆解

3.1 连接管理:TCP连接、断线重连与状态通知

连接这块第三版做了很明确的分层:ConnectAsync负责建立TcpClient并启动接收循环;DisconnectAsync负责释放资源并通知所有等待中的请求抛异常;MonitorConnectionAsync作为后台任务,利用心跳请求检测链路是否活着。

先看连接和断开的骨架代码:

public partial class ModbusTcpService : ObservableObject { private TcpClient _tcpClient = null!; private NetworkStream _stream = null!; private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); private readonly Dictionary<ushort, TaskCompletionSource<byte[]>> _pendingRequests = new(); private CancellationTokenSource _connectCts = new(); private ushort _transactionId = 0; private readonly object _transactionLock = new(); public async Task ConnectAsync(string ip, int port, int timeoutMs = 3000) { await DisconnectAsync(); _connectCts = new CancellationTokenSource(); try { _tcpClient = new TcpClient(); using var timeoutCts = new CancellationTokenSource(timeoutMs); await _tcpClient.ConnectAsync(ip, port, timeoutCts.Token); _stream = _tcpClient.GetStream(); IsConnected = true; ConnectionState = "已连接"; _ = Task.Run(() => ReceiveLoopAsync(_connectCts.Token)); } catch (Exception ex) { IsConnected = false; ConnectionState = "连接失败"; throw new InvalidOperationException($"连接 {ip}:{port} 失败", ex); } } public async Task DisconnectAsync() { _connectCts.Cancel(); try { _stream?.Dispose(); } catch { } try { _tcpClient?.Dispose(); } catch { } IsConnected = false; ConnectionState = "未连接"; foreach (var kvp in _pendingRequests.ToArray()) { kvp.Value.TrySetException(new IOException("连接已断开,请求取消")); } _pendingRequests.Clear(); await Task.CompletedTask; } }

ConnectAsync里用了一个很核心的套路:先用CancellationTokenSource(timeoutMs)给连接操作加超时,避免目标IP不存在时卡住几十秒。注意必须先把旧的连接断开,再建立新连接,否则重连时旧连接的接收循环还在跑,两套数据流互相打架。

断线通知所有等待请求这个细节很容易漏。生产环境里PLC断电重启,此时界面上可能有几十个采集任务正挂在等待响应,如果不强制SetException,这些任务会一直等满超时时间再抛错,极大拖慢整体恢复。一次性把这些TaskCompletionSource全部置为异常,调用方立刻感受得到连接异常,UI提示也更快。

3.2 请求帧构建与事务ID机制

ModbusTCP报文结构不复杂,但很多人栽在不重视事务ID上。一个标准读写请求帧包括:事务标识符(2字节)+ 协议标识符(2字节,固定为0)+ 长度(2字节)+ 单元标识符(1字节)+ 功能码(1字节)+ 数据。注意ModbusTCP没有CRC校验,因为TCP协议本身可靠;MBAP头的长度字段在拆包时反而成了关键。

事务ID必须是递增且唯一的,我用一个加锁的方法生成:

private ushort GetNextTransactionId() { lock (_transactionLock) { _transactionId = (ushort)((_transactionId + 1) & 0xFFFF); if (_transactionId == 0) _transactionId = 1; return _transactionId; } }

然后构建请求帧的核心方法:

private byte[] BuildRequestFrame(byte unitId, byte functionCode, ushort startAddress, ushort quantity) { ushort transactionId = GetNextTransactionId(); byte[] frame = new byte[12]; frame[0] = (byte)(transactionId >> 8); frame[1] = (byte)transactionId; // 协议标识符固定为00 00 // 长度字段固定为06,表示后续还有6个字节 frame[4] = 0x00; frame[5] = 0x06; frame[6] = unitId; frame[7] = functionCode; frame[8] = (byte)(startAddress >> 8); frame[9] = (byte)startAddress; frame[10] = (byte)(quantity >> 8); frame[11] = (byte)quantity; return frame; }

读保持寄存器用功能码0x03,读线圈用0x01,写单个寄存器用0x06,写多个寄存器用0x10。这些都是标准Modbus功能码,但在和西门子S7-200 SMART以及部分国产PLC联调时,建议先确认从站支持哪些功能码:Smart 200作为ModbusTCP从站,控制器里要调用MB_SERVER指令开启服务;不同固件对功能码的支持范围略有差异,测试时先把最常用的03、06、10跑通。

事务ID机制带来的最大收益就是:一个请求发出后,接收线程收到任何一帧响应,只需要查找_pendingRequests里是否有那个事务ID,就能确定是不是当前请求的结果。即便偶尔出现响应顺序错乱,任务也能正确匹配到对应的等待者。

3.3 响应帧的粘包半包处理与超时控制

响应拆帧是第三版改动最狠的地方。ModbusTCP请求发出后,从站返回的响应帧结构是:事务ID 2字节 + 协议ID 2字节 + 长度2字节 + 单元ID 1字节 + 功能码+数据(若干字节)。关键在长度字段:它表示“后面还有多少字节”。因此完整帧总长 = 6(MBAP头前6字节)+ 长度字段的值。

我用MemoryStream做接收缓冲,接收循环一次Read能读多少是多少,每接收到一段数据就尝试从缓冲里解析完整帧:

private async Task ReceiveLoopAsync(CancellationToken ct) { var buffer = new byte[4096]; while (!ct.IsCancellationRequested) { try { int read = await _stream.ReadAsync(buffer, ct); if (read <= 0) break; _receiveBuffer.Write(buffer, 0, read); while (TryParseFrame(out var responseFrame)) { ushort tid = (ushort)((responseFrame[0] << 8) | responseFrame[1]); if (_pendingRequests.TryGetValue(tid, out var tcs)) { _pendingRequests.Remove(tid); tcs.TrySetResult(responseFrame); } } } catch (OperationCanceledException) { break; } catch (Exception ex) { LastError = ex.Message; IsConnected = false; ConnectionState = "通信异常"; await Task.Delay(1000, ct); break; } } }

TryParseFrame的核心逻辑是先看缓冲里够不够6个字节的MBAP头,不够就直接返回false;够的话根据第4、5字节算出长度字段,再判断缓冲里是否已有完整的“6+Length”字节;如果完整就取出整帧并移除已消费部分,否则继续等待下一段数据。这套逻辑同时解决了半包和粘包:半包时等数据攒齐,粘包时循环解析多条完整帧。

超时控制这块,发送端在每个请求上都挂一个CancellationTokenSource:

public async Task<byte[]> SendRequestAsync(byte[] frame, int timeoutMs = 1500) { var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously); ushort tid = (ushort)((frame[0] << 8) | frame[1]); await _sendLock.WaitAsync(); try { _pendingRequests[tid] = tcs; await _stream.WriteAsync(frame); using var timeoutCts = new CancellationTokenSource(timeoutMs); var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs, timeoutCts.Token)); if (completedTask != tcs.Task) { _pendingRequests.Remove(tid); throw new TimeoutException($"Modbus请求超时,事务ID={tid}, 功能码={frame[7]:X2}"); } return await tcs.Task; } finally { _sendLock.Release(); } }

这里有个容易被忽略的点:TaskCompletionSource创建时一定要用RunContinuationsAsynchronously,否则响应线程在SetResult时会同步执行调用方的后续代码,可能在接收循环里造成阻塞。超时时间我习惯默认1500ms,大屏轮询场景下这个值比较平衡;如果现场转发网络复杂,建议调到3000ms,但不要超过5秒,否则故障恢复太慢。

3.4 对上位机日常用的四类功能码封装

通信层稳定之后,上层调用的方法就该足够语义化。第三版对外暴露了四组方法,覆盖绝大多数上位机场景,每个方法内部都是“构建帧 + 发送 + 解析响应 + 校验异常码”的标准流程。

public async Task<byte[]> ReadHoldingRegistersAsync(byte unitId, ushort startAddress, ushort quantity, int timeoutMs = 1500) { byte[] request = BuildRequestFrame(unitId, 0x03, startAddress, quantity); byte[] response = await SendRequestAsync(request, timeoutMs); CheckModbusError(response, 0x03); return response.Skip(9).Take(response[8]).ToArray(); } public async Task WriteSingleRegisterAsync(byte unitId, ushort address, ushort value, int timeoutMs = 1500) { byte[] request = new byte[12]; // 组装头 + 功能码 0x06 + 地址 + 值 byte[] response = await SendRequestAsync(request, timeoutMs); CheckModbusError(response, 0x06); } public async Task WriteMultipleRegistersAsync(byte unitId, ushort startAddress, ushort[] values, int timeoutMs = 2000) { // 功能码 0x10,字节数 = values.Length * 2 } public async Task<bool[]> ReadCoilsAsync(byte unitId, ushort startAddress, ushort quantity, int timeoutMs = 1500) { // 功能码 0x01,位转 bool[] }

CheckModbusError检查的是响应帧里的功能码最高位。如果从站报错,功能码会变成0x83、0x86、0x90这些值,数据部分第一个字节是异常码。异常码定义有章可循:0x01非法功能,0x02非法地址,0x03非法数据,0x04从站设备故障。收到异常码时一定要把它转成可读的中文说明抛出来,不然现场调试时看到“收到非法数据值0x02”根本不知道是地址越界还是长度不合法。

价值转换这一步也容易踩坑。Modbus里的寄存器数据是大端序,两个字节高字节在前。比如读取到的byte[]是{0x12, 0x34},对应的ushort值是0x1234 = 4660。WPF界面里显示温度、压力、流量这些变量时,通常还要再按工程量进行缩放,这个转换不要放在服务层里,放在ViewModel的转换逻辑里更合适。

4. 踩过的坑、实测数据与后续扩展

4.1 “发出去没反应”的排查清单

写到这里,必须把排查“发请求无响应”的思路完整整理出来。热词里那个“smart200 modbustcp done为0”其实就是这类问题的典型表现:上位机自认为请求发成功了,但PLC侧的状态位一直不置位。我整理了排查顺序,踩过坑的人能明显体会到顺序的重要性:

第一步查网络层:先在本机用TCP工具(SocketTool或者自写的socket测试程序)直连PLC的502端口,能连上说明IP和端口没问题。连不上就检查PLC的IP地址和PC网卡是否在同一网段,西门子Smart 200默认IP通常是192.168.0.1。第二步查PLC程序配置:Smart 200做ModbusTCP从站,必须在主程序里调用MB_SERVER指令并指定连接端口和保持寄存器参数,你没有调用MB_SERVER,上位机发多少请求都会石沉大海。第三步查请求帧本身:功能码、起始地址、数量是不是合法。S7-200 SMART的Modbus从站地址映射规则里,保持寄存器通常对应V区地址,例如Modbus地址40001对应VB0,如果上位机读的是40011,找对对应的V区才能拿到数据。第四步查响应超时设置:有些PLC型号本身处理请求需要更长时间,一次扫描周期没跑完你的超时就到了,调大超时时间再试。第五步抓包看协议层:实在找不到原因就抓包,看从站有没有回到TCP ACK、有没有回RST。正常情况下ModbusTCP响应会很快,如果PLC回了RST,大概率是PLC侧程序崩溃或者连接数超了。

这套排查路径写出来其实很简单,但现场里我见过太多人一上来就凭着经验改代码,结果查半天发现是PLC里连服务器都没调起来。记住一个原则:先免费后付费,先查配置后改代码。

4.2 高并发读写时的顺序问题

回到并发这个话题,第三版用“信号量锁 + 事务ID”的组合保证同一时刻只有一个请求在网上跑,但还有一个顺序问题容易忽略:多个ViewModel任务同时发起请求,谁先抢到信号量是随机的,不一定按发起顺序执行。对某些场景来说,读写顺序会影响业务正确性。比如一个按钮动作要求“先写启动位,再读运行状态”,如果读请求先抢到锁,读到的是启动前的旧状态。

解决办法有两个。如果你用的是单个按钮触发的异步命令,命令内部自然按先后顺序await,不存在此问题。如果是多个独立后台任务在轮询,并且彼此有先后依赖,就要用一个Channel<Func<Task>>或简单的队列,把请求按到达顺序排队,消费端逐个执行。我实际项目里选择的是后者,因为大屏项目里有多个采集定时器,它们分别负责不同的页面数据,彼此互不依赖,按到达顺序排队反而公平。

排队模型实现很直接:一个后台任务死循环读Channel,收到一个委托就await执行一个。通信服务内部本身就有信号量锁兜底,所以队列消费端不需要再加锁,代码清爽很多。这个设计在三十个采集点位同时上线时表现尤其明显,请求不会互相插队,帧顺序始终稳定。

4.3 后续还可以怎么扩展

第三版通信类的架子搭好之后,后续扩展方向其实非常清晰。一个方向是把轮询调度搬到服务层里面,做成智能调度:每个点位有自己的采集周期,服务层统一调度并按需合并,这样比多个Timer分别触发要高效得多。另一个方向是增加数据缓存:最近一次读取的数据存字典里,上层可以直接查询,避免界面刷新时反复读网络。

还有一个很实用的点是持久化记录:把所有异常响应、重连动作、超时事件写进日志文件。这样设备半夜掉线,第二天有据可查,能精确看到是几点几分几秒断了,恢复耗时多久。日志不要放服务层对外抛异常,而是服务层内部订阅自己的异常事件,统一写文件。

最后说一句身边的事。这个项目做到第三版,最大的体会是:上位机开发里最值钱的东西不是把某个功能码写得多溜,而是把通信层和业务层之间的关系理得多顺。一个稳定、可测试、失败可见的ModbusTCP服务类,能让后续加设备、改界面、做权限控制都轻松不少。如果这个系列对你有帮助,后面我还可以把大屏轮询调度的实现单独拎出来讲,那个场景下并发问题暴露得更彻底。

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

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

立即咨询