☰
MODBUS TCP与C#汇川PLC通讯:从报文封装到避坑实践
2026/10/1 23:44:47 网站建设 项目流程

简介:面向工业自动化与上位机开发人员的 MODBUS TCP 通信 C# 源码,基于 C# 编写并针对汇川 PLC 完成实际通信测试,属于可直接运行的实用程序。方案从串口 Modbus 延伸至 Modbus TCP,解释了两者在传输层上的差异,包含连接建立、报文构造、寄存器读写与响应解析等核心逻辑,既能帮助新手理解工业以太网通信原理,也为有经验开发者提供可复用的功能模块。压缩包共 38 个文件,以 cs 源码为主,配以 config/ini 配置、exe 可执行程序、resx/resources 资源文件、PDB 调试符号及 docx 说明文档,包体仅 322KB,结构清晰,方便对照学习与二次修改。已有 2049 人浏览学习;打开工程即可看到启动入口、主窗体、应用配置和指示灯控制等核心模块,界面示例加上后台通信流程,可以直观追踪请求发送与反馈解析过程,最终掌握 Modbus TCP 在汇川 PLC 通信中的落地写法。对准备搭建上位机与 PLC 通信环境的人员,这份源码可作为直接入手的学习样板。

1. 这套MODBUS TCP与C#汇川PLC通讯源码,解决的不只是“能通”

工控老马出品的这套MODBUS TCP + C#汇川PLC通讯源码,把从站扫描、读写寄存器、事务ID管理、超时重试这些在现场真正要命的事都封装好了。做上位机最怕的不是协议读不懂,而是网上找的Demo只能点个按钮读一个地址,一接MES或者产量统计系统就废掉,重写一遍又得折腾一两周。这套代码覆盖汇川H系列、AM系列和CODESYS平台的H5U与C#上位机的数据互通,读线圈、读保持寄存器、写单个、写多个这些日常操作用例都在里面。

适合的读者很明确:手上有汇川PLC,需要写C#上位机做产线数据采集、设备参数下发,或者触摸屏与上位机并存的场景;不想从裸Socket开始拼报文,想拿一套能直接落地的代码改改就用。我把它拆了一遍,功能上确实够用,但要跑得稳,寄存器映射和字节序这两个点得先想清楚,下面按我的思路展开。

2. 协议选型与汇川PLC的支持范围:为什么MODBUS TCP比RTU省心

2.1 总线型与网络型的取舍:现场走线和设备数量决定一切

MODBUS RTU走串口,RS485菊花链接法对布线顺序、终端电阻、地电位差都敏感,车间里变频器一启动,通讯偶发乱码是家常便饭。MODBUS TCP把链路交给以太网,交换机隔离了电气干扰,物理层的问题一下子少了大半。这是选TCP的首要原因,不是因为它“高级”,而是因为它省掉了串口现场那堆玄学问题。

另一个决定因素是设备数量。一条RS485总线虽然理论上能挂32个从站,但实际超过10台时轮询周期就会拉长,某台设备掉线还会拖慢整条链路。MODBUS TCP一台设备一个IP,上位机用多线程并发轮询,6台PLC同时采集,每个站独立超时互不影响。对需要对接MES或者SCADA的产线,TCP这种“并联”结构明显更合理。

MODBUS TCP和RTU的报文差异也值得说清楚,很多人第一次抓包会愣住。

对比项MODBUS RTUMODBUS TCP
物理层RS232/RS485以太网
校验方式CRC16(必须自己算)去掉CRC16,交给TCP/IP层
报文头地址+功能码+数据+CRCMBAP头(事务ID+协议ID+长度)+单元ID+功能码+数据
最大从站数理论32,实际10台以内稳妥取决于IP规划,几乎不受限
轮询方式串行,一台超时拖累全局多线程并行,单站独立超时
现场布线手拉手菊花链,终端电阻必须接交换机星型布线,走线随意很多

