☰
机器视觉上位机实战:PLC通信封装、条码扫描与指示灯控制
2026/10/9 1:13:30 网站建设 项目流程

1. 为什么先聊PLC通信封装:这个项目的地基比界面更重要

做机器视觉上位机,很多人一上来就急着拖WinForms控件,把界面画得花里胡哨,结果一到现场联调就傻眼:PLC那边的数据死活读不对,条码枪扫进去的字符莫名其妙多几个前缀,指示灯该亮的时候不亮、该灭的时候不灭——然后整个项目就在现场翻来覆去地改。你猜我见过多少这种项目?太多了。

这个项目的标题是“机器视觉上位机,PLC通信封装,WinForms实战,条码扫描录入,指示灯控制”。说白了,它要解决的就是这么一件事:让一台工控机(上位机)通过以太网或串口跟PLC对话,把扫描枪读到的条码传给PLC,同时根据PLC返回的状态去点亮或熄灭指示灯。听起来简单,但真正做过的人都知道,这里面坑最多的不是界面,而是通信层的设计和数据流的走向。

1.1 核心需求拆解:三个模块其实是一条数据链

先把这个项目拆开看。表面上是“PLC通信”“条码录入”“指示灯控制”三个独立功能,实际上它们是一条完整的数据链:

扫码枪读取条码 -> 上位机接收并解析 -> 通过封装好的通信层写入PLC寄存器 -> PLC执行逻辑 -> 上位机读取PLC状态 -> 更新界面上的指示灯。

任何一个环节断了,整条链路都跑不通。而其中最容易被低估的,就是这个“PLC通信封装”。为什么强调“封装”?因为如果不封装,你的Form1.cs里会到处散落着Socket代码、字节拼接逻辑、超时重试代码,后期维护起来就是灾难。

我个人的习惯是:任何跟外部设备打交道的过程,一律封装成独立的类库,界面层永远不直接碰协议细节。这不是教条,而是被现实教育出来的——现场实施时,PLC的程序员可能随时改寄存器地址,如果通信代码散落在界面的按钮点击事件里,改一个地址你得满项目搜索;封装好了,你只需要改一个配置文件或者一个常量类。

1.2 应用场景与目标读者:谁适合看这篇文章

这个项目的目标读者很明确:正在做或准备做上位机开发的工程师,尤其是那些需要跟PLC配合、处理扫码录入和设备状态显示的兄弟们。你可能用的是三菱PLC、西门子PLC、台达PLC、汇川PLC,或者通过Modbus TCP/RTU协议通信,思路都是通的。

另外,这个项目也很适合刚入门C#上位机开发的在校学生和转行人员。很多人学了C#语法、学了WinForms控件,但一到实际项目就不知道代码该怎么组织。通过这个案例,你能看到一套完整的上位机软件该怎么分层、怎么封装、怎么处理异常,这些是课本上不太会教的东西。

2. PLC通信封装层的设计思路:把协议细节关进“黑盒”里

2.1 通信方案选型:Modbus TCP还是串口?先说清楚再动手

PLC通信选型,是第一道分叉路口。视觉项目里最常见的两种方案:Modbus TCP(以太网)和Modbus RTU(串口RS485)。有的PLC还支持自定义协议,比如三菱的MC协议、西门子的S7协议,但万变不离其宗,原理都一样。

选型的核心判断依据就三条:距离、速率、稳定性。

  • 如果相机和PLC之间距离远(超过15米),或者数据量较大(比如要上传很多检测结果、图片路径字符串),果断选以太网。现场总线布线简单,一根网线搞定。
  • 如果距离近、点数少、成本敏感,选RS485串口。但要注意RS485需要上下拉电阻匹配,网络上很多资料讲这个,实际经验是120欧终端电阻该加就加,不然通信偶尔丢包查死你。
  • 如果PLC本身不支持Modbus协议(比如某些老型号专用协议),那就得用厂家提供的通信库或者DLL。这类封装思路也一样,只不过把你跟PLC交互的API包一层。

我这个项目用的是Modbus TCP,因为视觉项目通常数据量相对大,而且以太网调试起来方便,Wireshark抓包还能定位问题。

2.2 封装层到底封什么:连接管理、读写方法、错误处理三板斧

封装绝不是把Socket包一层那么简单。一套合格的PLC通信封装,至少要做三件事:连接管理、统一的读写接口、可靠的错误处理。

