去年年底接了个改造项目,客户现场有三台老旧PLC、两台智能网关,上位机跑的还是Windows XP上的WinForms祖传程序,通过串口Modbus RTU轮询数据,轮一圈45个点位要两秒多,界面稍微拖一下就掉线。甲方提了两个硬性要求:以后上位机要能部署到Linux工控机,界面得适配触摸屏,操作工戴着手套能点。我在技术栈里权衡了一圈,最后选了Avalonia做UI框架,通信侧走Modbus TCP去采集现场设备数据。
整套监控面板从原型到上线,踩的坑比我想象中多得多:有协议层面的、有线程模型的、有UI性能的,甚至还有部署环境的大坑。这趟经历整理出来,给准备用Avalonia碰工业通讯的朋友们打个样,也顺便把我当时排查问题的完整链路复现一遍。
1. 为什么我会用Avalonia去接Modbus TCP做监控面板
1.1 接手旧系统时的痛点
先说现场的环境。那套WinForms上位机跑在Windows XP工控机上,功能其实不复杂,就是把PLC里的温度、电流、转速读上来,显示在几个窗体里,超过阈值就弹窗报警。但问题很现实:串口通信距离有限,多设备得走轮询,点位一多整个采集周期就拉胯;再者XP系统本身已经没任何补丁可打,工控机坏了还不好找替代品,甲方IT是不允许老系统继续裸奔的。
新需求的核心就两条:一是支持跨平台部署,因为甲方打算在几个分厂试用Linux迷你主机;二是界面必须触摸友好,车间工人戴劳保手套也能操作,按钮要大、信息要一目了然。旧程序显然都不满足。
1.2 为什么没选WPF,也没选Web方案
聊选型之前,我先说个观点:技术选型没有绝对最优,只有匹配场景。我当时最先考虑的是WPF,毕竟团队里C#背景的人多,MVVM、绑定、DataGrid都熟。但WPF是Windows专属,甲方明确说Linux是必选项,直接出局。WinForms更不用提,跨平台遥遥无期。
Web方案也考虑过,前后端分离,后端用.NET 8写Modbus TCP采集服务,前端Vue或者React做看板。这个思路展示数据很漂亮,但如果要做本地离线部署、触摸屏上的无浏览器Kiosk模式、底层硬件的串口/USB外设访问,维护成本会明显高出一截。有些厂区的网络环境连在场内都一言难尽,更不可能依赖公网服务。
Avalonia在当时最打动我的三点:XAML语法和WPF几乎无缝迁移、Avalonia绑定和MVVM模型跟WPF习惯完全一致、运行时是.NET标准应用,可以部署到Windows/Linux/ARM盒子。社区在工控圈也越来越活跃,很多HMI厂商已经开始拿它做新一代上位机界面。用一句话说:WPF的开发体验,跨平台的交付半径。
1.3 Modbus TCP通信库选型
Modbus TCP本身协议挺简单,但成熟库能帮你少踩一半的坑。我当时评估了三个方向:NModbus4、NModbus(revamp)、以及完全自研轻量客户端。
NModbus4是老牌库,.NET Framework时代就在用,包支持.NET Standard 2.0,在.NET 8下可以正常引用,API风格朴实——IModbusMaster.ReadHoldingRegistersAsync这类直接调用,读线圈、读寄存器、写线圈都有。NModbus是社区revamp的分支,API更现代,带了更多异步和C#风格。我最后选了NModbus4,原因很实际:代码看得到底,出了问题好排查,而且它就是个纯协议栈,不带UI、不带日志、没有一堆抽象层。
自研的思路我劝退自己了。Modbus TCP看起来简单,但半包粘包、异步超时、异常帧处理这些边缘场景要测试到工业可用级别,投入时间不亚于一个星期的开发量。协议栈就十分钟能写完,但打磨稳定要很久。
1.4 开发环境与必备工具链
写Avalonia项目我用的是VS Code,配上C# Dev Kit、Avalonia for VS Code扩展、XML Language Support。Avalonia for VS Code目前提供了XAML智能提示和基础预览,复杂绑定场景还是要把程序跑起来看DataContext,但日常写XAML的效率已经比裸写高太多了。模板安装用一条命令解决:
dotnet new install Avalonia.Templates dotnet new avalonia.mvvm -n HmiPanel再推荐一个XAML格式化工具,叫XAML Styler,扩展市场直接搜,能按规则统一XAML的属性和缩进风格,多人协作时少吵很多架。离线开发环境就麻烦一点,后面第六章专门讲。
2. Modbus TCP协议那些坑:地址、字节序与数据解析
2.1 先理清四种寄存器类型再动手
Modbus协议家族里一共有四种数据对象:线圈(Coil)、离散输入(Discrete Input)、保持寄存器(Holding Register)、输入寄存器(Input Register)。看名字容易糊涂,我的理解方式是:线圈对应PLC里DO(数字量输出,可读可写),离散输入对应DI(数字量输入,只读),保持寄存器对应大部分模拟量和参数区(可读可写),输入寄存器对应只读的模拟量采集区。
功能码我也列一下,读操作最常用:
| 数据对象 | 位宽 | 读写属性 | 读功能码 | 写功能码 |
|---|---|---|---|---|
| 线圈 | 1 bit | 可读可写 | 0x01 | 0x05/0x0F |
| 离散输入 | 1 bit | 只读 | 0x02 | 无 |
| 保持寄存器 | 16 bit | 可读可写 | 0x03 | 0x06/0x10 |
| 输入寄存器 | 16 bit | 只读 | 0x04 | 无 |
温度、压力、电流这些模拟量在现场设备里很多走的是保持寄存器,但你要去看设备手册确认,不要想当然。我就遇到过一台变频器把运行频率放在输入寄存器区,用保持寄存器去读,读回来全是零。
2.2 地址偏移:40001和协议地址0的千年老坑
这是Modbus对接里最阴间的一个坑,几乎每个项目都要中招一次。设备手册上写着"启动频率保持寄存器地址为40001",但Modbus TCP协议帧里的地址字段是0x0000。也就是说,手册上那种4xxxx的编号是给人看的用户地址,协议里真正传输的地址是从0开始的实际偏移。
如果你直接拿着40001去组报文,等于目标地址写成了40001,远超协议地址范围,设备要么返回异常码,要么把你当成一个不存在的寄存器。我当时对接第一台PLC,工程师给的点位表全都是40001这种风格,我在解析层里撸了一版对照表,把"用户地址减40001"得到协议地址,才把数据全部读对。
配置层面我的建议是:底层协议用协议地址,配置界面显示User Address;点位的Address字段存"用户可读地址"(比如40001),发送前统一转成协议地址(减40001)。这样设备工程师拿点位表来核对的效率高,你也不用在脑子里来回换算。
2.3 双字数据的大小端处理:I16、U32、Float各有各的死法
16位单寄存器存整数大家差异不大,真正头疼的是32位数据——浮点数和32位整数。Modbus TCP协议本身规定大端传输(高字节在前),但设备厂商在实现时各有各的偏好。常见的有四种字节序:
| 类型标识 | 顺序 | 说明 |
|---|---|---|
| ABCD | 大端标准 | 高字节在前,协议标准 |
| CDAB | 字序交换 | 寄存器顺序反了,常见于部分日系设备 |
| BADC | 字节交换 | 每个寄存器的两个字节反了 |
| DCBA | 全反转 | 大端前提下整体反转 |
我不建议写死一种解析方式。经验做法是给每条点位加一个"字节序"配置项,解析时通过配置选择对应的转换函数。用C#写一个通用解析器,比如:
public static float ToFloat(ushort high, ushort low, EndianType endian) { byte[] bytes = endian switch { EndianType.ABCD => BitConverter.GetBytes(high).Concat(BitConverter.GetBytes(low)).ToArray(), EndianType.CDAB => BitConverter.GetBytes(low).Concat(BitConverter.GetBytes(high)).ToArray(), // 其他模式同理 }; return BitConverter.ToSingle(bytes, 0); }实战经验是这样:拿到设备手册先别急着写代码,用Modbus调试工具(比如ModbusPoll或者自己写个抓包脚本)去读一两个已知点位的原始寄存器值,看和手册里标称值差在哪,确定字节序后统一配置。我当时就是用这个方法,直接把三台PLC的所有浮点数据全部搞定。
2.4 字符串和位提取:比字节序更隐蔽的细节
字符串在Modbus里没有标准说法,各家实现很野。常见的是固定N个寄存器存ASCII码,读够N*2字节后按ASCII解码。但有些设备会把字符串的每个字符高字节置0或者置空格填充,还有些设备会把字符高低字节倒着放。我的排错方法还是先抓原始字节,比对着设备手册看空格填充规则,再决定是截断还是替换。
位提取相对好理解:一个16位状态寄存器,bit0可能表示1号电机过载,bit3表示2号电机过载。C#里判断位状态就用位运算判断某个bit是否为1。但要注意,有些PLC的位定义是从1开始的,文档说"第1位"实际对应协议寄存器的bit0,对不上容易报警条件写反。这个细节到现场调试时特别容易忽略,建议在配置表里统一标注"位起始编号0"。
2.5 数据解析层与UI解耦:点表驱动是唯一出路
UI界面、报警逻辑、趋势曲线、历史存储,全都要消费采集数据。如果每处都直接去读Modbus帧,代码会迅速腐化成一团浆糊。我最后的做法是做一个点表驱动的解析层:用一个点位集合定义每个数据项的寄存器类型、协议地址、数据类型、字节序、缩放系数、单位。通信层按点表批量拉取寄存器,解析层产出统一的数据字典(点位ID到值的映射),UI和报警都只依赖这个字典。
public sealed record PointItem { public int Id { get; init; } public string Name { get; init; } public RegisterType RegisterType { get; init; } public ushort Address { get; init; } // 协议地址 public DataType DataType { get; init; } public EndianType Endian { get; init; } public double Scale { get; init; } = 1.0; public string Unit { get; init; } }这个设计的好处是:设备点位表变了,改配置就行,解析代码一行不用动;多台设备之间还可以复用同一套点表模板。
3. 通信层设计:轮询节奏、超时重连与多设备并发
3.1 千万别在UI线程里同步读寄存器
第一个Demo我直接在按钮点击事件里调用ReadHoldingRegisters,界面瞬间卡死。道理很简单:TcpClient的读写是同步阻塞的,读一次PLC寄存器如果对方没响应,最坏要等系统TCP超时(几十秒),UI线程就冻住了。
正确姿势是用后台轮询任务持续采集数据,UI通过事件或者订阅数据流来刷新。设计上可以抽象成一个DeviceChannel:每个设备对应一个后台Task循环,循环里按点表分组发请求,读回数据后发布到数据总线。
while (!_cts.IsCancellationRequested) { try { var data = await _master.ReadHoldingRegistersAsync(slaveId, startAddress, quantity); _snapshot = ParseFrame(data); DataUpdated?.Invoke(_snapshot); await Task.Delay(_pollInterval, _cts.Token); } catch (ModbusException ex) { await HandleCommunicationErrorAsync(ex); } }3.2 轮询周期设计:连续读是银弹
Modbus TCP最浪费的操作就是逐点读取。一个点位一次请求,45个点位就要45个来回,每次算上握手和协议开销,轻松超过一秒钟。正确做法是按连续地址区间批量读:把点表按地址排序,把相邻的寄存器整块读回来,再在内存里切片解析。
我当时45个点位分布在15个连续区间内,每个区间一次ReadHoldingRegistersAsync,整体轮询周期从原来串口的2秒多降到了300毫秒以内,完全是质变。
轮询周期也不要一味求快。PLC虽然能响应高频请求,但通信任务会在PLC内部抢占一部分扫描周期,逼太狠会影响设备本身的控制逻辑。我一般把周期设在200-500ms之间,工控场景完全够用,设备也稳。
3.3 超时、重试与断线重连:把网络故障焊死在通信层
TCP的一个特性是:网络断了,TcpClient不一定立刻知道。你connect的时候成功了,但后面设备断网、PLC重启,socket还在那儿"假连接"。所以要主动设超时和重连机制。
连接超时我习惯用Task.WhenAny手动限时,比如:
var connectTask = _tcpClient.ConnectAsync(ip, port); if (await Task.WhenAny(connectTask, Task.Delay(3000)) != connectTask) { throw new TimeoutException("连接超时"); }读写超时在流上设置ReadTimeout和WriteTimeout,比如2000毫秒。轮询循环里捕获IOException、SocketException、ModbusException,统一走重连逻辑。重连用指数退避,第一次失败等2秒,第二次4秒,最多60秒,避免PLC没恢复时疯狂试探把网络打满。同时维护一个连接状态事件,UI上显示"在线/离线/重连中"的指示灯,这个用户体验极其重要。
3.4 多设备并发不要一把大锁搞全局
现场有三台PLC两个网关,每台设备一个独立的采集Task是最直观的设计。设备之间互不干扰,一台设备重连不影响其他设备的数据流。不要搞一个全局锁去串行访问所有设备,那样一台设备掉线,整个监控面板全部卡住,意义就没了。
通信层内部我用Channel<byte[]>或者Subject<DataSnapshot>这种流式队列来发布数据,UI和各业务模块订阅。System.Reactive的Subject在这里很好用——采集层只管推送快照,UI层用ObserveOn(Dispatcher.UIThread)拿到UI线程上更新界面,报警模块用自己的订阅独立判断阈值,天然解耦。
4. UI刷新与Avalonia绑定:从卡成PPT到丝滑60帧
4.1 MVVM绑定基本功别含糊
Avalonia的XAML绑定和WPF一样,核心是INotifyPropertyChanged。写个基类ViewModelBase实现SetProperty,后面所有ViewModel都继承它。界面上一块温度文本绑定一个温度属性,值一变界面自动刷新。
public class BoilerViewModel : ViewModelBase { private double _temperature; public double Temperature { get => _temperature; set => SetProperty(ref _temperature, value); } }XAML里这样写:
<Border Padding="12" CornerRadius="8" Background="{DynamicResource CardBrush}"> <StackPanel Orientation="Horizontal" Spacing="8"> <TextBlock Text="炉温"/> <TextBlock Text="{Binding Temperature, StringFormat={}{0:F1} °C}"/> </StackPanel> </Border>4.2 后台线程更新UI的两种正确姿势
采集数据到达时,后台线程不能直接改ViewModel的属性(跨线程操作在Avalonia里会检查线程上下文)。最直接的方式是用Dispatcher.UIThread.InvokeAsync:
Dispatcher.UIThread.InvokeAsync(() => { vm.Temperature = snapshot.GetValue("TEMP_01"); });如果整个数据更新流程已经通过ObserveOn(Dispatcher.UIThread)切到了UI线程,那你直接赋值就行,不用再嵌套一层。这两种方式别搞混了:一个是在后台线程手动切到UI;一个是让事件管道自动切到UI。
4.3 高频刷新时的性能杀手:无节制的PropertyChanged
点位多的时候(比如50个点),每秒刷新两三次,每个点都触发一次PropertyChanged,意味着大量绑定引擎的刷新。这时候CPU很容易飙上去,界面动不动20%以上的占用。我优化过这几板斧还挺有效:
一是合并通知。定义一个DataVersion版本号属性,一个点位列表的裸数据,界面只绑定DataVersion,当所有点位一次更新完后统一递增版本号,触发必要区域的刷新。二是用列表快照批量刷新,ObservableCollection不要每次全量Clear再Add,在后台生成新列表,一次性替换ItemsSource。三是降低UI帧率,很多模拟量1秒刷两次人眼根本看不出区别,趋势曲线刷到10Hz纯属浪费CPU。我最后把UI刷新节流到200ms一次,界面依然顺滑,CPU占用降了一半多。
4.4 报警闪烁和趋势曲线实现细节
报警项需要在触发时闪烁吸引注意。我的实现是用Avalonia的Style动画:报警状态切换时给边框加一个Alarm样式类,样式里定义一个颜色闪烁的Animation:
<Style Selector="Border.Alarm"> <Style.Animations> <Animation Duration="0:0:0.5" IterationCount="INFINITE" PlaybackDirection="Alternate"> <KeyFrame Cue="0%"> <Setter Property="Background" Value="#CCFF4444"/> </KeyFrame> <KeyFrame Cue="100%"> <Setter Property="Background" Value="#22FF4444"/> </KeyFrame> </Animation> </Style.Animations> </Style>报警解除时移除样式类,动画自动停止。比自己写DispatcherTimer去闪干净多了,也不容易漏关定时器。
趋势曲线我用的是LiveCharts2,它支持Avalonia的实时折线图。曲线刷新同样要做节流,1秒推一个新采样点足够看趋势,别追求高帧率,否则整个UI的布局和绘图开销会吃掉工控机那点可怜的性能。
5. 熬夜排掉的真实Bug记录(附完整排查链路)
这节里的四个Bug都是我真实踩过的,价值比项目本身的框架设计还高,我按排查链路的顺序写,方便你复现排查思路。
5.1 Bug1:字符串乱码——原以为是编码问题,结果是高低字节颠倒
现象:设备品牌名称读出来是乱码,带一堆不可见字符,比如"AB C"中间缺字符。
排查过程:一开始我怀疑是UTF-8和ASCII混了,把编码换成ASCII再试,还是乱。接着我用抓包工具直接把TCP报文拉出来,把原始寄存器字节打印成十六进制,对照设备手册上厂商给的ASCII值,发现每个字符的低字节和高字节是颠倒的。也就是说,设备把字符的高低字节按Word Swap方式存储,而我的解析器按顺序直接拼字节,于是每两个字节都错位。
修复方案:在字符串解析函数里加一个EndianType参数,对每两个字节做交换后再转ASCII。修复后乱码消失,品牌名和备注全部正常显示。
教训:字符串类问题不要靠猜,抓包看原始十六进制最直接。
5.2 Bug2:UI假死——Task.Result在UI线程上等自己
现象:程序运行几分钟后点击按钮没反应,窗口标题栏显示"无响应",过一会又恢复,但鼠标操作总是卡顿。
排查过程:我打开Visual Studio的并行任务窗口,发现有一个任务状态是"等待Task",而UI线程状态也是"等待任务完成",典型的死锁迹象。再仔细看代码,发现我在一个UI事件处理器里写了var result = _service.GetDataAsync().Result;,GetDataAsync内部又是用Task.Run去跑后台采集,采集完成后需要回到UI线程更新属性。结果就是:UI线程等Task.Result返回,后台任务等UI线程空闲,两边互相等,直接卡死。
修复方案:把所有Task.Result/.Wait()全部改成await,UI事件处理器改成async void(注意处理好异常),后台任务用ConfigureAwait(false)避免强制回UI线程。改完UI立刻恢复顺畅。
教训:异步编程里,千万不要在UI线程上用同步等待异步任务。这是WPF时代的死锁模型,Avalonia完全继承了这个坑。
5.3 Bug3:内存泄漏——事件没退订,定时器没停
现象:程序跑一晚上,内存从200MB涨到1.2GB,重启后降下来,但过一阵又涨上去。
排查过程:用dotnet-dump抓内存快照,分析托管堆,发现BoilerViewModel实例数量异常多,被一个DeviceChannel的事件持有强引用。原因是我在窗口关闭时没有退订DataUpdated事件,窗口被回收但ViewModel还被采集服务引用着,垃圾回收收不掉。另一个泄漏点在报警闪烁动画,DispatcherTimer如果是在报警开始时创建的,没有在报警结束时释放,一直循环触发。
修复方案:MessageWindow实现IDisposable,在OnClosed统一退订事件并释放定时器。事件订阅统一改成安全退订模式,或者用WeakEvent。报警定时器统一由报警管理器管理,不需要时Stop并置空。
教训:工业软件要连续运行几个月甚至几年,内存泄漏比功能Bug更致命。事件订阅和定时器这类隐式引用,必须在生命周期结束时手动清理。
5.4 Bug4:起始地址错一位导致数据全错——手册地址和协议地址的偏移
现象:温度、压力值从第5个点位开始全乱,乱得特别有规律——每个值都像是"前一个点的值"。
排查过程:用Modbus调试工具读原始寄存器,发现地址从40005开始读回来的数据和点表对不上,但地址从40004开始读完全正确。最后定位到配置层:设备工程师给的点位表是"用户地址"风格(40001表示保持寄存器第1个),我通信层发送时没做减40001转换,直接拿40005当作协议地址去读,等于真实寻址到了40006附近的寄存器,全部错位。
修复方案:在点表配置里增加UserAddress和ProtocolAddress两个字段,通信层统一用ProtocolAddress = UserAddress - 40001,UI和配置文件保留用户习惯的40001风格。
教训:这个坑通常一次中招就终身免疫,但团队里新来的人很容易再犯。在代码里显式做转换,别靠口头约定。
6. 部署与离线环境的坑:Avalonia项目上工控机
6.1 离线安装与NuGet本地包还原
工厂内网基本都连不上NuGet,这直接决定了开发阶段的依赖策略。我的做法是先在能联网的机器上把项目引用的所有包下载好,执行nuget locals all -list找到包缓存目录,把packages文件夹整个拷贝到内网机器。然后在内网开发机上设置NuGet.Config,加一个本地包源路径:
<packageSources> <add key="local" value="D:\packages" /> </packageSources>发布到工控机则用自包含单文件发布,把.NET运行时和Avalonia原生库全打进去,目标机器上不需要安装任何.NET环境:
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true发布完会在publish目录下生成一个大文件,拷贝到工控机直接运行。Avalonia涉及的原生库(Skia等)会打包在单文件内部,省去一堆DLL散落的问题。
6.2 Windows工控机部署:字体、DPI和触摸
客户工控机是台老掉牙的Windows平板一体机,拿到手第一个坑就是DPI缩放。默认100%缩放下,我的界面挤成一团,操作工根本点不中。Avalonia对高DPI支持还是可以的,但我踩了一个细节:界面设计时必须给触摸目标设置最小尺寸,按钮不要低于48x48像素,否则戴手套点击很容易误触旁边。后来我把关键操作按钮统一加到了MinHeight="56",误触率明显下降。
字体也是个暗坑。工控机上没有我开发机上的宋体或者微软雅黑各种版本,中文标签全变成方块或者粗细不一的字体。我最后在应用启动时主动注册了随包携带的中文字体:
FontManager.Current.AddFontCollection( new EmbeddedFontCollection(new Uri("fonts:/Assets"), new Uri("avares://HmiPanel/Assets/fonts/")) );把思源黑体的子集放到Assets/fonts下并设为EmbeddedResource,界面文字就稳定了。
唤醒策略也要做:工控机在车间里挂着,操作系统如果默认30分钟休眠,半夜数据就断了。我们部署时专门改了电源策略,并且应用内加了心跳日志,发现数据长时间不刷新就弹提示。
6.3 Linux盒子的坑:缺少字体和libSkiaSharp依赖
Linux工控机的部署比Windows痛苦得多。dotnet publish -r linux-x64 --self-contained true发布后,拷到Ubuntu主机上,运行时报libSkiaSharp相关错误。排查后发现是缺少系统依赖库,Avalonia的Skia渲染需要libfontconfig1,最小安装的Ubuntu默认不带:
sudo apt-get update sudo apt-get install -y libfontconfig1 libx11-6 libxrandr2 libxcursor1没有这些库,程序要么起不来,要么起来后文本渲染全是锯齿。中文字体问题在Linux上同样存在,我直接走了嵌入式字体的路子,和Windows端保持一致,省事很多。
另外触摸屏在Linux下的体验跟Windows差异明显。X11环境对多点触摸的支持不太统一,我最后在对接触摸屏时基本以鼠标事件替代触摸事件,把UI控件都做成大热区,反正操作工点按钮也就点个位置,不需要手势。
这个项目做完我再回头看,最深的感受是:真正折腾人的不是Avalonia这套UI框架,而是Modbus TCP的协议语义、字节序、地址偏移这些底层细节。框架的学习成本其实很低,XAML和绑定逻辑WPF玩家半天就能上手,但协议和工控环境的经验只能靠现场踩坑一点点攒。
如果你也要走这条路,我建议从点表驱动的数据解析层开始做,把通信、解析、UI三层彻底拆开,然后按"先抓包、再写码"的原则处理一切拿不准的协议问题。后面我打算把这个项目里的通信层继续抽成独立类库,把多设备配置和报警联动做得更通用一些,到时候再写一篇分享。