☰
C# MES加工装配系统实战:架构设计、核心代码与现场避坑
2026/10/9 6:55:44 网站建设 项目流程

简介:面向制造业信息化人员与MES系统学习者的C#实现MES加工装配模拟系统,涵盖服务端基础档案、计划管理、实时看板、数据初始化及实时监听,以及客户端加工与装配过程控制、搬运控制、质量异常处理等模块,能够帮助理解中小型制造车间数字化生产管理流程,也是一个可运行的完整工程。压缩包共445个文件,以175个cs源码文件为主,辅以dll类库、config配置、resx与resources资源文件、exe可执行程序等,配套数据库mdf/ldf文件和bak备份,整体约10MB。该资源已有1457人学习下载。包内含可直接附加的SQL Server数据库文件与《SimpleMES加工装配模拟系统》设计说明书,核心逻辑代理通过存储过程实现;开发环境基于Visual Studio 2010与.net 4.0,适合作为MES系统课程项目、毕业设计参考,也可供需要搭建简易生产执行原型或学习C#多层架构的人员按模块深入研究。

1. MES加工装配系统用C#做,到底卡在哪一步

制造业上MES(制造执行系统)最典型的场景是:车间里几十台装配工位,物料、工序、质检、防错全得在系统里流转,但市面上的MES产品多数是Java系,排产、报表动不动就要定制,二次开发的成本比买软件还贵。这几年越来越多的团队开始用C#来落地MES加工装配系统,原因很直接:车间里大量PLC、扫码枪、扭矩枪、上位机本身就是C#生态,直接用C#打通设备层和业务层,少一层异构转换,少一堆莫名其妙的通信故障。

我见过太多项目死在第一步:MES不是搞不定业务建模,而是搞不定车间现场的离散信号。你的装配工位要采集扭矩值、要控制防错门、要跟Andon联动,这些本质上都是串口、TCP、Modbus和数据库读写,而C#做上位机出身,处理这些东西几乎是本能。再加上C#的WinForms/WPF开发效率高,团队只要有一个熟手就能把车间看板、工单执行、质量追溯全串起来。

这篇文章按一个真实可落地的路径来讲:从C# MES加工装配系统的架构取舍讲起,给出一套能直接抄的数据库设计和工单装配执行的核心代码,再把物料防错、条码追溯、看板数据推送这些车间里躲不开的功能一步步拆开。最后是排错章节,把我在现场踩过的坑——串口丢数据、PLC握手超时、扫描枪焦点被抢——全部列出来,每条都是现象、原因、解决三件套。新手照这个能跑通最小闭环,熟手能直接拿来对照自己的项目边界。

2. 先搭骨架子:C# MES加工装配系统的架构取舍与数据库设计

2.1 为什么C#适合做MES:设备层和业务层的天然衔接

做MES加工装配系统的第一件事不是写代码,是想清楚系统边界。MES夹在ERP和车间设备中间,ERP管订单发料,设备层管动作执行,MES要干的事是把工单拆成工序任务,再把工序任务下发到工位,收集完工数据,最后形成追溯链。

C#在中间这一层的优势非常具体。首先是通信层,车间设备几乎绕不开串口、TCP、Modbus TCP、HTTP API,这些在C#里都有成熟方案——System.IO.Ports做串口,System.Net.Sockets做TCP,Modbus有NModbus库,对接PLC的S7协议也有S7.Net。其次是业务层,MES的界面大量是工位看板、扫码输入、防错提示、质量录入,WinForms的老练和WPF的绑定能力,做这类强交互界面比写网页顺手。最后是集成层,MES要跟ERP对接,跟数据库对接,C#的ADO.NET/EF Core和Web API都是标配。

最常见的选型争议是到底用WinForms还是WPF。我的建议很简单:如果团队熟手多、工期紧,WinForms直接上,控件成熟踩坑少;如果车间现场有复杂的图形化看板、实时曲线、动画流程,WPF的绑定和渲染能力更合适。实际项目里我见过WinForms跑得很稳的老系统,也见过WPF做得很炫的新看板,选哪个不决定成败,选完不摇摆才决定成败。

还有就是千万别把MES做成大单体。车间网络环境通常不稳定,工位电脑配置也参差不齐,我一般会把系统拆成三层:数据库层、业务服务层(Web API)、工位客户端层。工位客户端只需要装一个轻量程序,业务逻辑全部走API,数据库连接串只存在于服务端。这样后期加工位、换工位电脑都不用重新部署数据库配置。

2.2 数据库设计的五张核心表:工单、工序、装配记录、物料批次、防错规则

C# MES加工装配系统最怕的是上来就把表设计成Excel的样子,一个大宽表什么字段都往里塞。装配行业的生产特点是离散、多工序、多物料、强追溯,表设计要围绕「工单—工序—装配动作」这条主线展开。我常用的核心表就五张:工单表、工序表、装配记录表、物料批次表、防错规则表。

