ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于C#的二次注液机上位软件架构与源码避坑指南

基于C#的二次注液机上位软件架构与源码避坑指南

简介:这份资源是面向工业自动化上位机开发者的C#二次注液机上位软件完整源码,适合具备一定C#基础、希望深入理解设备控制软件架构的工程师与学习者。项目围绕二次注液机的液体精确注入场景,覆盖人机界面、数据库存储与欧姆龙PLC通信三大核心模块,可帮助读者掌握上位机与下位机的数据交互、工艺参数管理及报警信息记录等关键实现。压缩包共208个文件,约2.57MB,以63个cs源码文件为主体,辅以24个dll依赖库、23个csv数据文件、14个resx与resources界面资源,以及exe、config、sql、csproj等工程与部署文件,结构完整,可直接用于源码研读与二次开发参考。目前已有333人学习下载。通过梳理其Windows Forms界面逻辑、ADO.NET数据存取方式与串口通信代码,读者能获得一套可复用的工业控制软件设计思路,并借鉴其中的注释规范与多线程、异常处理实践,为自身项目开发提供参考。

1. 二次注液机上位软件:为什么C#是大型自动化设备的主流选择

一条锂电池产线上的二次注液机,往往要同时管控几十个伺服轴、称重模块、扫码枪和真空阀门,节拍动辄按秒计算。这类设备的上位软件如果用脚本语言硬扛,跑上几个月就会在通讯抖动、内存泄漏和界面卡顿上集中翻车。C# 在这个场景里能站稳,靠的是强类型、成熟的异步模型和 WinForms/WPF 的工业界面生态,配合 .NET 的 GC 与线程池,能把「设备层实时通讯」和「界面层数据刷新」拆得比较干净。这篇笔记围绕「基于 C# 的大型自动化设备二次注液机上位软件源码」这个方向,讲清楚一套可落地的上位机架构该怎么搭、通讯怎么做、参数怎么设、源码里哪些地方最容易埋雷。适合正在做锂电设备上位机、或者准备接手一套 C# 上位软件源码的工程师,新手能照着搭骨架,熟手能对照检查自己的边界处理。

2. 二次注液机上位软件的架构拆解与选型理由

2.1 为什么是「分层 + 状态机」而不是大循环

大型自动化设备的上位软件,最怕写成一个大 while 循环里塞满 if-else。二次注液机的流程本身是有明确阶段的:上料定位、扫码绑定、一次称重、抽真空、注液、静置、二次称重、下料。这些阶段天然适合用状态机描述,而不是靠标志位堆叠。常见做法是把设备流程抽象成「状态 + 迁移条件」,每个状态只关心自己该干的活和什么条件下跳走。

分层上,我一般会切成四层:通讯层(PLC、称重仪、扫码枪的驱动封装)、设备层(把通讯层的读写组合成有业务含义的动作)、流程层(状态机调度)、界面层(只做展示和指令下发)。这样切的好处是,换一台 PLC 或者换一个称重品牌,只动通讯层,流程层几乎不用改。源码里如果看到界面代码直接调 PLC 地址,那基本可以判定这套代码后期维护会很痛苦。

状态机的实现不必上重型框架,一个枚举加一个 switch,配合迁移表就够了。关键是每个状态的进入动作、退出动作、超时处理要写全,否则设备卡在某个状态时你连日志都定位不到。

2.2 通讯层封装:把 PLC 和称重仪抽象成统一接口

二次注液机常见的下位是 PLC(三菱、西门子、汇川都有),称重多用梅特勒-托利多或者国产模块,扫码枪走串口或网口。通讯层要做的第一件事,是定义一个统一的设备接口,让上层不关心底层是 Modbus TCP 还是串口。

// 统一设备通讯接口,屏蔽底层协议差异 public interface IDeviceChannel { bool IsConnected { get; } // 连接设备,参数由具体实现解释 Task<bool> ConnectAsync(CancellationToken token); // 读一个点位,返回原始字节或已解析值 Task<T> ReadAsync<T>(string address, CancellationToken token); // 写一个点位 Task<bool> WriteAsync(string address, object value, CancellationToken token); // 断开并释放资源 Task DisconnectAsync(); }

这段接口的关键在于ReadAsync<T>用泛型把解析责任交给实现类,上层拿到的就是 int、float、bool 这种业务类型,不用再关心字节序。参数上,address用字符串而不是整数,是为了兼容不同 PLC 的地址表达方式,比如D100、DB1.DBD0、40001。CancellationToken必须贯穿所有异步方法,否则设备断线时上层任务没法及时取消,会留下悬挂的 Task。

实现类里,Modbus TCP 用现成的库(比如 NModbus)能省很多事,但要注意它的同步 API 在 UI 线程调用会卡界面,必须包一层Task.Run或者用异步版本。串口设备则要注意SerialPort.DataReceived是在非 UI 线程触发的,直接更新控件会抛跨线程异常,得用Invoke或者IProgress<T>回传。

