C# Winform打造喷涂线SCADA系统:架构、配方与追溯实战

发布时间:2026/10/30 15:45:14
C# Winform打造喷涂线SCADA系统:架构、配方与追溯实战

简介:这套喷涂工艺SCADA系统是一份基于C#与Winform的完整工程源码,面向工厂自动化工程师、上位机软件开发人员及智能制造相关专业学习者,可用于快速理解并复用喷涂产线的集中监控与控制方案。系统覆盖生产看板、产线总控、配方管理、图表管理、报表管理、日志管理、用户管理及系统参数配置八大模块,能够解决生产参数实时监测、多配方切换、生产数据追溯和权限安全等实际问题。资源包共132个文件,以87个C#源码文件为核心,配合resx界面资源、png图标、sln/csproj工程文件,以及可直接运行的exe程序和PDF说明文档,压缩包约40.29MB,结构清晰,便于直接打开编译或对照学习。目前已有138人浏览学习。获取后可以借鉴其Winform界面布局、多模块解耦思路、数据库与图表联动设计,也可在此基础上扩展新的工艺参数或对接其他产线设备,对提升工业上位机开发能力很有帮助。

1. 喷涂线上 SCADA 系统要解决的不是屏幕,而是“对账”

喷涂线的班产数据还在靠班长下班前抄表,换色号还要翻工艺本,客户要追溯一批货的喷涂参数,三个人翻了两小时没找齐——这时候一套喷涂工艺 SCADA 系统就能派上用场。用 C# Winform 做整套上位机,覆盖生产看板、产线总控、配方管理、图表管理、报表管理、日志管理、用户管理和系统参数八个模块,把产线从“设备能跑”推进到“数据能对账、工艺能追责”。这篇我按自己做上位机的方式,把架构、看板与总控、配方下发、报表追溯、权限日志和交付验证一次讲透。适合产线设备工程师、上位机开发者和准备接 MES 的项目负责人。

2. 先拆系统再写代码:喷涂 SCADA 的模块边界与 Winform 工程骨架

2.1 八个管理模块划成三条线,数据流方向定清楚再动工

很多 Winform 项目最后失控,不是代码写崩了,是模块边界一开始就糊了。标题里那八个模块,我习惯先按数据性质分成三条线,再决定窗体怎么组织、数据走哪条路。

分组模块数据性质可靠性要求
实时线生产看板、产线总控、图表管理秒级或百毫秒级快照断线必须有明确状态,不允许显示假数据
工艺线配方管理、报表管理事件型、离散记录写入要校验,查询要能追溯
支撑线日志管理、用户管理、系统参数配置与审计随系统启动加载,日志不许丢

为什么把图表管理划进实时线?因为实时曲线本质上是看板的局部视图,图表复用的是同一份实时数据源;历史曲线才落到报表那一侧。系统参数名义上是支撑线,实际是横切的,采集周期、报表路径、看板轮播间隔都属于它。

分组想清楚以后,数据流就一句话:PLC 和仪表先把数据推给采集服务,采集服务更新内存缓存,再触发看板和总控刷新;配方则反着走,界面录入后先校验,再下发设备,设备确认后才允许换型。报表和日志只读生产数据,不反向改设备。这里面最忌讳的就是采集逻辑直接写在窗体 CodeBehind 里,换一个 PLC 型号就要重写界面。

顺带说一句选型。如果你只是想先看效果,用 Rapid SCADA 这类现成组态软件能很快出画面;但喷涂工艺要深度绑定配方权限、报表追溯和换型流程,还是自写 Winform 掌控感更强,这也是 C# 上位机在这类中小型产线上仍然常见的原因。

2.2 Winform 骨架:单例采集服务、事件推送与 MVP 回调

代码第一版先不追求炫,追求“界面和采集分得开”。我会先把数据模型建出来,再定服务接口,最后让窗体订阅事件。最小骨架大概是这样三块。

数据模型,字段直接对齐喷涂工艺参数:

