做工业自动化的朋友应该都有过这种经历:算法团队把视觉模型调到了98%的识别率,模型在测试集上跑得飞起,结果到了产线上却动不起来。原因不是视觉识别不行,而是视觉系统算出来的坐标和判定结果没人接——PLC不认JSON,不认图片框选结果,它只认寄存器里的数据。这时候就需要一个上位机在中间做“翻译”和“调度”。我最近刚完成一个类似的协同控制项目,用的是C#写上位机,通过Modbus TCP把AI视觉的结果实时同步给PLC,驱动现场机构完成定位抓取和品质分拣。整套链路从协议选型到地址映射、再到PLC侧的状态机配合,踩了不少坑,整理出来分享给正在做同类项目的人。
1. 为什么非要用上位机做视觉和PLC的中间层
先说结论:视觉直接接PLC的方案不是不能用,但你得先搞清楚自己的场景到底有多复杂。很多新手第一反应是“既然PLC有IO点,那把视觉的结果直接给IO不就行了”,如果产品只有“OK”和“NG”两个结果,IO点硬接确实最省事。但一旦涉及到坐标、角度、尺寸、缺陷类型编号这类实际数值,纯IO就会变得非常痛苦——你要把所有数值拆成二进制的IO组合,线缆多、调试慢,而且视觉系统每升级一个算法版本,IO定义可能就要推翻重来。
上位机做中间层解决的就是这三个问题:
第一个是格式转换。视觉系统算出的坐标是浮点数,缺陷类型是一个枚举,但PLC能处理的只是保持寄存器里的整数值。上位机负责把浮点数转成两个寄存器,把枚举映射成整数编号,必要时还要做坐标系的平移和缩放——比如相机像素坐标换算成机械世界坐标。
第二个是状态协调。完整的动作链是这样的:传感器检测到位,PLC告诉上位机“产品到了”,上位机触发视觉拍照,视觉返回“物料在X=120.5、Y=88.3,判定OK”,上位机把结果写入PLC的寄存器区,PLC再据此控制机械手抓取或者气缸推料。这里面每一步都有明确的时序依赖,用纯IO做硬连线几乎不可能实现这种柔性调度。
第三个是数据追溯。产线出了问题,你得能查出来“当时拍到的图像是什么、算法判定依据是什么、PLC拿到数据之后做了什么动作”。视觉数据的落库、日志记录、报警弹窗,这些放在上位机里做是顺理成章的。
所以我的建议是:别管网上怎么说“工业场景越简单越好”,只要你的产品线存在坐标计算、缺陷分类、多机台协同中的任意一种需求,上位机就是绕不开的枢纽。后面的内容就围绕我刚才提到的那个真实项目展开——一条小型电子装配线的视觉定位+协同抓取工位。
2. Modbus TCP的通信链路设计:先画数据地图再写代码
很多人写Modbus通信容易一上来就打开代码编辑器,先写个连接函数把PLC连上,然后按交流群里别人的案例直接开搞地址读写。结果写到一半发现:线圈用完了、寄存器不够用、或者读写地址跟PLC程序对不上。正确顺序是先在纸上把整张地址地图画出来,所有参与方都确认了,再动键盘。
2.1 从PLC侧规划保持寄存器和线圈功能区
这里的核心思路是按功能区块划分地址空间,而不是想到哪写到哪。下面是我在这个项目里实际使用的寄存器分配表,你可以直接参考,但要根据自己的PLC型号和工艺调整:
| 功能区 | 寄存器起始地址 | 长度 | 用途 |
|---|---|---|---|
| 控制区 | 0x0000 | 2 word | 系统启动/停止/复位/急停标志 |
| 状态区 | 0x0002 | 2 word | 当前运行状态、故障码 |
| 视觉触发区 | 0x0004 | 1 word | 上位机写1触发拍照,PLC写回来表示确认 |
| 视觉结果区 | 0x0005 | 8 word | X坐标、Y坐标、角度、尺寸、结果代号 |
| 握手区 | 0x000D | 1 word | 通信心跳、数据有效性标志 |
| 预留区 | 0x000E | 10 word | 后续扩展用 |
注意我用了“地址偏移”而不是Modbus里的实际协议地址,因为不同库对“地址”的表示方式不一样——有的用0开头,有的用40001开头,这个后边踩坑部分我会专门说。画这张表的时候,你脑子里要有一个概念:每一个寄存器背后都代表一个“信息单元”,上位机和PLC都要能把同一个信息单元对应到同一个物理地址上,这是通信不出错的根本保障。
2.2 控制字和状态字的位级设计
单个寄存器是16位的,但实际控制信号往往只有“启动/停止/复位/急停”这么几个。这时候有两种做法:一种是一个线圈对应一个信号,用Modbus的05和01功能码去操作线圈;另一种是把多个布尔信号打包成一个16位控制字,用写单个寄存器的0x06功能码完成。
我更推荐后者,尤其是信号数量超过4个的时候。原因很简单:线圈操作在实物PLC的Modbus从站实现里,很多是映射到内部继电器区的,未必能和梯形图里的扫描周期拉齐;而寄存器读写是PLC内部数据区直接交互,响应更稳定。另外,把布尔打包成控制字,先写好哪一位代表什么,后续加信号只要改位定义就行,不用重新分配线圈地址。
具体位定义是这个样子:
位0:启动(1有效) 位1:停止(1有效) 位2:复位故障 位3:视觉拍照触发 位4:夹爪闭合使能 位5~15:预留上位机要“启动系统”,就读取当前控制字的值,把第0位置1,再整体写回寄存器。这里一定要“读-改-写”,不能直接覆盖整个寄存器——否则你置位启动的同时,可能把别人刚写进去的“夹爪闭合”给冲掉了。这是个非常容易犯的低级错误。
2.3 视觉结果的数据格式约定
视觉系统算出来的坐标和角度是浮点数,但PLC的保持寄存器只有整型。常用的办法是把浮点数拆成高16位和低16位放进两个连续寄存器。这里有两个方案:
- 方案A:直接传输IEEE 754浮点的原始字节。上位机把float的内存表示拆成两个ushort写入,PLC侧把两个寄存器当成32位浮点数来读。好处是不损失精度,坏处是PLC侧要做字节拼装,写梯形图的人可能会骂人。
- 方案B:坐标值扩大100倍后转整数。比如X=123.4567mm,上位机乘100后得到12345,写入一个寄存器,PLC侧读出来再除以100。好处是PLC侧处理极其简单,坏处是精度锁定在0.01mm,而且负坐标需要额外处理。
我的项目用的是方案A,因为视觉的坐标精度要求到小数点后三位,而方案B的0.01mm在个别料位距离很近的地方会不够用。如果你是第一次做,没有PLC侧资深工程师配合,先从方案B开始会比较稳,后期再切换到方案A也来得及。
有一点要提前说清楚:Modbus寄存器本身是无符号整数,浮点数的符号位藏在32位数据里,PLC侧如果用“有符号整数”去读会把情况搞乱,一定在通信协议文档里写明这个寄存器区域的数据类型是“32位浮点”或者是“有符号整数乘以100”,别只写“数据”,否则调试现场两个人互相猜谜。
3. C#上位机通信模块:轮询、状态机与断线自愈
通信模块是整个上位机的地基。这个模块写得好,之后的逻辑全是“水电安装”;写得不好,光是通信超时和UI卡死就能耗掉你一周。我强烈建议把通信模块独立封装成一个类,永远不要和界面逻辑混在一起。
3.1 通信库的选择与自封装
Modbus TCP的通信库在工业圈里其实选择不多,主流就那几个开源库,用法大同小异,核心都对接到Modbus协议的读写方法上。我更推荐的方式是:先封装一个“通信服务类”,把库的细节全部藏起来,只给上层暴露几个方法:
public class ModbusTcpClientService { private readonly string _ip; private readonly int _port; private TcpClient _tcpClient; public ModbusTcpClientService(string ip, int port = 502) { _ip = ip; _port = port; } public bool Connect() { /* 建立TCP连接,设置读写超时 */ } public void Disconnect() { /* 关闭连接,释放资源 */ } public bool IsConnected { get; } public ushort ReadRegister(byte unitId, ushort address) { /* 读单个保持寄存器 */ } public ushort[] ReadRegisters(byte unitId, ushort startAddress, ushort length) { /* 批量读 */ } public bool WriteRegister(byte unitId, ushort address, ushort value) { /* 写单个寄存器 */ } public bool WriteRegisters(byte unitId, ushort startAddress, ushort[] values) { /* 批量写 */ } }为什么一定要做这一层封装?因为后续你会面临两个变化:第一,实际项目中大概率不是只连一台PLC,可能3到4台设备都要走Modbus,你需要一个通用的客户端实例管理;第二,一旦项目升级换用Profinet或者EtherCAT网关,你只需要替换这个类内部的实现代码,上层的业务逻辑一行都不用改。这个抽象带来的收益,在项目后期会体现得特别明显。
3.2 后台轮询和心跳保活的实现细节
Modbus TCP和PLC通信,永远不要想在UI线程里同步读写。PLC的扫描周期通常10到50毫秒,而UI线程要处理人机交互,一旦你同步读一个慢响应寄存器,界面立刻假死。
正确的做法是建一个后台任务循环,每100到200毫秒读一次PLC的关键寄存器区:
public async Task StartPollingLoop(CancellationToken token) { while (!token.IsCancellationRequested) { try { if (!_service.IsConnected && !await TryReconnectAsync(token)) { await Task.Delay(1000, token); continue; } // 读状态区,更新本地缓存 var statusRegisters = _service.ReadRegisters(0xFF, 0x0002, 2); UpdateLocalStatus(statusRegisters); // 读视觉结果区 var visionResult = _service.ReadRegisters(0xFF, 0x0005, 8); ProcessVisionResult(visionResult); // 刷新UI(用Dispatcher或BeginInvoke) RaiseDataUpdatedEvent(); await Task.Delay(_pollInterval, token); } catch (Exception ex) { Log($"轮询异常: {ex.Message}"); _service.Disconnect(); await Task.Delay(500, token); } } }这里有个关键的架构决策:读回来的数据先更新到本地内存缓存,再由事件通知UI层刷新。千万不要让UI控件直接绑定通信结果,因为通信是非连续的,UI一旦在通信空窗期读取数据,整个显示就乱了。
心跳保活的思路也很直接:PLC和上位机商定一个“心跳寄存器”,上位机每隔固定时间(比如1秒)把心跳计数器加1写进去;PLC在梯形图里做比较,如果3秒没收到心跳更新,就判定上位机掉线,自动停止视觉协同流程并且声光报警。反过来,上位机也监视状态区的一个“PLC心跳位”,如果连续读不到更新,就弹出“PLC无响应”提示。
3.3 断线重连和典型异常处理
Modbus TCP最让人头疼的问题不是连不上,而是连上之后突然断开,双方状态谁都不干净。我遇到的实际场景是这样的:设备间的工业交换机某个端口闪断,PLC程序继续运行,但上位机这边连接已经断了,重连时连接被拒绝,因为PLC的Modbus从站还认为上一个连接活着。
解决方法是三步走:
- 重连退避策略:第一次断开后,200毫秒重试,失败后翻倍,最多到3秒间隔,持续重试。避免每50毫秒连一次,把PLC的通信端口打满,导致PLC正常扫描周期被拖垮。
- 重连成功后先读状态:连接恢复后不要立刻下发任何命令,先读一次PLC的状态区和模式,确认PLC当前处于什么状态,再做后续动作。防止“上位机以为PLC是停止状态,实际在运行中”引起的误操作。
- PLC侧设置看门狗:当PLC检测到通信超时,自动清除视觉结果区和控制字,让整个设备进入安全停止状态。这个动作必须在PLC梯形图里实现,不能依赖上位机软件本身。
4. AI视觉与上位机的融合逻辑:从“拍照”到“机器人抓取”的完整链路
现在到了整个协同控制最有意思的部分——AI视觉系统怎么和上位机配合,最终驱动PLC动作。视觉算法本身不是本文的重点,但视觉和上位机的接口设计直接影响整个项目的成败。
4.1 视觉系统接入的两种接口形态
工业相机厂商和视觉软件通常提供两种交互方式:
第一种是SDK回调:视觉软件在算法跑完之后,主动向上位机推送一个结果对象,里面包含检测到的目标框、置信度、坐标等。通常是TCP/IP或者共享内存。这个方式时效性好,实时性强,适合产线节拍快的场景。
第二种是HTTP/REST拉取:上位机主动向视觉服务的HTTP接口发请求,询问“上一帧检测结果是什么”。这个方式实现起来最简单,任何语言都能调,缺点是延迟不可控,一轮请求加响应可能需要几百毫秒。
在这个项目里我用的是第一种。上位机先通过Modbus通知PLC“本轮拍照开始”,PLC给出拍照完成信号后,视觉SDK把结果回调到上位机。上位机收到结果后,先做坐标格式转换,然后写入Modbus寄存器,同时置位“视觉结果有效”标志位——PLC一看到这个标志位置1,马上取走视觉结果寄存器区,开始抓取动作。
4.2 浮点数坐标写入寄存器的具体实现
这里给出一个完整的坐标写入代码片段,方便你直接参考:
private void WriteVisionResultToPlc(float x, float y, float angle, int judgeCode) { // 将浮点数按IEEE 754拆成两个16位寄存器 byte[] xBytes = BitConverter.GetBytes(x); byte[] yBytes = BitConverter.GetBytes(y); byte[] angleBytes = BitConverter.GetBytes(angle); // Modbus是大端序,BitConverter在Windows上是小端序,需要反转 ushort xHigh = BitConverter.ToUInt16(new byte[] { xBytes[3], xBytes[2] }, 0); ushort xLow = BitConverter.ToUInt16(new byte[] { xBytes[1], xBytes[0] }, 0); // 同理处理y和angle... // 写入视觉结果区,起始地址0x0005 ushort[] values = new ushort[] { xHigh, xLow, yHigh, yLow, angleHigh, angleLow, (ushort)judgeCode, 0x0001 // 结果有效标志,置1 }; _service.WriteRegisters(0xFF, 0x0005, values); }注意这里我特别写了字节序转换。Modbus协议规定多字节数据的高字节在前(大端序),而C#的BitConverter在几乎所有Windows平台上都是小端序,不做反转的话,PLC侧读出来的浮点数就是完全错乱的。这个坑几乎每个人都会踩一次,我已经帮大家踩过了,你直接用上面的思路就行。
4.3 坐标系标定与偏移补偿
视觉识别出的坐标是“像素坐标”,而PLC和机械手要的是“世界坐标”。这道转换就是在标定阶段完成的,通常是一个仿射变换矩阵。上位机在把视觉结果写入PLC之前,必须先把这个变换作用上去。
实际项目里我做了这样的机制:上位机读一个“标定参数区”的寄存器组,里面存有标定时的原点偏移和标定比例。这样当现场机械结构因为换型调整了之后,操作员只需要在HMI上输入新的偏移量,PLC就会同步给上位机,上位机自动把视觉的像素坐标转换成机械世界坐标——不需要重新编译上位机代码,也不需要请视觉工程师重新标定。
这步很容易被忽略。很多人写完坐标写入之后,拿着相机随便拍一张,量一下像素误差就开始抱怨视觉算法不准。其实很可能是你压根没做像素到机械的标定换算,或者标定参数写死在了上位机代码里,现场一移动相机就全乱了。
4.4 视觉结果异常的可靠性保障
视觉结果不是每帧都可信的。我在这套系统里做了三层校验,对应三种不同的异常来源:
| 异常类型 | 校验方式 | 处理策略 |
|---|---|---|
| 算法漏检/误检 | 置信度阈值+结果代号判定 | 结果代号写3(未识别),PLC走异常处理流程,不抓取 |
| 图像质量差(过曝/反光) | 视觉系统返回采集质量标志 | 上位机记录日志,触发重新拍照一次 |
| 坐标越界(目标超出机械可达范围) | 上位机比对坐标范围模板 | 不写入PLC,置报警代码,等待人工确认 |
尤其是坐标越界这一层,它是最容易漏掉的。视觉在画面里检测到了目标,但目标可能在机械臂抓取范围之外,如果你不加校验直接把坐标发给PLC,机械手就会做一个“冲向极限位置”的危险动作。我见过项目里因为这个原因把夹爪撞变形的情况,损失几万块都是小事,出人身事故才是大事。
5. PLC侧的逻辑配合:从梯形图视角看上位机发来的数据
前面大部分篇幅在讲上位机怎么做,但协同控制是双向的——PLC侧的逻辑如果设计得不配合,上位机写再多寄存器也没用。站在PLC工程师的角度,我给你说清楚梯形图该怎么处理上位机来的信号。
5.1 三段式状态机设计
不要指望上位机发个“启动”信号,PLC就把整个流水线跑起来。正确做法是把视觉协同动作拆成三段状态,每一段都要明确“谁发起、谁确认、谁结束”:
空闲态:系统等待,上位机可下发“视觉触发”命令 触发态:PLC收到触发命令,执行拍照动作,完成后回写“拍照完成”标志 执行态:PLC读到“视觉结果有效”标志,取走坐标进行抓取或分拣,完成后回写“动作完成”这种三段式每一段都有明确的握手信号,歧义最小。最怕的是把整个流程串成一个梯形图大循环,一旦某一步没到位,排查起来要翻十几个网络。
5.2 手自动模式切换与权限管理
现场设备必须要有“手动模式”,这个没有商量余地。调试阶段、换型阶段、故障处理阶段,操作员要在手动模式下手动操作夹爪和气缸。这个模式切换必须在PLC里做硬互锁,上位机只负责展示状态和限制操作。
我的做法是:控制字的最高一个位预留为“手自动切换”。当这个位为0时,上位机的所有自动指令一律忽略(在PLC侧做判断,上位机软件里也做同样的灰度限制);当这个位为1时,手动按钮的操作被屏蔽,避免人和机器抢动作。
这个权限管理看似和视觉协同无关,但实际操作中经常发生“上位机调程序时手滑点了一下启动,而操作员正在气爪里放料”的情况。安全逻辑永远是最高优先级,哪怕视觉程序写得再漂亮,也必须在安全互锁全部通过之后才允许自动执行。
5.3 定时器与扫描周期的匹配
PLC的循环扫描周期通常在10到50毫秒之间,而上位机的轮询和TCP通信延迟可能在几十到几百毫秒。这意味着:上位机以为写给PLC的数据“立刻生效”,实际可能要在两三个扫描周期之后才被PLC逻辑真正用上。
所以PLC工程师在写视觉协同梯形图时,必须用定时器做信号确认:
- 触发命令发出后,等200毫秒再检查“拍照完成”信号,给足上位机和视觉系统响应时间
- “视觉结果有效”标志位一旦置位,保持至少300毫秒才允许上位机复位,保证PLC能可靠捕捉到
- 上位机写入“坐标”和写入“结果有效标志”之间,间隔不能过短——否则梯形图扫描第一次读到的可能是一组不完整的坐标
很多人只关注上位机的读写快慢,忽略了PLC扫描周期的存在,结果就是偶发性的数据错乱。排查的时候看寄存器里的值明明是对的,但设备动作就是不对,其实就是这个时序问题。
6. 实打实的踩坑清单:地址偏移、字节序、断电恢复和偶发断开
这一节是项目做完之后沉淀下来的血泪经验,每一条都对应一个真实发生过的故障或者返工场景。我按照排查的频率排序,很多是交流群里反复有人问的问题。
6.1 寄存器地址的“1偏置”问题
Modbus协议里地址编号有个行业惯例:数据模型地址从0开始,但在人机界面和文档里往往显示成40001这样的“协议地址”。于是问题出现了——你在代码里写ReadRegister(0x0005),但实际的寄存器编号在PLC侧可能是40006。
这个问题的本质是“5”和“40005”的换算。不同的PLC厂家、不同的上位机组态软件、不同的Modbus库,对这个偏置的处理方式不完全一致。解决办法只有一个:测试阶段先做一个“单寄存器环回测试”,上位机写一个已知值到某个地址,PLC监控表里看这个值是不是出现在你预期的位置。花十分钟做这个测试,比后续排查一整天的“为什么数据对不上”要划算得多。
6.2 批量读写与单点读写的响应时间差异
同样是一组寄存器,用ReadRegisters一次读8个字,比用ReadRegister循环读8次,在PLC端消耗的时间差异很大。PLC的Modbus从站处理单条请求,批量读是一次性完成寄存器区拷贝;而循环读相当于发起8次独立的请求,每次都要经过TCP握手和协议解析,在通信繁忙时可能造成请求排队。
所以上位机代码里,凡是连续地址的数据,一律用批量读写方法,这是性能和稳定性的双重保障。
6.3 断电重启后的初始状态同步
生产现场经常发生这种情况:设备突然断电,PLC程序从头开始,所有寄存器恢复成初始值,但上位机进程还活着,它以为PLC还在运行。这时候如果上位机按原状态继续写控制字,可能把PLC拽进一个未定义的模式。
我的做法是在上位机里维护一个“PLC状态未知”的状态:每次通信连接建立成功后,先清空上位机上所有的本地状态标记,强制把界面切到手动模式,提示操作员确认设备状态后再重新启动自动流程。这个动作不需要操作员做任何复杂操作,但必须存在。
6.4 TCP连接偶发断开的真正原因
Modbus TCP的断开往往不是上位机或者PLC主动断的,而是中间的工业交换机因为环网检测、广播风暴、QoS策略等原因,把非实时端口回收了。特别是某些工业交换机的端口节能策略,长时间无数据传输的TCP连接会被“静默断开”。
解决这种问题有两条路:一条是保证轮询频率不能太低,每200到500毫秒至少有一次数据交互,让连接保持活跃;另一条是在交换机配置里关闭端口节能,或者把上位机所在端口设置成“实时优先”队列。有些老工程师的经验是“加一个心跳包就行”,但这只对应用层有效,物理层的连接断开还是要靠交换机侧配合。
7. 系统整体联调:从代码调试到产线运行的最后一步
写到这里,代码逻辑、通信配置、PLC梯形图基本都齐了。但真正要把这套系统跑起来,联调阶段的准备工作决定你是“一周搞定”还是“一个月都在现场加班”。
建议按照这个顺序联调,每一步都确认无异常再进入下一步:
- 单点通信测试:上位机连接PLC,读几个已知寄存器,确认地址映射和字节序正确。
- 手动模式联动:在手动模式下,上位机触发视觉拍照,视觉结果写入PLC,操作员确认坐标值的正确性。
- 虚拟工件走流程:不放真实物料,用上位机模拟一个“到位信号”,走完整的三段式状态机流程,看寄存器状态的转换是否和预期一致。
- 真实工件试运行:放几个已知尺寸的工件,比较视觉识别坐标和人工测量的偏差,反复调整标定参数。
- 节拍测试:连续运行几十个工件,记录整个流程从拍照到抓取完成的总耗时,确认满足产线节拍要求。
- 异常注入测试:故意断开网络、断电、模拟视觉漏检,验证系统的故障保护和恢复逻辑是否可靠。
这六步做完,系统才算真正“交接”给现场使用。特别是第6步,很多开发人员会忽略,但在产线上,网络波动、人员误操作都是必然出现的,而系统能在故障后自动恢复是底线能力。
我在实际项目里还有两个小技巧可以分享。一个是每次联调开始前,清空所有保持寄存器的历史值,避免上一轮调试的残留数据干扰判断;另一个是在上位机日志里记录每一次控制字写入的完整内容,写前值和写后值都记下来——以后现场反馈“设备乱动但说不清什么时候动的”,全靠这份日志定位。
这套C#上位机加Modbus TCP的架构,看起来代码量不大,但每一步都关系到产线能否稳定运行。当初我接手这个视觉协同项目时,最担心的不是视觉算法精度,反而是“算法算对了,怎么把结果交给PLC”这段路。走完这一整圈之后我的体会是:工业协同项目里真正难的不是AI模型本身,而是那个“数据落地”的过程——地址映射、时序握手、异常自愈,每一个细节都能决定系统的成败。希望这篇记录能帮你少走几步弯路。