连接管理包括:建立连接、断开连接、断线重连、心跳检测。Modbus TCP本身不负责断线重连,你需要自己写。最简单的做法是:读写操作前检测TCP连接状态,如果连接断了自动重连,重连失败抛异常或返回错误码。

统一的读写接口长什么样?我提供一个常用的接口设计思路:

public interface IPlcClient { bool Connect(); void Disconnect(); bool IsConnected { get; } // 读取 bool ReadCoil(string address, out bool value); bool ReadRegister(string address, out ushort value); bool ReadRegisters(string startAddress, ushort length, out ushort[] values); // 写入 bool WriteCoil(string address, bool value); bool WriteRegister(string address, ushort value); bool WriteRegisters(string startAddress, ushort[] values); }

调用方只关心“地址”和“值”,至于Modbus报文怎么拼接、CRC怎么算、异常码怎么处理,一律在实现类里消化掉。这就是封装的威力:界面层看到的永远是业务化的接口,而不是一堆byte[]。

错误处理是封装层的灵魂。PLC通信常见的坑包括:PLC没开机、IP地址配错、寄存器地址越界、通信超时、设备忙(slave busy)。封装层要做的就是把这些情况转化成统一的结果,比如返回值false加一个错误码,或者抛出PlcCommunicationException,而不是让上层去猜。

2.3 地址解析的细节:三菱和Modbus的地址写法千万别混

这里有一个特别容易踩的坑——地址写法。不同的PLC、不同的协议,地址表达方式完全不一样。

Modbus协议里,线圈地址从0x0000开始,保持寄存器从0x0000开始,但不同厂家会映射到不同的PLC内部软元件。比如台达PLC用Modbus地址0x0100映射到内部寄存器D0,而汇川的映射又不一样。做封装的时候,我建议把地址映射单独抽出来,用一个配置文件或字典管理:

"PLC_D0" -> 0x0100 "PLC_D10" -> 0x010A "M0" -> 0x0800

这样如果PLC程序员说“寄存器地址变了”,你只需要改配置,不用改代码。这个习惯救过我无数次。不要用那些“专业版”重型组态软件,也别用第三方商业库——你自己写一个通信类,完全可控,依赖少,排除问题也快。

3. WinForms界面与GDI+坐标系:点位显示和状态面板该怎么做

3.1 为什么选WinForms而不是WPF:轻量、快速、部署省心

视觉项目现场工控机配置往往不高,CPU可能是老的赛扬或低功耗酷睿。WPF虽然界面华丽,但渲染开销相对大,而且部署时需要额外的依赖。WinForms轻量、启动快、部署简单,对工控场景非常合适。更关键的是,很多老工程师用WinForms十多年了,现场问题排查都快,交接也容易。

既然标题点到了“WinForms实战”,就强调一下:不要过度设计。界面是为了操作服务的,视觉上位机的界面本质上就是“实时状态可见、关键按钮可点、异常信息可查”。花里胡哨的动画、换肤,纯属给自己找事。

3.2 用GDI+画状态面板:指示灯不一定要用第三方控件

如果是“指示灯控制”,最常见的做法是放一个PictureBox,然后切换背景色或者Icon。但更好的方式是用GDI+自绘,比如画一个圆形指示灯,绿的时候内部填充绿色加外发光,红的时候填充红色。原理很简单:

public class IndicatorControl : Control { public Color OnColor { get; set; } = Color.LimeGreen; public Color OffColor { get; set; } = Color.Gray; public bool IsOn { get; set; } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); using var brush = new SolidBrush(IsOn ? OnColor : OffColor); float diameter = Math.Min(Width, Height) - 4; RectangleF rect = new RectangleF((Width - diameter) / 2, (Height - diameter) / 2, diameter, diameter); e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; e.Graphics.FillEllipse(brush, rect); // 加一圈边框,更像真实指示灯 using var pen = new Pen(Color.DarkGray, 2); e.Graphics.DrawEllipse(pen, rect); } }

这样你拖一个自定义控件,设置IsOn = true/false,调Invalidate()刷新,就是一个干净利落的指示灯。比用图片资源省事,还支持任意尺寸缩放。

3.3 WinForms坐标系与缩放:视觉定位点位显示必须会的基础

GDI+坐标系其实非常简单:原点在左上角,X轴向右,Y轴向下,单位是像素。很多视觉项目要在界面上叠加显示相机的检测点位、条码坐标、ROI区域,这时就要搞清楚“图像坐标”和“控件坐标”的换算。

一个常见的套路:PictureBox显示相机图像,用SizeMode = Zoom,然后你需要把图像上的像素坐标(来自视觉SDK)映射到PictureBox显示区域。公式不复杂:

float scaleX = pictureBox.ClientSize.Width / (float)originalImageWidth; float scaleY = pictureBox.ClientSize.Height / (float)originalImageHeight; float drawX = imagePointX * scaleX; float drawY = imagePointY * scaleY;

但要注意SizeMode = Zoom时图片会按比例缩放,四周会有留白,所以不能直接用上面公式,得先计算图片实际显示区域再换算。这是GDI+画叠加层最容易被坑的地方,实测中不少同事都在这翻车。

4. 条码扫描录入实战:从串口/USB接收到的字符串到PLC寄存器

4.1 条码枪的两种接入方式:USB键盘模式和串口模式

条码扫描枪的接入方式,直接影响你上位机的处理逻辑。这里要分清楚:

USB键盘模式(HID模式):扫描枪相当于一个键盘,扫到的条码会直接输入到当前焦点所在的输入框里,末尾带一个回车(Enter)键。这种方式上位机不需要任何通信处理,只要设置好TextBox焦点,订阅KeyDown或者TextChanged事件即可。但缺点也明显:如果界面上不小心有别的控件抢走了焦点,条码就跑偏了。

串口模式(COM口模式):扫描枪通过RS232或者USB转串口接入,上位机用SerialPort接收数据。这种方式可控性更强,不依赖焦点,数据是独立通道进来的。工业现场更推荐这种方式,因为你可以同时在后台处理扫描结果,前台用户爱点什么点什么,互不干扰。

这个项目标题既然强调“上位机”,建议直接用串口模式来做,灵活度和稳定性都更好。很多扫码枪(如基恩士、康耐视的读码器)还支持TCP/IP以太网输出,那就跟PLC通信一样,走Socket接收即可。

4.2 条码数据解析的三个坑:前缀、后缀、字符集

条码枪过来的数据不是想象中那么干净。常见的坑包括:

  • 前缀字符:有些扫码枪默认会发送ASCII前缀,比如]C0或~,表示码制类型。一定要在枪的配置软件里关掉,或者在代码里截掉。
  • 后缀换行和回车:串口模式下,条码枪一般会发送\r\n。Windows的SerialPort读到的字符串可能包含这些控制字符,要用Trim()或匹配截断。
  • 字符编码:中文内容(某些二维码包含中文)建议用Encoding.UTF8接收,不要用默认的ASCII。实测如果枪输出GBK编码而程序按UTF8解,中文会变成乱码。

给你一个实战接收事件的写法:

private void SerialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e) { string data = serialPort1.ReadExisting(); // 数据到齐后再处理,避免半包 if (!data.EndsWith("\r") && !data.EndsWith("\n")) return; data = data.Trim().Trim('\r', '\n'); if (data.Length == 0) return; // 剔除不需要的前缀 if (data.StartsWith("]C0")) data = data.Substring(3); // 切换到UI线程处理 BeginInvoke(new Action(() => ProcessBarcode(data))); }

4.3 录入不丢帧:用队列而不是直接处理

扫码枪有时候会连扫,尤其是流水线上连续过料的情况下。你如果在串口事件里直接做数据库查询或者PLC写入操作,CPU一卡,数据就丢了。我处理这类场景的习惯是:串口事件只负责接收和解析,把结果丢进一个线程安全的队列(ConcurrentQueue),再由一个独立的线程或定时器从队列里取数据做业务处理。

ConcurrentQueue<string> barcodeQueue = new ConcurrentQueue<string>(); private void SerialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e) { string data = serialPort1.ReadExisting(); // 把解析完的数据丢入队列 barcodeQueue.Enqueue(data); } private void Timer1_Tick(object sender, EventArgs e) { while (barcodeQueue.TryDequeue(out string barcode)) { WriteBarcodeToPlc(barcode); LogBarcode(barcode); } }

用定时器(例如100ms间隔)把队列里的数据逐个处理,既能防止界面卡顿,也避免了冲突。

4.4 条码写入PLC的常用方案:String还是ACSII?看PLC支持

条码写入PLC寄存器,有两种主流做法:

  • 按字符串类型写入:PLC支持字符串类型(如三菱的字符串存储),直接把字符逐个写到连续寄存器。每个寄存器16位,存两个ASCII字符,比如“AB”写入一个寄存器,高字节是A,低字节是B,或者反过来,得跟PLC工程师确认字节序。
  • 按ASCII码值写入:直接把条码的ASCII码按十进制数值存入寄存器。例如A=65,B=66,每个寄存器存一个数值。这种方式PLC处理起来简单,上位机也不麻烦。

实际项目里我往往会跟PLC工程师一起商量一个简单的“报文协议”:比如D100开始前2个寄存器定义为条码长度,后面N个寄存器存放条码ASCII值。这样PLC那边进行字符串拼接、判断长度都方便。

5. 指示灯控制的完整链路:PLC状态怎么变成界面上的真实“灯”

5.1 指示灯控制是“读”还是“写”?别搞反了

标题里出现“指示灯控制”,新手很容易理解成“上位机直接控制灯”。但从工业现场的实际语义来看,往往是“读取PLC输出的状态,在界面上显示灯的状态”。真正控制灯的,是PLC,因为PLC要负责安全逻辑、联锁逻辑和时序控制,上位机只做监视和显示。

当然也有上位机直接写线圈控制指示灯的场景,比如“手动模式”下操作员点击按钮点亮某个信号灯。但无论哪种,核心通信代码都一样:写入(控制)和读取(状态显示)。

5.2 状态显示刷新策略:定时轮询还是事件驱动?

WinForms上位机刷新PLC状态,最常用的是定时器轮询。这里有个频率设置的讲究:

  • 视觉项目的PLC扫描周期一般是几毫秒到几十毫秒,普通状态灯没有太高的实时性要求。
  • 推荐轮询周期100~200ms。太快会给PLC和网络造成压力(尤其是串口),太慢人眼会感觉延迟。
  • 一次轮询尽量把所有需要显示的状态都读完,用连续寄存器读取的方式,而不是一个点一个点地读。Modbus协议支持批量读,效率高很多。

关于事件驱动:西门子PLC有的支持HSC(高速计数器)中断,三菱的可以通过MC协议丢主动上报。但如果现场用的是通用Modbus TCP,轮询是最省事的、兼容性最好的方案。别想着搞花活。

5.3 代码实战:一个完整的指示灯刷新逻辑

这一步结合一个实际例子来讲。假设我们在界面上放了三个指示灯:运行中(绿色)、故障(红色)、待机(黄色),对应的PLC线圈地址分别是M0、M1、M2。

使用WinForms的Timer(这里不用异步阻塞的方式),刷新逻辑可以这样写:

private void StatusTimer_Tick(object sender, EventArgs e) { // 批量读取三个线圈,避免分次请求 bool[] states = plcClient.ReadCoils("M0", 3, out bool success); if (!success) { // 通信失败,把所有灯设为灰色,表示未知状态 indicatorRun.IsOn = false; indicatorRun.OffColor = Color.Gray; indicatorFault.IsOn = false; indicatorFault.OffColor = Color.Gray; indicatorStandby.IsOn = false; indicatorStandby.OffColor = Color.Gray; return; } indicatorRun.IsOn = states[0]; indicatorFault.IsOn = states[1]; indicatorStandby.IsOn = states[2]; }

注意,如果通信失败,你不仅要让灯灭,更要让操作员一眼看出“当前状态未知”——这就是为什么要加灰色状态。很多入门代码只会做两种颜色,真实项目建议至少三种状态:正常亮、灭、异常未知。这很重要,别小看这个细节。

5.4 进阶:用异步+状态机防止界面卡死

在视觉项目实际运行时,PLC通信有可能偶发变慢或超时。如果你直接在UI线程里同步调用通信方法,那通信一卡,界面就假死了,很掉价。我在做主界面时,会用异步状态机来刷新状态。

其实最优雅、又能少造轮子的方案是用System.Threading.Polling的Thread + BeginInvoke,在后台线程执行PLC读取,再用Invoke更新控件,或者更简单:用上面的Timer方案,这个机制本身就足够,只要计时器不阻塞就不会卡界面。但如果你在Timer里用了同步的ReadCoils,一旦Modbus超时设为2秒,界面就会“卡”一下。

我的办法是:将超时时间缩短(例如500ms),并且把PLC状态读取放到后台线程,用异步回调更新UI。

private void StatusTimer_Tick(object sender, EventArgs e) { Task.Run(() => { bool[] states = plcClient.ReadCoils("M0", 3, out bool success); BeginInvoke(new Action(() => UpdateIndicators(states, success))); }); }

这样接口哪怕慢,界面也不会卡。

5.5 状态机辅助:防止误动作的关键一招

如果你需要根据PLC的状态做相应的业务逻辑(比如“PLC处于自动模式时禁止扫码录入”),一定要在主程序维护一个简易状态机,而不是看到灯亮就执行动作。原因是:

  • PLC状态的读取有延迟(轮询周期内状态可能已变化)。
  • 通信偶发超时可能导致读到旧状态。

我习惯的做法:每次轮询读取后,先对比上一次的状态,如果发生变化则触发状态变更事件,业务逻辑订阅该事件。这样既避免了重复触发,也更容易排查问题。

6. 项目联调与实施:从代码到现场的最后一公里

6.1 通信联调的第一步:用工具验证PLC侧再写代码

别说你代码写完了就直接上现场测试。我在现场吃的亏太多了。你现在可以保存一个固定习惯:先用手持条码枪配套的串口调试助手或者Modbus调试工具(例如ModScan)确认PLC地址能不能读到、值对不对,再动手写C#程序。很多人代码没问题但通信不对,结果一查是地址映射错了。

另外,用Wireshark抓包看Modbus TCP报文也是一个好习惯,可以看到请求和响应帧、超时重试。能帮你快速判断是上位机发出的问题还是PLC没响应的问题。

6.2 现场最常遇到的5个问题和排查思路

问题1:上位机能Ping通PLC,但Modbus TCP连不上。大概率是PLC侧未启用Modbus服务或者端口号不是默认502。有些PLC有多个端口,需要在PLC端组态软件里设置。我遇到过有PLC的Modbus TCP端口被改成1502的,卡了很久。

问题2:条码枪串口能收到数据,但偶发多一个字符或少一个字符。多半是波特率不匹配或者线上干扰。可以尝试降低波特率、给串口线加磁环。另外在代码里做数据完整性判断(比如以\r\n结尾)。

问题3:指示灯状态刷新慢半拍。大概率轮询频率太低或者通信方式用的是串口。提高轮询频率到100ms,同时把多个点位的读取合并成一次批量读取。

问题4:条码数据写入PLC后,PLC偶尔解析错误。大概率是你和PLC工程师对字节序的理解不一致。建议写一个详细的通信协议文档,明确每个寄存器存几个字符、高低字节顺序。这个事情不要怕麻烦,写清楚,现场能省一天。

问题5:程序运行几天后Modbus TCP连接断掉,重连不上。这是典型的TCP长连接被防火墙或交换机老化断开。你需要在封装里加上自动重连和心跳机制。心跳最简单的方式就是周期性调用一次读取操作,比如每秒读取一个固定的寄存器。

6.3 日志系统:排查问题的最后一道防线

写到这,我强烈建议给这个上位机项目加上一套日志。不需要复杂的日志框架,用一个简单的文件追加就够。比如条码每一笔录入记录时间、条码内容、写入PLC是否成功,巡检状态记录轮询结果和错误码。

没有日志,现场出问题你基本是在乱猜。有了日志,你能快速回放故障前的数据流,判断是上位机没收到条码、还是收到但写入PLC失败、还是PLC没执行逻辑——这三个环节被日志一区分,问题范围立刻缩小80%。我用的是 NLog 库,或者自己写一个静态类搞文件追加,几十行代码的事。

7. 复盘与个人经验:这套方案帮我在现场省下的时间

最后分享一点我个人的体会。做机器视觉上位机,真正花时间的地方往往不是相机标定和算法,而是跟PLC打交道的过程。界面也好写,条码显示也好写,但通信不稳定、协议对不上、现场干扰这些软性坑,才是最消磨人意志的地方。

这也是我一再强调封装、分层、日志的原因。你前期多花半天时间把通信层写稳,把日志加上,把异常状态用灰色区分出来,后面至少能少去现场加班三天。关于PLC通信封装的方案,我现在基本不换框架——不管新项目用三菱、西门子、台达还是汇川,都是同一套接口思路,改改地址映射和协议实现就能复用。尤其后续如果还要对接运动控制卡或者机器人控制器,这套封装的思路同样适用,只是底层协议换掉而已。

最后再给你一个小技巧:写上位机的时候,你的解决方案里一定要把“通信层”“业务层”“界面层”分成三个项目。哪怕现在的项目很小,也坚持分。我见过太多同事一开始图省事全写在一起,等要加功能、加设备的时候,那叫一个拆得死去活来。分层的代码看起来很“多余”,但它让你在项目交付后的维护阶段笑着睡觉。

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

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

立即咨询