☰
C#与汇川PLC通信实战:Modbus TCP地址映射与避坑指南
2026/10/15 18:21:50 网站建设 项目流程

简介:面向工业自动化系统集成开发者的C#与汇川PLC通信示例,解决基于Modbus协议实现上位机与汇川PLC数据交换的典型需求。资源包共624个文件,大小约214.54MB,核心内容以源代码(cs、cpp、h)、工程文件(sln、vcxproj)、可执行程序(exe、dll)为主,并含lib、config及docx/pdf说明文档,工程结构完整,便于还原项目环境进行二次开发。已有578人学习下载。包内包含ModbusTCPDemo、StandardModbusApi、ModbusApiTest等多个模块,覆盖C#网络编程、Modbus报文构造与解析、寄存器读写、异常捕捉和性能优化等关键点。通过运行示例并阅读源码,可快速掌握汇川PLC地址映射、通信参数配置与错误排查方法,为同类项目提供可直接参考的代码实现。

1. 为什么“照抄的示例”通不过:C#与汇川PLC通信的真实门槛

搜“c#与汇川plc通信 示例”,网上能抄到一堆代码,但接到真机上十有八九跑不通:要么连不上,要么读出来的数值对不上,要么写成功了PLC却没反应。问题出在哪儿?出在C#代码本身的人很少,出在Modbus TCP报文、汇川的地址映射和字节序上的人占大多数。本文从一条可运行的通信链路出发,讲清楚C#这侧到底要写什么、PLC那侧要配什么、地址偏移怎么算、哪些坑让人翻车。适合正在写上位机、数据采集或设备启停功能的.NET开发者,也适合刚接触工控通信、想把“示例”变成“可用代码”的从业者。

2. 通信方式选型与PLC侧准备:先让端口通起来

2.1 Modbus TCP、Modbus RTU与厂商专用协议怎么选

C#与汇川PLC通信,选对通信方式是第一道选择题。常见做法有三条路:Modbus TCP、Modbus RTU、厂商专用以太网协议。绝大多数情况下,我选Modbus TCP。原因很直接:汇川带网口的PLC基本都内置Modbus TCP从站支持,不需要额外安装组件;报文格式公开,用调试工具能直接看;上位机只需要会用Socket,不用引入厂商SDK。

三种方式的差异可以这样看:

对比项Modbus TCPModbus RTU厂商专用协议
物理层以太网RS232/RS485以太网
报文基础TCP/IP串口字节流私有格式
PLC侧配置设置IP与使能设置串口参数依赖固件版本
上位机成本低,Socket即可中,需处理CRC和帧间隔高,通常要依赖SDK
排错便捷度高,可抓包中,需确认串口参数低,文档不全时只能猜

RTU不是不能用,而是老设备只有串口、距离远或走无线数传时才会被我翻出来用。厂商专用协议通常用来做组态软件对接,自己写上位机用它会碰到固件版本差异导致的翻车,文档不够细时连报文都拼不齐。如果标题里的场景是“示例、通信、上位机”,Modbus TCP就是最稳的起点。

2.2 汇川PLC侧必须完成的四件事

代码写得再好,PLC侧配置不对也是白搭。我在现场遇到最多的情况是:上位机Ping得通PLC,但Socket一连上就超时,最后查出来是PLC的Modbus TCP从站功能没有使能。这件事不做,后面的所有代码都等于对着一扇锁死的门敲。

第一件事:把PLC的IP地址、子网掩码、网关设好,并保证上位机与PLC在同一网段。第二件事:在PLC编程软件的通信配置里,找到Modbus TCP相关设置,使能从站功能。不同系列菜单入口不一样,但“MODBUS TCP从站”或“允许MODBUS通信”这类选项一定要打开。第三件事:确认从站ID(单元ID)。很多系列默认是1,也有的默认255,必须按实际配置填,不能想当然。第四件事:确认PLC的程序里没有把要读写的地址锁死,比如写保护的存储区或程序占用区。

配置好后不要急着写C#,先用两个命令做前置验证:

# 先确认IP层通不通 ping -n 1 -w 1000 192.168.0.10 # 再确认502端口是否被PLC监听(需要在bash环境执行,Windows可用Git Bash) timeout 1 bash -c 'echo > /dev/tcp/192.168.0.10/502' && echo "502 open" || echo "502 closed"

502端口返回closed时,问题基本出在PLC侧,回编程软件查使能选项和IP配置即可。注意这里的IP192.168.0.10是示例,实际填你PLC的地址。这个检查步骤能帮你省下一个下午的抓包时间。

2.3 上位机工程结构与连接参数起点

C#工程不需要引用第三方通信库,用.NET自带的System.Net.Sockets就够了。很多示例代码用库封装过,一出问题就是个黑匣子,反而不利于学习。我用原生Socket写,报文自己组、响应自己解析,任何一步都能拿调试工具对照。

工程上我建议先建一个控制台项目验证通信,跑通后再迁到WinForms/WPF或后端服务里。原因很简单:控制台项目没有界面干扰,日志直接打印,最适合做链路验证。连接参数先按以下起点设置,后续再根据现场调整:

参数起点值说明
PLC IP192.168.0.10编程软件里读到的实际IP
端口502Modbus TCP标准端口
单元ID1必须与PLC从站配置一致
连接超时1000 ms连不上时快速失败
读写超时2000 ms防止Receive卡死
重连间隔5000 ms稳定且不给PLC压力

这些参数不是死的。比如通过无线数传连PLC时,连接超时可能要放到3000~5000ms,因为链路时延高。而本地局域网场景,1000ms足够。我的习惯是先把超时写进配置文件,联调时再收紧。

3. 用原生Socket写最小可用的Modbus TCP客户端:从报文到读写方法

3.1 理解Modbus TCP请求报文结构

C#这段代码的骨架就是“拼报文、发出去、收响应、解析”。理解报文结构比背代码重要得多。以读保持寄存器为例,一次请求总共12字节,十六进制长这样:

00 01 00 00 00 06 01 03 00 00 00 02

逐字节拆开看:

字节区间字段名值含义
0-1事务ID00 01用来匹配请求和响应,递增即可
2-3协议ID00 00Modbus固定为0
4-5长度00 06从单元ID开始到报文结束的字节数
6单元ID01PLC从站地址
7功能码03读保持寄存器
8-9起始地址00 00从0号寄存器开始
10-11寄存器数量00 02连续读2个寄存器

这个报文结构是排错的基础。响应数据出问题时,我第一步就是拿帧头和这段期望值做对比,看PLC到底有没有按请求返回。注意“长度”字段计算的是第6字节之后的字节数,容易数错,数错一次后面全部错位。

3.2 读保持寄存器:从连接到解析响应

有了报文结构,代码就顺理成章了。下面的实现不含第三方依赖,可直接放进控制台工程跑通。

using System.Net; using System.Net.Sockets; // 连接PLC,带超时控制 static Socket ConnectPlc(string ip, int port, int timeoutMs) { var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); var result = socket.BeginConnect(ip, port, null, null); if (!result.AsyncWaitHandle.WaitOne(timeoutMs)) { socket.Close(); throw new TimeoutException($"连接 {ip}:{port} 超时"); } socket.EndConnect(result); return socket; } // 组装“读保持寄存器”请求帧 static byte[] BuildReadHoldRegisters(byte unitId, ushort startAddr, ushort count) { var frame = new byte[12]; frame[0] = 0x00; frame[1] = 0x01; // 事务ID,可递增 frame[2] = 0x00; frame[3] = 0x00; // 协议ID frame[4] = 0x00; frame[5] = 0x06; // 后续字节数 frame[6] = unitId; // 单元ID frame[7] = 0x03; // 功能码:读保持寄存器 frame[8] = (byte)(startAddr >> 8); frame[9] = (byte)startAddr; // 起始地址,大端 frame[10] = (byte)(count >> 8); frame[11] = (byte)count; // 寄存器数量,大端 return frame; }

这段代码里容易写错的是第8~11字节的大端处理。Modbus规定高位字节在前,直接强制类型转换会把高字节丢掉,地址就完全错了。unitId参数必须和PLC从站配置一致,不一致时PLC会直接丢弃请求或返回异常码。