// Models/LineStatus.cs public class LineStatus { public string DeviceId { get; set; } // 设备编号,如 SP01 public int DeviceState { get; set; } // 0待机 1运行 2换型 3停机 4报警 public int OutputCount { get; set; } // 班产累计数 public double FlowRate { get; set; } // 当前流量,单位 mL/min public double Temperature { get; set; } // 当前温度,单位 ℃ public string RecipeId { get; set; } // 当前配方编号 public DateTime UpdateTime { get; set; } }

DeviceState 用 int 而不是枚举,不是因为枚举不行,而是 PLC 字地址读上来的本来就是整数,UI 层再转成枚举或状态文案就好。采集服务接口只暴露事件和启停,不暴露任何控件:

// Services/IDataService.cs public interface IDataService { event Action<LineStatus> StatusUpdated; void Start(); void Stop(); }

模拟器实现,开发阶段没有真实 PLC 也能把界面跑起来:

// Services/SimulatedDataService.cs public class SimulatedDataService : IDataService { private readonly System.Timers.Timer _timer; private readonly Random _random = new Random(); private LineStatus _status; public event Action<LineStatus> StatusUpdated; public SimulatedDataService() { _status = new LineStatus { DeviceId = "SP01", DeviceState = 0, RecipeId = "R001" }; _timer = new System.Timers.Timer(1000); // 1秒推一次,模拟PLC扫描周期 _timer.Elapsed += OnTick; } private void OnTick(object sender, System.Timers.ElapsedEventArgs e) { _status.OutputCount += _random.Next(1, 4); // 模拟班产递增 _status.FlowRate = 180 + _random.Next(-5, 5); _status.Temperature = 200 + _random.Next(-3, 3); _status.UpdateTime = DateTime.Now; StatusUpdated?.Invoke(_status); } public void Start() => _timer.Start(); public void Stop() => _timer.Stop(); }

事件是观察者模式,采集线程把数据推给所有订阅者,窗体不需要知道数据是从哪台 PLC 来的。Timer 的 1000ms 是模拟扫描周期,真实项目里喷涂流量和温度用 500ms 到 1s 足够,产量计数不要靠差值算,让 PLC 累计后上位机做周期快照。Random 的区间大小影响曲线平滑程度,后面测报警阈值时可以故意调大看界面反应。

MainForm 那边只做一件事,订阅并切回 UI 线程:

// MainForm.cs 构造函数中 _dataService.StatusUpdated += OnStatusUpdated; private void OnStatusUpdated(LineStatus status) { if (InvokeRequired) { BeginInvoke(new Action<LineStatus>(OnStatusUpdated), status); return; } lblOutput.Text = status.OutputCount.ToString(); lblFlow.Text = status.FlowRate.ToString("F1"); lblTemp.Text = status.Temperature.ToString("F1"); }

为什么用 BeginInvoke 而不是 Invoke?BeginInvoke 不阻塞采集线程,高频场景下不会让两边互相等。这种事件推进的方式比窗体直接引用采集类好维护得多,后面接真实 PLC 时只需要换一个 IDataService 实现。

提示:如果你还在用 VS2015,目标框架默认是 .NET Framework 4.6 附近,代码里别急着用 C# 8 的新语法,后面报表代码我会专门讲这个坑。

3. 生产看板与产线总控:把实时性和“防误操作”做到位

3.1 看板数据流:批量推送、BeginInvoke 收敛与 200ms 合并刷新

单设备单事件没问题,但真实产线往往是五六台喷房同时推数据。如果每个设备每个字段都 BeginInvoke 一次,Winform 控件一多立刻卡给你看,这也是热词里“c# 控件多致 winfrom 卡”最常见的来源。

第一层优化是把单事件改成批量事件。接口里加一个 BatchUpdated:

// Services/IDataService.cs 增加批量事件 public interface IDataService { event Action<List<LineStatus>> BatchUpdated; // 一次推一批设备快照 void Start(); void Stop(); }

模拟器里 OnTick 构造一个 List 再触发,UI 侧不再每次收到数据就刷控件,而是先打脏标记:

// MainForm.cs 字段 private bool _dirty; // 是否有新数据待渲染 private List<LineStatus> _latestStatuses; // 最新一批快照 private readonly object _sync = new object(); // 采集线程回调,只保存快照并置脏标记 private void OnBatchUpdated(List<LineStatus> statuses) { lock (_sync) { _latestStatuses = statuses; _dirty = true; } } // UI 定时器,200ms 合并刷新一次 private void uiTimer_Tick(object sender, EventArgs e) { if (!_dirty) return; _dirty = false; List<LineStatus> snapshot; lock (_sync) { snapshot = _latestStatuses; } RenderBoard(snapshot); // 只在 UI 线程真正操作控件 }

_dirty 标记是关键,它把“数据到达频率”和“界面刷新频率”解耦。200ms 意味着即使采集线程每秒来了 10 次事件,界面最多每秒刷 5 次,人眼看起来已经是连续的。大屏只看分钟级汇总时,500ms 也行。RenderBoard 里再补一个小习惯:Label 先判断 Text 是否相同再赋值,文本没变就别触发重绘,这一条对降低 CPU 占用非常有效。

如果看板还要接入 UVC 摄像头监控喷房,DirectShow 回调里同样要用设备句柄做映射区分工位号,不能只靠画面特征去猜,这个我后面避坑章还会提。

3.2 产线总控状态机:中控员允许在什么状态下做什么

产线总控的界面看起来是一排启动、暂停、复位、换型按钮,但真正要设计的是状态迁移表,否则就会出现“运行中还能误触换型”这种低级事故。状态机不需要多重的框架,一张表加一个枚举就能约束住:

当前状态操作目标状态必要条件
待机下发启动运行安全门关闭、无报警、配方已生效
运行请求暂停停机允许在工序结束点暂停
运行/停机报警触发报警任一报警点触发
报警人工确认并复位待机或运行报警已恢复
运行换型请求换型产线进入安全位置
换型确认新配方运行或待机配方校验通过、温度达标

代码上不需要复杂的状态机框架,一个枚举加一个迁移校验函数就够。操作按钮的 Enable 状态跟着当前状态走:报警态时启动按钮置灰,换型态时配方下发按钮才亮。操作人员在中控屏上看到的永远是当前真实状态和可执行操作,而不是一排随时能点的按钮。

注意:上位机不背安全联锁的锅。SCADA 只负责展示请求和回显实际状态,真正的安全回路必须由 PLC 侧硬逻辑保证,上位机能做的是把误操作的可能性砍掉。

3.3 图表管理:实时曲线用缓冲队列,历史曲线走查询

图表管理分两半:实时曲线和曲线查询。很多人实时曲线卡,是因为每来一个点就刷新一次整个图表控件。正确的做法是缓冲队列加定时快照:

// ChartBuffer.cs 环形缓冲,容量固定 public class ChartBuffer { private readonly Queue<double> _buffer = new Queue<double>(); private readonly int _capacity; public ChartBuffer(int capacity) { _capacity = capacity; // 容量 = 显示的采样点数 } public void Push(double value) { _buffer.Enqueue(value); if (_buffer.Count > _capacity) { _buffer.Dequeue(); // 挤出最旧的点 } } public double[] Snapshot() { return _buffer.ToArray(); // 返回副本,避免 UI 遍历时集合被修改 } }

容量怎么算?1 秒一个点,显示 10 分钟曲线,容量就是 600。实时曲线刷新频率放到 500ms 一次,每次拿 Snapshot 重画,而不是每 Push 一次就重画一次。多路信号各自维护一个 ChartBuffer,渲染时按时间轴对齐。

历史曲线属于报表那一侧,从数据库或历史 CSV 文件按时间段查出来,再丢进同一个 ChartBuffer 渲染,这样实时和历史曲线共用一套绘制逻辑。看板的界面美化我一般用深色底加深绿、橙、红的语义色,现场 4K 大屏上远距离也能一眼分清运行、换型和报警状态。

4. 配方、报表、日志与权限:让喷涂工艺可追溯的四个模块

