ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#上位机开发:用继承设计设备基类,轻松接入PLC与Modbus设备

C#上位机开发:用继承设计设备基类,轻松接入PLC与Modbus设备 干上位机开发的人都知道工控硬件最折腾人的地方不在硬件本身而在你得用 C# 把一堆不同型号的设备接进同一个上位机。今天接西门子明天接三菱后天又来一台走 Modbus 协议的仪表如果代码从一开始就写死成“只认某一种设备”那每一次接新硬件都是一次大手术。把这种麻烦理顺的核心恰恰是面向对象编程里的继承。我见过不少新人蹲在电脑前改一晚上代码就为了把一套原来跑西门子的项目改成跑三菱。改完发现界面、数据解析、报警逻辑全部牵连不是这里报错就是那里漏改。问题出在哪儿不是 C# 语法没过关而是没把上位机的骨架搭好。继承这件事在上位机开发里不是课堂上那种“动物-猫-狗”的演示而是实打实的工程手段把设备之间的共性抽出来放到基类把协议差异留给子类让上层代码永远只面对一个统一的“设备”概念。这篇文章的讲法也很简单从一个非常贴近工控现场的案例出发把设备基类设计、具体 PLC 子类实现、再到工厂和配置管理捋一遍。过程中会用到抽象类、虚方法、多态、接口、组合这些 C# 面向对象知识点也会把我在现场踩过的坑一并说出来。适合正在做上位机开发、或者准备入行工控软件的同学参考也适合那些已经会写点 C# 但一遇到代码重构就头疼的人。1. 为什么写上位机必须先想清楚继承1.1 设备型号一变就改代码是设计问题不是手速问题先举个最典型的场景。项目初期只接入了一台西门子 S7-1200 PLC上位机通过以太网读写 DB 块里的数据。整个程序从上到下都围绕着S7Client写初始化的时候 new 一个客户端读取的时候调用 Read 方法断线了再调 Connect。一切顺利跑完验收客户非常满意。结果第二个月客户说车间又加了一条产线新设备是三菱 FX5U走 MC 协议还带一台走 Modbus RTU 的温控仪表。这时候你再回头看原代码会发现整个上位机的核心逻辑全部绑死在西门子那套 API 上。为了支持新设备你得在界面上加下拉框选择设备类型然后在读取数据的代码里写 if else 分支一个分支调 S7 的 Read另一个分支调三菱的读法再一个分支调串口 Modbus。代码越来越长分支越来越多测试用例指数级增长最后连你自己都不敢随便动了。这就是典型的“设备型号一变就改代码”的困境。问题不在你手速不够快而在于从一开始就没把设备的共性抽象出来。面向对象里的继承解决的不是你能不能写出这个功能而是当功能越来越多时你的代码还能不能保持稳定。1.2 封装、继承、多态三个特性在上位机里分别解决什么事很多教程喜欢把三大特性分开讲其实在上位机项目里它们是联动配合的。封装负责把每个硬件驱动内部的细节藏起来外部只暴露 Connect、Disconnect、ReadData 这类明确的方法继承负责把设备之间的公共逻辑上提到基类把协议差异下放到子类多态则让上层代码拿着基类的引用就能调用真正子类的实现不需要关心今天接的到底是西门子还是 Modbus 仪表。一个直观的类比是充电器。你的手机充电头暴露给你的接口就是 USB-C 或者 Lightning这是封装所有充电头都有“输出 5V 电压”这个共性行为但内部电路实现完全不同这是继承和多态。上位机里的 DeviceBase 就相当于那个统一的充电接口具体设备类就是内部电路板你怎么换手机都不需要改充电协议因为接口约定没变。有了这层理解再去看设计模式里的依赖倒置、开闭原则就不会觉得它们玄乎了。开闭原则说“对扩展开放对修改关闭”放在上位机里就是我要新接一台设备时新增一个子类就行不用去动已经稳定运行的老代码。1.3 先分清“不变”和“会变”再决定把成员放进基类还是子类面向对象设计最核心的一步不是写代码而是划分类的边界。我在动手之前会先问自己几个问题所有设备是不是都要连接和断开是不是都要读取数据这些设备在界面上呈现的形式是不是一样把这些问题里“肯定的、不变的”部分留在基类里而把“因设备不同而不同”的部分定义成抽象方法或虚方法。这里有一个容易忽略的点不要一上来就把所有字段都塞进基类。西门子设备需要 Rack 和 SlotModbus 设备需要从站地址和串口号这些都不是共性的东西。如果基类里堆满了五花八门的属性子类用不上的成员反而会造成困扰也破坏了抽象的意义。基类只保留最稳定的部分比如设备名、连接字符串、连接状态和读写协议剩下的差异化内容放到子类或者配置对象里。2. 设计设备基类前先把这些成员规划好2.1 抽象设备类 DeviceBase找出一台设备最通用的骨架我们把前面说的思路落到代码上。第一步自然是定义设备基类。这里我建议用抽象类abstract class而不是普通类原因后面会细说。一个上位机设备的基本能力有哪些往下拆就是能够连接、能够断开、能够读取数据、能够对外通知自己读到了新数据或出了故障。这四件事是所有设备都具备的所以它们应该被定义为基类成员。差异点在哪里连接怎么连、数据怎么读这些是千差万别的所以应该定义成抽象方法由子类强制实现。public abstract class DeviceBase { public string DeviceName { get; set; } public string ConnectionString { get; set; } public bool IsConnected { get; protected set; } public event EventHandlerDeviceData DataReceived; public event EventHandlerstring ErrorOccurred; protected DeviceBase(string deviceName) { DeviceName deviceName; } public abstract bool Connect(); public abstract void Disconnect(); public abstract DeviceData ReadData(); }这段代码很精简但它定下了整个项目的规矩不管以后接入什么硬件对外都必须提供这三件套。连接状态用protected set意味着外部只能读不能在业务代码里随手把状态改掉这是封装的一个实践。2.2 数据模型 DeviceData让界面层永远只认一套结构设备读取回来的数据长什么样是另一个必须提前想清楚的点。西门子读回来的可能是 bool、int、real 组成的结构体Modbus 读回来的是一堆寄存器数值温控仪表可能是一串 BCD 码。对应的方法有很多种但最省心的做法是做一个统一的数据容器把所有设备的数据都塞进去界面层只认识这一种类型。public class DeviceData { public string DeviceName { get; set; } public DateTime TimeStamp { get; set; } public Dictionarystring, object Values { get; set; } new Dictionarystring, object(); }用字典存值的最大好处是自由。西门子那边可以把键命名为DB1.DBW0Modbus 那边命名为Register_0界面通过遍历字典动态生成显示行完全不需要关心具体硬件。我之前在项目里见过有人为每台设备单独定义一个 DTO结果界面层每接一台新设备就要改一次 UI 绑定代码纯粹是在给自己找罪受。2.3 抽象类与普通类的区别以及定时轮询的模板方法回到刚才的问题为什么基类要定义为抽象类因为抽象类的意义在于“描述一种尚未具体化的概念”。设备确实是实际存在的但项目里不会出现一台“没有任何品牌、没有任何协议”的抽象设备你要用的永远是西门子、三菱、Modbus 这种具体实现。抽象类能阻止有人直接 new 出一个DeviceBase逼着他必须创建具体的子类这从语法层面就保证了架构的正确性。基类里还可以放一个定时轮询的模板方法。上位机读取现场数据很少是“点一次读一次”更多是按固定周期循环采集。轮询的逻辑对所有设备都是一样的完全可以放到基类里实现子类只需要提供具体的ReadData行为。public void StartPublishing(int intervalMs) { var timer new Timer(_ { try { var data ReadData(); DataReceived?.Invoke(this, data); } catch (Exception ex) { ErrorOccurred?.Invoke(this, ex.Message); } }, null, 0, intervalMs); }这个方法展示了模板方法模式的思想基类把“定时启动、循环调用、异常捕获、事件发布”这些骨架固定好子类只负责实现ReadData这一个点。这样现场加新设备时你只需要关注协议怎么解析不用重新写一遍线程逻辑和事件分发。2.4 基类需要向外暴露的能力清单通过上面几步我们可以把基类的成员分成几类属性设备名、连接字符串、连接状态、方法Connect、Disconnect、ReadData、StartPublishing、事件DataReceived、ErrorOccurred。属性负责描述状态方法负责执行动作事件负责把内部发生的情况通知给外部。这本身就是一个完整的对象模型。有一点要注意事件是解耦的关键。上位机界面层不应当直接调用子类的方法去拿数据而应该订阅DataReceived事件被动接收数据推送。这样设备何时读、怎么读界面层不关心设备的采集频率和界面刷新频率也互不干扰。我在工控项目里见过很多界面卡死的问题十有八九是直接在 UI 线程里同步调用了读数据的逻辑改造成事件驱动后问题基本都能消失。3. 手写继承实战西门子PLC与Modbus仪表的接入3.1 从基类出发定义第一批子类的骨架有了上面的DeviceBase接下来写具体设备类就顺理成章了。我们的目标是写两个子类一个是西门子 S7 协议的 PLC另一个是 Modbus RTU 仪表。两个类的公共结构都是一样的继承DeviceBase、补全自己的专属属性、重写抽象方法。先看西门子这一路。S7 协议连接需要 IP 地址、机架号 Rack 和槽号 Slot这些就是西门子设备区别于其他设备的个性部分。项目里一般会用 S7.Net 或 HslCommunication 这类库来封装底层 S7 通信所以子类的职责就是把第三方库的方法包装成我们基类规定的统一方法。public class SiemensS7Device : DeviceBase { public int Rack { get; set; } public int Slot { get; set; } private dynamic _s7Client; public SiemensS7Device(string name) : base(name) { } public override bool Connect() { try { _s7Client new S7Client(); // 以 S7.Net 或 HslCommunication 为例 int result _s7Client.ConnectTo(ConnectionString, Rack, Slot); if (result 0) { IsConnected true; return true; } ErrorOccurred?.Invoke(this, $S7连接失败错误码:{result}); return false; } catch (Exception ex) { ErrorOccurred?.Invoke(this, ex.Message); return false; } } public override void Disconnect() { _s7Client?.Disconnect(); _s7Client?.Dispose(); IsConnected false; } public override DeviceData ReadData() { var data new DeviceData { DeviceName DeviceName, TimeStamp DateTime.Now }; // 示意读取 DB1.DBW0 一个无符号字实际项目里通常会把地址清单做成配置 ushort value 0; _s7Client.Read(DB1.DBW0, out value); data.Values[DB1.DBW0] value; return data; } }这里我故意用dynamic来声明客户端目的是弱化具体库的 API 差异免得你被某个库的语法细节绊住。现场接 S7 时重点是记住这个套路基类管状态、子类管协议第三方库怎么用只是封装内部的事外面永远只看得到Connect和ReadData。3.2 子类实现二Modbus RTU 仪表怎么接进来Modbus RTU 走的是串口连接参数完全不同需要串口号、波特率、校验位、数据位、停止位还要一个从站地址。这些参数放到子类的属性里通过设备工厂或者配置文件统一注入。public class ModbusRtuDevice : DeviceBase { public byte SlaveId { get; set; } public int BaudRate { get; set; } public Parity Parity { get; set; } public int DataBits { get; set; } public StopBits StopBits { get; set; } private EasyModbus.ModbusClient _modbusClient; public ModbusRtuDevice(string name) : base(name) { } public override bool Connect() { try { _modbusClient new EasyModbus.ModbusClient( ConnectionString, BaudRate, Parity, DataBits, StopBits); _modbusClient.Connect(); IsConnected true; return true; } catch (Exception ex) { ErrorOccurred?.Invoke(this, ex.Message); return false; } } public override void Disconnect() { _modbusClient?.Disconnect(); IsConnected false; } public override DeviceData ReadData() { var data new DeviceData { DeviceName DeviceName, TimeStamp DateTime.Now }; int[] registers _modbusClient.ReadHoldingRegisters(SlaveId, 0, 10); for (int i 0; i registers.Length; i) { data.Values[$Register_{i}] registers[i]; } return data; } }看到了吧这个类跟西门子那个类的结构几乎一样区别只在连接参数和协议细节上。这正是继承的意义所在相同的骨架我们只写一遍差异点放在各自的子类里各自处理。3.3 多态调用业务层怎么拿父类引用干活现在核心来了。当你的设备类都继承自DeviceBase之后写业务层代码就变得非常舒服。不管界面上配的是西门子还是 Modbus 仪表你都可以声明一个DeviceBase类型的变量来接住它然后统一调用方法。DeviceBase device DeviceFactory.CreateDevice(config); device.Connect(); device.DataReceived OnDeviceDataReceived; device.StartPublishing(500);这个device到底是谁运行时它可能是SiemensS7Device也可能是ModbusRtuDevice但对调用方来说不重要。你调用Connect时实际执行的是当前真实对象的Connect。这就是多态也是面向对象编程最爽的时刻上层代码不用堆 if else 判断类型新增设备时业务层一个字符都不用改。3.4 什么时候用接口补位而不是继续堆继承继承虽然好用但 C# 只支持单继承一个类只能有一个直接父类。假如现场有一台设备既支持 S7 协议又支持远程升级固件升级这个能力不是所有设备都有就不该放在DeviceBase基类里。这时候就轮到接口出场了。public interface IFirmwareUpgradeable { bool UpgradeFirmware(string firmwarePath); } public class SiemensS7Device : DeviceBase, IFirmwareUpgradeable { // 原有实现... public bool UpgradeFirmware(string firmwarePath) { // 专属固件升级逻辑 return true; } }接口在某种程度上比继承更灵活因为它只是在声明“这台设备具备什么能力”而不强制你复用哪段代码。我实际项目里的习惯是公共行为和公共状态用继承解决跨领域能力用接口表达。继承管骨架接口管插槽两者配合基本上能覆盖工控现场 90% 以上的设备差异。4. 把继承用进工程工厂、配置与日志一个都不能少4.1 设备工厂 DeviceFactory新增型号不碰业务代码继承把设备类的结构理顺了但业务层每次创建对象时还是会遇到一个问题我到底该 new 哪个类如果让界面代码自己判断设备类型去 new那 if else 依然存在。所以需要一个工厂类把“根据配置创建具体设备对象”这件事集中管理起来。public static class DeviceFactory { public static DeviceBase CreateDevice(DeviceConfig config) { switch (config.DeviceType.ToLower()) { case siemenss7: return new SiemensS7Device(config.Name) { ConnectionString config.IpAddress, Rack config.Rack, Slot config.Slot }; case modbusrtu: return new ModbusRtuDevice(config.Name) { ConnectionString config.ComPort, BaudRate config.BaudRate, SlaveId config.SlaveId, Parity config.Parity, DataBits config.DataBits, StopBits config.StopBits }; default: throw new NotSupportedException($不支持的设备类型{config.DeviceType}); } } }工厂的好处在于业务代码根本不知道也不可能知道具体设备类的存在它只依赖DeviceBase和配置对象。将来要支持三菱你只需要新增一个MitsubishiMcDevice类再在工厂里加一个分支界面层和业务层完全不用动。这就是开闭原则在项目里的典型落地。4.2 用配置文件驱动设备实例再往前一步就是让工厂从配置文件里读取设备参数实现“改配置不改代码”。现场换一台设备或者调整波特率只需要改一下 JSON 配置程序重启后工厂就能创建出对应的设备实例。{ Devices: [ { DeviceType: SiemensS7, Name: 主站PLC, IpAddress: 192.168.0.10, Rack: 0, Slot: 1 }, { DeviceType: ModbusRtu, Name: 温控表1, ComPort: COM3, BaudRate: 9600, SlaveId: 1, Parity: None, DataBits: 8, StopBits: One } ] }项目上线之后客户自己买了新设备你只需要远程指导他填一段配置文件上位机就能支持新设备。这个体验和我开头描述的那种“改一晚上代码”相比区别是天壤之别。4.3 日志组件也做成可扩展的上位机跑在现场最怕的就是设备断线了、数据不对了但你看不到任何痕迹。所以日志系统是刚需。日志和设备驱动一样也可以抽象成接口和实现类做得不好就把日志写死成只输出到控制台现场调试时什么都查不到。public interface ILogger { void Info(string message); void Error(string message); } public class FileLogger : ILogger { private readonly string _filePath; public FileLogger(string filePath) { _filePath filePath; } public void Info(string message) { File.AppendAllText(_filePath, ${DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] {message}{Environment.NewLine}); } public void Error(string message) { File.AppendAllText(_filePath, ${DateTime.Now:yyyy-MM-dd HH:mm:ss} [ERROR] {message}{Environment.NewLine}); } }然后在基类的ErrorOccurred事件订阅里把日志接进去或者在设备工厂里给每个设备都设一个ILogger属性。这样设备连接失败、读取异常都会自动写进文件。想换数据库日志、MQTT 日志加一个实现类就行不用到处改设备代码。4.4 界面层、业务层、驱动层的分工最后说下整体分层。我习惯把上位机项目至少分成三层驱动层、业务层、界面层。驱动层就是那些设备类负责跟硬件打交道业务层负责数据加工、报警判断、工艺流程控制界面层只管展示和用户交互。有了继承和接口之后层与层之间的依赖关系会非常清晰界面层引用业务层接口业务层引用驱动层的DeviceBase驱动层不反向依赖任何上层。这样的好处是你甚至可以在没有真实硬件的情况下写一个SimulatedDevice : DeviceBase模拟数据驱动界面和业务流程跑起来。调试通信逻辑、培训新人、演示系统全靠这个模拟设备类省下的时间非常可观。5. 继承实战最容易踩的坑与排查思路5.1 构造函数参数不一致的坑设计子类时最容易掉进的坑就是构造函数。比如基类构造函数需要一个设备名西门子类还需要 IPModbus 类还需要串口号。你可能会想着把参数都塞到构造函数里结果子类构造函数变得又长又乱调用时极易传错参数。我的习惯是构造函数只保留基类必要的参数其他全部用对象初始化器去赋值从工厂的代码里也能看到这种模式。这样参数之间的对应关系一目了然不会出现new SiemensS7Device(主站, 192.168.0.1, 0, 1, 其它乱七八糟的参数)这种让人头皮发麻的调用。5.2 new 隐藏方法与 virtual/override 混用的坑C# 里有个隐蔽的语法陷阱如果你在基类定义了一个普通方法在子类里写了一个同名同签名的方法编译器会警告你“建议使用 new 关键字隐藏”。这个new和override的含义完全不同。如果子类想实现继承来的虚方法就必须用override否则调用方的引用类型不同执行的结果会完全不同。我用一句口诀帮大家记忆virtual 是“允许你重写”override 是“我正在重写”new 是“我不打算继承你我另起炉灶”。实战中尽量少用 new 隐藏因为它会破坏多态的一致性调试时可能在基类引用上调到完全不是预期的方法造成数据错乱。如果你看到子类方法没有 override 关键字却想跟基类“同名同功能”一定先停下来检查设计是不是出问题了。5.3 里氏替换原则不要随手把父类强转成子类继承有一个重要约束叫里氏替换原则凡是基类出现的地方用任意子类替换都应该能正常工作。反过来说父类对象能直接当子类用吗不行。有些同事为了调用某个子类特有的方法喜欢把基类引用强转成子类类型比如((SiemensS7Device)device).Rack 1。这种写法一旦类型判断错了运行时就抛InvalidCastException非常难排查。更稳妥的做法是用is和as安全判断。或者在业务代码里就不要强转把子类特有操作封装进基类的虚方法里。比如“读取温度”这件事西门子、三菱各有各的读法那就把ReadTemperatures()定义成基类虚方法子类各自实现上层只调用统一方法自然就不用强转了。5.4 单继承限制组合优先C# 只支持单继承这个限制有时候会让人头疼。比如你想让一个日志类既具备写文件能力又具备上报 MQTT 能力这时候再硬塞继承就不合适了。我见过有人试图用多层继承链去堆能力结果类层次越来越深改一处全崩。遇到能力叠加的场景优先考虑组合。把日志、报警、数据转发这些功能做成独立组件通过属性或者构造参数注入到设备类里。继承负责解决“是什么”的问题组合负责解决“有什么”的问题。一个设备是 PLC这是继承一个设备有日志记录能力这是组合。两者的边界分清楚设计就会干净很多。5.5 常见问题速查表问题现象原因解决思路调用方法执行了父类逻辑明明重写了方法却执行默认行为方法没有声明为 virtual子类没有使用 override基类方法加 virtual子类加 override连接状态和实际情况不一致IsConnected 为 true 但设备已经掉线没有在断线异常时更新状态在异常捕获和轮询逻辑中及时更新 IsConnected界面卡死点击连接按钮后整个窗体无响应在 UI 线程同步调用了耗时的 Connect/ReadData改用异步方法、Task.Run 或事件驱动轮询事件重复触发重新连接后收到多份数据每次 Connect 都新建了订阅旧订阅未移除在 Disconnect 里取消事件订阅并释放 Timer强转类型报错InvalidCastException把基类引用直接强转为不匹配的子类使用 is 判断、as 安全转换或封装虚方法新设备接入要改大量代码加了新类型后业务层到处改 if else没有用工厂和配置隔离变化引入 DeviceFactory按配置创建设备实例5.6 一个现场排查实例从断线重连说起说个我实际遇到的案例。现场有一台西门子 PLC程序运行几个小时后偶发断线断线后设备不会自动重连必须重启上位机。排查过程费了不少劲最后发现问题出在轮询逻辑ReadData内部一旦抛出异常虽然事件会把错误信息发出来但根本没有重连机制。后来我在基类的轮询公共方法里加了异常后的重连补偿逻辑每次读取失败就自动重新调用一次Connect并且设置最大重试次数和退避间隔问题才彻底解决。这个案例说明一个道理继承设计不光要考虑“正常路径”还要考虑“异常路径”的共享。断线重连是所有设备都需要的公共能力把它放在基类里所有子类自动获得这个能力现场再遇到掉线效果会好很多。做了几年的上位机项目后我的体会是继承不是让代码看起来高大上而是让你在客户现场少挨骂。你甚至可以在接下一个新设备之前先在纸上画一下这个类图想清楚哪些共性会被复用、哪些差异点会被各子类分开实现。最后再分享一个小技巧——我在动手写任何设备驱动之前一定会先问自己三句话这个设备一定会变的点是什么永远不变的点是什么上层界面最关心这个设备的什么行为把这三句话想明白基类的设计基本就稳定了。后面写代码就只是一个按部就班执行的过程。
返回列表