接着写接收和解析部分:

// 读取指定长度的数据,解决TCP粘包/半包问题 static byte[] RecvExact(Socket socket, int length) { var buffer = new byte[length]; int offset = 0; while (offset < length) { int received = socket.Receive(buffer, offset, length - offset, SocketFlags.None); if (received == 0) throw new IOException("连接被对端关闭"); offset += received; } return buffer; } // 读保持寄存器并解析成short数组 static short[] ReadHoldRegisters(Socket socket, byte unitId, ushort startAddr, ushort count) { socket.Send(BuildReadHoldRegisters(unitId, startAddr, count)); // 先读9字节:MBAP头7字节 + 功能码1字节 + 字节计数1字节 var header = RecvExact(socket, 9); int length = (header[4] << 8) | header[5]; // MBAP长度字段 int dataLength = length - 2; // 去掉单元ID和功能码 var body = RecvExact(socket, dataLength); // body[0]是功能码,body[1]是字节计数,从body[2]开始是寄存器数据 var result = new short[count]; for (int i = 0; i < count; i++) { int idx = 2 + i * 2; result[i] = (short)((body[idx] << 8) | body[idx + 1]); } return result; }

RecvExact是这里最重要的函数。TCP是字节流,没有消息边界,一次Receive可能只收到半个报文,也可能一次收两个报文。不做“收满指定长度”的处理,程序就会出现偶发性读错数据。这里为什么先读9字节?因为MBAP头7字节加上功能码和字节计数共9字节,而长度字段在第4~5字节,读完前9字节就能算出剩余长度。body[1]是PLC返回的数据字节数,正常应该等于count * 2,不相等说明响应帧不对,需要抓报文看PLC侧到底返回了什么。

3.3 写线圈和写寄存器:设备启停的两种方式

设备控制场景里,启停电机常用写线圈,下放参数常用写寄存器。很多示例只给读不给写,我补上两个最常用的写操作。

// 写单个线圈(0x05):适合启停、复位等开关量 static void WriteSingleCoil(Socket socket, byte unitId, ushort addr, bool value) { var frame = new byte[12]; frame[0] = 0x00; frame[1] = 0x02; // 事务ID,每次请求递增 frame[2] = 0x00; frame[3] = 0x00; frame[4] = 0x00; frame[5] = 0x06; frame[6] = unitId; frame[7] = 0x05; // 功能码:写单个线圈 frame[8] = (byte)(addr >> 8); frame[9] = (byte)addr; frame[10] = value ? (byte)0xFF : (byte)0x00; // ON必须是0xFF frame[11] = 0x00; // 固定补0 socket.Send(frame); // 读完12字节响应,避免残留数据影响下一次请求 var echo = RecvExact(socket, 12); if (echo[7] != 0x05) throw new Exception($"写线圈失败,功能码返回:0x{echo[7]:X2}"); }

写线圈和写寄存器在功能码上很容易混。0x05是写单个线圈,0x06是写单个寄存器。线圈的ON值固定是FF 00,不能把01 00发过去,这是协议规定,写错现场就等着设备不动吧。写寄存器类似,数据区改成两字节大端即可:

// 写单个寄存器(0x06):适合设定温度、速度等数值 static void WriteSingleRegister(Socket socket, byte unitId, ushort addr, short value) { var frame = new byte[12]; frame[0] = 0x00; frame[1] = 0x03; frame[2] = 0x00; frame[3] = 0x00; frame[4] = 0x00; frame[5] = 0x06; frame[6] = unitId; frame[7] = 0x06; // 功能码:写单个寄存器 frame[8] = (byte)(addr >> 8); frame[9] = (byte)addr; frame[10] = (byte)(value >> 8); frame[11] = (byte)value; socket.Send(frame); var echo = RecvExact(socket, 12); if (echo[7] != 0x06) throw new Exception($"写寄存器失败,功能码返回:0x{echo[7]:X2}"); }