4.1 配方管理与系统参数:JSON 存储、启动加载与下发校验

配方管理是喷涂 SCADA 的核心。配方本质上是一组“有边界”的设备参数,按产品编码存好,换型时一键下发,而不是让工艺员手动敲几十个寄存器地址。

// Models/SprayRecipe.cs public class SprayRecipe { public string RecipeId { get; set; } // 配方编号,如 R001 public string ProductCode { get; set; } // 产品编码 public string Color { get; set; } // 色号 public int GunPressure { get; set; } // 喷枪压力,单位 kPa public double FlowRate { get; set; } // 流量,单位 mL/min public int PreheatTemp { get; set; } // 预热温度,单位 ℃ public int ConveyorSpeed { get; set; } // 链速,单位 m/min public int MinPressure { get; set; } // 校验下限 public int MaxPressure { get; set; } // 校验上限 }

存储用 JSON 文件而不是数据库,通常是因为单机部署的配方数量在几百个以内,JSON 直观、可复制、坏了还能用文本编辑器修。多线共享配方时再考虑数据库加版本号。RecipeManager 负责保存、加载和校验:

// Services/RecipeManager.cs public class RecipeManager { private const string RecipeFolder = @"C:\ScadaData\Recipes"; public void Save(SprayRecipe recipe) { var json = JsonConvert.SerializeObject(recipe, Formatting.Indented); var path = Path.Combine(RecipeFolder, $"{recipe.RecipeId}.json"); File.WriteAllText(path, json); } public SprayRecipe Load(string recipeId) { var path = Path.Combine(RecipeFolder, $"{recipeId}.json"); return JsonConvert.DeserializeObject<SprayRecipe>(File.ReadAllText(path)); } public string Validate(SprayRecipe recipe) { if (recipe.GunPressure < recipe.MinPressure || recipe.GunPressure > recipe.MaxPressure) return "喷枪压力超出允许范围"; if (recipe.PreheatTemp < 20 || recipe.PreheatTemp > 250) return "预热温度超出工艺范围"; return string.Empty; // 返回空字符串表示校验通过 } }

校验必须做两次:界面保存前一次,下发 PLC 前再一次。下发时的校验尤其重要,因为配方文件可能被手动改过。换型流程我一般做成闭环:停线吹扫、选择产品编码、加载配方、展示新旧配方差异、校验、写入 PLC、读回确认、写操作日志、看板更新配方号。任何一步失败都不能进入换型,这才是“换型五分钟”而不是“换型半小时”。

系统参数我不放数据库,放 XML 或 App.Config,因为只有几十项。启动时读进一个静态类 SystemParameters,界面和采集服务都只读内存缓存,修改后写回文件。采集周期、报表路径、看板轮播间隔、报警阈值都在这里。

提示:操作日志要在下发入口记一次,不能只在成功后记,失败原因同样要留痕,后面追溯时才说得清是哪一步拦下来的。

4.2 报表管理:先查 DataTable,再用模板导出 Excel

报表管理的目标是把生产数据变成客户能看的班产表、配方使用表、报警统计表。我的习惯是查询和导出分开:先用参数化 SQL 查出 DataTable,再用模板填充 Excel,而不是在 DataGridView 上直接导。因为 DataGridView 的行列顺序和显示格式经常和报表要求不一致。

-- 班产报表查询,时间段左闭右开 SELECT ProductCode, Color, OutputCount, DefectCount, StartTime FROM dbo.ProductionLog WHERE StartTime >= @start AND StartTime < @end AND Shift = @shift ORDER BY StartTime;
// Services/ReportManager.cs public DataTable QueryReport(DateTime start, DateTime end, string shift) { var sql = "SELECT ProductCode, Color, OutputCount, DefectCount, StartTime " + "FROM dbo.ProductionLog " + "WHERE StartTime >= @start AND StartTime < @end AND Shift = @shift"; using (var conn = new SqlConnection(_connStr)) using (var cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@start", start); cmd.Parameters.AddWithValue("@end", end); cmd.Parameters.AddWithValue("@shift", shift); var dt = new DataTable(); using (var da = new SqlDataAdapter(cmd)) { da.Fill(dt); } return dt; } }

