简介:一套面向工控开发者和C#程序员的ModbusTcp通讯源码,基于西门子1200PLC设计,完整覆盖Modbus协议全部八种功能码的读写操作,既适合刚接触PLC通信的新手对照学习,也能为有经验的开发人员提供可直接改造的通讯模块,可应用于上位机软件、数据采集、设备监控等场景。压缩包共47个文件,核心为13个C#源文件,配合Visual Studio工程文件可快速编译,另有XML配置、TXT说明、EXE可执行程序和PDB调试符号,整体仅14.12MB。目前已有2831人学习下载。源码对功能码的报文构造与解析逻辑做了清晰封装,并配有可运行的示例界面,能直观体验与西门子1200PLC的联调过程;对于需要快速落地以太网通讯的自动化项目,这套代码可显著降低从零开发、联调排错的时间成本,也方便在此基础上扩展自定义业务。 做上位机开发这些年,经常有同行问我:想用C#和西门子1200PLC通讯,到底走什么协议最省事?有人一上来就学S7协议,觉得西门子必须用它,结果被PDU、TSAP这些概念绕晕。其实如果你的场景是中高速数据采集、设备状态监控、参数下发,ModbusTCP完全够用,而且C#实现起来非常直接。这篇就把我实际项目中用C#通过ModbusTCP和西门子1200PLC通讯的完整思路、源码、踩坑记录都写出来。
我会从协议本身讲起,再到PLC侧的组态要点,最后给一份可以跑的C#源码示例,并重点说说轮询频率、UI卡顿、扫码枪触发这类高频问题。不管你是刚接触上位机的新人,还是被PLC通讯折磨过的老手,这篇应该都能给你省点时间。
1. 整体设计思路与方案选型
1.1 为什么选ModbusTCP而不是S7协议
先说结论:能用ModbusTCP的地方,我一般优先选它,哪怕对面是西门子1200。原因有三点:
- 协议简单,调试成本低。ModbusTCP就是把Modbus RTU报文塞进TCP包里,报文结构一目了然,你用串口调试助手都能看懂。S7协议要处理TPKT、COTP、S7COMM多层封装,抓包看起来都费劲。
- 跨平台、跨语言友好。以后你想换成Java、Python、Go写采集端,或者接第三方组态软件,ModbusTCP几乎是公认的“通用语言”,不用重新设计通讯层。
- 1200对ModbusTCP支持很成熟。从固件V4.0开始,TIA Portal里可以直接调用MB_SERVER指令,把1200配置成Modbus从站,不需要额外硬件模块。
那什么时候不选它?如果你需要读写1200的DB块里的Bool、Real、String等复杂数据类型,并且对实时性要求极高,那还是用S7协议更顺手,因为ModbusTCP的保持寄存器和线圈本质上还是映射到PLC存储区,结构复杂时映射关系会很绕。但对绝大多数工业数据采集场景,ModbusTCP足够了。
1.2 通讯方案架构与数据流设计
我常用的架构是:PLC作为ModbusTCP从站(Server),C#上位机作为主站(Client)。上位机主动轮询PLC的保持寄存器,读取设备状态、温度、压力等数据;上位机也可以写入寄存器,实现参数下发、启停控制。
整体数据流是这样的:
- 采集层:PLC把传感器、变频器、执行机构的数据汇总到固定地址的保持寄存器区。
- 传输层:C#上位机通过ModbusTCP周期性读取这些寄存器,数据以字节流形式在TCP连接上传输。
- 应用层:上位机解析字节流,转换成温度、压力、速度等工程值,绑定到UI界面或存入数据库。
- 控制层:上位机把操作指令(如启动、停止、设定值)写入PLC指定的保持寄存器,PLC检测到数值变化后执行对应动作。
这种架构的好处是职责清晰。PLC只负责“被读”和“被写”,上位机掌握主动权,不需要PLC频繁主动上报,简单可靠。需要注意的是,ModbusTCP最多建议单连接轮询,多线程同时读写同一连接时要自己加锁。
2. 与西门子1200PLC通讯的前置准备
2.1 确认PLC固件版本和软件环境
动手之前一定先确认两件事:
- 1200PLC的固件版本。我见过不少项目还在用V3.0甚至更老的固件,固件版本太老的话TIA Portal里根本没有MB_SERVER指令,需要升级固件才支持ModbusTCP。建议至少V4.0以上。
- TIA Portal版本。我用的比较多的是V15.1和V16,不同版本界面略有差异,但指令位置差不多。
另外,PLC和电脑的IP地址要在同一网段。比如PLC设192.168.0.1,电脑设192.168.0.10,子网掩码255.255.255.0。这是最容易被忽略的坑——代码写好了,连不上,查了半天发现是IP不在一个网段。
2.2 在TIA Portal中配置ModbusTCP从站
在TIA Portal里把1200配置成ModbusTCP从站,核心就三步:
第一步,在程序块中调用MB_SERVER指令。这个指令位于“指令 > 通信 > 开放式用户通信”下,也有的版本在“通讯处理器”里,直接搜“MB_SERVER”最快。
第二步,配置MB_SERVER的背景DB和连接参数。MB_SERVER指令有几个关键引脚:
- DISABLE:0表示启用服务。
- MB_HOLD_REG:指向保持寄存器数据块,这里要单独建一个DB,比如DB100,里面定义好WORD数组,上位机读写的就是这个DB。
- CONNECT:指向连接描述结构,一般用系统生成的TCON_IP_v4结构,填入PLC的IP地址和端口502。
- NDR、DR、ERROR等:状态输出,用于诊断。
第三步,下载组态到PLC并监控。MW_SERVER启动后,你可以用TCP调试助手从电脑连一下PLC的502端口,如果连接成功,说明PLC侧已经就绪。
这里有我踩过的一个坑:MB_HOLD_REG指向的DB块,建议把“优化块访问”属性取消勾选。因为ModbusTCP是按地址偏移访问的,优化块访问会把地址隐藏掉,你根本不知道寄存器映射到哪个偏移,通讯会莫名其妙失败。建DB块时记得右键属性,在“属性 > 属性 > 优化的块访问”里取消勾选。
2.3 地址映射与寄存器规划
ModbusTCP的数据模型分四类:线圈(Coil)、离散输入(Discrete Input)、输入寄存器(Input Register)、保持寄存器(Holding Register)。和1200通讯时,最常用的是保持寄存器(功能码03读、06写单个、16写多个)。
保持寄存器的地址是从0开始计数的,对应Modbus协议中的40001地址区。怎么和PLC的DB块对应?
默认情况下,MB_SERVER指令的MB_HOLD_REG指向的DB偏移0就是Modbus地址0。比如你在DB100中定义了:
- 第1个WORD(偏移0):设备状态字,对应Modbus地址0(40001)。
- 第2个WORD(偏移2):温度值,对应Modbus地址1(40002)。
- 第3和第4个WORD(偏移4和6):累加器值(32位),对应Modbus地址2和3(40003和40004)。
注意,在TIA中WORD是按字节编址的,但Modbus寻址是按寄存器(16位)寻址的。很多新人在这里搞混:以为偏移2对应Modbus地址2,其实偏移2字节是第2个WORD,对应Modbus地址1。这个映射关系一定要捋清楚。
3. ModbusTCP协议解析与MBAP头详解
3.1 MBAP头的7个字节
ModbusTCP报文和串口Modbus最大的区别就是TCP报文前面多了一个MBAP报文头(Modbus Application Protocol Header)。这个头固定7个字节,具体结构如下表:
| 字段 | 长度 | 说明 |
|---|---|---|
| 事务处理标识符 | 2字节 | 每次请求的流水号,响应报文中会原样返回,用于匹配请求和响应,一般从0开始递增 |
| 协议标识符 | 2字节 | 固定为0x0000,表示Modbus协议 |
| 长度 | 2字节 | 表示后面字节的数量,等于单元标识符字节数+PDU字节数 |
| 单元标识符 | 1字节 | 相当于串口Modbus的从站地址,西门子默认填1或255,要和PLC侧配置一致 |
MBAP头的长度字段比较坑,它只算“单元标识符 + PDU”的长度,不含MBAP本身。比如一个读保持寄存器的请求,功能码1字节+起始地址2字节+寄存器数量2字节,一共5字节PDU,加上单元标识符1字节,长度就是6(0x0006),所以发过去的报文是:00 01 00 00 00 06 01 03 00 00 00 0A。
我见过很多新手自己组报文时把长度算成整个报文的长度,结果PLC直接不响应。其实你把事务标识符、协议标识符、长度这三个字段填对,报文基本就对了八成。调试时可以用Wireshark抓包,对照MBAP头逐个字段看,哪里不对一目了然。
3.2 常用功能码与报文示例
ModbusTCP常用的功能码就这几个:
- 0x03:读保持寄存器。请求PDU:功能码+起始地址+寄存器数量,响应PDU:功能码+字节数+寄存器数据。
- 0x06:写单个保持寄存器。请求PDU:功能码+寄存器地址+要写入的值。
- 0x10(16):写多个保持寄存器。请求PDU:功能码+起始地址+寄存器数量+字节数+数据。
- 0x01:读线圈。对应PLC的位存储区。
- 0x05:写单个线圈。
- 0x0F(15):写多个线圈。
举个例子,读从地址0开始的10个保持寄存器,请求报文是:
00 01 00 00 00 06 01 03 00 00 00 0APLC正常响应就会返回:
00 01 00 00 00 17 01 03 14 [20个字节数据]其中0x14(20)是数据字节数,因为10个寄存器每个占2字节,一共20字节,后面跟着20字节的寄存器值。
写单个保持寄存器(把0x0001写入地址0),请求报文:
00 02 00 00 00 06 01 06 00 00 00 01响应报文是请求的镜像,PLC处理成功后原样返回。如果返回的报文和你发的不一致,基本就是写失败了,多半是寄存器地址超出范围或者写保护了。
3.3 字节序问题:大端与小端
字节序是Modbus通讯中最容易翻车的点,没有之一。ModbusTCP规定多字节数据按大端(Big-Endian)传输,也就是高字节在前。
比如你要从PLC读到一个UInt16数值0x1234,报文里就是12 34,C#里用BitConverter.ToUInt16(buffer, 0)直接转,得到的就是0x1234,没问题。
但如果你读的是32位数据,比如Float或者DInt,情况就复杂了。PLC里一个32位数据占用两个寄存器,比如数值1.0在IEEE754中表示为0x3F800000,Modbus传输顺序可能是:
3F 80 00 00(按寄存器顺序,第一个寄存器是高16位3F80,第二个是低16位0000)C#里如果直接把四个字节按BitConverter.ToSingle转换,得到的结果是对的,因为字节流刚好是按大端顺序排列的。但如果PLC内部数据存储是低字在前(西门子默认是Big-Endian,但不同品牌的PLC可能不一样),那就需要交换字的顺序。
这就是为什么很多C#代码里有这样一个函数:
static float SwapUInt16ToFloat(byte[] data, int startIndex) { byte[] temp = new byte[4]; temp[0] = data[startIndex + 2]; temp[1] = data[startIndex + 3]; temp[2] = data[startIndex]; temp[3] = data[startIndex + 1]; return BitConverter.ToSingle(temp, 0); }新手阶段经常在这上面耗掉一整天,调来调去发现数值就是不对。我的建议是:先写一个小工具,把所有寄存器原始字节打出来,对照PLC里的监控值,你就能快速确认是字序问题还是字节序问题。字节序这种事纸上谈兵没用,必须以实际报文为准。
4. C#核心实现与源码级案例
4.1 用Socket手写ModbusTCP客户端
我不想一上来就丢一个库给你,因为只有手写一遍报文,你才能真正理解协议。等理解了,再用库也不迟。
我封装了一个最简单的ModbusTCP客户端类,核心就四件事:连接、组报文、发报文、解析响应。
先看连接部分:
using System.Net.Sockets; public class ModbusTcpClient { private TcpClient _tcpClient; private NetworkStream _stream; private ushort _transactionId = 0; public bool Connect(string ip, int port = 502) { try { _tcpClient = new TcpClient(); _tcpClient.Connect(ip, port); _stream = _tcpClient.GetStream(); _tcpClient.NoDelay = true; // 禁用Nagle算法,减少延迟 return true; } catch (Exception ex) { Console.WriteLine($"连接失败: {ex.Message}"); return false; } } public void Disconnect() { _stream?.Close(); _tcpClient?.Close(); } }然后读取保持寄存器的核心方法:
public ushort[] ReadHoldingRegisters(byte unitId, ushort startAddress, ushort count) { // 组装MBAP头 + PDU byte[] request = new byte[12]; _transactionId++; request[0] = (byte)(_transactionId >> 8); // 事务标识符高字节 request[1] = (byte)(_transactionId & 0xFF); // 事务标识符低字节 request[2] = 0x00; // 协议标识符高字节 request[3] = 0x00; // 协议标识符低字节 request[4] = 0x00; // 长度高字节 request[5] = 0x06; // 长度低字节:单元标识符1 + PDU 5 = 6 request[6] = unitId; request[7] = 0x03; // 功能码:读保持寄存器 request[8] = (byte)(startAddress >> 8); request[9] = (byte)(startAddress & 0xFF); request[10] = (byte)(count >> 8); request[11] = (byte)(count & 0xFF); // 发送请求 _stream.Write(request, 0, request.Length); // 读取响应头(先读9个字节:MBAP头7个 + 功能码1个 + 字节数1个) byte[] header = new byte[9]; int readCount = ReadExactly(header, 9); if (readCount < 9) throw new Exception("响应不完整"); int dataLength = header[8]; // 返回的字节数 byte[] data = new byte[dataLength]; readCount = ReadExactly(data, dataLength); if (readCount < dataLength) throw new Exception("数据不完整"); // 解析成 ushort 数组 ushort[] result = new ushort[dataLength / 2]; for (int i = 0; i < result.Length; i++) { result[i] = (ushort)((data[i * 2] << 8) | data[i * 2 + 1]); } return result; }注意,上面用了一个ReadExactly方法,原因是NetworkStream.Read不保证一次把数据读完,尤其数据量大的时候,可能分好几次到达。ReadExactly的逻辑是循环读,直到凑够指定字节数。这一点特别重要,我在项目里见过不少人直接用一次Read就解析,结果偶发性地解析出错,就是没处理半包、粘包问题。
ReadExactly的实现非常简单:
private int ReadExactly(byte[] buffer, int count) { int totalRead = 0; while (totalRead < count) { int read = _stream.Read(buffer, totalRead, count - totalRead); if (read == 0) throw new Exception("连接被关闭"); totalRead += read; } return totalRead; }4.2 写寄存器的完整实现
写保持寄存器分单写和多写。单写寄存器(功能码0x06)一次只能写一个寄存器,适合写启动命令、模式切换这种单点控制。多写寄存器(功能码0x10)适合下发一组参数,比如温度设定值、PID参数。
单写寄存器:
public bool WriteSingleRegister(byte unitId, ushort address, ushort value) { byte[] request = new byte[12]; _transactionId++; request[0] = (byte)(_transactionId >> 8); request[1] = (byte)(_transactionId & 0xFF); request[2] = 0x00; request[3] = 0x00; request[4] = 0x00; request[5] = 0x06; request[6] = unitId; request[7] = 0x06; request[8] = (byte)(address >> 8); request[9] = (byte)(address & 0xFF); request[10] = (byte)(value >> 8); request[11] = (byte)(value & 0xFF); _stream.Write(request, 0, request.Length); byte[] response = new byte[12]; int readCount = ReadExactly(response, 12); if (readCount < 12) throw new Exception("响应不完整"); // 正常响应是请求的镜像,判断事务标识符和功能码 return response[7] == 0x06; }多写寄存器更复杂一点,报文的长度字段要动态计算。比如写3个寄存器,PDU部分是:功能码1字节+起始地址2字节+数量2字节+字节数1字节+数据6字节,一共12字节,加上单元标识符1字节,长度字段就是13(0x000D)。
这里要特别注意:MBAP的长度字段在发送前必须正确设置,这个数值直接决定了PLC怎么解析后面的PDU。我之前遇到过一个问题:长度字段多算了MBAP本身,结果请求发出去了PLC没反应,抓包才发现长度算错了,白白浪费了半个小时。
4.3 循环轮询与多线程安全
工业上位机很少只读一次数据,通常都是定时轮询。轮询有两种实现方式:定时器驱动和后台线程死循环。
我推荐用后台线程 +while循环 +Thread.Sleep组合,因为定时器在UI线程里容易卡,而且定时器事件如果执行时间超过间隔,会引发重入问题。后台线程的示例:
private bool _isRunning; private Thread _pollThread; public void StartPolling(int intervalMs) { _isRunning = true; _pollThread = new Thread(() => { while (_isRunning) { try { ushort[] data = _client.ReadHoldingRegisters(1, 0, 20); OnDataReceived?.Invoke(data); } catch (Exception ex) { // 记录日志,连续失败多次可以尝试重连 OnError?.Invoke(ex.Message); } finally { Thread.Sleep(intervalMs); } } }); _pollThread.IsBackground = true; _pollThread.Start(); }这里有个很容易被忽视的问题:轮询频率设置。我看到很多人图快,把轮询间隔设成50ms甚至10ms。ModbusTCP本身支持这么高频的请求,但你要想清楚两个后果:
- PLC侧如果同时还在跑逻辑程序,高频读写会增加PLC通讯负载,影响程序扫描周期。
- 如果多个上位机功能模块都在轮询同一个PLC,又没有做好协调,请求会互相干扰,甚至出现“覆盖其他数据”的现象。
我自己的经验是:常规数据采集500ms轮询一次就足够了,需要快速响应的急停信号走硬件IO,不要指望以太网轮询来保命。如果确实需要毫秒级响应,那就用TCP推送或者西门子的S7协议通讯,而不是Modbus轮询。
另外,如果多个线程同时调用_client的读写方法,一定要加锁,否则事务标识符会乱掉。最简单的做法是给读写方法加lock:
private readonly object _lockObj = new object(); public ushort[] ReadHoldingRegisters(byte unitId, ushort startAddress, ushort count) { lock (_lockObj) { // 原有逻辑 } }你不加锁,两个线程同时发请求,事务标识符都变成同一个,响应回来了都不知道是谁的。这块是我在项目里真正踩过的坑,之前做的一个多线程采集系统,偶发数据错乱,查了整整一天,最后定位到是这个锁的问题。
4.4 扫码枪触发事件与UI刷新卡顿
热词里有人提到“扫码枪触发事件”,这在实际项目中特别常见。扫码枪本质上是一个输入设备,它的工作模式一般有两种:模拟键盘输入和串口/网口通讯。
如果是模拟键盘输入的扫码枪,最简单的方式是让扫码枪把光标焦点定位在一个TextBox上,扫码成功后触发TextChanged或KeyDown事件。我在项目里常用的做法是:
private void txtBarcode_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.Enter) { string barcode = txtBarcode.Text.Trim(); // 触发业务处理 ProcessBarcode(barcode); txtBarcode.Clear(); txtBarcode.Focus(); // 保持焦点,方便连续扫描 } }如果是串口或网口扫码枪,那就得在后台线程里监听数据,收到完整条码后触发一个事件。这种场景下要注意:扫码枪数据是异步到达的,事件回调里不能直接操作UI控件,必须通过Invoke或BeginInvoke切换到UI线程。
说到UI刷新,这是C#上位机开发的热门问题。很多新手踩过的坑是:直接在后台轮询线程里更新Label.Text或DataGridView,结果界面卡成PPT,甚至直接闪退。原因是UI控件只能在UI线程中修改,跨线程操作要么抛异常要么被框架自动丢弃。
我推荐的模式是:后台线程只负责收集数据并触发事件,UI线程通过BeginInvoke更新界面。
private void OnDataReceived(ushort[] data) { if (this.InvokeRequired) { this.BeginInvoke(new Action<ushort[]>(OnDataReceived), data); return; } label1.Text = data[0].ToString(); label2.Text = data[1].ToString(); }比Invoke更高效的是BeginInvoke,因为Invoke是同步等待UI线程处理完才返回,如果UI卡了,后台采集也会被拖住。BeginInvoke是异步投递消息,后台线程不用等,采集节奏不会受UI影响。
还有一个更高效的方案:如果你用System.Windows.Forms.Timer做定时刷新,可以设置100ms刷新一次UI,但数据采集是500ms一次。让采集和UI解耦,显示永远比采集慢一拍,但界面流畅度会好很多。这种“生产-消费”模型在高频采集场景下尤为重要。
5. 常见问题与排查技巧实录
5.1 轮询读取频率过高导致数据覆盖
热词里有一条“西门子1200plc进行modbus轮询读取频率会覆盖其他数据”,这确实是很多人遇到的真实问题。我拆解一下产生原因和解决办法。
所谓“覆盖其他数据”,我理解为两种情况的统称:
第一种情况,上位机轮询太快,PLC的Modbus服务程序还没处理完上一个请求,下一个请求就到了。西门子1200的ModbusTCP服务是分时处理的,高频请求会导致服务队列拥堵,表现为某些请求响应超时或者响应错乱。
第二种情况更隐蔽:你轮询读取的寄存器区域,和PLC逻辑程序正在写入的寄存器区域重叠了。比如PLC程序周期性更新DB100的某些字,然后上位机也去读DB100,读到的数据是“读到一半被PLC更新”,可能读到新旧混合的数据。这不是轮询频率的问题,而是数据一致性问题。
解决办法有三个级别:
- 最简单:降低轮询频率,500ms以上一般能避开绝大多数问题。
- 中等做法:轮询时不直接读业务数据区,而是把需要上传的数据先由PLC程序复制到“通讯缓冲区”,上位机只读缓冲区,避免读到PLC正在更新的半成品数据。
- 进阶做法:在PLC中使用“数据快照”机制,上位机先写一个命令寄存器请求“锁定数据”,PLC收到后把当前数据整体复制一份,上位机再读复制区域。
这个问题的本质是“生产数据”和“传输数据”的竞争,你在设计PLC程序时就要把这两块分开,不要指望靠网络协议来保证一致性。
5.2 通信超时与断线重连机制
ModbusTCP走的是TCP协议,本身就带重传机制,但工业现场电磁环境复杂,网线松动、交换机故障都会导致连接断开。一旦连接断开,TCP不会自动恢复,你必须处理断线重连。
我常用的检测机制是:每次读写操作设置超时时间。TCP默认的超时可能几百毫秒甚至几秒,太慢了。C#里可以这样设置:
_tcpClient.ReceiveTimeout = 1000; // 1秒 _tcpClient.SendTimeout = 1000;如果读写抛出异常,或者ReadExactly返回长度不够,就认为连接异常,进入重连逻辑。重连不能太频繁,我一般用“指数退避”策略:第一次等1秒、第二次等2秒、第三次等4秒,最大30秒,避免PLC重启过程中上位机疯狂重连把PLC通讯资源耗尽。
还有一个细节:重连成功之后,一定要重新初始化状态,比如把_transactionId重置为0,清空缓冲区。不然上一个连接残留的数据会污染新的连接。
5.3 连接数限制与多个客户端同时访问
热词里有人问“c# tcp连接数量多少”,这个在ModbusTCP场景下值得展开一下。
西门子1200做ModbusTCP从站时,默认支持的最大连接数是有限的。以1200为例,不同型号和固件版本支持的开放式通信连接数在8到16个左右。如果你同时开了多个上位机软件、HMI、编程软件,连接数会被占满,新的连接请求会被PLC拒绝。
遇到过一种情况:上位机程序没写好,每失败一次就重新new一个TcpClient去连接PLC,旧连接没有释放,几分钟后PLC的连接资源就被耗尽了,表现为“连不上”“通讯失败”,但是重启上位机后又好了。这就是典型的连接泄漏。
解决思路:全项目共享一个ModbusTcpClient实例,不要每个窗口都创建连接。用单例模式管理连接,重连也是复用同一个实例。另外,上位机正常退出时一定要调用Disconnect()释放连接,否则TCP连接要等超时才被系统回收。
5.4 地址偏移和数据类型转换中的坑
最后集中说一下地址偏移和数据类型转换的问题。
Modbus的地址编号从0开始,但很多人习惯用40001表示的地址,于是代码里就写成ReadHoldingRegisters(1, 40001, 10),然后发现PLC返回异常。这是概念理解错误:Modbus协议报文里的地址就是0开始的偏移,40001只是“逻辑地址”,上位机发送时应该减去40001映射到0。又比如你在PLC侧的保持寄存器定义为DB100.DBW0对应地址0,那么上位机要读“第5个寄存器”,发过去的地址是4,而不是5或者40005。这个映射关系在设计PLC程序时就要固化下来,形成一张“点位表”,后续开发都按这张表来。
数据类型转换方面,读回来的ushort[],如果要转成float,要注意字节序问题。我在第3.3节已经详细说过,这里再补充一个现象:有些上位机工程师为了方便,用Encoding.ASCII.GetString去解析寄存器里的字符串,结果中文乱码。Modbus寄存器一个占16位,存字符串时通常一个字节放一个ASCII字符,高位填0,所以byte数组里会夹杂大量0x00。你要自己过滤掉0字节再转字符串,不能用Encoding.ASCII.GetString直接转整个数组。
最后再分享一个调试技巧:写一个小工具,把原始请求报文和响应报文的十六进制字节全部打印到日志里。一旦出问题,你看到的不是“通讯失败”四个字,而是完整的报文交互过程。我入行头两年查这类通讯问题基本靠猜,后来养成打印报文的习惯后,故障定位速度至少快了一倍。做工业通讯,你手里有报文,心里才不慌。
本文还有配套的精品资源,点击获取