这里强调一点:写操作必须把响应读完。Modbus TCP是请求-响应模式,一次请求对应一次响应,响应不读干净,下一次ReadHoldRegisters拿到的就是上次的残留帧,解析必然错位。这个细节看似不起眼,但现场联调时一大半“读出来是乱码”都跟它有关。

3.4 响应长度判定与半包处理

上一节代码里的RecvExact已经解决了半包问题,但实际报文还有另一种情况:响应帧里的长度字段决定后续读取量,必须严格按它来。发一个读50个寄存器的请求,响应数据区有100字节,如果代码里写死读固定字节数,碰到异常响应就会卡住。

完整响应帧结构如下:

字段字节数说明
事务ID2与请求一致
协议ID2固定0
长度2单元ID到数据末尾的字节数
单元ID1从站ID
功能码1Echo返回请求功能码
字节计数1后续数据字节数
数据N寄存器值,每寄存器2字节

还有一种不常见但很恶心的情况:PLC返回异常码,功能码最高位置1,比如请求功能码0x03但响应是0x83,后面带1字节异常码。代码里一定要判断功能码最高位,发现异常时抛出带异常码的错误,而不是硬着头皮解析数据。用四五年下来,我见过的异常码里,02(非法地址)和03(非法数据)占九成,出现这两个基本就是地址映射范围错了。

4. 汇川地址映射与数据类型转换:示例代码跑不通的元凶

4.1 PLC地址到Modbus地址的映射规则

C#代码跑通、报文也发了,但读回来的数据和触摸屏上显示的不一样,这就要进入地址映射环节。汇川PLC的编程地址和Modbus协议地址不是一回事,X、Y、M、D这些软元件要映射到Modbus不同的数据区。

先明确Modbus四类数据区:

数据区功能码读写性典型对应
线圈01/05可读写Y输出、部分M继电器
离散输入02只读X输入
保持寄存器03/06/16可读写D数据寄存器、部分M区
输入寄存器04只读模拟量输入通道

汇川PLC的D区通常映射到保持寄存器,X区映射到离散输入,Y区映射到线圈,这是大方向。但偏移量就因系列而异了:有的系列D0对应保持寄存器地址0,有的系列D0从地址256开始;M区更麻烦,有些型号把M0~Mxx映射到线圈区,有些却把M区打包放进保持寄存器里用位来读写。示例代码里直接写startAddr = 100能读D100,换一个系列可能读到的是完全不同的软元件。

所以动手前先打开PLC手册,找到“MODBUS地址映射表”,把它抄下来贴在工位上。抄表时注意一个关键区别:Modbus报文里的地址是0起始的协议地址,不是PLC界面显示的编号。比如手册写“保持寄存器40001对应D0”,报文里起始地址写的就是0,不是40001,也不是1。这个换算搞错,屏幕上数值就是驴唇不对马嘴。

4.2 Bool、Short、Float与寄存器的字节序

寄存器是16位,C#的short可以直接对上;int和float需要两个寄存器拼;bool要么走线圈功能码,要么从寄存器里取某一位。通用转换逻辑我封装成下面这组方法:

// 两个字节拼short,Modbus默认大端 static short ToInt16(byte hi, byte lo) { return (short)((hi << 8) | lo); } // 四个字节拼int,按大端顺序拼接 static int ToInt32(byte b0, byte b1, byte b2, byte b3) { return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3; } // 四个字节拼float,注意C#平台字节序 static float ToFloat32(byte[] bigEndian) { if (BitConverter.IsLittleEndian) Array.Reverse(bigEndian); return BitConverter.ToSingle(bigEndian, 0); } // 读回一个寄存器后,取出第bitIndex位 static bool GetBitFromRegister(short regValue, int bitIndex) { return (regValue & (1 << bitIndex)) != 0; }

这套方法看起来简单,但翻车基本都翻在细节上。比如温度传感器返回的是float,占用两个寄存器,代码里如果用读short的方式读一个寄存器,显示出来就是几百上千的乱数。还有一种坑是字节序配置:有些汇川型号的浮点字节序可以在PLC参数里切换,上位机侧必须两边对齐。验证方法很直接:PLC里给D100写入固定值1.5(浮点),程序读出来后打印四个原始字节,和1.5的IEEE 754表示做逐字节对比。比对一致后再解析成float。