导出用 NPOI 的 XSSFWorkbook 操作 xlsx,先打开一个做好的报表模板,模板里把表头、合并单元格、边框都排好,代码只负责填数据行:

using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; public void ExportToExcel(DataTable dt, string templatePath, string savePath) { XSSFWorkbook workbook; using (var fs = File.OpenRead(templatePath)) { workbook = new XSSFWorkbook(fs); } var sheet = workbook.GetSheetAt(0); var headerRow = sheet.GetRow(0); for (var c = 0; c < dt.Columns.Count; c++) { headerRow.GetCell(c).SetCellValue(dt.Columns[c].ColumnName); } var rowIndex = 1; foreach (DataRow row in dt.Rows) { var excelRow = sheet.CreateRow(rowIndex++); for (var c = 0; c < dt.Columns.Count; c++) { excelRow.CreateCell(c).SetCellValue(row[c].ToString()); } } using (var outFs = File.Create(savePath)) { workbook.Write(outFs); } }

务必不要用 Microsoft.Office.Interop.Excel,客户服务器大概率没装 Office,一调就崩。模板导出的好处是样式不用在代码里重画,改报表格式时只改 xlsx 模板,不用重新编译。

注意:报表批量入库时如果用 SqlBulkCopy,目标表增加列或调整列顺序就会报“列名不匹配”。写之前先查 INFORMATION_SCHEMA.COLUMNS 动态构造列映射,别把列集合写死在代码里。

4.3 日志管理、用户权限与操作留痕

日志管理在 C# 上位机里最容易写成 Console.WriteLine,但 SCADA 需要的是按天滚动、带级别、带操作人的审计文件。我的 LogManager 很轻量:

// Services/LogManager.cs public static class LogManager { private static readonly object LockObj = new object(); private static readonly string LogDir = @"C:\ScadaData\Logs"; public static void Write(string level, string message, string userName) { lock (LockObj) { var date = DateTime.Now; var file = Path.Combine(LogDir, $"scada_{date:yyyyMMdd}.log"); var line = $"{date:yyyy-MM-dd HH:mm:ss}|{level}|{userName}|{message}"; File.AppendAllText(file, line + Environment.NewLine); } } }

既然叫 SCADA 日志管理,就要解决并发写日志的问题。lock 保证多个采集线程同时调 Write 时不会互相插行;文件名按天滚动,配合文件大小上限可以定期清理。级别我定四个:INFO、WARN、ERROR、OPERATION,其中 OPERATION 专记配方下发、用户登录、参数修改,报表追溯时直接筛这个级别就能还原操作链。

用户权限这块,账号密码不能存明文。登录时计算哈希加盐,和数据库中的哈希比对:

// Services/UserManager.cs 核心逻辑 public User Login(string account, string password) { var hash = ComputeMd5(password + _salt); // 查库:SELECT * FROM T_User WHERE Account=@account AND PwdHash=@hash } public bool HasPermission(User user, string permission) { // 角色:Admin / Engineer / Operator // Admin 全量权限 // Engineer 可改配方、改系统参数 // Operator 只能看板、总控操作 }

权限的粒度不用卡到按钮级,三级角色足够:操作员能看能操作、工程师能改配方和参数、管理员能管用户和删日志。按钮的 Enabled 在登录后统一刷新,换人登录时同步刷新所有权限位。注意按钮不要用 Visible=false 来隐藏,会让界面布局跳动,用 Enabled 置灰更自然。连续输错 5 次密码锁账号 10 分钟这个需求也常有,用户管理表里记一下 FailedCount 和 LockUntil 就行。

5. 避坑:喷涂 SCADA 现场最容易翻车的 5 个真实场景

5.1 数据采集类:通信断线、界面卡顿、配方越界

