资讯动态

C# MES加工装配系统实战:架构设计、核心代码与现场避坑

发布时间:2026/10/9 6:55:50 来源:尧图企业网站定制
简介面向制造业信息化人员与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做TCPModbus有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还是DapperC#的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.QueryTaskDto(sql, new { StationCode stationCode }).ToList(); return Ok(tasks); }这段SQL的核心是子查询取最小工序号。同一个工位可能在不同料号的不同工序节点上都有任务子查询保证只取当前工位最前面的那道工序避免操作工同时面对多个工序无从下手。ORDER BY CreateTime按工单下发时间排队保证先进先出这是车间管理的基本规矩。3.2 首工序开工扫描序列号生成WIP追溯链的头装配追溯的起点是首工序开工。操作工在工位终端上扫描产品主条码或打印的序列号标签系统需要确认这个序列号没有被其他工单占用、当前工单还在生产状态然后生成追溯链路的第一条装配记录。这个动作是整个MES里最敏感的节点因为一旦序列号绑错了工单后面所有追溯数据全乱。首工序的逻辑不是直接插一条记录那么简单它要同时做三件事检查序列号唯一性、检查工单状态、写入装配记录。下面代码用了一个事务包住这三步任何一步不过都整体回滚。public async Taskbool 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.ExecuteScalarAsyncint(checkSerialSql, new { req.SerialNo }, tx); if (count 0) { await tx.RollbackAsync(); return false; // 序列号已存在拒绝重复开工 } var order await conn.QueryFirstOrDefaultAsyncWorkOrderState( 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 中间工序过站校验上工序是否完成防止跳站装配行业最让人头疼的现场问题之一就是跳站。操作工图省事或者新员工不熟悉工艺上道工序没做标记直接把产品推到下道工序了。等整机装配完测试不合格反查是哪道工序出了问题发现追溯链缺了一环整批都要隔离排查损失非常大。中间工序过站因此必须校验前一道工序是否已有合格记录。校验逻辑很简单却特别容易被忽略查当前序列号在前一工序有没有Result0OK的记录。如果有放行没有拦截并提示操作工先回到前工序把装配动作补上。public async Taskbool 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.ExecuteScalarAsyncint(sql, new { SerialNo serialNo, PrevStepNo prevStepNo }); return okCount 0; }跳站校验的SQL要留一个心眼Result必须限定为0OK不然上工序有NG记录也算通过那这个防跳站就形同虚设。有些项目的NG记录是单独一套流程会把不合格品先送去维修工位维修完再重新上线此时上工序的NG记录会被一条新的OK记录覆盖吗不会NG归NGOK归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 TaskMaterialCheckResult 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(); Liststring allowed (await conn.QueryAsyncstring(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.QueryFirstOrDefaultAsyncMaterialBatchInfo( 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.ConcurrentQueuebyte _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 工位客户端崩溃后未提交的装配记录怎么找回车间电脑蓝屏、断电、程序被误关装配记录已经在本地处理但还没提交到数据库这种意外一发生操作工找班组长班组长找ITIT找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(IEnumerableAssemblyRecord 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版本。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