4.3 用“已知值+调试工具”验证地址对不对

地址不是靠猜的,靠验证。我现场排错固定走三条路,顺序不能乱。

第一步:在PLC程序里把目标地址写入一个已知值。比如D100写十进制12345,十六进制就是0x3039。第二步:用Modbus调试工具直接读这个地址的保持寄存器,看返回是否30 39这两个字节。这一步能验证PLC侧映射和上位机无关,工具读对了,问题就在代码逻辑;工具读不对,问题在PLC映射配置。第三步:用自己写的C#程序再读一遍,三个环节的数据必须完全一致才往下走。

地址配置最好收敛到一处管理,不要让偏移散落在业务代码里。工程化做法是这样:

// 以“逻辑名 -> 协议地址”的形式管理,修改时只改这里 var tagMap = new Dictionary<string, ModbusTag> { ["设备启停"] = new ModbusTag(ModbusArea.Coil, 0, true), ["当前温度"] = new ModbusTag(ModbusArea.HoldingRegister, 100, DataType.Float), ["运行模式"] = new ModbusTag(ModbusArea.HoldingRegister, 102, DataType.Short), ["急停状态"] = new ModbusTag(ModbusArea.DiscreteInput, 0, true) };

业务代码里只出现设备启停、当前温度这些逻辑名,地址计算收敛到ModbusTag内部。这样整条链路出了问题,查配置表就行,不用在几百行代码里搜魔数地址。数值“读出来不对”时也多了一条检查路径:先看配置表里的地址是不是被复制粘贴错了,再看数据类型定义对不对。

5. 汇川PLC通信的踩坑记录:现象、原因与解决

5.1 连接超时:IP能Ping通,TCP也通了,但Send后没有回包

现象:ping通PLC,测试502端口也是open,Socket连接也返回成功,但一发送读请求就卡在Receive,直到超时抛异常。

原因:这种情况十有八九是PLC的Modbus TCP从站功能没使能,或者单元ID和PLC里配置的不一致。PLC对非法请求直接丢弃,不返回任何字节。少数情况是PLC程序里同一个地址正在被其他上位机以异常方式占用,连接被挂起。

解决:回编程软件确认“MODBUS TCP从站”或“MODBUS通信”是使能状态;核对单元ID,PLC配了255就得填255;还不行就用调试工具发同样的请求,工具也没响应就是PLC侧问题,工具有响应而代码没响应就抓报文,对比两个请求帧的字节差异。我当时就是被单元ID坑过,代码里默认写1,PLC实际是255,光调试就花了半天。

5.2 读到的数值和触摸屏/编程软件显示完全不一样

现象:触摸屏显示温度25.0,C#读回来却是5120,或者是个几千几万的数。地址明明对着,数据类型也换过,还是不对。

原因:最常见是寄存器地址偏移错了。触摸屏和PLC程序里写的是“D100”,Modbus协议地址可能是100,也可能是100加一个基础偏移,具体看手册映射表。第二个常见原因是数据类型定义错:25.0是float占两个寄存器,代码按short读一个寄存器,读出来只会是半个浮点字节序。第三个原因是字节序反转,数据变成高字节和低字节对调。

解决:先写已知值验证。PLC里把D100写12345,C#读回并打印原始字节,如果读回0x39 0x30说明字节序反了,读回0x30 0x39说明映射正确。数据字节序验证通过后,再去处理float拼装。手册上那张地址映射表,真花半小时弄明白,后面省的是几天时间。

5.3 写入返回成功但PLC程序不动作

现象:WriteSingleCoil和WriteSingleRegister都返回了正常响应,没有异常,但现场设备就是不动,PLC程序里的逻辑状态也没变化。

原因:最大可能是功能码用错了对象。PLC程序监视的是Y0或M0这类线圈,上位机却用写保持寄存器的方式写了一个数字量地址;或者反过来,程序监视的是D区,上位机去写了线圈。第二常见原因是PLC程序扫描周期短,写入瞬间被后续逻辑覆盖。第三种情况很少见但存在:PLC侧对某些区域启用了写保护,返回成功但实际没生效。