第一条,通信断线后界面假死。现象是现场工人把网线一拔,整个上位机鼠标转圈,等了十几秒都没反应。原因是采集线程里用了同步阻塞读,PLC 没响应就一直等。解决方法是通讯超时控制在 500ms 到 3 秒之间,断线后不要重试同一台设备,把断线状态通过一个 DeviceOffline 事件推给界面,看板灰显、总控按钮禁用,后台重连按 5 秒、10 秒、30 秒退避。上位机可以卡顿,但绝不能让操作员以为产线还在正常运行。

第二条,控件一多界面就卡成 PPT。现象是看板上有五六台设备的温度、流量、产量、状态,每个控件都在高频更新,CPU 占用直接冲到 30% 以上。原因是每个设备每个字段各调一次 BeginInvoke,跨线程切换开销巨大。解决方法就是第三章那套批量事件加脏标记,200ms 合并刷新一次,并且赋值前先判断文本是否变化。我把这条归为“用线程思路省 UI 开销”,而不是无脑加定时器。

第三条,配方参数越界,温度 800 度差点写进 PLC。现象是工艺员在配方编辑框里手滑多输了个零,保存后直接下发,设备动作异常。原因是配方模型里没有参数边界,校验函数也没在下发入口执行。解决方法是每个数值参数都带 Min/Max 元数据,Validate() 在界面保存前、下发 PLC 前各跑一次,服务端再兜底校验一次。配方下发这种事,宁可多拦一道也不要少拦一道。

5.2 界面与交付类:4K 大屏错位、安装包缺框架

第四条,4K 大屏上看板布局全乱。现象是现场换了台 4K 显示器,Winform 窗体字体发虚,控件错位,按钮叠在一起。原因是程序没有声明 DPI Aware,系统按缩放拉伸导致布局计算混乱。解决方法是 app.manifest 里声明 dpiAware,布局尽量用 TableLayoutPanel 加 Anchor,不要用绝对坐标;发布前在 100% 和 150% 缩放两种模式下各截图检查一遍。大屏看板的字号我给到 28px 以上,远距离才能看清,这比任何界面美化技巧都重要。

第五条,客户机装完安装包后启动直接报错。现象是开发机跑得好好的,拷到现场工控机双击就弹“应用程序无法启动”或者提示缺少 .NET Framework。原因是目标框架选得太高,安装包也没带引导程序。Winform 打包成安装程序时,项目属性里要把 .NET Framework 引导勾上,发布前拿一台干净的虚拟机或者现场工控机实测一遍。客户机还在用 Win7 的话,目标框架别超过 4.6.2,别迷信新版本。

6. 交付前用一把“假 PLC”做整链路验证,顺便把安装包收尾

6.1 用开关切换模拟器与真实 PLC,流程先跑顺再上真机

交付前最怕的是拿真实产线调试换型流程,停线一次就是几十万的产能损失。我一般会在 SystemParameters 里放一个 UseSimulator 开关,启动时决定创建哪个数据服务:

// Program.cs public static IDataService CreateDataService() { var useSimulator = SystemParameters.GetBool("UseSimulator"); return useSimulator ? new SimulatedDataService() : new PlcDataService(); } [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); SystemParameters.Load(); var mainForm = new MainForm(CreateDataService()); Application.Run(mainForm); }

这样从模拟器切到真实 PLC,只需要改一个配置项,一行业务代码都不用动。模拟器不能只模拟正常数据,还要能模拟断线、报警、温度超限,这样才能验证看板的灰显逻辑和报警状态迁移。

6.2 三项验证清单,过了再发安装包

交付前我会按这个清单跑一遍:日志文件按天分割且包含操作人,跨天报表查询不漏行;断开模拟器连接后看板置灰而不是转圈,恢复后能自动续采;用低权限账号登录,配方和系统参数按钮应置灰不可点。全部过了再打安装包。

工控现场有些问题确实是重启大法比查配置还快,这是玄学,但 SCADA 这类系统不行,每一次启停都可能影响产线。我现在养成的习惯是:模拟器跑够两天、日志和报表全走一遍、权限用例过完,才敢把安装包发到现场。希望帮到你。

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