从RTU转TCP后,原来计算CRC16那段代码可以直接删掉,但代价是协议头从1个字节地址变成了7个字节的MBAP头,事务ID的维护成了新的坑。后面第3章会详细讲事务ID,这是TCP版和串口版最大的不同。

2.2 汇川不同系列对MODBUS TCP的支持:从站配置先查明白

汇川PLC对MODBUS TCP的支持分三个梯队。老款H1U、H2U系列本体没有以太网口,要加扩展模块才支持,具体映射关系看扩展模块手册。AM系列和MC系列本身带以太网,支持MODBUS TCP从站,但需要在轴控参数或者通讯配置里打开“MODBUS TCP服务器”开关,默认不一定开启。H5U系列是CODESYS平台,在“Modbus TCP”从站配置界面里拖一个从站设备绑定到以太网口就行,地址映射非常直观。

不管哪个系列,动手写C#之前先做一件事:在PLC编程软件里找到MODBUS TCP从站配置页,查看当前固件版本下的寄存器映射表。部分版本有个“地址偏移”选项,勾选后MODBUS地址会整体加1,网上很多“明明读了地址100却返回0”的求助帖,最后都是这个偏移没对上的问题。

还有端口占用要注意。PLC的以太网口如果同时给触摸屏、编程软件、上位机用,502端口是MODBUS TCP默认端口,有的触摸屏占用了502,PLC就无法再作为从站响应MODBUS请求。现场排查时用一条命令行确认连通性:

ping 192.168.0.10

提示:如果ping通了但上位机连不上,先telnet 192.168.0.10 502确认端口是开放状态。端口不通基本就是PLC侧从站服务没启动或端口被占用,别急着查代码。

3. 报文封装与读写实现:功能码、事务ID和超时重试三件套

3.1 读保持寄存器:12字节请求帧的构造与响应解析

读保持寄存器是数据采集用得最频繁的操作,功能码0x03。MODBUS TCP请求帧固定12字节,代码里可以按位置直接把字节填好,比用MemoryStream再ToArray更直观,也不会引入额外的内存分配。