2.3 流程层状态机的落地写法

流程层是二次注液机的核心。下面是一个简化到能跑的状态机骨架,重点看迁移条件和超时处理。

public enum MachineState { Idle, // 待机 Loading, // 上料定位 Scanning, // 扫码绑定 WeighBefore, // 注液前称重 Vacuum, // 抽真空 Injecting, // 注液 Settling, // 静置 WeighAfter, // 注液后称重 Unloading, // 下料 Fault // 故障 } public class ProcessEngine { private MachineState _state = MachineState.Idle; private DateTime _stateEnterTime; private readonly IDeviceChannel _plc; private readonly ILogger _log; // 每个状态的超时时间,单位毫秒 private static readonly Dictionary<MachineState, int> TimeoutMs = new() { [MachineState.Loading] = 5000, [MachineState.Scanning] = 3000, [MachineState.WeighBefore] = 4000, [MachineState.Vacuum] = 15000, [MachineState.Injecting] = 20000, [MachineState.Settling] = 30000, [MachineState.WeighAfter] = 4000, [MachineState.Unloading] = 5000, }; public async Task TickAsync(CancellationToken token) { // 超时统一拦截,避免每个状态各写一遍 if (TimeoutMs.TryGetValue(_state, out var limit) && (DateTime.Now - _stateEnterTime).TotalMilliseconds > limit) { _log.Warn($"状态 {_state} 超时,转入故障"); await TransitionAsync(MachineState.Fault, token); return; } switch (_state) { case MachineState.Idle: if (await _plc.ReadAsync<bool>("M100", token)) await TransitionAsync(MachineState.Loading, token); break; case MachineState.Loading: if (await _plc.ReadAsync<bool>("M101", token)) // 定位完成信号 await TransitionAsync(MachineState.Scanning, token); break; // 其余状态按同样模式展开,此处省略 default: break; } } private async Task TransitionAsync(MachineState next, CancellationToken token) { _log.Info($"状态迁移 {_state} -> {next}"); _state = next; _stateEnterTime = DateTime.Now; await Task.CompletedTask; } }

逻辑上,TickAsync由定时器按固定周期(比如 50ms)调用,每次只做一次状态判断,不阻塞。超时判断放在 switch 之前统一处理,避免每个 case 里重复写。参数上,TimeoutMs的数值要按实际节拍调,抽真空和注液给的时间要留余量,但也不能太大,否则真出故障时设备停不下来。TransitionAsync里应该加上进入动作和退出动作的钩子,比如进入 Vacuum 时打开真空阀,退出时关闭,这些动作放在迁移方法里比散落在 case 里更好维护。

2.4 界面层与数据绑定:别让刷新拖垮节拍

界面层最容易出的问题是刷新频率过高。二次注液机的称重值、真空度这些实时数据,如果每个 PLC 扫描周期都往控件上写,界面线程会被拖死。常见做法是通讯层用独立线程按 100ms 采集,存到一个内存快照对象里,界面用定时器按 200~300ms 从快照读一次。这样界面刷新和通讯解耦,节拍不受影响。

WinForms 里用BindingList或者自己实现INotifyPropertyChanged都行,关键是别在采集线程里直接label.Text = ...。WPF 的绑定机制天然适合这种场景,但要注意ObservableCollection的更新必须在 UI 线程,跨线程更新一样会抛异常。

3. 从源码到跑通:二次注液机上位软件的实操步骤

3.1 环境准备与项目结构

拿到一套 C# 上位软件源码,第一步不是急着 F5,而是先看项目结构。一个健康的二次注液机上位软件,解决方案里通常有这几个项目:Communication(通讯层)、Equipment(设备层)、Process(流程层)、UI(界面层)、Common(工具类和日志)。如果所有代码都堆在一个项目里,那说明这套源码大概率是赶工出来的,接手要有心理准备。

环境上,.NET Framework 4.7.2 或 .NET 6/8 都能跑,取决于源码用的库。工业现场很多老设备还在用 Win7,那就只能锁 .NET Framework。开发机装 Visual Studio 2022,把 NuGet 包还原一遍,常见的依赖有 NModbus、Newtonsoft.Json、NLog 或 Serilog。如果源码里引用了某个厂商的 DLL(比如某品牌运动控制卡),要确认那个 DLL 的位数和平台目标一致,32 位 DLL 配 AnyCPU 会在 64 位系统上直接报BadImageFormatException。

# 还原 NuGet 包并编译,先确认能过编译再谈运行 dotnet restore SecondaryFilling.sln dotnet build SecondaryFilling.sln -c Release

编译通过后,别急着连设备。先把通讯层的连接参数改成模拟模式,很多成熟源码会留一个SimulationMode开关,打开后通讯层返回预设值,这样能在没有硬件的情况下把流程跑一遍。如果没有这个开关,可以自己加一个假的IDeviceChannel实现,返回固定值。

3.2 通讯参数配置与联调

联调阶段最容易翻车的是通讯参数。Modbus TCP 要确认 IP、端口(默认 502)、从站号;串口要确认波特率、数据位、停止位、校验位,这四项错一个就是收不到数据。称重仪常见的是连续输出模式,上位机只管收,不用发指令,但要注意它的输出格式是 ASCII 还是二进制,解析方式完全不同。

// 串口称重仪初始化,参数必须和仪表菜单里一致 var port = new SerialPort("COM3") { BaudRate = 9600, // 波特率,仪表里常见 9600 或 19200 DataBits = 8, // 数据位,几乎都是 8 StopBits = StopBits.One, // 停止位,1 或 2 Parity = Parity.None, // 校验位,None/Even/Odd ReadTimeout = 500 // 读超时,避免卡死 }; port.DataReceived += (s, e) => { // 注意:这里是非 UI 线程,不要直接碰控件 var raw = port.ReadLine(); // 解析逻辑按仪表协议来,常见是 "ST,GS,+00123.45g" ParseWeight(raw); }; port.Open();

参数说明:ReadTimeout一定要设,否则设备掉线时ReadLine会一直阻塞。DataReceived事件里读数据要用ReadExisting或ReadLine,但ReadLine在数据不完整时会等换行符,如果仪表不发换行就会超时。更稳的做法是ReadExisting拿到缓冲区所有数据,自己按协议找帧头帧尾。

联调时先单独测通讯,别一上来就跑整机流程。写个小测试程序,把每个点位的读写都过一遍,确认地址映射没错。PLC 地址映射错一位,可能读到的就是完全无关的寄存器,这种问题在整机联调时会被流程逻辑掩盖,很难查。

3.3 流程参数标定与节拍优化

流程跑通后,下一步是标定参数。二次注液机的关键参数有:注液量、真空度、静置时间、称重上下限。这些参数一般放在配置文件或者数据库里,不要硬编码在代码里。常见做法是用 JSON 配置文件,启动时加载,界面上提供修改入口。

{ "Process": { "InjectVolumeMl": 12.5, "VacuumKpa": -85, "SettleSeconds": 25, "WeightLowerG": 12.3, "WeightUpperG": 12.7 }, "Communication": { "PlcIp": "192.168.1.10", "PlcPort": 502, "PlcSlaveId": 1 } }

参数说明:InjectVolumeMl是目标注液量,实际值由注液泵的标定曲线决定,改这个值不会直接改变泵的输出,还要配合泵的脉冲当量。VacuumKpa是负值,表示真空度,数值越小真空越强。WeightLowerG和WeightUpperG是称重合格区间,超出就判 NG。这些参数在界面上改完后要能热加载,不用重启软件,否则调机时反复重启很浪费时间。

节拍优化上,先测每个状态的实际耗时,找出瓶颈。常见瓶颈是抽真空和静置,这两个是物理过程,软件层面能做的有限,但可以把称重和扫码这些能并行的动作挪到静置期间做。另外,PLC 的扫描周期和上位机的轮询周期要匹配,上位机轮询太快没意义,太慢会漏信号,一般 50~100ms 比较合适。

3.4 日志与追溯:出问题时能查到什么

大型设备上位软件,日志系统是后悔药。二次注液机要记录每个电芯的注液量、称重值、时间戳、操作员,这些数据要能按电芯条码查。日志分两类:运行日志(状态迁移、通讯异常)和追溯数据(每个电芯的工艺参数)。运行日志用 NLog 或 Serilog 写到文件,按天滚动,保留 30 天。追溯数据写数据库,SQLite 或 SQL Server 都行,量大的话上 MySQL。

// 追溯数据写入,每个电芯一条记录 public class TraceRecord { public string Barcode { get; set; } // 电芯条码 public double WeightBefore { get; set; } // 注液前重量 public double WeightAfter { get; set; } // 注液后重量 public double InjectVolume { get; set; } // 实际注液量 public DateTime Timestamp { get; set; } // 完成时间 public string Result { get; set; } // OK / NG } // 用 Dapper 或 EF Core 写入,注意批量提交而不是每条一次 public async Task SaveTraceAsync(TraceRecord record) { using var conn = new SqliteConnection(_connString); await conn.ExecuteAsync( "INSERT INTO Trace(Barcode,WeightBefore,WeightAfter,InjectVolume,Timestamp,Result) " + "VALUES(@Barcode,@WeightBefore,@WeightAfter,@InjectVolume,@Timestamp,@Result)", record); }

参数说明:Barcode要建索引,否则按条码查历史数据会全表扫描。Timestamp用 UTC 还是本地时间要统一,跨时区或者跨夏令时的时候本地时间会出问题。写入频率高的话,用批量插入或者消息队列缓冲,别每条都开一次连接。

4. 二次注液机上位软件源码里的避坑与排查

4.1 跨线程更新控件导致界面随机崩溃

现象:软件跑一段时间后,界面突然卡死或者抛InvalidOperationException,提示控件被非创建线程访问。原因:通讯线程或定时器回调里直接更新了控件属性。解决:所有跨线程更新统一走Control.Invoke或BeginInvoke,或者用IProgress<T>把数据回传到 UI 线程。更彻底的做法是界面只读内存快照,采集线程写快照,两边用锁或者Interlocked保护。

4.2 通讯断线后任务悬挂,设备停不下来

现象:网线拔掉后,软件界面还在显示旧数据,流程卡在某个状态不动,点停止也没反应。原因:异步读写没有超时,await一直等不到结果,CancellationToken没有正确传递。解决:所有通讯方法都要带超时,CancellationTokenSource设CancelAfter,断线检测用心跳,连续几次读失败就主动断开并转故障状态。停止按钮要能取消所有挂起的任务。

4.3 称重值跳变导致误判 NG

现象:同一颗电芯连续称几次,数值差好几十毫克,合格品被判 NG。原因:称重仪没稳定就取值,或者串口数据帧解析错位。解决:称重前先判断稳定标志位,很多仪表有ST(稳定)信号,稳定后再读值。串口解析要按帧头帧尾校验,别用ReadLine硬等换行。另外,称重传感器的接地和屏蔽要做好,工业现场干扰大,信号线要和动力线分开走。

4.4 配置文件被误改导致启动失败

现象:调机人员改了配置文件,格式错了,软件启动直接崩,连界面都出不来。原因:配置加载没有异常处理,JSON 解析失败直接抛。解决:加载配置时 try-catch,解析失败就用默认值并弹窗提示,别让软件起不来。配置文件改之前先备份,界面上提供「恢复默认」按钮。参数校验也要做,比如注液量不能为负,真空度不能超过泵的极限。

4.5 数据库写入阻塞主流程

现象:追溯数据写入变慢后,整个流程节拍被拖长。原因:数据库操作是同步的,或者每条记录都单独提交。解决:追溯写入放到独立线程或队列,主流程只管把记录丢进队列,后台慢慢写。数据库连接用连接池,批量提交。如果数据库在远程,网络抖动会影响写入,本地缓存加断线重传是更稳的方案。

5. 进阶:用模拟通道做无硬件回归测试

设备软件最头疼的是没有硬件就没法测。一套成熟的二次注液机上位软件,应该能在没有 PLC、没有称重仪的情况下,靠模拟通道把流程完整跑一遍。我一般会实现一个SimulatedChannel,它按预设的时序返回信号,比如收到「开始上料」后延迟 2 秒返回「定位完成」,收到「开始注液」后延迟 5 秒返回「注液完成」并给出一个模拟重量值。

public class SimulatedChannel : IDeviceChannel { private readonly Dictionary<string, object> _registers = new(); private readonly Random _rand = new(); public bool IsConnected => true; public Task<bool> ConnectAsync(CancellationToken token) => Task.FromResult(true); public async Task<T> ReadAsync<T>(string address, CancellationToken token) { // 模拟信号延迟,贴近真实设备响应 await Task.Delay(50, token); if (_registers.TryGetValue(address, out var val) && val is T t) return t; // 称重值模拟:在目标值附近加随机波动 if (address.StartsWith("W")) { var baseVal = 12.5 + _rand.NextDouble() * 0.2 - 0.1; return (T)(object)baseVal; } return default; } public Task<bool> WriteAsync(string address, object value, CancellationToken token) { _registers[address] = value; return Task.FromResult(true); } public Task DisconnectAsync() => Task.CompletedTask; }

这个模拟通道的价值在于,流程逻辑、界面刷新、日志追溯、异常处理都能在没有硬件的情况下验证。改流程代码后先跑模拟测试,确认状态迁移正确,再去现场联调,能省掉大量来回跑的时间。参数上,Task.Delay的延迟时间要按真实设备调,太快测不出时序问题,太慢又浪费时间。模拟的称重值要带随机波动,这样才能测出上下限判断的逻辑对不对。

回归测试的用例要覆盖:正常流程走完、每个状态超时、通讯断线、称重 NG、急停。这些用例写成自动化脚本,每次改代码后跑一遍,比人工点界面靠谱得多。我自己的习惯是,任何流程层的改动,先在模拟模式下跑通全部用例,再上真机。这个习惯帮我挡掉过好几次状态迁移写错导致的设备误动作。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进