工单表存的就是生产计划下达的制造订单,核心字段包括工单号、料号(成品料号)、计划数量、完成数量、状态、创建时间。工序表存的是这个料号要经过哪些装配步骤,工序号、工序名称、工位编码、是否强制扫料、是否采集扭矩、标准工时。装配记录表是追溯的核心,每完成一个装配动作就落一条记录,包括工单号、料号、序列号(唯一追溯码)、工序号、操作工、装配时间、采集值(比如扭矩值)、判定结果。物料批次表管的是物料批次和序列号的绑定关系,MES防错全靠它判断当前工位扫的物料是不是这道工序该用的。防错规则表则是可配置的核心,工序号、物料料号、有效期、数量上限、启用状态都放这里,现场换型的时候直接改规则,不用改代码。

WIP(在制品)的跟踪是这个设计的核心逻辑。装配记录表里每一条都带序列号,序列号从首工序扫码那一刻生成,之后每经过一道工序就追加一条记录。这样追溯的时候按序列号一查,整条装配链路就完整呈现出来了。下面是核心建表脚本,用的SQL Server语法,这是C# MES最常见的搭配:

-- 工单主表 CREATE TABLE dbo.WorkOrder ( WorkOrderId INT IDENTITY(1,1) PRIMARY KEY, WorkOrderNo NVARCHAR(50) NOT NULL, MaterialCode NVARCHAR(50) NOT NULL, -- 成品料号 PlanQty INT NOT NULL DEFAULT 0, CompletedQty INT NOT NULL DEFAULT 0, Status TINYINT NOT NULL DEFAULT 0, -- 0未开始 1生产中 2完成 3挂起 CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); -- 工序表 CREATE TABLE dbo.ProcessStep ( StepId INT IDENTITY(1,1) PRIMARY KEY, MaterialCode NVARCHAR(50) NOT NULL, -- 关联成品料号 StepNo INT NOT NULL, -- 工序顺序 StepName NVARCHAR(100) NOT NULL, WorkStationCode NVARCHAR(50) NOT NULL, -- 工位编码 RequireScan BIT NOT NULL DEFAULT 1, -- 是否强制扫物料 RequireTorque BIT NOT NULL DEFAULT 0, -- 是否采集扭矩 StandardCycleTime INT NOT NULL DEFAULT 0 -- 标准工时(秒) ); -- 装配记录表(追溯核心) CREATE TABLE dbo.AssemblyRecord ( RecordId BIGINT IDENTITY(1,1) PRIMARY KEY, WorkOrderNo NVARCHAR(50) NOT NULL, SerialNo NVARCHAR(100) NOT NULL, -- 产品序列号 StepNo INT NOT NULL, StepName NVARCHAR(100) NOT NULL, WorkStationCode NVARCHAR(50) NOT NULL, OperatorCode NVARCHAR(50) NOT NULL, TorqueValue DECIMAL(8,2) NULL, -- 采集值,如扭矩 Result TINYINT NOT NULL DEFAULT 0, -- 0 OK 1 NG RecordTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE INDEX IX_AssemblyRecord_SerialNo ON dbo.AssemblyRecord(SerialNo); -- 物料批次表 CREATE TABLE dbo.MaterialBatch ( BatchId INT IDENTITY(1,1) PRIMARY KEY, MaterialCode NVARCHAR(50) NOT NULL, BatchNo NVARCHAR(100) NOT NULL, SerialNo NVARCHAR(100) NOT NULL, -- 要追溯到哪个产品 Quantity INT NOT NULL DEFAULT 0, ExpireDate DATETIME NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE INDEX IX_MaterialBatch_BatchNo ON dbo.MaterialBatch(BatchNo); -- 防错规则表 CREATE TABLE dbo.AntiErrorRule ( RuleId INT IDENTITY(1,1) PRIMARY KEY, MaterialCode NVARCHAR(50) NOT NULL, -- 工位当前生产料号 StepNo INT NOT NULL, AllowedMaterial NVARCHAR(50) NOT NULL, -- 本工序允许使用的物料料号 MaxQtyPerProduct INT NOT NULL DEFAULT 1, EnableFlag BIT NOT NULL DEFAULT 1 );

这个脚本的精髓在防错规则表。很多初版MES把防错逻辑写死在代码里,换一个料号就要发版,而把料号对应关系抽成表后,工艺员在管理界面里就能维护。装配记录表加了SerialNo索引,因为追溯查询按序列号走是最高频的操作,这个索引一定要建。

2.3 数据访问层选型:EF Core还是Dapper

C#的MES项目做数据访问,团队最常纠结的是EF Core还是Dapper。EF Core胜在开发效率高,模型跟表映射后写LINQ很快,适合业务逻辑密集的模块;Dapper胜在性能可控、SQL完全透明,适合对查询性能敏感、SQL复杂的场合。MES加工装配系统的实际特点是:写操作极高频(装配记录一秒一条),查询场景相对固定(按序列号追、按工单查进度),所以我个人倾向用Dapper做数据访问,SQL写死了排查也方便。

用Dapper的另一个理由是车间环境的数据库负载问题。工位客户端一条条往上提交装配数据,总是用EF Core取实体再SaveChanges,会产生大量不必要的追踪开销。Dapper写出来的代码更贴近SQL,性能可控,一旦出问题可以直接把SQL捞出来在数据库里跑。

下面这段是装配记录提交的仓储方法,体现的是一次插入加状态更新的原子操作。注意这里用了本地事务,避免出现装配记录落了但工单完成数没加的情况——这种数据不一致是MES最常见的脏数据来源。

using System.Data; using System.Data.SqlClient; using Dapper; public class AssemblyRepository { private readonly string _connString; public AssemblyRepository(string connString) { _connString = connString; } // 提交一条装配记录,同时累加工单完成数量 // 必须在同一个事务里完成,否则追溯链和数量统计会对不上 public bool SubmitAssembly(AssemblyRecord record) { const string insertSql = @" INSERT INTO dbo.AssemblyRecord (WorkOrderNo, SerialNo, StepNo, StepName, WorkStationCode, OperatorCode, TorqueValue, Result, RecordTime) VALUES (@WorkOrderNo, @SerialNo, @StepNo, @StepName, @WorkStationCode, @OperatorCode, @TorqueValue, @Result, GETDATE());"; const string updateSql = @" UPDATE dbo.WorkOrder SET CompletedQty = CompletedQty + 1 WHERE WorkOrderNo = @WorkOrderNo AND Status = 1;"; using var conn = new SqlConnection(_connString); conn.Open(); using var tx = conn.BeginTransaction(IsolationLevel.ReadCommitted); try { int inserted = conn.Execute(insertSql, record, tx); if (inserted <= 0) { tx.Rollback(); return false; } int updated = conn.Execute(updateSql, new { record.WorkOrderNo }, tx); if (updated <= 0) { tx.Rollback(); return false; } tx.Commit(); return true; } catch (Exception ex) { tx.Rollback(); // 记录异常日志后向上抛,让界面提示操作工重试 throw new ApplicationException("提交装配记录失败,事务已回滚", ex); } } }

这段代码要紧的是两个Execute一定要共用一个连接和事务。很多人刚开始会分开写两个方法,各开各的连接,结果第一条插入成功了、第二条更新失败了,数据就花了。另外,Dapper的参数名匹配是大小写不敏感的,但建议保持和SQL参数完全一致,排查的时候省事。

3. 把工单下发到工位:C# MES生产执行的核心流程与代码

3.1 工序路由与工位终端的待执行任务队列

MES加工装配系统的执行层核心,是让每个工位知道自己现在要干什么。工序路由表已经定义了料号对应的工序顺序,工位终端只需要按工位编码查自己当前要执行的工序,再结合工单状态拉出待执行任务。这里的关键是「当前工序」的判定逻辑:一个工位同时只执行一个工序,但可能存在多工单并行,工位终端要能区分优先做哪个。

我见过的工位任务队列,最基本的呈现方式是「工单号 + 料号 + 工序名 + 计划数 + 已完数」。执行逻辑可以描述为一个状态机:空闲、待加工、加工中、完工、异常挂起。工位终端启动时调用API拉取任务列表,操作工选中一个工单就进入待加工状态,扫序列号开始首工序时就进入加工中,全部工序完成后工单状态翻为已完成。

状态机的推进必须依赖后端,不能在前端自己改状态。因为多个工位可能同时在操作同一个工单的不同工序,前端本地改状态会导致数据互相覆盖。下面这段代码是从服务端拉取工位任务列表的API实现,注意查询条件是工位编码和工单状态,用状态过滤掉未开始的工单。

[HttpGet] public IActionResult GetStationTasks(string stationCode) { const string sql = @" SELECT wo.WorkOrderNo, wo.MaterialCode, ps.StepNo, ps.StepName, ps.RequireScan, ps.RequireTorque, wo.PlanQty, wo.CompletedQty FROM dbo.WorkOrder wo INNER JOIN dbo.ProcessStep ps ON ps.MaterialCode = wo.MaterialCode WHERE ps.WorkStationCode = @StationCode AND wo.Status = 1 -- 只取生产中的工单 AND ps.StepNo = ( SELECT MIN(StepNo) FROM dbo.ProcessStep WHERE MaterialCode = ps.MaterialCode AND WorkStationCode = @StationCode ) ORDER BY wo.CreateTime ASC; -- 先下发的先做 "; using var conn = new SqlConnection(_connString); var tasks = conn.Query<TaskDto>(sql, new { StationCode = stationCode }).ToList(); return Ok(tasks); }

这段SQL的核心是子查询取最小工序号。同一个工位可能在不同料号的不同工序节点上都有任务,子查询保证只取当前工位最前面的那道工序,避免操作工同时面对多个工序无从下手。ORDER BY CreateTime按工单下发时间排队,保证先进先出,这是车间管理的基本规矩。

3.2 首工序开工:扫描序列号生成WIP追溯链的头

装配追溯的起点是首工序开工。操作工在工位终端上扫描产品主条码或打印的序列号标签,系统需要确认这个序列号没有被其他工单占用、当前工单还在生产状态,然后生成追溯链路的第一条装配记录。这个动作是整个MES里最敏感的节点,因为一旦序列号绑错了工单,后面所有追溯数据全乱。

首工序的逻辑不是直接插一条记录那么简单,它要同时做三件事:检查序列号唯一性、检查工单状态、写入装配记录。下面代码用了一个事务包住这三步,任何一步不过都整体回滚。

public async Task<bool> StartFirstStep(FirstStepRequest req) { const string checkSerialSql = @" SELECT COUNT(1) FROM dbo.AssemblyRecord WHERE SerialNo = @SerialNo;"; const string checkOrderSql = @" SELECT Status, CompletedQty, PlanQty FROM dbo.WorkOrder WHERE WorkOrderNo = @WorkOrderNo;"; const string insertSql = @" INSERT INTO dbo.AssemblyRecord (WorkOrderNo, SerialNo, StepNo, StepName, WorkStationCode, OperatorCode, TorqueValue, Result, RecordTime) VALUES (@WorkOrderNo, @SerialNo, @StepNo, @StepName, @WorkStationCode, @OperatorCode, NULL, 0, GETDATE());"; using var conn = new SqlConnection(_connString); await conn.OpenAsync(); using var tx = await conn.BeginTransactionAsync(); int count = await conn.ExecuteScalarAsync<int>(checkSerialSql, new { req.SerialNo }, tx); if (count > 0) { await tx.RollbackAsync(); return false; // 序列号已存在,拒绝重复开工 } var order = await conn.QueryFirstOrDefaultAsync<WorkOrderState>( checkOrderSql, new { req.WorkOrderNo }, tx); if (order == null || order.Status != 1 || order.CompletedQty >= order.PlanQty) { await tx.RollbackAsync(); return false; // 工单不存在或已完工 } await conn.ExecuteAsync(insertSql, req, tx); await tx.CommitAsync(); return true; }

这段代码里的三个检查一个都不能省。序列号唯一性如果不查,后面同一序列号重复过站会产生两条链路,质量追溯的时候根本没法判断哪条是真的。工单状态和完成数量不查,已完工的工单还能继续往里面塞产品,库存数据立刻就失控了。值得强调的是,这里用ExecuteScalarAsync取COUNT,比先查再遍历更高效,车间工位高频扫码场景下,这种细节能明显减轻数据库压力。

3.3 中间工序过站:校验上工序是否完成,防止跳站

装配行业最让人头疼的现场问题之一就是跳站。操作工图省事,或者新员工不熟悉工艺,上道工序没做标记,直接把产品推到下道工序了。等整机装配完测试不合格,反查是哪道工序出了问题,发现追溯链缺了一环,整批都要隔离排查,损失非常大。

中间工序过站因此必须校验前一道工序是否已有合格记录。校验逻辑很简单,却特别容易被忽略:查当前序列号在前一工序有没有Result=0(OK)的记录。如果有,放行;没有,拦截并提示操作工先回到前工序把装配动作补上。

public async Task<bool> CheckPreviousStep(string serialNo, string materialCode, int currentStepNo) { // 当前工序的前一道工序号 int prevStepNo = currentStepNo - 1; const string sql = @" SELECT COUNT(1) FROM dbo.AssemblyRecord ar WHERE ar.SerialNo = @SerialNo AND ar.StepNo = @PrevStepNo AND ar.Result = 0; "; using var conn = new SqlConnection(_connString); await conn.OpenAsync(); int okCount = await conn.ExecuteScalarAsync<int>(sql, new { SerialNo = serialNo, PrevStepNo = prevStepNo }); return okCount > 0; }

跳站校验的SQL要留一个心眼:Result必须限定为0(OK),不然上工序有NG记录也算通过,那这个防跳站就形同虚设。有些项目的NG记录是单独一套流程,会把不合格品先送去维修工位,维修完再重新上线,此时上工序的NG记录会被一条新的OK记录覆盖吗?不会,NG归NG,OK归OK,追溯要看整条链路。所以校验必须只认OK记录。如果发现校验失败率高,不要急着改代码放行,先查工序表StepNo的连续性,很多跳站问题其实是工序编号配错了。

4. 物料防错与扫描采集:C#怎么把现场设备串成闭环

4.1 扫码枪输入进WinForms:焦点控制和回车触发

车间里没有人在键盘上敲料号,都是扫枪。扫枪本质上是一个键盘输入设备,扫完条码自动敲一个回车。所以WinForms里做扫码输入,核心不是读取什么SDK,而是控制好输入框的焦点和回车事件。

实操中最常见的做法是:扫描输入框设置为只读获取焦点,回车触发扫描事件。界面上一旦有多个输入框,扫枪内容就不知道跑到哪个框里去,这是新手最容易翻车的地方。我的习惯是每个工位界面只保留一个扫描输入框,屏蔽Tab键跳转,让焦点永远留在这个框上。

// 扫描框的KeyDown事件,回车意味着一次完整扫码 private void txtScan_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.Enter) { e.SuppressKeyPress = true; // 屏蔽回车产生的默认响声 string barcode = txtScan.Text.Trim(); if (string.IsNullOrEmpty(barcode)) { statusBar.Text = "扫描内容为空,请重新扫描"; return; } // 交给业务逻辑,解析条码并判断是哪一种(序列号/物料批次/工单号) ProcessBarcode(barcode); txtScan.Clear(); txtScan.Focus(); // 扫描完立即把焦点拉回扫描框 } }

这段代码里最关键的是e.SuppressKeyPress和txtScan.Clear()。前者避免回车键触发按钮默认行为(有时候界面上的按钮会因为回车被误触发),后者是确保上一次扫描的条码不会和下一次混在一起。焦点拉回扫描框这步我强调过很多次,因为很多操作工扫完会用鼠标点一下屏幕,一旦焦点丢了,下一次扫描内容就可能被填到别的控件里,轻则数据错乱,重则物料防错误判。

4.2 物料防错的三步校验:料号对不对、批次在不在、有效期过没过

物料防错是MES加工装配系统里最有业务价值的功能。装配工位扫一个物料批次码,系统要立刻判断这个物料能不能用在当前工位当前工单上。判断分三步,每一步都不能省。

第一步判断料号是否匹配。用当前工单的成品料号和当前工序号去查防错规则表,看这个物料料号在不在允许清单里。第二步判断物料批次是否有效。批次要在物料批次表里存在,且没有被耗尽或禁用。第三步判断有效期。有些行业的物料有严格有效期,过期物料必须拦截。三层都通过才放行,任何一个不通过,界面要给出明确的错误提示,让操作工知道是料号错了还是批次过期了。

public async Task<MaterialCheckResult> CheckMaterial(string materialCode, string batchNo, string workOrderNo, int stepNo) { const string sql = @" SELECT ar.AllowedMaterial FROM dbo.AntiErrorRule ar WHERE ar.MaterialCode = ( SELECT MaterialCode FROM dbo.WorkOrder WHERE WorkOrderNo = @WorkOrderNo ) AND ar.StepNo = @StepNo AND ar.EnableFlag = 1; "; const string batchSql = @" SELECT Quantity, ExpireDate FROM dbo.MaterialBatch WHERE BatchNo = @BatchNo AND MaterialCode = @MaterialCode; "; using var conn = new SqlConnection(_connString); await conn.OpenAsync(); List<string> allowed = (await conn.QueryAsync<string>(sql, new { WorkOrderNo = workOrderNo, StepNo = stepNo })).ToList(); // 第一层:规则不存在直接拦 if (allowed.Count == 0) return MaterialCheckResult.Fail("当前工序未配置物料防错规则"); // 第二层:料号是否允许 if (!allowed.Contains(materialCode)) return MaterialCheckResult.Fail($"物料[{materialCode}]不允许用于当前工序"); // 第三层:批次是否有效且未过期 var batch = await conn.QueryFirstOrDefaultAsync<MaterialBatchInfo>( batchSql, new { BatchNo = batchNo, MaterialCode = materialCode }); if (batch == null) return MaterialCheckResult.Fail($"批次[{batchNo}]不存在或不属于物料[{materialCode}]"); if (batch.ExpireDate.HasValue && batch.ExpireDate.Value < DateTime.Now) return MaterialCheckResult.Fail($"批次[{batchNo}]已过期,到期日:{batch.ExpireDate:yyyy-MM-dd}"); return MaterialCheckResult.Ok(); }

这个实现的要点是防错规则的可配置性。规则不存在的时候要明确拦截而不是放行,有些项目偷懒把这种情况当作通过,结果新料号上线忘了配规则,物料全搞乱了才发现,这是血泪教训。批次有效期这种业务规则不放在代码里写死,直接在SQL里查出来比,后续调整规则只改数据不动代码。另外要注意,这个接口要加一个操作日志,记录物料码、批次码、校验结果、操作工、时间,出了问题能追溯是谁在哪个时间点扫了哪一批料。

4.3 扭矩数据采集:从Power Focus 6000读数值的通信封装

装配工位上的电枪、拧紧轴、扭矩扳手,是MES加工装配系统里最难缠的设备。我见过Power Focus 6000这类拧紧控制器,输出扭矩值的通信方式倒是不难,难的是数据格式解析和掉线重连。这类设备一般走TCP/IP,控制器作为服务器监听端口,工位客户端作为客户端去连接,连接后发送请求命令,控制器返回数据帧。

C#里封装的思路一般是:建立TCP连接,按控制器的协议构造请求帧,读取响应帧,解析出扭矩值和判定结果,然后插入装配记录。下面给一个精简的TCP读取框架,实际的协议字段要按控制器型号查手册,但结构是通用的。

public class TorqueReader : IDisposable { private TcpClient _client; private NetworkStream _stream; private readonly IPAddress _ip; private readonly int _port; private readonly int _timeoutMs = 3000; public TorqueReader(string ip, int port) { _ip = IPAddress.Parse(ip); _port = port; } public bool Connect() { try { _client = new TcpClient(); IAsyncResult result = _client.BeginConnect(_ip, _port, null, null); if (!result.AsyncWaitHandle.WaitOne(_timeoutMs)) throw new TimeoutException("连接扭矩控制器超时"); _client.EndConnect(result); _stream = _client.GetStream(); _stream.ReadTimeout = _timeoutMs; return true; } catch (Exception ex) { // 返回false并记录日志,让上层决定是重试还是提示人工处理 Console.WriteLine($"[TorqueReader] 连接失败: {ex.Message}"); return false; } } public decimal ReadTorque() { // 构造读取命令,具体字节按控制器手册定义 byte[] cmd = { 0x02, 0x31, 0x03 }; // 示例帧,不可直接使用 _stream.Write(cmd, 0, cmd.Length); byte[] buffer = new byte[256]; int len = _stream.Read(buffer, 0, buffer.Length); // 这里要按协议解析,常见格式是ASCII文本或十六进制帧 string response = Encoding.ASCII.GetString(buffer, 0, len); return ParseTorque(response); } private decimal ParseTorque(string response) { // 不同控制器的扭矩值位置不一样,需要按实际帧格式做截取 // 常见的做法是Split后用Convert.ToDecimal string[] parts = response.Split(','); return decimal.Parse(parts[1], CultureInfo.InvariantCulture); } public void Dispose() { _stream?.Dispose(); _client?.Close(); } }

扭矩采集最常翻车的不是协议解析,而是读取超时。车间里的控制器可能同时被多台电脑连接,或是有其他程序占用了端口,导致Read阻塞在那里,界面假死、操作工干等。所以Connect和Read都要设置超时,一旦挂了就提示操作工检查控制器连接状态,而不是让程序无响应。还有一点,扭矩值解析出来以后一定要做边界判断,比如超过设定上限或者为负值,大概率是读取了错误帧,这种情况下宁可判NG也不要放行。

5. 现场数据怎么变成车间看板:C#的实时推送与查询优化

5.1 用SignalR把完工数据推到看板大屏

车间看板是MES最容易被领导看的功能,一块大屏上展示各工位的完工数、良品率、当前生产料号。如果看板数据是靠前端轮询数据库,工位多了以后数据库会被查询打爆,而且数据刷新有延迟,领导站在屏前看着数字半天不动,体验很差。C#项目里最顺手的方案是SignalR,服务端主动推送,客户端被动接收,实时性高且数据库压力小。

SignalR的用法很直接:服务端在装配记录提交成功之后,调用Hub向指定工位或全车间广播最新统计。客户端页面用JavaScript的SignalR客户端接收消息,直接更新DOM。下面是一个精简的Hub实现。

public class MesHub : Hub { // 工位提交完工后,广播该工位的最新统计给所有看板客户端 public async Task BroadcastStationUpdate(string stationCode) { // 实际项目里在这里查询最新统计数据 var payload = new StationUpdateDto { StationCode = stationCode, CompletedCount = await GetCompletedCount(stationCode), TargetCount = await GetTargetCount(stationCode), DefectCount = await GetDefectCount(stationCode), UpdateTime = DateTime.Now }; await Clients.All.SendAsync("OnStationUpdated", payload); } }

SignalR最容易被忽视的地方是长连接的稳定性。车间网络如果经常掉线,客户端要写重连逻辑,而且掉线期间的数据要有办法补回来。我的习惯是看板客户端每5秒额外做一次轻量轮询做兜底,等SignalR恢复了就自动切回推送模式,这样即使推送断了,看板最多落后5秒,不会出现大屏死在那里的尴尬局面。SignalR的部署还要注意代理问题,如果使用了反向代理,要配置好WebSocket的支持,否则客户端连不上。

5.2 百万级装配记录的分页查询:序列号追溯为什么要走索引

MES跑三个月以后,装配记录表轻易就能到百万级。这时候最常见的翻车场景是追溯查询变慢,输入一个序列号点查询,转圈好几秒才出结果。原因通常不是数据库不行,而是查询没走对索引,或者查询条件里写了函数导致索引失效。

序列号追溯的场景其实非常简单:WHERE SerialNo = @SerialNo ORDER BY RecordTime。只要SerialNo上有索引,这条查询在百万级数据里应该在几十毫秒内返回。真正影响性能的是好多人会在查询里加LIKE '%' + @serialNo + '%'来模糊匹配。一旦写成这样,索引就废了,全表扫描,数据量一大就必慢。追溯就该用精确匹配,模糊匹配是给搜索场景用的,不是给追溯用的。

另一个性能杀手是分页查询的写法。老式的ROW_NUMBER()分页在深页次会越来越慢,因为数据库要计算并丢弃前面所有的行。现在SQL Server 2012+有OFFSET FETCH,写法更简洁,性能也更好。下面这段是装配记录追溯查询的标准写法:

SELECT RecordId, WorkOrderNo, SerialNo, StepNo, StepName, WorkStationCode, OperatorCode, TorqueValue, Result, RecordTime FROM dbo.AssemblyRecord WHERE SerialNo = @SerialNo ORDER BY RecordTime DESC OFFSET @PageIndex * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY;

这条SQL的两个关键点:第一个是WHERE直接用等值条件匹配SerialNo,配合前文的IX_AssemblyRecord_SerialNo索引做Seek;第二个是分页用OFFSET FETCH而不是先取出全部记录再在内存里Skip。很多人会把分页参数从客户端传过来直接拼进SQL,这么做有SQL注入风险,一定要用参数化查询。实际项目里我会再加一个索引——IX_AssemblyRecord_WorkOrderNo_StepNo,用于按工单查进度统计,因为工单查询也高频。

6. 现场实施避坑指南:C# MES加工装配系统最常见的五个坑

6.1 串口通信丢数据:明明是9600波特率,为什么字节会丢

装配工位偶尔还要接串口设备,比如扫码枪走串口模式、老的扭矩仪走串口输出。C#用SerialPort接收数据,最常见的现象是接收数据不完整,时而丢头,时而丢尾。查了一圈波特率、校验位都没问题,最后发现是接收事件处理得太慢。

现象:串口设备连续输出一大段内容,SerialPort.DataReceived事件里收到的字节数不对,或者内容被截断。原因:DataReceived事件在后台线程触发,如果你在事件里直接做数据库写入或界面更新,处理时间超过了串口缓冲区被新数据覆盖的时间,数据就丢了。解决:接收事件里只做一件事,把字节存到内存缓冲区,另外开一个工作线程去消费缓冲区数据。

private readonly System.Collections.Concurrent.ConcurrentQueue<byte> _buffer = new(); private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) { // 接收事件里只入队,不做业务处理,防止阻塞导致丢字节 int count = _serialPort.BytesToRead; byte[] data = new byte[count]; _serialPort.Read(data, 0, count); foreach (byte b in data) _buffer.Enqueue(b); }

关键点在于处理线程从ConcurrentQueue取数据,然后按协议解析完整帧。如果这里不做缓冲而是直接在事件里解析,一旦解析逻辑里有数据库I/O,分分钟把串口阻塞住。另外SerialPort的ReceivedBytesThreshold默认值是1,也就是说每来一个字节就会触发一次事件,如果设备一次发几十个字节,事件会触发几十次,效率极低。可以把这个阈值设为和协议帧长一致,减少事件回调次数。

6.2 扫描枪焦点被抢走:同一台工位机既要扫码又要敲键盘

工位电脑上操作工既要扫条码,又要偶尔输入数量、备注,扫描枪焦点被抢的问题几乎每个项目都会遇到。现象是扫枪扫完条码,内容没进扫描框而是跑到某个文本框里去了,或者回车触发了不该触发的按钮。

根源是光标焦点。扫码枪就是键盘,它把字符发送到当前焦点控件上,所以要保证扫码那一刻焦点一定在扫描框里。解决方式不止一个,我的习惯做法是扫描框禁止鼠标点击进入其他控件,同时用LostFocus事件把焦点强制拉回来。

// 界面所有可能抢焦点的控件,统一在GotFocus时把焦点还给扫描框 private void AnyControl_GotFocus(object sender, EventArgs e) { if (sender is TextBox tb && tb.Name != "txtScan") { // 如果是手动操作键盘输入,允许焦点停留 // 如果是扫码触发,判断时间间隔和内容特征 // 简化方案:扫描框始终吃焦点,手动输入用弹窗 txtScan.Focus(); } }

但这里有个矛盾:操作工有时候确实需要在备注框里输入文字。如果所有焦点都被强制拉回扫描框,手动输入就废了。我最终用的是时间戳方案:记录最后一次键盘输入时间,如果是人工敲键盘(间隔超过200毫秒),允许焦点变化;如果是扫枪连续输入(间隔极小),马上把焦点抢回来。这个问题不处理好,防错功能会被现场操作工定性为“不好用”,然后他们就会想办法绕过系统,项目就危险了。

6.3 工位客户端崩溃后,未提交的装配记录怎么找回

车间电脑蓝屏、断电、程序被误关,装配记录已经在本地处理但还没提交到数据库,这种意外一发生,操作工找班组长,班组长找IT,IT找MES开发。MES要提前设计好事件溯源机制,而不是等到发生后再去翻数据库日志。

我的方案是工位客户端在提交前先写一条本地日志文件,包含完整装配数据,提交成功后再标记为已处理。程序启动时扫描未标记的日志并自动重发。这个机制不用太复杂,一个文本文件就够了。

public void SaveLocalRecord(AssemblyRecord record) { // 本地日志目录按工位编码区分,方便排查 string dir = Path.Combine(Application.StartupPath, "LocalRecords", record.WorkStationCode); Directory.CreateDirectory(dir); string fileName = $"{record.SerialNo}_{DateTime.Now:HHmmssfff}.json"; string json = JsonSerializer.Serialize(record); File.WriteAllText(Path.Combine(dir, fileName), json); }

启动重发的逻辑要小心一件事:重发时必须再次校验当前工位、工单状态、序列号是否已被其他工位提交过。否则断电前提交成功但日志标记没写,重启后重发就会造成重复记录。我的解决方式是API层做幂等校验,相同序列号、相同工序、相同工位、相同操作工的记录在极短时间内重复提交时,第二次直接返回已存在。这个坑特别隐蔽,等数据乱了你都不知道是哪一次重启造成的。

6.4 装配记录写入慢:SQLite还是SQL Server的边界

有些工位客户端项目想省事,直接在本地装了个SQLite,装配记录先写本地库,再定时同步到中央库。这个思路本身没错,但很容易掉进“数据同步冲突”的坑,尤其是两个工位同时处理同一个序列号,本地库和中央库的记录就对不上了。

SQLite做MES本地缓存,我一般只建议用在离线模式。车间网络一旦断了,工位还要继续生产,此时本地SQLite暂存数据,网络恢复再自动上传。但连接恢复后的上传顺序必须严格按RecordTime排序,并且上传前要重新跑防错校验和工序校验。因为网络断开的这段时间里,物料批次可能已经被其他工位用掉了,或者工单状态已经变化了。

如果车间网络还算稳定,直接连中央SQL Server其实是最省心的,省掉同步逻辑等于省掉一大批bug。SQL Server连接串放在客户端,注意加密和权限控制,每个工位用独立的数据库账号,只授予增删改查的权限。性能问题用连接池优化,而不是把数据库换成文件型数据库。很多团队在性能焦虑下选中了SQLite,最后发现同步冲突的维护成本比特么性能优化高多了。

6.5 WPF界面卡死:为什么数据绑定不能让线程随便改

在WPF的项目里,工位界面时不时卡死,刷新看板的时候整个窗口无响应,最终定位是后台线程直接修改了界面控件的值。WPF的UI线程模型比WinForms严格得多,只要不是UI线程更新界面,就会抛异常或造成视觉上的卡顿,而很多人的做法是“先用Dispatcher.BeginInvoke包一层”,但没理解里面还有更深的问题。

// 一般的写法是把数据更新丢给UI线程 Application.Current.Dispatcher.BeginInvoke(() => { txtStatus.Text = "当前工单已完成"; btnSubmit.Enabled = false; });

但是如果你在后台线程里频繁调用Dispatcher.Invoke(同步版本),每次都会阻塞后台线程等待UI处理,如果UI线程本身在忙(比如列表刷新),后台线程就会排队,导致数据采集延迟、串口缓冲区溢出。我的习惯是后台线程只把状态封装成一个ViewModel对象,然后用BeginInvoke异步交给UI线程一次性刷新。界面上的数据刷新频率控制在每秒最多5次,看板显示不需要50毫秒的真实时间,太频繁反而让操作工看着眼花。

7. 进阶:用Dapper批量提交提升装配数据写入吞吐量的一个技巧

装配车间的工位数量到十几二十个以后,每条装配记录都走一次单条INSERT,数据库的写压力就容易成为瓶颈。尤其是测试工位,一个产品要写几十条参数,逐条提交会产生大量网络往返和事务开销。C#里用Dapper的批量提交可以显著改善,操作简单效果明显。

最常见的批量提交方式是用Dapper的Execute一次执行多条SQL,办法是传入一个IEnumerable集合。Dapper内部会逐条执行,但只有一次网络往返,比循环调用Execute快很多。下面的代码把一批装配记录一次性插入数据库。

public bool BulkInsertAssembly(IEnumerable<AssemblyRecord> records) { const string sql = @" INSERT INTO dbo.AssemblyRecord (WorkOrderNo, SerialNo, StepNo, StepName, WorkStationCode, OperatorCode, TorqueValue, Result, RecordTime) VALUES (@WorkOrderNo, @SerialNo, @StepNo, @StepName, @WorkStationCode, @OperatorCode, @TorqueValue, @Result, @RecordTime);"; using var conn = new SqlConnection(_connString); // 一次Execute传入整个集合,Dapper自动循环执行 int affected = conn.Execute(sql, records); return affected == records.Count(); }

这里有个参数要注意:RecordTime不要在SQL里写GETDATE(),因为如果业务上要求同一批产品记录时间一致,每条都用GETDATE()会产生细微的秒级偏差。我会在C#代码里取一次DateTime now = DateTime.Now,赋给集合里的每一条记录,保证同一批数据的时间戳完全一致。这样做还有一个好处,后续任何时间维度统计都不会出现同一批产品跨秒的怪象。

批量提交虽然能在吞吐量上救急,但不要指望它解决全部性能问题。如果单个工位一分钟要写上千条记录,先别急着上批量提交,回头看看业务逻辑:是不是测试数据的采集频率太高了?是不是可以把某个工序的多组数据合并成一条大字段存储?我在现场见过很多所谓的“MES慢”,最后查出来是采集逻辑写了死循环,或者是把大量历史记录加载到界面上来了。先把业务搞清楚再优化技术,这才是MES项目该有的顺序。

现场实施给我的最大教训是:C# MES加工装配系统的技术难点从来不是哪一个点特别深,而是设备通信、数据一致性、界面交互、数据库性能这些零零碎碎的东西互相纠缠。你解决了一个串口丢包,可能又踩到重发数据导致重复记录的坑。所以每写一个模块,都要回头想一遍网络断了怎么办、多工位并发怎么办、操作工误操作怎么办。这套设计思路我用了好几个项目,从最早的WinForms单机版到现在的Web API加SignalR架构,核心没变过——先把现场流程吃透,再写代码。希望帮到你,也期待你在车间里跑出自己的MES版本。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询