public byte[] BuildReadRequest(byte unitId, ushort startAddr, ushort count) { // 事务ID每帧递增,从站响应时会原样带回,客户端靠它匹配请求和响应 _transactionId++; byte[] frame = new byte[12]; frame[0] = (byte)(_transactionId >> 8); // 事务ID高字节 frame[1] = (byte)(_transactionId & 0xFF); // 事务ID低字节 frame[2] = 0x00; // 协议ID:MODBUS固定为0 frame[3] = 0x00; frame[4] = 0x00; // 后续字节长度 frame[5] = 0x06; // 6 = 单元号1 + 功能码1 + 地址2 + 数量2 frame[6] = unitId; // 从站单元号,汇川默认视配置为1或255 frame[7] = 0x03; // 功能码:读保持寄存器 frame[8] = (byte)(startAddr >> 8); // 起始地址高字节 frame[9] = (byte)(startAddr & 0xFF); frame[10] = (byte)(count >> 8); // 数量高字节 frame[11] = (byte)(count & 0xFF); return frame; }

这段代码的核心是把MODBUS TCP的MBAP头和PDU拼在一起。frame[4]和frame[5]是长度字段,表示“单元号+功能码+数据”的字节数,读请求固定是6,所以frame[5]直接填0x06。count参数是寄存器数量,协议上限是125,超过这个值从站直接返回异常响应,代码里应该加一个防御判断。

响应解析比请求构造更容易出错。响应帧结构是“事务ID2字节+协议ID2字节+长度1字节+单元号1字节+功能码1字节+字节数1字节+数据N字节”,解析时要先看功能码最高位是否为1,是1说明从站返回的是异常码。

public ushort[] ParseReadResponse(byte[] response) { // 异常响应:功能码最高位置1,下一个字节是错误码 if ((response[7] & 0x80) != 0) { throw new ModbusException($"MODBUS异常响应,错误码=0x{response[8]:X2}"); } int byteCount = response[8]; // 数据区字节数,等于寄存器数×2 ushort[] values = new ushort[byteCount / 2]; for (int i = 0; i < values.Length; i++) { // 单寄存器内高字节在前,低字节在后,这是MODBUS标准字节序 values[i] = (ushort)((response[9 + i * 2] << 8) | response[10 + i * 2]); } return values; }

这里有个容易忽略的点:response数组的前6个字节里包含了请求时发出去的事务ID,但这段代码没有校验它。实际多线程环境下,必须确认response[0]和response[1]等于当前请求的事务ID,否则你拿到的可能是上一次超时重试残留的旧响应。识别到不匹配时直接丢弃,不要解析。

3.2 写单个与写多个:功能码06、10、05的使用边界

写操作比读操作要谨慎,因为一旦写错寄存器,设备可能直接动作。功能码的选择看操作对象:05写单个线圈,06写单个寄存器,0F写多个线圈,10写多个寄存器。日常参数下发用06就够,批量初始化配方参数时用10一次写入一堆连续寄存器,效率高很多。

public byte[] BuildWriteMultipleRequest(byte unitId, ushort startAddr, ushort[] values) { int dataBytes = values.Length * 2; // 帧长 = MBAP头7字节 + 单元号1 + 功能码1 + 起始地2 + 数量2 + 字节数1 + 数据N byte[] frame = new byte[12 + dataBytes]; _transactionId++; frame[0] = (byte)(_transactionId >> 8); frame[1] = (byte)(_transactionId & 0xFF); frame[2] = 0x00; frame[3] = 0x00; // 长度字段 = 单元号1 + 功能码1 + 地址2 + 数量2 + 字节数1 + 数据N int length = 7 + dataBytes; frame[4] = (byte)(length >> 8); frame[5] = (byte)(length & 0xFF); frame[6] = unitId; frame[7] = 0x10; // 功能码:写多个保持寄存器 frame[8] = (byte)(startAddr >> 8); frame[9] = (byte)(startAddr & 0xFF); frame[10] = (byte)(values.Length >> 8); frame[11] = (byte)(values.Length & 0xFF); frame[12] = (byte)dataBytes; // 数据字节数 for (int i = 0; i < values.Length; i++) { frame[13 + i * 2] = (byte)(values[i] >> 8); frame[14 + i * 2] = (byte)(values[i] & 0xFF); } return frame; }

注意length的计算:MBAP头里长度字段是从单元号开始算的,所以是“1(单元号)+1(功能码)+2(起始地址)+2(数量)+1(字节数)+dataBytes”,即7+dataBytes。很多人在长度上多算或少算一个字节,从站返回的异常码是0x03(非法数据值),排查时先检查这里。

写单个寄存器的功能码06帧长只有12字节,比写多个简单得多,但要注意:写单个完成后从站会回显完整请求帧,不是空响应;写多个只回显“事务ID+协议ID+长度+单元号+功能码+起始地址+数量”,共12字节。响应解析逻辑不同,别拿同一个函数去套。

3.3 超时与重试:为什么重试时必须换新事务ID

TcpClient自带的ReadTimeout在UDP和连接断开场景下表现不可靠,尤其是PLC断电重启的瞬间,同步读可能直接抛IOException而不是超时。更稳妥的做法是读写操作都走自定义超时,用ManualResetEvent或CancellationToken实现,重试逻辑单独封装。

public ushort[] ReadRegistersWithRetry(byte unitId, ushort startAddr, ushort count, int retryTimes = 3) { Exception lastException = null; for (int i = 0; i < retryTimes; i++) { try { // 每次循环都重新构造请求,事务ID会继续递增,不会复用旧帧 byte[] request = BuildReadRequest(unitId, startAddr, count); byte[] response = ExecuteTransaction(request, timeoutMs: 500); return ParseReadResponse(response); } catch (Exception ex) { lastException = ex; // 重试前清空TCP接收缓冲区,把上次可能残留的半包数据丢掉 ClearTcpBuffer(); Thread.Sleep(100); } } throw lastException; }

重试的关键是绝不能复用同一个byte[]数组。如果重试时把上一帧原样再发一次,事务ID还是同一个,汇川从站会把这一帧当作重复请求悄悄丢弃,你只能一直等到超时。事务ID就像TCP的序列号,必须全局唯一并且递增。

超时时间的选择也有讲究。500ms对局域网内的PLC通讯足够,车间现场如果经过多级交换机,建议放大到1000ms。重试次数3次是底线,超过3次还在失败,应该切换为重新连接而不是继续重试,因为此时大概率是物理链路断了,再试只是浪费CPU。

提示:ExecuteTransaction内部要同时处理Socket异常、超时和半包读取。半包需要通过累积缓冲区处理,因为一帧MODBUS TCP响应可能分两次到达,TCP是流协议不是报文协议。

4. 汇川PLC寄存器映射与数据类型:地址和字节序搞对再谈通讯

4.1 寄存器地址映射:%MW、%MX与MODBUS地址的换算

汇川PLC里变量地址和MODBUS地址不是总能直接画等号。%MW是最常用的保持寄存器区,H5U和AM系列的%MW0通常对应MODBUS地址0,但老款H系列或者开了“地址偏移”选项后会整体错位。开始写代码之前,先把编程软件里“Modbus TCP从站映射表”截图存档,所有地址换算以那张表为准。

汇川数据区数据类型典型MODBUS地址说明
%MW0~%MW9999WORD保持寄存器0~9999参数读写最常用
%MDxDINT双字按字地址顺延占2个寄存器,注意高低字顺序
%MFxREAL浮点按字地址顺延IEEE754格式,占2个寄存器
%MXx.y位/线圈视型号映射有些型号需要单独映射到线圈区
%QWxWORD输出视型号映射和%MW不同段,小心重叠

我遇到过这样一个现场:HMI上配置的地址是%MW100,PLC编程软件里的映射表显示MODBUS地址也是100,但C#上位机读100一直返回0。最后打开PLC侧配置才发现,从站设置里勾选了“Modbus地址偏移1”,实际要读101才对应%MW100。这种坑查代码查不出结果,必须回到PLC侧看配置。

线圈%MX的映射更麻烦。PLC里一个字节的位地址通常是%MX10.0到%MX10.7,映射到MODBUS线圈地址时,有些固件按位展开,有些固件按字展开,位序还可能反转。读线圈的报文本身很简单,功能码01或02,但解析回来的字节数组需要按位拆包,位序错了数值就全错。

4.2 浮点数与32位整数的拆装:高字在前还是低字在前

汇川PLC的DINT和REAL都占两个寄存器。麻烦的是,C#的BitConverter在小端机器上处理字节序的方式和PLC内部的大端布局不一样,直接拿寄存器值硬拼会得到完全错误的浮点数。下面是可靠的转换方式。

/// <summary> /// 两个寄存器合成浮点数 /// </summary> public static float RegistersToFloat(ushort high, ushort low) { // reg[0]是高位寄存器,reg[1]是低位寄存器,这是汇川默认布局 uint bits = ((uint)high << 16) | low; return BitConverter.ToSingle(BitConverter.GetBytes(bits), 0); } /// <summary> /// 浮点数拆成两个寄存器 /// </summary> public static (ushort high, ushort low) FloatToRegisters(float value) { byte[] bytes = BitConverter.GetBytes(value); // 小端机器上 bytes[0]/[1] 是浮点的低16位,bytes[2]/[3] 是高16位 ushort high = (ushort)((bytes[3] << 8) | bytes[2]); // 对应reg[0] ushort low = (ushort)((bytes[1] << 8) | bytes[0]); // 对应reg[1] return (high, low); }

逻辑说明:IEEE754单精度浮点共4字节,x86机器上BitConverter.GetBytes返回小端序列b0、b1、b2、b3,其中b3是最高字节。汇川PLC寄存器按大端存储,高字放进第一个寄存器,低字放进第二个。RegistersToFloat把high左移16位拼上low,就得到了完整的32位浮点位模式,再交给BitConverter解析。

参数说明:high是PLC侧第n个寄存器的值,low是第n+1个寄存器的值,不是反过来的。如果现场发现浮点数值是对的但符号位或指数错乱,大概率就是高低字对调了。DINT的处理方式一样,只是拼接完直接强转int,不需要经过浮点位模式。

4.3 批量读取:一次读60个寄存器比循环60次快一个数量级

MODBUS TCP的局域网往返时间大概0.5到2毫秒,读一个寄存器和读60个寄存器的时间几乎一样,因为报文长度差异只有几十字节。把参数规划成连续寄存器块,一次批量读回来,再按偏移拆成工程变量,是提升采集效率最直接的手段。

public Dictionary<string, object> ReadParamGroup() { // 上位机与PLC约定:%MW200开始连续60个字,全部是设备参数 ushort[] raw = ReadRegistersWithRetry(0xFF, 200, 60, retryTimes: 3); // 按协议拆变量:2个字放运行速度,2个字放目标位置,1个字放报警字…… return new Dictionary<string, object> { ["运行速度"] = RegistersToFloat(raw[0], raw[1]), ["目标位置"] = RegistersToFloat(raw[2], raw[3]), ["当前报警字"] = raw[4] }; }

建议一次读60个寄存器而不是125个,理由有两个:一是避免超出某些老款固件单帧处理的缓冲区限制,二是如果里面混着掉电保持区与非保持区,分组规划时要留出呼吸空间。PLC侧规划变量时尽量把同类型、同用途的参数排在一起,上位机维护一个地址分配表,比读一个变量定义一个地址要清晰得多。

批量读之后要做的第一件事不是解析数据,而是校验返回的寄存器数量是否等于预期数量。从站响应数据字节数不对时,直接按固定偏移解析会把整帧数据解释成乱码,而且这种错误不会抛异常,只会得到一堆看起来合理但实际不正确的数值。

5. 避坑记录:五个通讯故障的现象、原因与排查路径

5.1 偶发超时:事务ID重复导致从站丢弃请求

现象:程序每运行十几分钟出现一次超时异常,重试一次就恢复,不影响整体功能但日志很难看。

原因:上位机里有多个线程触发读写,每个线程各自维护了一套事务ID计数,两个线程产生相同的事务ID。汇川从站认为事务ID重复的帧是重复请求,直接丢弃不响应,客户端等到超时。

解决:事务ID改成全局唯一,用Interlocked.Increment保证原子性;重试时调用BuildReadRequest重新构造报文,不能复用旧帧。排查方式是给每帧的响应打时间戳,看超时是否集中在某个写入操作附近。

5.2 数据错乱:32位数据的高低字顺序搞反

现象:写入DINT数值100,PLC侧读出来是65546这种莫名奇妙的数。

原因:DINT占两个寄存器,上位机把低位寄存器放进第一个,高位寄存器放进第二个,和PLC内部布局相反。MODBUS寄存器本身是大端,但“哪个寄存器是高字节”由PLC厂商定义,汇川绝大多数型号是高字在前。

解决:写数据前先读一次该地址的原始寄存器值,把两个寄存器的十进制值做对比,数值大的那个是高字。确认后再用4.2节的FloatToRegisters风格去拼装。这类问题最容易出现在从别的品牌PLC项目移植过来的代码里。

5.3 地址偏移:按HMI地址写MODBUS请求,读出来全是0

现象:HMI上显示%MW100的变量值是123,C#读MODBUS地址100返回0,读101才读到123。

原因:PLC侧“Modbus从站配置”里启用了地址偏移。地址偏移选项在某些固件里默认开启,偏移量通常是1,导致%MW100映射到MODBUS地址101。

解决:以PLC编程软件映射表为准,不要以HMI地址为准。排查时用Modbus Poll之类工具手动扫一段地址范围,找出实际有数据的地址区间,再把上位机地址表整体对齐。

5.4 数据不刷新:TCP缓冲残留旧响应

现象:PLC变量值和上位机显示值不一致,点击刷新按钮有时能恢复,有时不能。

原因:TCP接收缓冲区里有上次响应的残留字节,下一帧响应到达后和残留数据拼在一起,解析器按固定偏移截取,截出来的数据是错位的。特别是请求超时后没有清空缓冲区就发下一帧,最容易触发。

解决:每次重试前调用ClearTcpBuffer清空接收缓存;解析响应前强制校验事务ID,不匹配直接丢弃整个字节数组。排查时在解析函数入口打印响应前4字节,对比请求的事务ID,一眼就能看出错位。

5.5 PLC重启后上位机卡死:同步读阻塞与断线重连

现象:现场断电检修后PLC恢复,上位机界面卡住,CPU占用不高但任何操作都没反应。

原因:TcpClient的同步Receive阻塞在等待数据上,PLC断电时链路没有正常发FIN包,客户端不知道连接已死,ReadTimeout在某些网络异常场景下不会触发。

解决:读操作统一走带超时的ExecuteTransaction,超时后主动关闭Socket并重新连接;业务层加心跳轮询,一个看门狗线程定时读一个固定寄存器,连续3次失败就触发重连。从那以后我每个上位机项目都强制带看门狗线程,不再信任裸TCP的长连接。

6. 进阶落地:事件分发、写队列与断线重连

把第3章的通讯层代码单独封装成类之后,下一步是解决“怎么和UI界面协同”的问题。直接让按钮事件里去调用ReadRegisters会卡界面,用BackgroundWorker又容易在窗体关闭时抛出ObjectDisposedException。我一般会在通讯层之上加一个事件总线的壳,读写操作全部异步化,UI只订阅事件。

public class PlcDataService { // 数据更新事件:参数名 + 寄存器原始值数组 public event Action<string, ushort[]> OnDataUpdated; public async Task PollLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { ushort[] values = _plc.ReadRegistersWithRetry(0xFF, 200, 60); OnDataUpdated?.Invoke("产量参数块", values); } catch (Exception ex) { // 连续失败3次,触发ReconnectRequested,由外部断线重连 ReconnectRequested?.Invoke(ex); } await Task.Delay(100, token); } } }

订阅事件的一方在UI线程里更新文本框和曲线,天然规避了Control跨线程访问的问题。接口层会按照这个思路演化成一个完整的C#上位机通用框架:扫描周期可配置、参数块地址用配置表维护、写入操作做成队列避免多个线程同时往一个地址写。

写入队列的做法是:所有写请求放进ConcurrentQueue,由独立线程串行消费,每次写入后等待响应或超时再处理下一条。这样即使多个界面对同一个寄存器发写命令,也不会出现两条指令交错的脏数据。断线重连在事件分发之外单独做,重连成功后重新拉一次全量参数,保持界面数据新鲜。

这套源码最值钱的部分不是那几十个函数,而是事务ID管理、批量读取、重试策略这些经过现场验证的组合方式。我接手过的项目里,有照着网上Demo随便拼的,第一版都能跑,三个月后全在改超时和字节序的坑。现在新项目我会直接把通讯层独立成一个DLL工程,UI永远不直接持有TcpClient对象,数据变更全走事件,写操作全进队列——刚从这套源码迁移过来时觉得绕,后来发现排除问题省了一半时间。希望帮到你。

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

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

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

立即咨询