C#轻量级上位机实战:从0到1打通MES与视觉检测系统(附通信源码)
现象:连接MES的OPC UA服务器时,报“Certificate validation failed”;原因:MES的OPC UA服务器用的是自签名证书,客户端默认不信任;解决:在OPC UA客户端配置里禁用证书验证(测试环境),或者把MES的证书导入客户端信任列表(生产环境):// 在创建ApplicationConfiguration时添加:这个轻量级上位机的优势就是“小而快”:不用装额外依
一、项目背景:为什么要做这个上位机?
上个月接了个电子厂的小需求:车间里3台视觉检测机(检测PCB焊点)的数据全存在本地电脑里,MES系统(生产执行系统)要实时拿检测结果、NG原因和设备状态——之前靠质检员手抄数据,一天错好几次,生产线组长天天吐槽。
工厂的痛点很明确:
- 视觉系统和MES是“信息孤岛”,数据不通;
- 不想装笨重的中间件(比如工业网关),成本高还难维护;
- 要求断网时数据不丢,联网后自动补发。
最终决定用C#做个轻量级上位机:一边连视觉检测机的SDK拿数据,一边用OPC UA给MES传数据,中间加个SQLite缓存兜底。全程没用到复杂框架,开发周期一周,部署后稳定跑了快一个月——这篇就把实战细节拆透,中小工厂的同类需求直接抄作业就行。
二、需求拆解与技术选型(不做无用功)
先把需求掰碎,避免开发时跑偏:
1. 核心需求清单
| 模块 | 具体要求 |
|---|---|
| 视觉数据采集 | 实时获取检测结果(PASS/NG)、NG原因(比如“焊点偏移”)、设备状态(运行/待机) |
| MES数据上传 | 按MES的OPC UA协议推送数据,频率1秒/次 |
| 断网缓存 | 网络断开时数据存本地,联网后自动补发 |
| 状态监控 | 简单UI显示设备连接状态、数据上传日志,方便车间工人查看 |
2. 技术选型(轻量优先)
- 开发框架:C# WinForms(比WPF快,车间电脑跑着不卡)+ .NET Framework 4.7.2(兼容性好,老电脑也能装);
- 视觉对接:用视觉检测机厂家提供的SDK(C#版,封装了TCP通信接口);
- MES通信:OPC UA客户端(工业标准协议,MES系统原生支持);
- 本地缓存:SQLite(轻量,无需安装,单文件数据库);
- 日志记录:log4net(简单配置就能用,排查问题方便)。
为什么不用数据库直连MES?MES是甲方的核心系统,不让直接连数据库,OPC UA是他们指定的接口;为什么不用Python?车间维护人员只会看C#的exe,Python脚本怕他们误删。
三、核心开发:从代码层面打通“任督二脉”
这部分贴的都是项目里跑通的核心代码,注释写得细,改改参数就能用。
1. 第一步:封装视觉系统SDK对接类
视觉厂家给的SDK其实是个DLL,里面有VisionClient类,封装了TCP连接和数据读取。我们再包一层,处理重连和异常:
using System;
using System.Threading;
using VisionSDK; // 厂家提供的SDK命名空间
namespace VisionMesBridge
{
/// <summary>
/// 视觉检测系统数据采集类
/// </summary>
public class VisionDataCollector
{
private VisionClient _visionClient;
private Thread _collectThread;
private bool _isRunning = false;
private string _visionIp = "192.168.1.101"; // 视觉机IP
private int _visionPort = 8080;
/// <summary>
/// 收到视觉数据的回调
/// </summary>
public Action<VisionResult> OnVisionDataReceived;
/// <summary>
/// 启动数据采集
/// </summary>
public bool StartCollect()
{
try
{
_visionClient = new VisionClient();
// 连接视觉机,超时3秒
bool connectSuccess = _visionClient.Connect(_visionIp, _visionPort, 3000);
if (!connectSuccess)
{
LogHelper.Error("视觉机连接失败");
return false;
}
_isRunning = true;
_collectThread = new Thread(CollectLoop) { IsBackground = true };
_collectThread.Start();
LogHelper.Info("视觉数据采集启动成功");
return true;
}
catch (Exception ex)
{
LogHelper.Error($"视觉采集启动异常:{ex.Message}");
return false;
}
}
/// <summary>
/// 采集循环(异步避免阻塞UI)
/// </summary>
private void CollectLoop()
{
while (_isRunning)
{
try
{
// 读取最新检测结果(SDK方法)
VisionResult result = _visionClient.GetLatestResult();
if (result != null && result.IsValid)
{
// 触发回调,传给上层处理
OnVisionDataReceived?.Invoke(result);
}
Thread.Sleep(1000); // 1秒采集一次,和视觉机频率匹配
}
catch (Exception ex)
{
LogHelper.Error($"采集数据异常:{ex.Message}");
// 断连后自动重连
ReconnectVision();
Thread.Sleep(3000); // 重连间隔3秒,别太频繁
}
}
}
/// <summary>
/// 视觉机重连逻辑
/// </summary>
private void ReconnectVision()
{
if (_visionClient != null)
{
_visionClient.Disconnect();
}
bool reconnectSuccess = _visionClient.Connect(_visionIp, _visionPort, 3000);
if (reconnectSuccess)
{
LogHelper.Info("视觉机重连成功");
}
else
{
LogHelper.Error("视觉机重连失败,3秒后重试");
}
}
/// <summary>
/// 停止采集
/// </summary>
public void StopCollect()
{
_isRunning = false;
if (_collectThread != null && _collectThread.IsAlive)
{
_collectThread.Join(2000);
}
if (_visionClient != null)
{
_visionClient.Disconnect();
}
LogHelper.Info("视觉数据采集已停止");
}
}
/// <summary>
/// 视觉检测结果实体(和SDK返回结构对应)
/// </summary>
public class VisionResult
{
public bool IsValid { get; set; } // 数据是否有效
public string SN { get; set; } // 产品序列号
public bool IsPass { get; set; } // PASS/NG
public string NgReason { get; set; } // NG原因
public string DeviceStatus { get; set; } // 设备状态:Running/Standby/Error
public DateTime CollectTime { get; set; } // 采集时间
}
}
2. 第二步:OPC UA客户端封装(对接MES)
MES系统用的是OPC UA服务器,我们需要用C#的OPC UA客户端库(选Opc.Ua.Client,NuGet直接装)来上传数据:
using Opc.Ua;
using Opc.Ua.Client;
using System;
namespace VisionMesBridge
{
/// <summary>
/// MES系统OPC UA客户端
/// </summary>
public class MesOpcUaClient
{
private Session _opcSession;
private string _opcServerUrl = "opc.tcp://192.168.1.200:4840"; // MES的OPC UA地址
private string _opcUsername = "mes_user";
private string _opcPassword = "Mes@123456";
/// <summary>
/// 连接MES的OPC UA服务器
/// </summary>
public bool Connect()
{
try
{
var endpointUrl = _opcServerUrl;
var endpoint = new ConfiguredEndpoint(null, new EndpointDescription(endpointUrl));
// 设置登录凭据
var userIdentity = new UserIdentity(_opcUsername, _opcPassword);
// 创建会话
_opcSession = Session.Create(
new ApplicationConfiguration(),
endpoint,
false,
"VisionMesBridge",
60000,
userIdentity,
null
).Result;
LogHelper.Info("OPC UA连接MES成功");
return true;
}
catch (Exception ex)
{
LogHelper.Error($"OPC UA连接失败:{ex.Message}");
return false;
}
}
/// <summary>
/// 上传数据到MES的OPC UA节点
/// </summary>
/// <param name="nodeId">MES的节点ID(比如"ns=2;s=VisionData.SN")</param>
/// <param name="value">要上传的值</param>
public bool UploadData(string nodeId, object value)
{
if (_opcSession == null || !_opcSession.Connected)
{
LogHelper.Warn("OPC UA未连接,尝试重连");
if (!Reconnect())
{
return false;
}
}
try
{
// 写入节点值
var writeValue = new WriteValue
{
NodeId = new NodeId(nodeId),
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value))
};
_opcSession.Write(null, new[] { writeValue }, out var results);
if (results[0].StatusCode.IsGood())
{
LogHelper.Info($"上传数据成功:{nodeId} = {value}");
return true;
}
else
{
LogHelper.Error($"上传数据失败:{results[0].StatusCode}");
return false;
}
}
catch (Exception ex)
{
LogHelper.Error($"上传数据异常:{ex.Message}");
return false;
}
}
/// <summary>
/// OPC UA重连
/// </summary>
private bool Reconnect()
{
try
{
_opcSession?.Reconnect();
return _opcSession != null && _opcSession.Connected;
}
catch
{
return Connect(); // 重连失败就重新创建会话
}
}
/// <summary>
/// 断开连接
/// </summary>
public void Disconnect()
{
_opcSession?.Close();
_opcSession?.Dispose();
LogHelper.Info("OPC UA已断开连接");
}
}
}
3. 第三步:本地缓存与数据补发(断网兜底)
用SQLite做本地缓存,先建个VisionData表,结构很简单:
CREATE TABLE IF NOT EXISTS VisionData (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
SN TEXT,
IsPass BOOLEAN,
NgReason TEXT,
DeviceStatus TEXT,
CollectTime DATETIME,
IsUploaded BOOLEAN DEFAULT 0 -- 是否已上传MES
);
然后封装缓存操作类,处理数据存储和补发:
using System;
using System.Data.SQLite;
using System.Linq;
namespace VisionMesBridge
{
/// <summary>
/// SQLite本地缓存助手
/// </summary>
public class SqliteCacheHelper
{
private string _dbPath = "VisionDataCache.db"; // 数据库文件路径
/// <summary>
/// 初始化数据库(创建表)
/// </summary>
public void InitDb()
{
using (var conn = new SQLiteConnection($"Data Source={_dbPath};Version=3;"))
{
conn.Open();
string createTableSql = @"
CREATE TABLE IF NOT EXISTS VisionData (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
SN TEXT,
IsPass BOOLEAN,
NgReason TEXT,
DeviceStatus TEXT,
CollectTime DATETIME,
IsUploaded BOOLEAN DEFAULT 0
);";
using (var cmd = new SQLiteCommand(createTableSql, conn))
{
cmd.ExecuteNonQuery();
}
}
}
/// <summary>
/// 保存视觉数据到本地缓存
/// </summary>
public void SaveToCache(VisionResult result)
{
using (var conn = new SQLiteConnection($"Data Source={_dbPath};Version=3;"))
{
conn.Open();
string insertSql = @"
INSERT INTO VisionData (SN, IsPass, NgReason, DeviceStatus, CollectTime)
VALUES (@SN, @IsPass, @NgReason, @DeviceStatus, @CollectTime);";
using (var cmd = new SQLiteCommand(insertSql, conn))
{
cmd.Parameters.AddWithValue("@SN", result.SN);
cmd.Parameters.AddWithValue("@IsPass", result.IsPass);
cmd.Parameters.AddWithValue("@NgReason", result.NgReason);
cmd.Parameters.AddWithValue("@DeviceStatus", result.DeviceStatus);
cmd.Parameters.AddWithValue("@CollectTime", result.CollectTime);
cmd.ExecuteNonQuery();
}
}
LogHelper.Info($"数据存入缓存:SN={result.SN}");
}
/// <summary>
/// 补发未上传的数据到MES
/// </summary>
public void RetryUpload(MesOpcUaClient opcClient)
{
using (var conn = new SQLiteConnection($"Data Source={_dbPath};Version=3;"))
{
conn.Open();
// 查询未上传的数据
string selectSql = "SELECT * FROM VisionData WHERE IsUploaded = 0 ORDER BY CollectTime;";
using (var cmd = new SQLiteCommand(selectSql, conn))
{
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
string sn = reader["SN"].ToString();
bool isPass = Convert.ToBoolean(reader["IsPass"]);
string ngReason = reader["NgReason"].ToString();
string deviceStatus = reader["DeviceStatus"].ToString();
int id = Convert.ToInt32(reader["Id"]);
// 上传到MES的对应节点
bool uploadSn = opcClient.UploadData("ns=2;s=VisionData.SN", sn);
bool uploadPass = opcClient.UploadData("ns=2;s=VisionData.IsPass", isPass);
bool uploadNg = opcClient.UploadData("ns=2;s=VisionData.NgReason", ngReason);
bool uploadStatus = opcClient.UploadData("ns=2;s=VisionData.DeviceStatus", deviceStatus);
// 全部上传成功,标记为已上传
if (uploadSn && uploadPass && uploadNg && uploadStatus)
{
string updateSql = "UPDATE VisionData SET IsUploaded = 1 WHERE Id = @Id;";
using (var updateCmd = new SQLiteCommand(updateSql, conn))
{
updateCmd.Parameters.AddWithValue("@Id", id);
updateCmd.ExecuteNonQuery();
}
LogHelper.Info($"补发数据成功:SN={sn}");
}
else
{
LogHelper.Warn($"补发数据失败:SN={sn},下次重试");
break; // 有一条失败就停,避免死循环
}
}
}
}
}
}
}
}
4. 第四步:UI整合与业务逻辑串联
最后在WinForms里把这些模块串起来,UI不用复杂,放几个Label显示状态,一个TextBox显示日志就行:
using System;
using System.Windows.Forms;
namespace VisionMesBridge
{
public partial class MainForm : Form
{
private VisionDataCollector _visionCollector;
private MesOpcUaClient _mesOpcClient;
private SqliteCacheHelper _sqliteHelper;
public MainForm()
{
InitializeComponent();
// 初始化组件
_sqliteHelper = new SqliteCacheHelper();
_sqliteHelper.InitDb();
_visionCollector = new VisionDataCollector();
_mesOpcClient = new MesOpcUaClient();
// 绑定视觉数据回调
_visionCollector.OnVisionDataReceived += VisionDataReceivedHandler;
}
/// <summary>
/// 视觉数据接收处理
/// </summary>
private void VisionDataReceivedHandler(VisionResult result)
{
// 跨线程更新UI
Invoke(new Action(() =>
{
lblSN.Text = $"产品SN:{result.SN}";
lblResult.Text = $"检测结果:{(result.IsPass ? "PASS" : "NG")}";
lblStatus.Text = $"设备状态:{result.DeviceStatus}";
txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] 采集到数据:SN={result.SN},结果={(result.IsPass ? "PASS" : "NG")}\r\n");
}));
// 先存本地缓存
_sqliteHelper.SaveToCache(result);
// 尝试上传MES
bool uploadSuccess = true;
uploadSuccess &= _mesOpcClient.UploadData("ns=2;s=VisionData.SN", result.SN);
uploadSuccess &= _mesOpcClient.UploadData("ns=2;s=VisionData.IsPass", result.IsPass);
uploadSuccess &= _mesOpcClient.UploadData("ns=2;s=VisionData.NgReason", result.NgReason);
uploadSuccess &= _mesOpcClient.UploadData("ns=2;s=VisionData.DeviceStatus", result.DeviceStatus);
if (uploadSuccess)
{
// 上传成功,标记缓存数据为已上传(这里简化处理,实际可以在补发时统一标记)
}
}
/// <summary>
/// 启动按钮点击事件
/// </summary>
private void btnStart_Click(object sender, EventArgs e)
{
// 连接MES
bool mesConnected = _mesOpcClient.Connect();
if (!mesConnected)
{
MessageBox.Show("MES连接失败,检查网络和配置!");
return;
}
// 启动视觉采集
bool visionStarted = _visionCollector.StartCollect();
if (!visionStarted)
{
MessageBox.Show("视觉采集启动失败!");
_mesOpcClient.Disconnect();
return;
}
// 启动补发线程(后台定期补发)
var retryThread = new System.Threading.Thread(() =>
{
while (true)
{
_sqliteHelper.RetryUpload(_mesOpcClient);
System.Threading.Thread.Sleep(10000); // 10分钟补发一次
}
}) { IsBackground = true };
retryThread.Start();
btnStart.Enabled = false;
btnStop.Enabled = true;
txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] 系统启动成功\r\n");
}
/// <summary>
/// 停止按钮点击事件
/// </summary>
private void btnStop_Click(object sender, EventArgs e)
{
_visionCollector.StopCollect();
_mesOpcClient.Disconnect();
btnStart.Enabled = true;
btnStop.Enabled = false;
txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] 系统已停止\r\n");
}
}
}
四、实战踩坑总结(这些坑我替你们踩过)
坑1:OPC UA证书验证失败
- 现象:连接MES的OPC UA服务器时,报“Certificate validation failed”;
- 原因:MES的OPC UA服务器用的是自签名证书,客户端默认不信任;
- 解决:在OPC UA客户端配置里禁用证书验证(测试环境),或者把MES的证书导入客户端信任列表(生产环境):
// 在创建ApplicationConfiguration时添加: config.SecurityConfiguration.AutoAcceptUntrustedCertificates = true;
坑2:视觉SDK调用导致UI卡死
- 现象:启动采集后,窗体拖不动,按钮点不了;
- 原因:直接在UI线程调用SDK的阻塞方法;
- 解决:把采集逻辑放到后台线程(参考
VisionDataCollector里的_collectThread),用回调传数据,UI更新用Invoke。
坑3:断网后数据补发重复
- 现象:联网后同一条数据被补发多次;
- 原因:补发时没加锁,多线程同时处理同一条数据;
- 解决:给补发方法加
lock锁,避免并发问题:private readonly object _retryLock = new object(); public void RetryUpload(MesOpcUaClient opcClient) { lock (_retryLock) { // 补发逻辑... } }
坑4:SQLite数据库被占用
- 现象:程序退出后再启动,报“数据库文件被锁定”;
- 原因:数据库连接没释放;
- 解决:所有SQLite操作都用
using语句,确保连接自动释放。
五、总结与拓展
这个轻量级上位机的优势就是“小而快”:不用装额外依赖,一个exe就能跑,车间工人也能操作。如果要拓展,还能加这些功能:
- 多视觉机支持:把
VisionDataCollector改成支持多实例,配置不同IP就行; - 报警功能:设备异常时弹框或发短信通知管理员;
- 数据报表:用RDLC做简单的日报/周报,导出Excel;
- MQTT对接:如果MES支持MQTT,换成MQTT协议更轻量。
最后提一句:中小工厂的工业互联需求,不一定非要用高大上的框架,C# WinForms+轻量级组件就够了——稳定、易维护才是硬道理。代码里的细节可以根据实际需求调整,有问题评论区交流就行。
更多推荐


所有评论(0)