
做工业上位机开发的朋友应该都有体会:AI视觉检测跑通不难,难的是最后和PLC联动这一公里。尤其是近几年国产PLC在3C、锂电、包装线的用量越来越大,很多团队要么只会用Modbus凑合用,要么对厂商自定义协议摸不清门路,结果就是视觉数据传得慢、指令卡壳、现场丢信号的问题层出不穷。
去年我做3C产品缺陷分拣项目的时候,从0到1啃了汇川H5U的以太网协议,用C#实现了完整的视觉-PLC交互闭环,实测单轮交互延迟稳定在15ms以内,连续跑了三个月没出过大问题。今天把整个实现过程、优化思路和踩过的坑全部分享出来。
一、前期准备:环境与整体架构
动手写代码之前,先把系统角色和数据流理清楚,这是后续所有交互的基础。
1. 硬件与软件环境
- 上位机:基于C# .NET 6开发,负责视觉结果中转、协议解析和PLC指令下发,VS2022开发环境
- PLC:汇川H5U-1614MTD,国产主流以太网型PLC,支持自有高速协议与Modbus TCP双模式
- AI视觉端:YOLOv8检测模型,推理完成后输出JSON格式结果,包含OK/NG标识、缺陷类型、像素坐标
- 组网:所有设备通过千兆交换机组成局域网,固定IP分配,避免DHCP波动导致断连
2. 完整交互闭环
整个产线检测的控制逻辑分为四步,是典型的半双工握手交互:
AI视觉模块C国产PLCAI视觉模块C国产PLC#mermaid-svg-GdPGPzdji96uvCc9{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GdPGPzdji96uvCc9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GdPGPzdji96uvCc9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GdPGPzdji96uvCc9 .error-icon{fill:#552222;}#mermaid-svg-GdPGPzdji96uvCc9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GdPGPzdji96uvCc9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GdPGPzdji96uvCc9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GdPGPzdji96uvCc9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GdPGPzdji96uvCc9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GdPGPzdji96uvCc9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GdPGPzdji96uvCc9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GdPGPzdji96uvCc9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GdPGPzdji96uvCc9 .marker.cross{stroke:#333333;}#mermaid-svg-GdPGPzdji96uvCc9 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GdPGPzdji96uvCc9 p{margin:0;}#mermaid-svg-GdPGPzdji96uvCc9 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GdPGPzdji96uvCc9 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-GdPGPzdji96uvCc9 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GdPGPzdji96uvCc9 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-GdPGPzdji96uvCc9 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-GdPGPzdji96uvCc9 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-GdPGPzdji96uvCc9 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-GdPGPzdji96uvCc9 .sequenceNumber{fill:white;}#mermaid-svg-GdPGPzdji96uvCc9 #sequencenumber{fill:#333;}#mermaid-svg-GdPGPzdji96uvCc9 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-GdPGPzdji96uvCc9 .messageText{fill:#333;stroke:none;}#mermaid-svg-GdPGPzdji96uvCc9 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GdPGPzdji96uvCc9 .labelText,#mermaid-svg-GdPGPzdji96uvCc9 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-GdPGPzdji96uvCc9 .loopText,#mermaid-svg-GdPGPzdji96uvCc9 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-GdPGPzdji96uvCc9 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GdPGPzdji96uvCc9 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-GdPGPzdji96uvCc9 .noteText,#mermaid-svg-GdPGPzdji96uvCc9 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-GdPGPzdji96uvCc9 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GdPGPzdji96uvCc9 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GdPGPzdji96uvCc9 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GdPGPzdji96uvCc9 .actorPopupMenu{position:absolute;}#mermaid-svg-GdPGPzdji96uvCc9 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-GdPGPzdji96uvCc9 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GdPGPzdji96uvCc9 .actor-man circle,#mermaid-svg-GdPGPzdji96uvCc9 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-GdPGPzdji96uvCc9 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}写入触发标志位(拍照请求)触发相机采集+模型推理返回检测结果与坐标写入结果数据+完成标志位读取结果并执行控制动作
二、分步实操一:国产PLC以太网协议拆解
很多人以为国产PLC都用Modbus,其实头部厂商基本都有自己的以太网私有协议,批量读写效率比标准Modbus TCP高30%以上,还支持位操作、断点续传等特性。只是官方文档不对外公开,需要自己抓包拆解。
当时为了摸透帧结构,我用Wireshark抓了整整两天,对比官方组态软件的收发数据,才把完整报文格式理清楚。
1. 基础报文结构
汇川H5U以太网协议的报文分为帧头、长度域、功能域、数据域、校验位五部分:
- 帧头:固定2字节 0xAA 0xBB,作为报文起始标识,用于粘包分包处理
- 长度域:2字节,记录功能域+数据域的总长度,采用小端模式存储
- 功能码:1字节,0x01=读寄存器,0x02=写单寄存器,0x03=批量写寄存器
- 数据域:变长,根据功能码携带起始地址、寄存器数量、读写数据等内容
- 校验位:2字节CRC16-Modbus校验,低字节在前,高字节在后
2. 典型读写报文示例
读取D0~D9共10个寄存器的请求报文:
AA BB 06 00 01 00 00 0A 00 XX XX
- AA BB:帧头标识
- 06 00:数据长度6字节(小端模式,对应十进制6)
- 01:读寄存器功能码
- 00 00:起始地址D0(对应十六进制0x0000)
- 0A 00:读取数量10个
- XX XX:CRC16校验值
PLC返回的响应报文结构一致,数据域部分会替换为对应寄存器的数值。
三、分步实操二:C# 实现协议核心通信层
通信层直接用原生Socket实现,不依赖任何第三方驱动,好处是体积小、可控性强、没有授权费用,适合轻量化上位机项目。
1. 客户端基础封装
public class DomesticPlcClient
{
private Socket _socket;
private readonly string _ip;
private readonly int _port = 502;
private readonly byte[] _frameHeader = { 0xAA, 0xBB };
public bool IsConnected => _socket?.Connected ?? false;
public DomesticPlcClient(string ip)
{
_ip = ip;
}
}
2. 连接与超时控制
工业现场网络波动大,超时参数不能太长也不能太短,实测1000ms是比较均衡的取值,既不会因为短暂波动误判断连,也不会卡住主线程太久。
public bool Connect(int timeoutMs = 1000)
{
try
{
_socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_socket.SendTimeout = timeoutMs;
_socket.ReceiveTimeout = timeoutMs;
_socket.Connect(_ip, _port);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"PLC连接失败:{ex.Message}");
return false;
}
}
3. 读寄存器核心实现
这里最容易踩坑的是字节序。大部分国产PLC是小端存储,和C#默认一致,但部分老款机型是大端,读出来数值不对的时候优先调换字节顺序。
public ushort[] ReadRegisters(ushort startAddr, ushort count)
{
if (!IsConnected) throw new InvalidOperationException("PLC未连接");
// 拼接请求报文
int dataLength = 4; // 功能码1 + 地址2 + 数量2 = 5?不对,功能码1+地址2+数量2=5字节,这里按实际协议调整
byte[] sendBuffer = new byte[2 + 2 + 5 + 2]; // 帧头2 + 长度2 + 功能数据5 + 校验2
Buffer.BlockCopy(_frameHeader, 0, sendBuffer, 0, 2);
BitConverter.GetBytes((ushort)5).CopyTo(sendBuffer, 2);
sendBuffer[4] = 0x01;
BitConverter.GetBytes(startAddr).CopyTo(sendBuffer, 5);
BitConverter.GetBytes(count).CopyTo(sendBuffer, 7);
// 追加CRC校验
ushort crcValue = Crc16(sendBuffer, 0, sendBuffer.Length – 2);
BitConverter.GetBytes(crcValue).CopyTo(sendBuffer, sendBuffer.Length – 2);
// 收发数据
_socket.Send(sendBuffer);
byte[] recvBuffer = new byte[1024];
int recvLen = _socket.Receive(recvBuffer);
// 基础校验
if (recvLen < 6 || recvBuffer[0] != 0xAA || recvBuffer[1] != 0xBB)
throw new IOException("无效响应帧头");
if (Crc16(recvBuffer, 0, recvLen – 2) != BitConverter.ToUInt16(recvBuffer, recvLen – 2))
throw new IOException("CRC校验失败");
// 解析寄存器数据
ushort[] result = new ushort[count];
for (int i = 0; i < count; i++)
{
result[i] = BitConverter.ToUInt16(recvBuffer, 6 + i * 2);
}
return result;
}
4. CRC16校验算法
国产PLC基本都用标准Modbus CRC16算法,多项式0xA001,直接复用即可。
private ushort Crc16(byte[] data, int offset, int length)
{
ushort crc = 0xFFFF;
for (int i = offset; i < offset + length; i++)
{
crc ^= data[i];
for (int j = 0; j < 8; j++)
{
if ((crc & 0x0001) != 0)
crc = (ushort)((crc >> 1) ^ 0xA001);
else
crc >>= 1;
}
}
return crc;
}
写寄存器的逻辑和读大同小异,替换功能码、在数据域追加写入值即可,这里就不重复贴代码了。
四、分步实操三:AI视觉数据与PLC寄存器映射
通信打通只是第一步,怎么把AI输出的结构化数据,转换成PLC能识别的寄存器数值,才是联动的核心。这部分没有统一标准,完全取决于和PLC工程师约定的地址分配。
1. 寄存器地址分配约定
我项目里用的地址分配方案,覆盖了绝大多数分拣场景的需求,大家可以参考:
| D0 | 拍照触发标志位 | bool | PLC写,上位机读 |
| D1 | 检测完成标志位 | bool | 上位机写,PLC读 |
| D2 | 检测结果:0=OK,1=NG | ushort | 上位机写,PLC读 |
| D3-D4 | 缺陷X坐标(32位浮点数) | float | 上位机写,PLC读 |
| D5-D6 | 缺陷Y坐标(32位浮点数) | float | 上位机写,PLC读 |
| D7 | 缺陷类型编码 | ushort | 上位机写,PLC读 |
2. AI结果转换写入逻辑
AI输出JSON结果后,先解析成实体类,再按协议格式转换成寄存器数值,批量写入PLC。
public void WriteAiDetectResult(AiResult result)
{
ushort[] regData = new ushort[7];
regData[0] = (ushort)(result.IsFinish ? 1 : 0);
regData[1] = (ushort)(result.IsDefect ? 1 : 0);
regData[2] = result.DefectTypeCode;
// float拆分为两个16位寄存器
byte[] xBytes = BitConverter.GetBytes(result.PositionX);
byte[] yBytes = BitConverter.GetBytes(result.PositionY);
regData[3] = BitConverter.ToUInt16(xBytes, 0);
regData[4] = BitConverter.ToUInt16(xBytes, 2);
regData[5] = BitConverter.ToUInt16(yBytes, 0);
regData[6] = BitConverter.ToUInt16(yBytes, 2);
// 从D1地址开始批量写入7个寄存器
WriteMultipleRegisters(1, regData);
}
3. 握手机制避免丢数据
千万不要直接循环覆盖寄存器,一定要做双向握手。我见过很多项目丢数据、误动作,根源都是没做握手。
这套机制虽然简单,但能彻底避免数据覆盖、漏处理的问题。
五、分步实操四:实时性与稳定性优化
工业现场的通信,稳定永远是第一位的,在此基础上再追求速度。分享几个实测有效的优化手段。
1. 长连接+心跳检测
不要每次读写都建连断开,TCP三次握手的开销在高频场景下非常明显。保持长连接,每5秒读一次固定寄存器做心跳,检测到断开自动重连。
实测长连接比短连接的响应速度快5~10倍,分拣线这种每秒触发好几次的场景,差距尤其明显。
2. 批量读写替代单寄存器操作
尽量一次读写多个寄存器,不要循环单个操作。比如写7个寄存器,一次批量写入的耗时和写1个几乎没差,但循环7次耗时会翻好几倍。
我项目里把原来的单寄存器逐个写,改成批量写入后,单轮写入耗时从20ms降到了3ms以内。
3. 异步通信不阻塞UI
上位机一般都带操作界面,同步Socket调用会直接卡住UI线程。所有通信操作都放到Task.Run里执行,或者用Socket异步API,保证界面流畅。
4. 信号防抖过滤
现场传感器、PLC输入都可能有电气抖动,触发信号要做防抖处理,比如连续两次读到标志位为1,才判定为有效触发,避免误拍误动作。
六、问题排查:国产PLC通信常见踩坑
这部分都是我实际踩过的坑,很多新手卡一两天都找不到原因,列出来帮大家避坑。
1. 寄存器地址偏移
国产PLC的地址规则特别乱,有的是0-based,有的是1-based,有的文档写十进制,有的写十六进制。
读不到数据的时候,先把起始地址±1试试,90%的问题都出在这里。汇川H5U的D寄存器是0-based,而信捷部分机型是1-based,这点要特别注意。
2. 字节序与数据格式
16位寄存器的高低字节、32位浮点数的寄存器顺序,不同厂商、不同系列都可能不一样。
读出来的数值明显不对的时候,先调换两个字节的顺序试试;float数值不对,就调换两个寄存器的前后顺序,基本都能解决。
3. TCP连接数限制
国产PLC的以太网连接数普遍不高,很多型号最多只支持8个TCP连接,多了就会拒绝连接或者踢掉旧连接。
上位机一定要复用连接,不要开多个线程分别建连,否则现场很容易出现时通时断的玄学问题。
4. 超时与重连机制
工业现场网线松动、交换机重启都是常事,一定要做超时重连。建议设置3次重试,每次间隔500ms,重试失败再触发完整重连逻辑,不要一次失败就直接报错。
七、总结
国产PLC的协议解析看起来门槛高,其实核心就是报文拼接、校验计算、数据转换三步。把帧结构理清楚,把握手机制做扎实,再把稳定性优化到位,AI视觉和PLC的交互闭环其实并不难。
相比第三方OPC服务器或者商业驱动,自己用C#实现协议的好处是可控性强、部署简单、没有授权成本,还能根据项目需求做定制优化,特别适合嵌入式上位机、轻量化设备这类场景。
最后提醒一句,工业现场调试一定要做好安全防护,PLC写入指令前先做空载测试,确认逻辑没问题再联机运行,避免误动作损坏设备。
网硕互联帮助中心


评论前必须登录!
注册