
干工控上位机开发五年我拆过最多的烂代码不是界面写得丑而是把每个硬件都当成独立项目在写。PLC一套串口逻辑、称重仪表再来一套连异常处理都长得一模一样改一处就得全部跟着改。今天这篇我把面向对象里的“继承”拉回工控场景用真实硬件思路来演示怎么设计设备基类怎么让新增一台设备只写几行代码就能接入同一个上位机框架。如果你已经在写C#上位机但还没把类与类之间的关系理清楚或者正打算从头搭一套支持多设备的采集程序这篇就是你需要的“第四节”实战课。1. 先谈我为什么把控制器看成“类”而不是“一堆代码”1.1 上位机项目里最典型的“复制粘贴病”我见过很多上位机项目的原始版本功能都能跑但结构让人难受。每个设备一个窗体串口打开关闭复制三遍Modbus CRC校验复制五遍异常日志再复制七遍。最离谱的一次项目里有三台西门子PLC、两台温控表、一台扫码枪代码里居然有五个几乎一样的SendCommand方法。改串口超时时间的时候开发的人要打开五个文件逐个改漏一个就等着现场半夜电话。这不是写代码能力的问题是压根没用面向对象去约束。上位机本质上就是一个“设备状态收集器 指令下发器”小项目只有一台设备时随手写没问题。但工业现场不会只有一种设备今天加一台称重仪表明天加一套视觉系统后天客户的PLC从三菱换成了基恩士所有逻辑就全乱了。继承在这里要做的事很简单把“所有设备都一样的地方”放到基类把“每台设备不一样的地方”留给子类去实现。1.2 设备共性与个性的拆法先画一张“设备树”写继承之前我会先拿一张纸把所有设备共性列出来。不管PLC、扫码枪、称重仪表还是温控器只要是通过串口、网口接入上位机它们大概率都具备这些东西设备名称、连接端口、波特率/IP地址打开、关闭、连接、断开连接读取数据、写入数据、解析数据连接状态、最后一次通信时间、错误信息心跳检测、重连机制、日志输出。这些就是“设备基类”的候选成员。设备与设备之间的差异则是PLC读的是M区D区扫码枪读的是字符串扫码结果称重仪表读的是连续重量数值温控器读的是PV/SV温度。这些差异可以变成抽象方法也可以变成虚方法甚至变成接口成员这才是继承在工控硬件里真正落地的位置。我个人的经验是不要一开始就把类设计得很大而是等第二台设备出现时才去抽象。手头只有一个PLC时硬抽基类基本是在猜有两台真实设备的协议文档摆在一起共性几乎是自己跳出来的。1.3 一个最简单的设备基类长什么样下面是我新项目里常用的最简版基类不掺任何厂商私有协议只保留通信硬件的骨架public abstract class BaseDevice { public string DeviceName { get; set; } public string PortName { get; set; } public int BaudRate { get; set; } 9600; public bool IsConnected { get; protected set; } protected SerialPort _serialPort; protected readonly object _lockObj new object(); public event EventHandlerDeviceDataReceivedEventArgs DataReceived; public virtual void Initialize() { // 默认初始化串口参数子类可能需要额外初始化 _serialPort new SerialPort(PortName, BaudRate); _serialPort.DataReceived OnDataReceived; } public virtual void Open() { if (_serialPort ! null !_serialPort.IsOpen) { _serialPort.Open(); IsConnected true; } } public virtual void Close() { if (_serialPort ! null _serialPort.IsOpen) { _serialPort.Close(); IsConnected false; } } protected virtual void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var data ReadRawData(); var parsed ParseData(data); DataReceived?.Invoke(this, new DeviceDataReceivedEventArgs(parsed)); } protected abstract byte[] ReadRawData(); protected abstract string ParseData(byte[] rawData); }这段代码里已经能看到继承的价值串口打开关闭、事件触发这些脏活累活全部收敛到基类。子类只需要知道“我读到了原始字节然后怎么解析”其他的事情基类帮你兜着。凡是上层界面要调用的基类都给了统一的Open()、Close()、ReadRawData()方法界面完全不用关心它面对的是PLC还是扫码枪。2. 工控硬件继承的核心基类里放“协议骨架”派生类里填“设备细节”2.1 抽象方法、虚方法、普通方法怎么选很多新手在基类里写方法时不知道用abstract还是virtual。我的判断标准很简单如果这个动作每个设备都做但做法完全不同就写成abstract如果大多数设备用一种默认做法个别设备要微调就写成virtual如果所有设备做法都一样就写成普通方法。举例来说ReadRawData()每个设备从缓冲区读原始字节的逻辑都不同PLC可能要从串口按帧读取扫码枪可能按行读取所以抽象。Open()默认打开串口是所有串口设备通用的但有的设备打开前要先发握手命令所以设计成虚方法子类可以base.Open()再继续。LogInfo(string message)基本每个设备都输出同一种格式的日志直接普通方法就完事。这样分层之后新增设备时开发者的思路就变成了“我只需要重写那两三个抽象方法别的都不用动”维护效率会明显提升。2.2 三菱PLC、Modbus仪表、温控表三种派生类的写法为了让抽象类不那么空洞我拿三个实际设备来示范。第一个是三菱FX系列PLC走的是串口编程口协议读取D100寄存器的指令大致是帧头、命令、地址、校验、帧尾。第二个是常见的Modbus RTU温控表读取PV值需要构造从站地址、功能码03、寄存器地址、寄存器数量、CRC校验。第三个是扫码枪通常串口收到的是ASCII字符一行就是一次扫码结果。它们的基类都相同但实现差异很大public class MitsubishiPlcDevice : BaseDevice { public int DStartAddress { get; set; } 100; protected override byte[] ReadRawData() { byte[] command BuildReadDCommand(DStartAddress, 10); _serialPort.Write(command, 0, command.Length); Thread.Sleep(50); int available _serialPort.BytesToRead; byte[] buffer new byte[available]; _serialPort.Read(buffer, 0, available); return buffer; } protected override string ParseData(byte[] rawData) { // 三菱协议解析省略CRC具体细节 return Encoding.ASCII.GetString(rawData); } } public class ModbusTemperatureDevice : BaseDevice { public byte SlaveAddress { get; set; } 1; protected override byte[] ReadRawData() { byte[] command new byte[] { SlaveAddress, 0x03, 0x00, 0x00, 0x00, 0x01 }; var crc Crc16Modbus(command); command command.Concat(crc).ToArray(); _serialPort.Write(command, 0, command.Length); Thread.Sleep(30); int available _serialPort.BytesToRead; byte[] buffer new byte[available]; _serialPort.Read(buffer, 0, available); return buffer; } protected override string ParseData(byte[] rawData) { if (rawData.Length 5) return 0; return ((rawData[3] 8) | rawData[4]).ToString(); } } public class QrScannerDevice : BaseDevice { protected override byte[] ReadRawData() { int available _serialPort.BytesToRead; byte[] buffer new byte[available]; _serialPort.Read(buffer, 0, available); return buffer; } protected override string ParseData(byte[] rawData) { return Encoding.ASCII.GetString(rawData).Trim(); } }你看三个子类都只需要关心三件事怎么构造读指令、怎么读回原始字节、怎么解析结果。至于串口什么时候打开、连接状态怎么维护、事件怎么触发全在基类里。这就把“设备协议”和“上位机基础设施”彻底解耦了。2.3 构造函数与初始化顺序这块最容易翻车很多人写派生类时会把初始化逻辑直接塞进构造函数结果基类构造函数一执行就调用了抽象方法而子类字段还没初始化直接空引用。我自己就踩过一次PLC类里有个PlcModel属性构造函数里给它赋了值但基类的Initialize()在子类字段赋值之前就被调用了导致初始化时读到了null。正确的顺序应该是基类构造函数只做无状态的基础准备例如设置默认波特率真正的设备初始化放到一个单独的Initialize()方法中由外部在创建对象后显式调用。或者你也可以在构造函数参数里把子类需要的值全部传进去而不是依赖字段赋值时机public MitsubishiPlcDevice(string deviceName, string portName, int baudRate, int dStartAddress) : base(deviceName, portName, baudRate) { DStartAddress dStartAddress; }这样所有依赖数据在基类构造时就准备好了避免了“子类字段还没赋值基类方法先执行”的经典坑位。新写通信设备类时我强烈建议把初始化流程拆成构造函数准备参数 - 外部调用 Initialize() - Open()三个阶段而不是一股脑塞进构造函数。3. 继承之上是多态设备列表、消息循环和界面刷新的统一调度3.1 用基类列表装所有设备foreach遍历不用挨个if继承最大的甜头其实不是少写几个方法而是可以“把不同设备当成同一种类型去管理”。当你的系统里有三台PLC、两台温控表、一台扫码枪时你不用为每一种设备单独写一个集合而是直接public ListBaseDevice Devices new ListBaseDevice(); Devices.Add(new MitsubishiPlcDevice(PLC-1, COM3, 9600, 100)); Devices.Add(new ModbusTemperatureDevice(温控-1, COM4, 9600) { SlaveAddress 1 }); Devices.Add(new QrScannerDevice(扫码-1, COM5, 115200)); foreach (var device in Devices) { device.Initialize(); device.Open(); }这段代码的魔力在于以后来一台新的设备你只要让它的类继承BaseDevice然后Devices.Add(new 新设备(...))就行了。界面层、报表层、报警层的代码完全不需要因为新增设备而改动。 这就是多态的典型好处代码面对的是抽象而不是具体。如果哪一天你想单独把某个PLC拿出来操作用泛型和类型判断也能安全处理var plc Devices.OfTypeMitsubishiPlcDevice().FirstOrDefault(d d.DeviceName PLC-1);这比传统的一长串switch (device.DeviceType)干净得多而且新加类型时你不需要改任何旧的switch分支。3.2 事件与委托设备数据主动“上报”给界面有了基类事件之后界面和设备的通信方式会发生一个质的变化。以前写串口程序很多人习惯在主窗体的定时器里轮询读取各个设备读取完再手动去更新各个TextBox。这种写法在小项目里没问题但设备一多定时器里的逻辑会变得像一团乱麻先查PLC再查温控表还要判断哪个设备超时最后还得关心界面控件跨线程调用。用基于事件的继承模型后每个设备自己知道什么时候有数据数据一到就触发DataReceived事件。上位机主程序只需要订阅这个事件然后在事件处理函数里刷新界面private void OnDeviceDataReceived(object sender, DeviceDataReceivedEventArgs e) { if (InvokeRequired) { BeginInvoke(new Actionobject, DeviceDataReceivedEventArgs(OnDeviceDataReceived), sender, e); return; } if (sender is MitsubishiPlcDevice plc) { txtPlcData.Text e.Data; } else if (sender is ModbusTemperatureDevice temp) { txtTemp.Text e.Data; } }注意这里依然有类型判断但只存在于“界面展示”这一层。业务逻辑层已经全部用多态解决掉了界面层拿到的是一个已经解析好的字符串不需要关心它底层是Modbus还是三菱协议。事件和委托在这里成了基类向外暴露的“窄接口”设备内部怎么折腾外部一概看不见。3.3 后台采集线程、定时刷新和界面卡顿的关系继承模型不是银弹线程模型没理顺照样卡界面。我的做法通常是这样的每个设备在自己的工作线程里做读取和解析只把最终结果通过事件抛出来界面统一用BeginInvoke回到UI线程做显示。这样即使某个设备的串口读数据阻塞了2秒也不会卡住整个上位机。有一种常见误区是给每个设备都开一个BackgroundWorker或者Task.Run但忘了控制访问串口的并发。同一台设备如果采集线程和写入线程同时操作同一个串口轻则数据错乱重则异常。我通常会在基类里放一个_lockObj所有串口读写都走锁这样多个定时器、多个任务同时调用时不会互相踩踏protected byte[] ReadWithLock() { lock (_lockObj) { return ReadRawData(); } }这种细节一旦放进基类所有派生类自动获得线程安全能力这也是继承在基础设施层的价值。4. 实战把PLC、扫码枪和称重仪表塞进同一个设备容器4.1 设备容器的设计与泛型化上一节我们已经有了DeviceManager的概念这里我再把它落成一个泛型容器。基础版本的设备容器大概长这样public class DeviceManager { private readonly ListBaseDevice _devices new ListBaseDevice(); public void AddDevice(BaseDevice device) { _devices.Add(device); } public void StartAll() { foreach (var device in _devices) { device.Initialize(); device.Open(); } } public T GetDeviceT(string name) where T : BaseDevice { return _devices.OfTypeT().FirstOrDefault(d d.DeviceName name); } public void StopAll() { foreach (var device in _devices) { device.Close(); } } }GetDeviceT这种泛型方法非常有用。比如界面需要单独操控某个PLC的启动按钮时可以这样写var plc _deviceManager.GetDeviceMitsubishiPlcDevice(冲压机PLC); if (plc ! null) { plc.StartMotor(); }注意这个容器类本身不依赖于任何具体设备它是面向BaseDevice编程的。以后加新设备容器一行代码都不用改。这一点非常重要真正做到了“新增设备不改原有代码”的开闭原则。4.2 在WPF/WinForms界面里接入设备数据流我用WinForms举一个例子需要实时显示PLC数据、扫码枪扫码结果和称重值。界面上有三个Label/TextBox我一个一个订阅事件var plc new MitsubishiPlcDevice(PLC-1, COM3, 9600, 100); var scanner new QrScannerDevice(扫码-1, COM5, 115200); var weight new WeightDevice(称重-1, COM6, 9600); plc.DataReceived OnPlcData; scanner.DataReceived OnScannerData; weight.DataReceived OnWeightData; _deviceManager.AddDevice(plc); _deviceManager.AddDevice(scanner); _deviceManager.AddDevice(weight); _deviceManager.StartAll();事件处理函数里我只做显示不做事关业务的重计算。这样当扫码枪在产线上连续扫码时界面不会因为处理事件而卡顿。假如设备数据要同时写入数据库我会把数据库写入放在单独的线程池任务里而不是直接塞在事件处理函数内。4.3 字符串截取、连续编号不重复、字段显示这些实战小细节上位机开发不是只有类设计很多实际需求很琐碎。比如扫码枪读到的原始字符串可能是“SNABC123;Batch001”要显示SN就得截取PLC要下发一个连续编号还要保证重启后不重复数据库里查出来一条记录的某个字段要显示到界面上。我会把这类通用能力拆成静态工具类而不是塞进设备类里面public static class StringHelper { public static string GetValueBetween(string source, string startTag, string endTag) { int startIndex source.IndexOf(startTag) startTag.Length; int endIndex source.IndexOf(endTag, startIndex); if (startIndex 0 || endIndex 0) return string.Empty; return source.Substring(startIndex, endIndex - startIndex); } }连续编号不重复这块如果只是内存里编一个号重启就丢了。工业现场要求重启后继续用上次的编号我一般会把编号保存到本地配置或者数据库中取号和回写做成原子操作。如果两台电脑同时启动上位机还要考虑文件锁或数据库自增序列。 这些东西看起来和面向对象关系不大但它们是同一个上位机系统的组成部分工具类和设备类各司其职即可。4.4 Modbus库、GRBL、vofa这类上位机生态的启发做上位机久了你会发现市面上有大量现成的上位机和调试工具。比如Grbl控制器的上位机、CAN调试工具cangaroo、PID调参用的vofa、还有各种SCADA软件。它们的界面和通信协议都不同但如果拆开看内部同样有设备抽象层。用Modbus时很多人直接用EasyModbus库库内部就封装了统一的Master/Slave模型这就是一个很好的面向对象设计参照。我写设备基类时会习惯性地看看这些工具是怎么抽象协议的。SCADA和上位机的区别也值得理解SCADA偏组态和监控上位机偏向定制业务逻辑SCADA通常已经封装好驱动上位机则经常要从底层协议开始写。用继承建出来的设备层越干净将来想切换底层通信方式就越容易这也是为什么我用基类而不是直接写死串口的一大原因。5. 继承的边界什么时候该用接口、组合和泛型容器5.1 三层以上的设备继承树就是灾难我见过一种设计BaseDevice派生SerialDeviceSerialDevice又派生ModbusDeviceModbusDevice又派生ModbusTemperatureDevice然后温度仪表又分DeviceA、DeviceB。听着很严谨实际开发时痛苦不堪。因为每一次继承都会把父类的所有成员带下来哪怕子类完全不需要。你改一个中间层的方法可能影响七八个底层类跑一遍全厂通信测试少了两个小时下不来。我的原则是继承最多两层必要时用接口和组合代替深层继承。 比如“支持TCP通信”是一种能力不是一种设备类型。让TcpPlcDevice直接从BaseDevice派生同时实现ITcpCommunication接口比从TcpBaseDevice继承下去清晰得多。能力用接口表达共性用基类表达这样关系的层次不会越搞越深。5.2 组合优于继承真实的机器是组合关系不是父子关系很多工控对象本身是“组合”而不是“继承”。比如一台冲压机它有PLC、扫码枪、称重仪表、温控器。如果用继承来建模就会写出MigWeldingMachine : BaseDevice这种奇怪的东西可冲压机并不是一种“通信设备”它是若干设备组成的整体。正确模型是把设备对象作为属性组合进机器类public class StampingMachine { public MitsubishiPlcDevice Plc { get; } public QrScannerDevice Scanner { get; } public WeightDevice Scale { get; } public StampingMachine(...) { Plc new MitsubishiPlcDevice(...); Scanner new QrScannerDevice(...); Scale new WeightDevice(...); } }这就是“组合优于继承”的典型场景继承表达的是“is-a”关系温控表是一种设备组合表达的是“has-a”关系冲压机有温控表。 很多初学面向对象的人一心想着继承反而把系统搞僵化了。判断方法很粗暴你问自己“这个东西是不是那东西的一种”如果是才能用继承如果说“这个东西拥有那东西”请用组合。5.3 接口在工控通信层到底怎么用接口不是摆设它在跨厂商设备时特别有用。比如你的项目里既有串口扫码枪又有TCP扫码枪它们的ReadData()、ParseData()肯定不同但上层都希望有个统一的Scan()方法。这时候可以定义public interface IBarcodeScanner { string LastCode { get; } event EventHandlerstring Scanned; void StartScan(); void StopScan(); }然后让串口扫码枪和TCP扫码枪都实现这个接口。界面层引用的全是IBarcodeScanner将来换品牌、换通信方式界面代码不用动。基类负责串口基础设施接口负责业务能力契约两者配合起来上位机的扩展性会非常强。5.4 泛型容器是为了保留“设备类型”信息回到4.1的GetDeviceT这个泛型容器本质上就是在“用继承建模用泛型恢复类型信息”。 如果你只用ListBaseDevice取出对象时需要自己判断类型觉得麻烦。用GetDeviceMitsubishiPlcDevice(PLC-1)这样的泛型方法编译器能直接告诉你取出来的类型不用到处as、is。泛型不是和继承对立的它俩是互补的。继承提供统一抽象泛型提供强类型访问。 在设备管理系统里我甚至会为每种设备写一个独立的DeviceTableT或者字典但这要视项目规模而定。设备少于10个时一个DeviceManager足够了真到了大型工厂MES对接再考虑分布式缓存和数据库设备表。6. 继承实战中我踩过的最深的坑6.1 坑一基类构造函数里调用虚方法导致子类字段还是默认值前面提到过这个问题几乎每个C#工控开发都会遇到。微软官方其实明确不建议在构造函数中调用虚方法但很多人没当回事。一旦你在基类构造函数里调用了ReadConfig()、LoadParams()这类虚方法而子类重写后又依赖自己的字段就会出现“字段还没赋值方法先跑了”的诡异问题。我的对策是构造函数只赋入参字段任何可能调用子类重写逻辑的初始化全部放到Initialize()中由外部统一调用。这虽然打破了一点“构造函数即完整初始化”的直觉但在设备开发的项目里是稳妥的选择。6.2 坑二串口设备Dispose没有在基类处理上位机里最珍贵的资源就是串口、Socket、数据库连接。子类用完串口只想Close()但假如基类没有实现IDisposable串口对象释放不干净下一次Open()就会报“端口被占用”。我一开始也吃过这个亏把关闭串口的操作散落在各个子类结果有一个子类忘记关现场设备就“锁死”了。解药很直接让BaseDevice实现IDisposable在基类里统一释放串口和事件订阅public virtual void Dispose() { Close(); if (_serialPort ! null) { _serialPort.DataReceived - OnDataReceived; _serialPort.Dispose(); } }所有子类默认继承这套释放逻辑特殊情况再重写Dispose并把Dispose(true)调用透传回去。6.3 坑三界面线程和设备线程打架Invoke乱用的后果事件从设备线程抛到界面后很多人习惯直接在主窗体里this.Invoke(...)。但在窗体关闭或设备正在断开的时候Invoke有可能抛出异常程序直接崩掉。再用继承基类时这个问题会被放大因为事件是从基类统一抛出的你不知道当前到底在哪个线程。我的处理是基类的事件只管抛出绝不参与任何界面刷新界面层在自己的包装方法里用BeginInvoke做线程切换并在窗体关闭前把事件退订干净。如果设备事件在窗体关闭后才触发因为已经退订自然不会有空引用异常。6.4 坑四基类里放太多通用字段子类用不上却全要处理还有一种继承设计法是“上帝基类”把所有设备可能的字段全都塞进基类IpAddress、PortNumber、ChannelId、CalibrationCoefficient、PulsePerMeter。结果扫码枪不需要CalibrationCoefficient温控表不需要PulsePerMeter。这些冗余字段不只浪费内存更坑的是序列化配置、界面绑定、数据库映射时全都混在一起非常容易出错。正确的做法是基类只放“确实通用”的成员例如DeviceName、PortName、BaudRate、IsConnected、Open、Close、DataReceived。其余的设备专属字段通通下沉到子类。不要担心子类之间重复定义重复字段比强行抽象更安全等出现第三个设备需要同一字段时再往上提不迟。6.5 坑五只想着继承对象忘记设备有“心跳”和“重连”最后一个是方法论层面的坑。很多继承设计图把设备静态化处理好像设备创建完就永远在线。但工业现场设备断电、网线松了、PLC停机都是常态。如果基类里没有设计心跳机制和自动重连就算你的类结构再漂亮现场也会被设备离线问题折磨死。我一般会在基类里加一个CheckOnline()虚方法默认用上次通信时间与当前时间做差值判断超时就自动尝试重连。子类可以重写为协议层心跳查询。然后把心跳动作放到统一的后台定时器里public virtual bool CheckOnline() { if (!IsConnected) return false; return (DateTime.Now - LastCommunicationTime).TotalSeconds TimeoutSeconds; }这样一来继承就真正覆盖了工控设备“连接、通信、断开、重连”的全生命周期而不只是把方法挂到类上。现在我再重新搭一套上位机框架第一步永远是先写设备基类把心跳、重连、日志、事件这几个基础设施做扎实。设备子类反而越薄越好越薄越不容易出问题。这大概就是面向对象继承在上位机项目里最务实的打开方式。