简介:基于C#的设备信息化管理系统源码是一份企业级软件开发学习项目,面向C#开发者、设备管理从业者及对资产管理感兴趣的编程学习者。系统覆盖资产管理、设备维修保养、备件管理、文件管理和可视化仪表盘等核心模块,能帮助读者理解从设备台账、维修工单到库存预警的完整业务流程。压缩包共497个文件,约293MB,包含243个cs源码文件、27个resx资源文件、37个resources及20个dll等,文件类型丰富,目录结构清晰,便于对照学习。源码虽删除了csproj项目文件无法直接编译运行,但正适合通过阅读代码研究C#与.NET框架下的模块化设计、数据库交互、异常处理与数据验证等实践技巧,也能学习设备管理系统的分层架构与模块拆分思路。目前已有698人学习使用,对于想提升C#工程能力或搭建设备管理系统的开发者来说,是一份难得的参考资料。
1. 设备信息化管理系统:C# 上位机的第一行代码该写在哪
车间里最不缺的就是设备异常:半夜PLC报警,维修工不知道备件在哪,设备主管计算OEE还要拿Excel手算。设备信息化管理系统就是把“设备台账、运行状态、维修工单、备件更换”放进同一套软件,让每台设备从进场到报废都有痕可查。基于C#做这套系统,主要原因不是C#只是写业务,而是它做“上位机+管理系统”的组合最顺:WinForms/WPF画实时看板,SerialPort和NModbus4接PLC,EF Core或Dapper接数据库,从设备层到管理层一套语言全打通。
源码.zip是起点,但直接解压编译通常不会一次成功。数据库版本不匹配、通讯库引用缺失、连接字符串指向旧机器,这些问题只是把项目干净整理过一遍的人反而学不到。这篇按我做这类项目的习惯,先把“系统要管什么”拆清楚,再把通信、数据层和界面分开搭,一步步落到能运行的层次。适合刚拿到设备信息化管理源码包、想改造成自己业务的工程师,也适合从零用C#做设备管理平台的人。
我们不会把几千行代码塞进一页,而是先讲清楚哪些参数最容易卡住你:串口波特率、PLC从站地址、寄存器起始地址、数据库并发连接数。这些设定好了,再贴能直接用的最小实现。读完你可以自己换通信协议,改表结构,甚至把WinForms换成WPF,基础路径是一样的。
2. 设备信息化管理系统的数据层:设计表、实体类与Dapper查询
2.1 为什么先分四层而不是直接拖控件
写设备信息化管理系统,最容易犯的错误是把界面、协议解析、数据库语句全部塞进同一个窗体。设备数量多了以后,一个COM口被两个窗体抢占,数据库锁表,界面刷新卡死,排查时要把整个项目翻一遍。我一般会把项目分成四个逻辑层:UI层(WinForms/WPF)、业务层BLL、数据访问DAL、设备通信层。UI层只负责显示和用户操作,通过接口调用BLL;BLL处理工单状态流转、OEE计算;DAL只写SQL或ORM映射;通信层独立暴露“读寄存器/写线圈”这类方法,与UI线程完全隔离。
分层的好处在你改源码包时特别明显。换PLC型号时,只改通信层;把WinForms换成WPF时,BLL和DAL完全不动;数据库从SQL Server换成MySQL时,DAL做适配。对拿到的“源码.zip”项目做二次开发,第一步不是到处找按钮事件,而是先按命名空间或文件夹把这几层找出来,确认边界。
2.2 设备台账、状态日志、维修工单三张核心表
设备信息化管理系统至少要有“静态台账、动态状态、维修过程”三类数据。台账管设备身份,状态管运行情况,维修管问题闭环。下面是SQL Server建表脚本,如果目标库是MySQL,把IDENTITY(1,1)换成AUTO_INCREMENT、去掉[]即可:
CREATE TABLE DeviceInfo ( DeviceId INT IDENTITY(1,1) PRIMARY KEY, DeviceCode NVARCHAR(50) NOT NULL, DeviceName NVARCHAR(100) NOT NULL, Location NVARCHAR(100), Manufacturer NVARCHAR(100), InstallDate DATE, IsActive BIT DEFAULT 1 ); CREATE TABLE DeviceStatusLog ( LogId BIGINT IDENTITY(1,1) PRIMARY KEY, DeviceId INT NOT NULL FOREIGN KEY REFERENCES DeviceInfo(DeviceId), StatusCode NVARCHAR(20), -- Running / Idle / Fault OccurTime DATETIME NOT NULL, DurationMinutes INT DEFAULT 0 ); CREATE TABLE RepairOrder ( OrderNo NVARCHAR(50) PRIMARY KEY, DeviceId INT NOT NULL FOREIGN KEY REFERENCES DeviceInfo(DeviceId), ReportedTime DATETIME, FaultDesc NVARCHAR(500), RepairStatus TINYINT DEFAULT 0, -- 0待报修 1处理中 2已完成 RepairMan NVARCHAR(50), FinishTime DATETIME );字段说明:DeviceCode是设备唯一编码,现场贴二维码扫描用的就是它;StatusCode建议用短字符串而不是数字,否则查数据库时要不停对照编码表;RepairStatus用TINYINT可以节省空间,但C#这边必须转成枚举后再显示。DeviceStatusLog增长很快,我在实际项目里加过CREATE INDEX IX_DeviceStatusLog_DeviceId_Time ON DeviceStatusLog(DeviceId, OccurTime);,否则按设备统计开动率时会全表扫描。
2.3 用Dapper映射实体类,避免重复手写赋值代码
C#里直接用SqlConnection加DataTable在小型Demo里没问题,但字段一旦超过二十个,手动从reader["DeviceName"]赋值很容易漏。现在更多项目用Dapper做对象映射,体积小、性能接近原生SQL,又不像EF Core那样引入复杂跟踪机制。先装NuGet包Dapper,再定义实体类:
public class DeviceInfo { public int DeviceId { get; set; } public string DeviceCode { get; set; } public string DeviceName { get; set; } public string Location { get; set; } public string Manufacturer { get; set; } public DateTime? InstallDate { get; set; } public bool IsActive { get; set; } }配合DAL类的查询方法:
public List<DeviceInfo> GetActiveDevices(string connectionString) { using var conn = new SqlConnection(connectionString); string sql = @" SELECT DeviceId, DeviceCode, DeviceName, Location, Manufacturer, InstallDate, IsActive FROM DeviceInfo WHERE IsActive = 1 ORDER BY DeviceCode;"; return conn.Query<DeviceInfo>(sql).ToList(); }这里连接字符串先写死在调用处,只适合开发环境。生产环境建议把User Id=sa;Password=******换成Integrated Security=True,或从appsettings.json读取。Dapper的Query<T>要求SQL别名与实体属性名匹配,比如SQL里列名是BaseLocation而实体属性叫Location,就必须写[Location] AS BaseLocation。这个坑在改源码包时经常出现:界面显示全是默认值,查SQL结果集正常,最后发现是列名映射错位。
事务也是设备管理系统里随手要用的特性。维修工单完成时,需要同时更新RepairOrder状态和写入一条DeviceStatusLog,这两步不能只成功一半。Dapper配合IDbTransaction的写法不复杂,关键是要把同一个connection和transaction传入所有Execute。把这些封装好在DAL里,BLL层调用时就不需要关心数据库连接的开启和关闭。
3. C#设备数据采集与NModbus4:寄存器、串口参数与轮询调度
3.1 为什么先选Modbus协议,又用NModbus4
设备信息化管理系统的数据源头是PLC、传感器、仪表,这些设备十有八九支持Modbus RTU或Modbus TCP。Modbus协议简单:读写寄存器、线圈,没有复杂的会话管理,上位机轮询就行。C#里我用得最多的是NModbus4,一个老牌开源库,NuGet直接安装,能同时处理串口和以太网。虽然这个库已经很久没有新版本,但设备通信协议变化极慢,目前仍可用。如果现场设备要求OPC UA,再在通信层后面加一个适配器,把NModbus4的返回值转换成统一状态模型。
3.2 Modbus TCP最小读取代码与寄存器类型表
以太网方式适合现场已经组好交换机的场景,比串口稳定,不受长线缆干扰。下面这段代码连接PLC,读取5个保持寄存器:
using System; using System.Net.Sockets; using Modbus.Device; public class ModbusTcpReader : IDisposable { private TcpClient _tcp; private IModbusMaster _master; public void Connect(string ip, int port) { _tcp = new TcpClient(); _tcp.Connect(ip, port); // 建立TCP连接 _master = ModbusIpMaster.CreateIp(_tcp); // 创建Modbus主站对象 _tcp.SendTimeout = 3000; _tcp.ReceiveTimeout = 3000; } public ushort[] ReadRegisters(byte slaveId, ushort start, ushort count) { return _master.ReadHoldingRegisters(slaveId, start, count); } public void Dispose() => _tcp?.Close(); }参数含义:slaveId是从站地址,范围1到247,需要和PLC组态里的从站地址一致;start是寄存器起始地址,注意PLC里很多地址是1基的,比如40001对应Modbus数据地址0x0000,在NModbus4里start填0;count是一次请求长度,Modbus协议限制最多125个寄存器。SendTimeout和ReceiveTimeout一定要设,PLC断电时ReadRegisters会在3秒内抛异常,而不是死等。CRC校验由NModbus4内部完成,不需要自己算。
寄存器类型对应关系如下表:
| 寄存器类型 | Modbus功能码 | NModbus4方法 | 典型PLC地址段 |
|---|---|---|---|
| 线圈 | 01 | ReadCoils | 00001 - 09999 |
| 离散输入 | 02 | ReadDiscreteInputs | 10001 - 19999 |
| 输入寄存器 | 04 | ReadInputRegisters | 30001 - 39999 |
| 保持寄存器 | 03 | ReadHoldingRegisters | 40001 - 49999 |
读数全是0时,第一步不是怀疑设备,而是确认你调的方法和地址段是否匹配。保持寄存器是可读可写,输入寄存器只读,PLC点位表里如果标着3xxxx,就应该用ReadInputRegisters,否则拿到默认0或异常。
3.3 串口参数设置与RS485现场排错
车间大量设备还是RS485接口,通过USB转串口接入上位机。NModbus4用串口时,最小代码如下:
using System.IO.Ports; using Modbus.Device; var port = new SerialPort("COM3") { BaudRate = 9600, DataBits = 8, Parity = Parity.None, StopBits = StopBits.One }; port.Open(); IModbusMaster master = ModbusSerialMaster.CreateRtu(port); byte slave = 0x01; ushort startAddress = 0; ushort count = 2; ushort[] values = master.ReadHoldingRegisters(slave, startAddress, count);9600,8,N,1是默认配置,但PLC侧如果设成了19200或偶校验,接收结果会不稳定。现场排查顺序:先用串口助手发送十六进制报文,例如读保持寄存器的请求帧01 03 00 00 00 02 C4 0B,其中01是从站地址,03是功能码,00 00起始地址,00 02数量,C4 0B是CRC。有回包说明电缆和波特率正确,问题在C#侧;没回包则先查接线和主从关系。USB转串口线使用中偶尔会从COM3跳成COM5,代码里写死端口会让程序启动报“端口不存在”,建议把串口号放进配置文件中。
3.4 轮询调度与UI线程隔离
设备状态采集不能直接在WinForms的Timer事件里调用ReadRegisters,因为网络或串口阻塞时界面会假死。我一般用一个后台循环轮询,把最新状态写入ConcurrentDictionary,UI再通过事件订阅刷新:
private readonly ConcurrentDictionary<byte, ushort[]> _cache = new(); private CancellationTokenSource _cts = new(); private async Task PollLoop(IModbusMaster master, byte slaveId) { while (!_cts.IsCancellationRequested) { try { ushort[] values = await Task.Run(() => master.ReadHoldingRegisters(slaveId, 0, 10)); _cache[slaveId] = values; } catch (Exception ex) { Trace.WriteLine($"轮询异常:{ex.Message}"); } await Task.Delay(1000); } }轮询间隔一般设500毫秒到5秒,看工艺要求。间隔太短会大量占用PLC通信资源,间隔太长状态看板不实时。多台PLC时,每个PLC单独创建一个TcpClient和IModbusMaster实例,不能并发使用同一个Master,因为NModbus4的Master对象不是线程安全的。关闭窗体时调用_cts.Cancel(),再在finally里释放串口或TcpClient,避免下次启动时端口被占用。
4. 实现设备台账、维修工单与状态看板:C#代码与SQL联调
4.1 设备台账界面:DataGridView绑定与搜索
台账界面是用户每天打开次数最多的页面。用DataGridView绑定List<DeviceInfo>最简单,但搜索逻辑不能写在UI事件里一大坨。下面代码展示一个带异步查询的搜索按钮:
private async void BtnSearch_Click(object sender, EventArgs e) { try { string keyword = txtKeyword.Text.Trim(); var list = await Task.Run(() => _deviceDao.SearchByNameOrCode(keyword)); dataGridView1.DataSource = list; lblCount.Text = $"共 {list.Count} 条设备"; } catch (Exception ex) { MessageBox.Show($"查询失败:{ex.Message}"); } }逻辑说明:Task.Run把查询放到线程池,避免数据量大时WinForms界面卡顿;DataSource直接绑定Dapper返回的实体列表,DataGridView会自动按属性名生成列。IsActive字段默认显示True或False,业务人员不习惯,可以在实体类加一个只读属性:
public string StatusText => IsActive ? "启用" : "停用";DataGridView会把只读属性也识别为列,效果比在单元格里写事件干净。如果你拿到的源码包界面比较旧,想要更换主题风格,常见的C# WinForm主题实现方法是重写ToolStripProfessionalRenderer或ColorTable,不引第三方库也能改整体配色;关键是把颜色常量放在统一类里,避免每个窗体单独写死。
4.2 维修工单状态流转:乐观锁防止重复操作
维修工单最常见的坑是:两个操作员同时处理一张单,A点完成,B也点完成,最后的完成时间覆盖成了B操作的时间。业务规则是“处理中”只能完成一次,代码里要用带条件的UPDATE实现乐观锁:
public void CompleteOrder(string orderNo, string repairMan, string connectionString) { using var conn = new SqlConnection(connectionString); string sql = @" UPDATE RepairOrder SET RepairStatus = 2, RepairMan = @RepairMan, FinishTime = GETDATE() WHERE OrderNo = @OrderNo AND RepairStatus = 1;"; int affected = conn.Execute(sql, new { OrderNo = orderNo, RepairMan = repairMan }); if (affected == 0) throw new InvalidOperationException("工单已被处理,当前状态不允许完成操作"); }WHERE RepairStatus = 1保证只有状态是“处理中”时才会更新,更新影响行数为0说明工单已经变化,程序直接抛异常提醒。完成时间用数据库的GETDATE()而不是DateTime.Now,避免现场电脑时钟不一致,这也影响到后面统计平均维修时长。
工单状态与数据库数值的映射建议如下表:
| 枚举名称 | 数值 | 状态文本 | 可流转到 |
|---|---|---|---|
| Pending | 0 | 已报修 | Processing |
| Processing | 1 | 处理中 | Completed |
| Completed | 2 | 已完成 | 无 |
在BLL层用枚举操作,DAL层读写时转换整数,显示层再转文本,这样的两层映射比到处写if (status == 2)好维护。
4.3 用状态日志算OEE:SQL分组与时间边界
OEE(设备综合效率)是设备信息化管理系统里使用方最常问的指标,但错误计算很常见。OEE = 时间开动率 × 性能开动率 × 良品率,通常按班次或按天统计。自动计算OEE的前提是DeviceStatusLog里有可靠的状态持续时间:
SELECT DeviceId, SUM(CASE WHEN StatusCode = 'Running' THEN DurationMinutes ELSE 0 END) AS RunMinutes, SUM(DurationMinutes) AS TotalMinutes FROM DeviceStatusLog WHERE OccurTime >= @ShiftStart AND OccurTime < @ShiftEnd GROUP BY DeviceId;DurationMinutes不该在写入日志时才计算,而应在设备状态迁移时记录:采集层发现设备从Running切到Idle,就把上一状态开始时间到当前时间的差值作为一条新日志写入。如果日志表里只有状态变化时间,没有持续时间,统计时用相邻两条记录的差值计算也行,但SQL会复杂许多。界面显示OEE时,用WinForms的Chart控件,或者把DataGridView结果导出到Excel。源码包里如果没有OEE页面,按上面的SQL加一个“OEE统计”窗体即可,不需要改通信层。
5. 源码部署排错与优化:从连接字符串到轮询性能
5.1 解压源码后先改的三个位置
拿到源码.zip,第一步不是逐行读懂代码,而是让解决方案可以编译启动。打开解决方案后,依次检查三处:app.config或web.config里的connectionString,改成当前SQL Server实例名;设备通信参数,PLC的IP地址、端口、串口号,这些通常在单独的配置类或常量类中;NuGet包是否还原成功,项目引用NModbus4、Dapper、MySql.Data时,在VS里执行“还原NuGet程序包”,否则编译会报大量“类型或命名空间不存在”。
我遇到过源码包里引用了某个DLL,但Git忽略.dll导致源码缺失的情况。这时不要硬编译,检查packages文件夹或者用NuGet重新装同名库。最好把xml注释文档也打开,缺少XML文件时,VS的智能提示全部消失,排查速度会慢很多。
5.2 高频故障:串口仍被占用,寄存器读数全为0
设备上位机最诡异的错误是端口被占用:上次程序异常退出,但串口对象没有释放。C#里没有直接命令查看哪个进程占用了COM口,常见做法是打开设备管理器监听COM口,或用handle.exe搜索串口号;开发期最快捷的路径是关闭VS后打开任务管理器,结束所有残留的目标进程,再重新F5。注意WinForms程序直接点窗口右上角关闭,如果后台轮询线程没有正确停止,进程并不会真正结束,表现在运行后数据库连接数持续上涨。
寄存器读数全是0,先区分是读到了真实0还是没读到数据。可以在读取后立即打印原始值到输出窗口,例如Trace.WriteLine(string.Join(",", values));,再用DebugView观察。确认站点表里要把地址段和功能码对应起来,常见错误是把保持寄存器地址段和输入寄存器搞混,导致NModbus4访问了错误的寄存器区间。
5.3 轮询性能优化:批量写库、DoubleBuffered与内存波动观察
状态日志如果每条都单独INSERT,每秒轮询一次时数据库会频繁产生提交日志,性能受限。建议在通信层维护一个缓冲列表,积攒一定条数后批量写入:
private readonly List<DeviceStatusLog> _buffer = new(capacity: 100); if (_buffer.Count >= 100) { await _db.BulkInsertAsync(_buffer); _buffer.Clear(); }BulkInsertAsync可以自己在DAL里用SqlBulkCopy实现,也可以引入第三方批量库。这样即使采集间隔只有500毫秒,数据库压力也只集中在一秒内的几次批量操作。
DataGridView在实时状态下刷新容易闪烁,开启双缓冲效果最明显。在构造函数中添加:
typeof(DataGridView).InvokeMember("DoubleBuffered", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.SetProperty, null, dataGridView1, new object[] { true });用反射开启的注意点是把System.Reflection命名空间引入。最后打开任务管理器观察内存占用,如果程序长时间运行后内存持续上升,优先检查轮询循环里是否每次都订阅了不需要的事件,或者某个CancellationTokenSource没有释放。把这些优化项放进代码后再次编译运行,重点看运行48小时后的句柄数和提交内存变化。
本文还有配套的精品资源,点击获取