一、为什么换一台 PLC上位机就要改一堆代码做过工业上位机开发的朋友应该都遇到过类似的问题。项目刚开始时只需要连接一台 PLC读取几个温度、压力和运行状态直接调用通信库就能完成。后来客户增加了设备原来的西门子 PLC 换成了三菱另一条产线又接入了欧姆龙。麻烦就来了。同样是读取一个温度值不同 PLC 的地址表示方式可能不同通信报文结构不同数据类型和字节排列也可能不同。有的库直接返回byte[]有的返回short、int或float还有的需要开发者自己处理寄存器和位地址。如果一开始没有设计统一的数据解析层代码很容易变成这样if(plcTypeSiemens){// 西门子读取逻辑// 地址转换// 数据解析}elseif(plcTypeMitsubishi){// 三菱读取逻辑// 地址转换// 数据解析}elseif(plcTypeOmron){// 欧姆龙读取逻辑// 地址转换// 数据解析}刚开始只有一两台设备这样写似乎没什么问题。但当设备数量增加业务代码中就会出现大量品牌判断。读取温度要判断一次读取运行状态要判断一次写入控制参数又要判断一次。最后的结果通常是修改一种 PLC 的解析逻辑其他设备也跟着出现问题。真正需要统一的不是所有 PLC 的底层协议而是上位机使用 PLC 数据的方式。本文以 C# 和 .NET 为基础设计一套可以逐步落地的 PLC 数据统一解析架构重点解决以下问题不同品牌 PLC 如何使用统一的读写接口如何把原始字节转换成bool、short、int、float等业务类型如何处理大小端、字序、位地址和字符串编码差异如何避免业务代码直接依赖具体品牌的通信库新增 PLC 品牌时怎样尽可能不修改原有业务代码需要先说明西门子、三菱、欧姆龙各自存在不同系列和通信协议不能简单地认为同一品牌的所有 PLC 都使用相同的数据格式。本文中的代码重点展示架构与数据解析方法具体地址格式和报文规则应以目标 PLC 型号、通信协议及驱动库文档为准。二、先看整体架构把品牌差异关在适配层里统一解析的核心思想是分层。建议把系统拆成四个部分┌──────────────────────────────────┐ │ 上位机业务层 │ │ 温度显示、设备状态、报警、控制 │ └────────────────┬─────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ 统一数据模型 │ │ PlcValue / PlcDataType / Result│ └────────────────┬─────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ PLC驱动适配层 │ │ SiemensDriver / MitsubishiDriver │ │ OmronDriver │ └────────────────┬─────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ 通信与协议层 │ │ TCP / 串口 / S7 / MC / FINS等 │ └──────────────────────────────────┘这里有一个关键点统一接口不等于统一协议。例如西门子 S7 通信、三菱 MC 协议和欧姆龙 FINS 协议在报文格式、地址表达和通信过程上都可能存在明显差异。不能为了统一接口就把这些协议强行写进一个通用解析函数。正确的方式是每个品牌的驱动负责处理自己的通信协议。驱动将读取到的原始数据转换成统一的数据模型。业务层只使用统一接口不关心 PLC 的具体品牌。当增加新品牌时只需新增对应的驱动适配器并补充必要的配置。这样业务层和协议层之间就形成了清晰的边界。三、第一步设计统一的 PLC 数据类型在设计解析架构之前需要先定义上位机支持哪些数据类型。工业现场比较常见的数据包括类型C# 类型常见用途布尔量bool启停、限位、故障状态16 位有符号整数short计数值、设定值16 位无符号整数ushort状态码、寄存器值32 位有符号整数int累计计数、长整型参数32 位浮点数float温度、压力、流量64 位浮点数double高精度测量值字符串string设备编号、状态文本原始字节byte[]自定义协议或特殊数据区先定义统一枚举publicenumPlcDataType{Bool,Int16,UInt16,Int32,UInt32,Float32,Float64,String,Bytes}这里没有使用 PLC 品牌作为类型分类依据。例如温度点位可以是西门子 PLC 中的 REAL也可以是其他 PLC 中的浮点寄存器。只要驱动最终能够把数据转换成float业务层就不需要知道底层使用了哪种协议。但要注意数据类型并不能完整描述一个点位。例如同样是Int16它可能位于不同的寄存器区域可能采用不同的地址表达方式也可能存在缩放系数。因此还需要一个点位配置模型。publicsealedclassPlcTag{publicstringName{get;init;};// PLC原生地址由对应驱动解释publicstringAddress{get;init;};publicPlcDataTypeDataType{get;init;}// 工程量换算Engineering Raw * Scale OffsetpublicdoubleScale{get;init;}1.0;publicdoubleOffset{get;init;}0.0;publicstringUnit{get;init;};}例如vartemperatureTagnewPlcTag{NameTankTemperature,AddressDB10.DBD0,DataTypePlcDataType.Float32,Scale1.0,Offset0.0,Unit℃};这里的地址只是某种西门子地址风格的示例并不是所有西门子 PLC 都支持相同的寻址方式。三菱、欧姆龙以及其他系列需要使用各自驱动能够识别的地址表达。另外Scale和Offset用于原始值与工程量之间的换算不应该和字节序转换混为一谈。四、第二步统一数据返回格式很多上位机项目存在一个问题通信成功与否、解析是否成功、数据值是多少全都混在一个返回值里。例如floattemperatureplc.ReadFloat(DB10.DBD0);如果发生超时、地址错误或者解析失败调用方可能只能通过异常来判断。更合理的方式是统一封装读取结果。publicsealedclassPlcResultT{publicboolSuccess{get;init;}publicT?Value{get;init;}publicstringErrorCode{get;init;};publicstringErrorMessage{get;init;};publicstaticPlcResultTOk(Tvalue){returnnewPlcResultT{Successtrue,Valuevalue};}publicstaticPlcResultTFail(stringerrorCode,stringmessage){returnnewPlcResultT{Successfalse,ErrorCodeerrorCode,ErrorMessagemessage};}}这样无论底层是 S7、MC 还是 FINS业务层都可以使用统一的结果格式。例如varresultawaitplc.ReadAsyncfloat(TankTemperature);if(result.Success){Console.WriteLine($温度{result.Value:F2}℃);}else{Console.WriteLine($读取失败{result.ErrorCode}result.ErrorMessage);}实际项目中还可以增加设备编号。点位名称。采集时间。数据质量状态。原始值。通信耗时。数据来源和错误分类。这些字段对于后续的报警分析、历史数据记录和通信诊断非常有帮助。需要注意Success只能表示本次读取流程成功完成不代表数值一定符合工艺要求。比如读取到了9999通信和解析都可能成功但该数值仍可能超出合理的温度范围。五、第三步统一驱动接口彻底隔离品牌差异接下来是整个架构中最关键的部分。定义统一的 PLC 驱动接口usingSystem;usingSystem.Threading;usingSystem.Threading.Tasks;publicinterfaceIPlcDriver:IAsyncDisposable{TaskConnectAsync(CancellationTokencancellationToken);TaskDisconnectAsync(CancellationTokencancellationToken);TaskPlcResultTReadAsyncT(PlcTagtag,CancellationTokencancellationToken);TaskPlcResultboolWriteAsyncT(PlcTagtag,Tvalue,CancellationTokencancellationToken);}这个接口并没有暴露任何具体品牌的通信方法。业务层只需要传入点位配置和目标类型就可以完成读取。接下来为不同品牌实现适配器IPlcDriver │ ├── SiemensPlcDriver │ └── S7通信库 │ ├── MitsubishiPlcDriver │ └── MC协议通信库 │ └── OmronPlcDriver └── 对应系列支持的通信协议这些驱动实现相同接口但内部逻辑可以完全不同。例如西门子驱动可能需要处理 DB 块地址和偏移量三菱驱动可能需要处理 D、M 等设备区域欧姆龙驱动则可能涉及 DM、CIO 等区域及对应协议的地址表示。具体规则由驱动自身处理业务层不应该再写一遍。这就是适配器模式在工业通信场景中的实际价值。驱动实现示例下面用一个简化的驱动骨架展示这种结构publicabstractclassPlcDriverBase:IPlcDriver{publicabstractTaskConnectAsync(CancellationTokencancellationToken);publicabstractTaskDisconnectAsync(CancellationTokencancellationToken);publicabstractTaskPlcResultTReadAsyncT(PlcTagtag,CancellationTokencancellationToken);publicabstractTaskPlcResultboolWriteAsyncT(PlcTagtag,Tvalue,CancellationTokencancellationToken);publicvirtualValueTaskDisposeAsync(){returnValueTask.CompletedTask;}}具体品牌驱动可以继承这个基类也可以直接实现接口。这里的抽象类只是为了演示结构并不意味着所有驱动都必须继承它。对于接口简单、行为差异较大的项目直接实现接口往往更加灵活。另外接口中的ReadAsyncT只是统一调用形式。驱动内部仍然必须验证T与PlcDataType是否匹配不能把任意原始数据直接强制转换为调用者指定的类型。六、第四步真正解决字节解析问题统一接口解决了调用方式的问题但不同 PLC 的原始数据格式仍然需要正确解析。假设读取到以下四个字节42 F6 E9 79如果将它们按照 IEEE 754 单精度浮点数的大端字节顺序解释可以得到一个浮点数。但如果字节顺序不同解析结果就可能完全不同。这里必须区分三个概念字节序多字节数值中字节排列的顺序。字序某些设备对 16 位字进行交换的规则。位序一个字节内部的位编号与位地址映射规则。这三个概念不是一回事。例如某些设备使用两个 16 位字表示一个 32 位浮点数。假设原始字节为A B C D不同设备可能采用A B C D B A D C C D A B D C B A这几种排列只是用于说明不同的字节布局不代表某个品牌的所有 PLC 都采用其中某一种。如果在程序里直接写floatvalueBitConverter.ToSingle(data,0);那么解析结果会受到输入字节排列以及平台字节序的影响。更稳妥的做法是明确规定字节顺序再进行转换。1. 使用 BinaryPrimitives 解析整数.NET 提供了BinaryPrimitives可以明确指定大端或小端读取方式。usingSystem.Buffers.Binary;byte[]data{0x12,0x34};shortbigEndianValueBinaryPrimitives.ReadInt16BigEndian(data);shortlittleEndianValueBinaryPrimitives.ReadInt16LittleEndian(data);对于相同的两个字节两种读取方式得到的数值可能不同。因此不能只看到 PLC 返回了两个字节就直接假定它们应该如何组合。2. 正确解析 32 位浮点数对于明确采用 IEEE 754 格式的 32 位浮点数可以先将字节转换为整数位模式再转换为float。usingSystem;usingSystem.Buffers.Binary;publicstaticclassPlcDataParser{publicstaticfloatReadFloat32BigEndian(ReadOnlySpanbytedata){if(data.Length4){thrownewArgumentException(浮点数至少需要4个字节,nameof(data));}intbitsBinaryPrimitives.ReadInt32BigEndian(data[..4]);returnBitConverter.Int32BitsToSingle(bits);}publicstaticfloatReadFloat32LittleEndian(ReadOnlySpanbytedata){if(data.Length4){thrownewArgumentException(浮点数至少需要4个字节,nameof(data));}intbitsBinaryPrimitives.ReadInt32LittleEndian(data[..4]);returnBitConverter.Int32BitsToSingle(bits);}}这种写法有几个优点不依赖当前机器的原生字节序。能够明确表达数据格式。便于针对不同 PLC 编写单元测试。遇到字节顺序问题时更容易定位。不过这两个方法只处理标准的大端和小端布局。如果某个设备采用特殊的 16 位字交换规则就应该在调用解析函数之前先进行相应的字节重排。不要把所有字节交换逻辑都塞进一个难以理解的通用函数里。3. 将字节排列规则配置化可以定义一个枚举publicenumByteLayout{BigEndian,LittleEndian,WordSwap}然后由驱动根据设备配置选择对应的转换策略。这里的WordSwap只是示意名称真实项目还需要明确它具体交换哪些字节以及该规则适用于哪些数据类型。更完整的设计中可以把字节布局抽象为独立的转换器publicinterfaceIByteLayoutConverter{intReadInt32(ReadOnlySpanbytedata);floatReadFloat32(ReadOnlySpanbytedata);}这样不同 PLC 驱动可以选择不同的布局转换器而不必在每个数据读取方法中重复编写字节交换代码。七、第五步统一解析之后还要解决工程量转换PLC 中保存的数据不一定就是业务需要的真实数值。例如某台设备使用整数保存温度PLC原始值253 实际温度25.3 ℃这种情况下数据读取和类型解析都没有问题但上位机还需要进行工程量转换。可以使用前面定义的公式[EngineeringValueRawValue\times ScaleOffset]对应的 C# 方法publicstaticdoubleToEngineeringValue(doublerawValue,doublescale,doubleoffset){returnrawValue*scaleoffset;}调用示例doublerawValue253;doubletemperatureToEngineeringValue(rawValue,scale:0.1,offset:0);Console.WriteLine(temperature);// 25.3在工程项目中建议将以下过程明确分开原始字节 ↓ 协议解析 ↓ PLC原生数据类型 ↓ 工程量转换 ↓ 业务数据不要在解析字节时顺手乘以缩放系数也不要在 UI 显示层再次做一次相同转换。否则同一个点位可能在历史记录、实时显示和报警判断中得到不同结果。另外工程量转换也需要考虑数据范围、精度、单位和异常值。例如温度值超出工艺范围时系统应该标记数据质量异常而不是直接把数值截断成一个看似正常的结果。八、第六步如何统一读取不同品牌 PLC前面的模型、接口和解析器已经准备好接下来将它们组合起来。假设上位机中注册了多个设备publicsealedclassPlcDevice{publicstringId{get;init;};publicstringBrand{get;init;};publicIPlcDriverDriver{get;init;}null!;}可以使用字典管理设备vardevicesnewDictionarystring,PlcDevice();注册设备时根据配置创建对应的驱动devices[PLC_01]newPlcDevice{IdPLC_01,BrandSiemens,DriversiemensDriver};devices[PLC_02]newPlcDevice{IdPLC_02,BrandMitsubishi,DrivermitsubishiDriver};这里的siemensDriver和mitsubishiDriver是已经构建并配置好的驱动实例并不是直接可用的系统内置对象。业务层不需要再关心设备品牌。例如读取不同设备的温度点位时可以采用统一调用方式vartagnewPlcTag{NameTemperature,AddressconfiguredAddress,DataTypePlcDataType.Float32};vardevicedevices[PLC_01];varresultawaitdevice.Driver.ReadAsyncfloat(tag,cancellationToken);需要强调实际项目应当从设备配置中读取configuredAddress而不是在业务代码中手动拼接不同品牌的地址。如果某个驱动只支持整数寄存器而某个点位被配置成Float32应在驱动层明确返回类型不支持的错误而不是尝试猜测数据格式。再进一步统一管理点位当设备数量达到几十台甚至几百台时可以把点位配置独立存储在 JSON、数据库或配置中心中。例如{DeviceId:PLC_01,Brand:Siemens,Points:[{Name:Temperature,Address:DB10.DBD0,DataType:Float32,Scale:1.0,Offset:0.0,Unit:℃}]}这只是一个示意配置。真正使用时还需要增加 PLC 型号、驱动版本、通信参数、点位权限以及必要的地址校验规则。配置化的价值不只是减少代码还在于将设备差异从业务逻辑转移到可维护的数据配置中。当现场增加一台相同类型的设备时很多情况下只需要新增设备配置而不必修改界面和业务代码。九、实际项目中最容易忽略的几个问题1. 不要认为同一品牌的数据格式完全一致不同 PLC 系列、不同通信协议、不同驱动库可能具有不同的地址规则、数据布局和能力限制。因此驱动选择最好落实到具体型号和通信方式而不只是品牌名称。2. 不要把通信成功当成数据正确数据解析成功只能说明字节能够被转换成目标类型。它不代表设备地址一定正确也不代表数据对应的工程含义正确。例如某个地址返回了合法的浮点数1.4E-43程序并不会因为这个值看起来异常就自动知道地址配置错了。因此关键点位需要增加合理范围检查、数据质量标记和必要的设备状态验证。3. 不要让多个线程同时随意操作同一连接上位机中可能同时存在轮询线程、报警线程和用户手动操作。如果这些线程同时访问同一个串口或共享一个不支持并发请求的通信客户端就可能导致报文交错、响应错配或者连接状态异常。建议为每个设备建立独立的通信管理器并根据驱动能力实现请求串行化、队列调度或者受控并发。是否可以并发读取必须以具体协议和通信库的线程安全能力为依据。4. 不要忽略写入数据的边界统一写入接口并不意味着所有数据都可以直接写入。写入之前至少要检查数据类型是否匹配。地址是否属于允许写入的区域。数值是否超出范围。是否需要进行工程量反向转换。是否需要确认设备当前处于允许操作的状态。尤其是启动、停止、复位和参数修改等控制命令不应仅仅因为底层通信返回成功就认为业务操作已经完成。5. 不要把所有异常都转换成一个错误码连接超时、协议错误、地址错误、类型不匹配和设备拒绝操作代表完全不同的问题。如果统一封装后只返回“读取失败”现场排查依然会很困难。建议设计稳定的错误码体系并保留原始异常、设备标识、点位名称和通信阶段等信息。这样统一封装才不仅仅是接口统一还能让故障定位更加高效。十、如何验证这套统一解析架构在工业现场最忌讳的是只验证“能读取一个值”就认为整个解析层已经完成。建议至少覆盖以下测试。第一类数据类型测试。验证bool、16 位整数、32 位整数、浮点数、字符串和字节数组的解析结果。第二类字节布局测试。使用已知原始字节构造测试数据分别验证大端、小端和设备实际使用的字交换规则。第三类地址测试。验证有效地址、无效地址、越界地址以及不同设备区域的寻址规则。第四类通信异常测试。模拟断线、超时、无响应、协议校验失败和设备拒绝操作。第五类并发测试。模拟多个业务任务同时读取同一设备观察是否发生响应错配、重复请求和资源竞争。第六类回归测试。每新增一个 PLC 驱动都应该验证原有驱动的解析结果没有受到影响。对于字节解析器最好建立一组固定的测试向量输入字节、预期数值、数据类型和字节布局都明确记录。以后修改解析代码时可以直接运行这些测试避免因为修复某个型号的问题而破坏其他型号。十一、总结真正统一的是业务模型而不是底层协议多品牌 PLC 接入同一套 C# 上位机最大的难点往往不是建立 TCP 连接也不是将几个字节转换成一个浮点数。真正困难的是如何长期维护这些差异并且不让它们不断渗透到业务代码中。比较稳妥的方案是使用统一接口隔离不同品牌的驱动。使用统一数据模型表达业务需要的数值和状态。使用独立解析器处理字节序、字序和数据类型。使用点位配置管理地址、缩放系数和单位。使用统一结果模型管理异常、数据质量和诊断信息。使用单元测试和通信仿真验证每一种驱动的行为。当这些基础设施建立起来后新增一种 PLC 品牌时主要工作就集中在驱动适配、地址映射和数据转换上而不是重写整个上位机。一套好的工业通信架构不是让所有 PLC 变得一样而是让它们的差异不会影响业务层的正常工作。这才是统一解析与封装真正要解决的问题。
阅读完成 · 觉得有帮助?