解决:先确认PLC程序里到底监视哪个软元件。Y/M用0x05写线圈,D区用0x06或0x10写寄存器,不要混。写完立即观察PLC程序在线监视的值,别只看上传下发的返回值。如果值是闪一下就变回去,查程序里是不是有别的逻辑在覆盖这个地址。这一步不难,但需要把“返回成功”和“实际生效”当成两件事。

5.4 多设备连接时报“地址已被占用”或频繁连接失败

现象:只连一台PLC没问题,一连接多台或频繁重连,Socket就报“通常每个套接字地址只允许使用一次”,重连间隔越短越明显。

原因:频繁Connect后连接进入TIME_WAIT状态,操作系统要等一段时间才能释放端口。每次读数据都新建Socket、读完就Close,这种写法在多设备场景必然触发端口不足。另一个原因是多个线程共用了同一个Socket发送请求,导致响应错位,看起来像连接挂了。

解决:一台PLC维持一个长连接,不要每次读写都建连。重连时先Close旧Socket,再新建,两次Try之间至少间隔2秒。多线程访问同一Socket时必须加锁,或者干脆每台PLC一个后台线程独占连接。连接不是越多越好,PLC侧Modbus TCP并发连接数也有限制,通常几个到十几个,别把PLC当高并发服务器用。

5.5 设备断电重启后,上位机一直重连不上

现象:PLC断电后再上电,上位机的自动重连逻辑一直在跑,但重连永远失败,必须把上位机也重启一遍才能恢复通信。

原因:PLC断电瞬间TCP连接异常断开,但上位机不知道。Socket对象还认为连接活着,Send偶尔成功、Receive永远超时。旧连接不释放,新连接就一直建不上。另一个原因是重连逻辑只在Send/Receive报错后才触发,而Socket超时时间太长,错误迟迟没暴露。

解决:给每个长连接加上应用层心跳,周期性读一个固定寄存器,比如每秒读一次D0。心跳连续失败两次就主动断开Socket并重新连接。Socket.ReceiveTimeout也要设置,不能无限期等下去。我记得有一次现场调试,PLC断电重启了,上位机足足卡了十分钟才报错,从那以后所有通信代码都强制加“心跳+快速失败”。重连间隔做成可配置的,5000ms比较稳妥,太短反而容易把网络设备打崩。

6. 多设备并发与长期稳定运行:轮询框架与验证技巧

多台PLC的场景里,代码结构比单台复杂一个量级。我的习惯是每台PLC一个独立连接实例、一个独立轮询任务,互不共享Socket,避免帧错位。轮询周期放在500~1000ms,太短了PLC和网络都受不了,太长了监控实时性又不够。下面是一个多设备轮询骨架:

// 模拟每台PLC一个客户端实例,各自持有一个Socket var clients = new Dictionary<string, PlcClient> { ["线体1"] = new PlcClient("192.168.0.10", 502, 1), ["线体2"] = new PlcClient("192.168.0.11", 502, 1) }; using var cts = new CancellationTokenSource(); while (!cts.IsCancellationRequested) { foreach (var kv in clients) { try { var values = kv.Value.ReadHoldRegisters(0, 10); UpdateUi(kv.Key, values); } catch (Exception ex) { Log($"{kv.Key} 读取失败:{ex.Message}"); await kv.Value.ReconnectAsync(); } } await Task.Delay(500, cts.Token); }

这个骨架看起来简单,但稳定性全靠PlcClient内部的细节撑着:心跳失败才重连、读写加锁、事务ID自增、所有异常都记录。联调完成后不要直接上线,我习惯连续跑24小时,期间每两小时看一次内存、句柄数、日志里有没有偶发超时。

最后说一个保命级的习惯:所有通信工程里都留一个“原始报文日志”开关,平时关闭,出问题时打开,把收发字节按十六进制打印出来。C#与汇川PLC通信的多数疑难杂症,靠读日志一眼就能定位是地址偏移、字节序还是PLC配置问题。我在现场被折磨过几次之后,这个开关再也没省过。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询