简介:一套经过实际测试的上位机与汇川可编程控制器通讯解决方案,基于Modbus TCP协议实现,可稳定对接H5U与H3U系列,适合自动化工程师、上位机开发者及视觉通讯集成项目使用。方案支持M、Y、X、D、DD、S、R等寄存器读写,并提供快速批量读取功能,读写操作可同时进行,突破了常见只能单状态读写的限制,连续多地址采集时效率更高。压缩包约10.34MB,共87个文件,以C#源程序、可执行文件、动态库、Visual Studio解决方案及配置文档为主,附有调试符号与中文注释,便于学习移植和二次开发。其中可执行文件可直接运行演示,源程序与工程文件帮助理解协议逻辑,文本与配置说明提供使用指引,目录结构清晰。目前已有20395人学习下载,资源内含完整示例与使用标注,读者可直接参考寄存器映射、批量读取和并发读写的实现思路,快速应用到自身项目中。
1. 上位机与汇川PLC通讯:一个视觉检测项目把我逼进了Modbus的世界
当时接了一条装配产线的视觉改造,相机拍完产品,得把OK/NG结果实时发给汇川PLC,由PLC决定气缸放行还是剔除。工控机上跑的上位机,就是中间那个传话的角色。真正上手才发现,上位机与汇川PLC通讯这件事,卡了我一周的居然不是拍照算法,而是寄存器地址映射和两边程序的配合——同一个D0寄存器,在不同通讯库眼里可能是完全不同的地址,一个字节序弄错,读回来的良率数据全是乱码。这篇笔记把整个联调过程拆开:协议怎么选、连接怎么建、D寄存器怎么读、线圈怎么写、视觉信号怎么跟PLC做握手,以及我在现场踩过的坑。适合两类人:刚接手产线数据对接的上位机开发,想给现有设备加视觉联动又怕翻车的PLC工程师。
2. 技术选型:与汇川PLC通讯的三种路子,为什么选了Modbus TCP
2.1 三种通讯方式对比,先别急着写代码
先把范围内的三种方式摆出来:Modbus RTU走RS485串口,Modbus TCP走以太网,EtherCAT走实时以太网总线。三者的定位差别很大,选错了后期都是痛苦。
| 通讯方式 | 物理层 | 典型场景 | 速度 | 上位机接入难度 |
|---|---|---|---|---|
| Modbus RTU | RS485/RS232 | 老旧产线、短距离 | 9600~115200 bps | 需要串口转接线,帧间隔要自己管理 |
| Modbus TCP | 以太网 | 产线联网、视觉对接 | 百兆/千兆 | 一个Socket就能做,任何语言都有现成库 |
| EtherCAT | 以太网 | 伺服运动控制总线 | 微秒级同步 | 一般轮不到上位机读写,属于PLC内部总线 |
汇川PLC很多型号自带以太网口,固件里就内置了Modbus TCP从站功能,这意味着上位机不需要额外硬件,一根网线就能通讯。Modbus TCP基于TCP/IP传输,没有RTU那套CRC校验和帧间隔要求,排查起来直接用Wireshark抓包就能看到明文请求和响应。这对联调阶段太重要了——出问题的时候抓一个包,就能分清是上位机没发对还是PLC没回。
EtherCAT虽然也跑在以太网物理层上,但它是PLC用来带伺服、做运动控制的总线。上位机想直接读写EtherCAT从站,需要专门的网关或主站授权,成本和复杂度都不值当。除非你是要开发运动控制系统本身,否则产线视觉对接这层,Modbus TCP就是最省事的选择。
2.2 数据流向:上位机只负责“放数据”,不负责“驱动设备”
新手最容易绕晕的点在这:上位机到底能不能直接控制PLC的物理输出Y端子?答案是不能。Modbus TCP模型里,上位机访问的是PLC的寄存器区,碰不到I/O端子。
以汇川PLC为例,D区是数据寄存器,M区是内部中间继电器,Y是物理输出。上位机通过Modbus写D区,只是把数值放进PLC的内存区域。要让数值真正起作用,PLC梯形图里必须有一句搬运逻辑,把D区内容搬到一个控制输出的中间继电器上,再靠PLC扫描周期刷新到Y端子。
完整链路是这样的:
相机拍照 → 视觉程序识别OK/NG → 上位机把1或0写入D100 → PLC梯形图把D100数值搬到中间继电器M50 → PLC程序把M50状态映射到Y0输出 → 气缸动作。
这就是联调前要先定的“点表”:哪个D寄存器放结果、哪个M中间继电器做握手标志、Y0对应哪个气缸。点表没定就写代码,后期改起来就是灾难。我一般建议先把数据交互表写在纸上,PLC工程师和上位机开发一人一份,再开始干活。
2.3 开发语言和通讯库:C#上生产,Python做验证
工业上位机开发,C#还是主流。WinForm/WPF做界面快,跟相机SDK、数据库、IO板卡的生态都成熟。通讯库方面,HslCommunication对汇川PLC的支持比较到位,直接用封装好的ModbusTcpClient,省掉自己拼报文的过程。Python我一般只用来做联调前的自测脚本——pymodbus三行代码就能验证PLC的Modbus从站通不通,比开一整个Visual Studio工程快得多。如果项目团队是脚本背景,Python直接上生产也不是不行,只是工控机上要装Python环境,部署不如C#打一个exe干净。
这里有个常见误用:有人把Modbus TCP当成普通TCP Socket,直接从串口调试助手发十六进制报文来测试。调试阶段可以,生产代码里自己拼报文,很容易在事务号、协议标识、长度字节这些地方出错。用成熟的库,把精力放在业务逻辑上,才是正路。
Modbus TCP的请求报文其实只有12字节,结构固定:事务处理标识2字节、协议标识2字节(0表示Modbus)、长度2字节、单元标识1字节(站号)、功能码1字节、起始地址2字节、寄存器数量2字节。读保持寄存器功能码是0x03,写单个线圈是0x05,写单个寄存器是0x06。打个最简单的读请求:00 00 00 00 00 06 01 03 00 00 00 0A,就是让1号从站从地址0开始读10个保持寄存器。这段报文结构值得背下来——排查问题的时候,你会感谢自己记得它。
3. 核心模块拆解:连接、读写、心跳和视觉握手的完整实现
3.1 工程目录与模块划分
一个标准的上位机通讯工程,我习惯按四层拆:通讯层、点位层、业务层、界面层。这套与汇川PLC通讯的Demo工程目录结构如下:
HuiChuanPlcDemo/ ├── App.config # 连接参数配置 ├── Modbus/ │ ├── PlcConnection.cs # 连接管理与读写封装 │ ├── PlcPointTable.cs # 点位表:寄存器地址与业务含义 │ └── HeartbeatManager.cs # 心跳保活与断线重连 ├── Business/ │ └── VisionGateway.cs # 视觉信号握手状态机 ├── UI/ │ └── MainForm.cs # 监控界面 └── Program.cs这样拆的好处是:换PLC型号、换视觉品牌、改界面,三者互不牵连。通讯层只管把寄存器地址变成可读写的变量;业务层不关心Modbus报文细节,只负责状态流转;界面层只做显示和操作。后面所有核心代码都会落到这几个文件里。
3.2 连接管理:TCP会话不能只连一次就算了
连接这块,新手容易写成“程序启动时连接一次,后面就一直读”。但现场实际情况是:PLC断电重启、网线松动、交换机重启,任何一环断了,连接就死了。所以连接管理必须把断线重连考虑进去,下面是连接封装的基础代码:
using HslCommunication; using HslCommunication.ModBus; public class PlcConnection { private readonly string _ip; private readonly int _port; private ModbusTcpClient _client; public PlcConnection(string ip, int port = 502) { _ip = ip; _port = port; } public OperateResult Connect() { _client = new ModbusTcpClient(_ip, _port); _client.Station = 1; // 从站号,和PLC通讯参数页保持一致 _client.ConnectTimeOut = 3000; // 连接超时3秒,PLC没起来时快速失败 var result = _client.ConnectServer(); // 建立TCP连接 if (result.IsSuccess) { Console.WriteLine("已连接PLC,IP: " + _ip); } return result; } }逻辑说明:这里把连接超时设为3秒,是为了PLC不在线时界面不卡死。TCP连接如果目标IP不可达,默认可能等十几秒才超时,现场操作员会误以为程序崩溃。Station参数对应PLC通讯配置里的Modbus站号,汇川PLC通常默认是1,但要以PLC侧实际配置为准。
3.3 寄存器读写:D区、M区、Y区的地址写法
HslCommunication在地址字符串上做了统一封装,读D区用"d0"、读M区用"m0"、写线圈用"m0"。要注意这些字母只是库的语法糖,真正发到报文里的还是Modbus地址。比如读d0,报文的起始地址就是0,功能码是03。核心读写方法写出来是这样:
// 读D区16位整数:d0对应Modbus报文的地址0 public OperateResult<int> ReadD16(int index) { return _client.ReadInt16("d" + index); } // 写单个寄存器:把16位值写入D区 public OperateResult WriteD16(int index, short value) { return _client.Write("d" + index, value); } // 写M中间继电器(线圈) public OperateResult WriteM(int index, bool value) { return _client.WriteCoil("m" + index, value); }逻辑说明:汇川PLC的Modbus从站映射里,D0从Modbus地址0开始,M0从线圈地址0开始。这个对应关系一定要现场确认,不同系列PLC可能预留地址偏移。32位数据读写时,汇川PLC默认的高低位排列和上位机库的默认字节序不一定一致——如果读出来是0或者巨值,多半就是字节序问题,需要在库的初始化参数里调整大小端。
3.4 心跳保活与自动重连
汇川PLC作为Modbus从站,一般不会主动断开TCP连接,但现场的路由器、交换机通常有会话老化机制,长时间没有数据交互的TCP连接会被中间设备清掉。所以上位机必须有个心跳机制,既保活又做数据刷新。我这边的做法是用一个2秒定时器周期读D0:
public class HeartbeatManager { private readonly PlcConnection _plc; private System.Timers.Timer _timer; public HeartbeatManager(PlcConnection plc) { _plc = plc; } public void Start() { _timer = new System.Timers.Timer(2000); // 2秒心跳间隔 _timer.Elapsed += (s, e) => { var result = _plc.ReadD16(0); if (!result.IsSuccess) { Console.WriteLine("心跳检测失败,尝试重连..."); _plc.Connect(); } }; _timer.Start(); } }逻辑说明:每2秒读一次D0,成功说明链路存活;失败说明连接断了,直接重新走一遍Connect。这比捕获异常要主动得多,现场跑起来基本不用管。心跳间隔2秒对PLC负载很小,要求实时性的场景可以改成500毫秒。但要注意,心跳不要和业务读写混在同一个线程,否则一次慢读会阻塞所有操作——这也是很多人容易翻车的地方。
3.5 视觉信号握手:为什么不能只写一个“OK”真假值
视觉联动是这套通讯的核心业务。我第一次做的时候,做法很简单:相机检测完,给PLC写一个线圈,OK写true,NG写false。结果现场偶发漏检——PLC明明收到了信号却没动作。
原因是PLC扫描周期通常几毫秒,而上位机写的线圈状态如果只拉高一次,PLC程序可能没扫描到。稳妥的握手方式要三步:结果寄存器、就绪标志、PLC应答。完整状态机代码如下:
public async Task HandleVisionHandshakeAsync() { while (!_cancelled) { // 第一步:读M100,PLC拉高表示“请求拍照” var request = _plc.ReadM(100); if (request.IsSuccess && request.Content) { // 第二步:执行检测,得到OK/NG bool ok = _visionDetector.Detect(); // 第三步:把检测结果写入D100(1=OK,0=NG) _plc.WriteD16(100, (short)(ok ? 1 : 0)); // 第四步:写D101=1,告诉PLC“结果已就绪” _plc.WriteD16(101, 1); // 第五步:等PLC把M100清零,表示结果已被取走 var clear = _plc.ReadM(100); while (clear.Content) { await Task.Delay(10); clear = _plc.ReadM(100); } // PLC取走后,复位D101就绪标志,完成一轮 _plc.WriteD16(101, 0); } await Task.Delay(5); } }这个握手流程看着繁琐,但每步都有意义:D100放结果只是数据,D101的上升沿才是给PLC的“请取走”信号。PLC梯形图里用D101的上升沿把结果锁存到内部继电器,然后清零M100来应答。上位机看到M100清零,再复位D101,整个闭环才算完整。这套流程跑下来,视觉OK/NG信号几乎不可能丢。
配套的PLC梯形图逻辑大致是:
// 伪代码,实际在PLC编程软件里实现 D101上升沿 -> 锁存D100的值到M50 M50 -> Y0 // 驱动气缸输出 M50取走确认 -> 清零M1004. 从零跑通:PLC参数、配置文件和两步验证
4.1 硬件连接与PLC侧设置
这套Demo的硬件要求不高:一台带以太网口的汇川PLC、一台工控机或笔记本、一根网线。把网线直接插在PLC以太网口和电脑网口之间,电脑设静态IP,跟PLC在同一个网段。
PLC侧需要确认三件事。第一,Modbus TCP从站功能要启用,不同系列PLC里这个选项可能叫“网口从站”“Modbus网关”,在通讯参数页找到并打开。第二,从站号要和上位机代码配置一致,建议默认用1。第三,记录PLC的IP地址,常见的是192.168.0.10,具体以PLC本身设置或编程软件里看到的为准。
梯形图里至少要有一段映射逻辑,否则上位机写D100,物理输出不会有任何变化。前面伪代码里的MOVE逻辑就是干这个的。实际调试时,建议先用PLC编程软件的在线监控确认D100写入后内部值真的变了,再查Y输出不动作的问题。
4.2 上位机连接参数与配置文件
连接参数不要写死在代码里,改成配置文件,换产线时改几个值就行。下面这套参数表是Demo工程里最核心的配置项:
| 配置项 | 示例值 | 说明 |
|---|---|---|
| PlcIp | 192.168.0.10 | PLC以太网IP |
| PlcPort | 502 | Modbus TCP默认端口 |
| PlcStation | 1 | 从站号,必须与PLC设置一致 |
| ConnectTimeout | 3000 | 连接超时毫秒数 |
| HeartbeatInterval | 2000 | 心跳间隔毫秒数 |
| RequestAddress | m100 | PLC请求拍照的线圈地址 |
| ResultAddress | d100 | 结果数据寄存器地址 |
| ReadyAddress | d101 | 结果就绪标志寄存器地址 |
4.3 第一步验证:先用Python脚本确认通讯通不通
不用上来就编译整个工程。打开命令行装好pymodbus,跑这个脚本:
from pymodbus.client import ModbusTcpClient client = ModbusTcpClient('192.168.0.10', port=502) print('connect result:', client.connect()) # 从Modbus地址0开始读10个保持寄存器,即D0~D9 rr = client.read_holding_registers(address=0, count=10, slave=1) if not rr.isError(): print('D0-D9:', rr.registers) # 写D100为1,验证写入通路 wr = client.write_register(address=100, value=1, slave=1) print('write D100:', 'OK' if not wr.isError() else 'FAIL') client.close()参数说明很关键:address=0对应的就是D0,不是40001。很多从串口工具转过来的人习惯把地址写成40001,这是功能码和寻址的混淆——40001是Modbus协议文档里的数据区编号,而库函数里的address参数直接就是协议报文里的起始地址。slave=1就是站号,如果PLC里配的是2,这里必须同步改成2。
4.4 第二步验证:用Modbus Poll模拟上位机读写
Python脚本通过后,再用Modbus Poll这类的调试工具做正式联调前的最后确认。新建连接,填IP、端口502、从站号1,寄存器起始地址填0,功能码选03(保持寄存器),就能看到从D0开始的一整片数据实时刷新。
这时候可以手动改几个寄存器的值,观察PLC梯形图的联动;也可以反过来在PLC里强制Y输出,看Modbus Poll里对应的线圈状态变化。两边数据对上了,才轮到C#工程出场——这一步就像做菜之前先试调料,连通讯都没通就写业务代码,出了问题你会分不清是协议问题还是业务问题。
4.5 跑起来之后的观察点
程序跑起来后,第一件事不是看界面,而是观察三样东西:心跳日志有没有周期性重连、读写返回耗时是否稳定、PLC侧Y输出跟视觉结果对不对得上。
另外提醒一点:Windows防火墙默认会拦掉外部访问502端口,第一次跑不通先关掉防火墙或者加放行规则,比反复改代码快得多。工控机如果有多个网卡,还要确认网卡优先级,别让上位机往错误的网卡发请求。
5. 通讯避坑:五个现场问题,从现象到原因到解决
5.1 现象:能连上,但读上来的寄存器全是0或65535
这是最常见的翻车点。原因之一是地址映射错位:把Modbus地址0当成了D1,实际D0对应地址0,差一个寄存器整体错位。另一个常见原因是数据类型搞混,PLC里D区存的是32位整数,上位机按16位读,读出来要么是0要么数值巨大。还有一种情况是字节序问题,高低位反了,读出来的值对不上。
解决:先用Modbus Poll从地址0开始连续读一片寄存器,对照PLC监控表确认每个地址的真实数据。32位数据读不出来就先按16位半字读,手动拼一下高低位,确认顺序后,再去调库的字节序参数。
5.2 现象:通讯正常,但每隔几分钟断一次线,日志里全是重连
原因:交换机或路由器的空闲会话超时。TCP连接上如果没有数据流动,网络设备会在超时后清掉会话表,PLC并不知道,还在傻等,上位机下一次读请求就超时了。多半是上位机没用心跳,或者心跳间隔比网络设备超时时间长。
解决:把心跳间隔缩到2秒,同时主动周期探测,别等业务读写报错了才重连。有条件的话把TCPKeepAlive也打开,两层保险。这个坑在无线网桥环境下尤其常见,有线网络虽然少,但现场交换机配置五花八门,还是会踩到。
5.3 现象:上位机写M100成功,PLC程序就是不动作
原因:写线圈和PLC梯形图的扫描时序没对上。上位机写的是一个瞬时电平,PLC扫描周期几毫秒,如果梯形图对这个线圈的处理是“边沿检测”,就可能漏掉。另外要确认这个线圈在PLC程序里没有被别的地方重复赋值——一个线圈被两处写,本来就是定时炸弹。
解决:要么在PLC侧做脉冲展宽锁存,用上升沿配合置位指令把瞬间信号保持住;要么干脆改用D寄存器配合就绪标志的握手方式。我的经验是,涉及视觉这种“一次触发一次响应”的业务,别用单个瞬时线圈,用寄存器加标志位组合最稳。
5.4 现象:视觉OK/NG偶尔丢一次,不良品混进良品堆
原因:握手协议不完整。上位机直接写了OK/NG结果,没有“就绪标志”,PLC不知道这个结果是新的还是旧的。如果连续两个产品都是OK,结果值相同,PLC就以为没有新任务,后面的那个产品就被跳过了。
解决:加一个“就绪标志寄存器”,用上升沿让PLC锁存数据,上位机等到PLC应答后再复位。代码就是第三章那个VisionGateway状态机。这套握手看起来有点像是经验玄学,但现场少漏一个不良品,它就值回票价了。
5.5 现象:通讯延迟忽高忽低,轮询一多PLC就不理人
原因:上位机把所有读写放在一个线程里串行执行,某一瞬间多个操作排队,加上PLC扫描周期波动,响应时间就不稳定。更隐蔽的是有些代码用了同步阻塞读,一个请求卡住,后面全部排队。
解决:生产代码里把“心跳刷新”和“事件性读写”拆成两个通道,或者给每个操作设超时。HslCommunication的异步接口可以让慢操作不阻塞界面。轮询周期也要控制,别一上来就搞50毫秒扫一次,PLC从站响应不过来的。
6. 改造思路:把Demo变成你自己产线的视觉联动程序
6.1 点位表配置化,换设备不用改代码
把PLC IP、端口、站号、所有D/M地址集中到一个JSON配置文件里,程序启动时加载到内存。换一条产线,改配置文件就行。我习惯再加一个地址合法性校验:程序启动时把配置里的所有地址读一遍,不通就在界面上标红,避免开着开着才发现地址配错了。
{ "plc": { "ip": "192.168.0.10", "port": 502, "station": 1 }, "points": { "request": { "type": "M", "index": 100 }, "result": { "type": "D", "index": 100 }, "ready": { "type": "D", "index": 101 } } }6.2 状态机复用,把视觉联动做成通用组件
把第三章的握手逻辑从界面层抽成一个独立类,输入是“请求线圈地址+结果寄存器地址”,输出是检测结果。界面只做状态显示,不掺和通讯逻辑。这样将来从视觉检测扩展到扫码读取、称重数据回传,都是同一个状态机换几个地址的事。
6.3 离线验证:用Modbus模拟器做回归测试
改造完最怕把原有逻辑弄崩。我的做法是:用一个Modbus TCP从站模拟器加载同样的点位表,模拟PLC端D区和M区的读写行为,然后写一个自动化脚本,按顺序触发握手并断言结果。每次改完代码,十分钟就能跑一遍全流程,不用占着现场设备做测试。
这段经验其实是被逼出来的。有一次我改完地址映射没验证就上了现场,结果把D0写到了D1,整条产线等我调了四十分钟。从那以后,每次改地址映射,我都强制走一遍“模拟器→脚本断言→Modbus Poll人工复核”三步流程,再难调的联调也能在半小时内收工。希望帮到你。
本文还有配套的精品资源,点击获取