ModBus 218

csharp 复制代码
#region 粘包/分包
 {
     Socket server=new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
     server.Bind(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9000));
     server.Listen();
     Task.Run(() =>
     {
         Socket c = server.Accept();
         List<byte> respBytes = new List<byte>();
         while (true)
         {
             byte[] data = new byte[1];
             c.Receive(data);
             respBytes.Add(data[0]);
             if (respBytes.Count >= 5)
                 respBytes.Clear();
             short len = BitConverter.ToInt16(new byte[] { data[3], data[4] }, 0);
             data = new byte[len];
             c.Receive(data);
             Console.WriteLine(Encoding.UTF8.GetString(data));


         }
     });
     Socket client=new Socket(AddressFamily.InterNetwork,SocketType.Stream, ProtocolType.Tcp);
     client.Connect(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9000));
 for(int i = 0;i<10; i++)
     {
         string msg = "hello";
         byte[] data=Encoding.UTF8.GetBytes(msg);
         List<byte> bytes = new List<byte>();
         bytes.Add((byte)i);
         bytes.AddRange(Encoding.UTF8.GetBytes("zx"));
         short len = (short)data.Length;
         bytes.AddRange(BitConverter.GetBytes(len));
         bytes.AddRange(data);
         client.Send(bytes.ToArray());
     }
     Console.WriteLine("发送完成"); 
 }
 #endregion  一句话解释:代码在干嘛,为什么报错

这段代码创建了两个UDP Socket:第一个绑定9001端口接收数据,第二个向9000端口发送广播数据,但由于端口不匹配(9000≠9001),接收方无法收到这个广播消息。

一句话解释:socket ,socket tcp,socket udp ,tcp ,udp

好的,作为一名初学者,我用一个"通信工具箱"的比喻,帮你一句话理清这些概念:

  1. Socket(套接字):编程世界里用来网络通信的"万能插座"或"对讲机"。它本身不是具体的通信方式,而是一个标准的编程接口(工具),让你能用代码通过网线或无线进行数据收发。你在代码中写的 new Socket(...) 就是在创建这个工具。

  2. Socket TCP:使用"打电话"模式的Socket。当你创建Socket时指定了 ProtocolType.Tcp,就意味着你准备用这个Socket工具,按照可靠、有连接的"打电话"规则来通信。使用前需要先 Connect 拨号建立连接,确保对方在线并能按顺序、不丢包地对话。

  3. Socket UDP:使用"发短信/广播"模式的Socket。当你创建Socket时指定了 ProtocolType.Udp,就意味着你准备用这个Socket工具,按照快速、无连接的"发短信"规则来通信。无需提前连接,直接 SendTo 把数据包发出去,但不保证对方一定能收到,也不保证按发送顺序到达。

  4. TCP(传输控制协议):"打电话"的通信规则本身。它规定了如何先"三次握手"建立可靠连接、如何保证数据不丢失不乱序、如何"四次挥手"礼貌挂断。Socket TCP 是具体实现这套规则的工具。

  5. UDP(用户数据报协议):"发短信/广播"的通信规则本身。它规定了数据被打包成一个一个独立的"数据报"直接发送,不管对方在不在线、收没收到。Socket UDP 就是实现这套规则的工具。

简单来说:TCP和UDP是通信规则(怎么传),而 Socket 是使用这些规则的编程工具。Socket TCP 和 Socket UDP 就是你手里分别用来"打电话"和"发短信"的那个具体的工具。

tcplistener

TcpListener 是 .NET 框架中专门用来创建并启动一个 TCP 服务端的对象,它像一个固定的接待员,监听指定端口,等待客户端连接,并通过 Accept 方法接收具体的连接对象(如 TcpClient 或 Socket)来进行后续通信。

这段被注释的代码演示了如何用 TcpListener 创建一个 TCP 服务端程序,就像一个固定的服务台:它在 9009 号端口"竖起耳朵"开始监听 (Start()),然后等待并接受 (AcceptTcpClient 或 AcceptSocket) 客户端的连接请求。

一句话解释:#region tcp client

{

TcpClient tcpClient = new TcpClient();

tcpClient.Connect("127.0.0.1", 9000);

NetworkStream stream = tcpClient.GetStream();

stream.Write(new byte[] { 0x01, 0x02 }, 0, 2);

byte[] buffer = new byte[2];

int count = stream.Read(buffer, 0, 2);

}

#endregion

这段代码创建了一个 TCP 客户端,就像使用一部专用的"网络电话":它先拨号连接到本机(127.0.0.1)的 9000 号端口,然后通过这条建立好的"通话线路"(NetworkStream)发送两个字节的数据(0x01, 0x02),并立即尝试从同一线路中读取两个字节的回复。

udp

csharp 复制代码
这段代码创建了一个UDP接收端,就像一个调频到9000频道的对讲机守听着:它随时准备接收该频道上任意呼叫者发来的消息,并在收到数据时记录下呼叫者的地址信息。

modbus

Modbus协议基础:

起源与地位:

Modbus协议由莫迪康(现施耐德)公司于1979年开发,是全球第一个真正用于现场的总线协议,作为一种通用语言,实现控制器之间或控制器与设备的通信。

核心特点:

开放免费:协议标准公开,无版权和许可费用。

接口灵活:支持多种电气接口(如RS232/485、以太网)和传输介质。

简单易用:格式简洁紧凑,易于理解和实施。

Modbus通信方式与数据存储模型:

通信分类:

主要分为基于串口(如RS485)的 Modbus ASCII​ 和 Modbus RTU,以及基于以太网的 Modbus TCP/UDP。

数据存储模型(核心概念):

协议将设备的数据存储区划分为4个不同的区域,类似于数据库的表,每个区域有特定的访问属性和地址范围:

线圈状态 (Coils):

地址 0XXXX,可读写的位(Bit),代表布尔量(如开关状态)。

输入离散量 (Discrete Inputs):地址 1XXXX,只读的位(Bit),通常来自传感器等输入设备。

输入寄存器 (Input Registers):地址 3XXXX,只读的16位字(Word),用于存储模拟量输入等。

保持寄存器 (Holding Registers):地址 4XXXX,可读写的16位字(Word),是最常用的数据区,可存储整型、浮点数等。

功能码(Function Codes):功能码是Modbus报文中的核心指令,用于指定操作类型。文档列出了详细的功能码表,其中最常用的包括:

01 (0x01):读线圈状态

03 (0x03):读多个保持寄存器

04 (0x04):读输入寄存器

05 (0x05):写单个线圈

06 (0x06):写单个保持寄存器

10 (0x10):写多个保持寄存器

仿真测试与开发工具:

仿真工具:推荐使用 Modbus Slave(模拟从站设备)、Modbus Poll(模拟主站/上位机)以及虚拟串口工具进行协议测试和调试。

开发库:在.NET中,可以使用 NModbus​ 或 NModbus4​ 等开源库(通过NuGet获取)来简化Modbus通信程序的开发。

协议报文格式(关键差异):

Modbus RTU:采用二进制格式,报文紧凑,以CRC校验结尾。文档给出了读寄存器(03)、写单寄存器(06)、写多寄存器(10)等功能的请求与响应报文帧结构示例。

Modbus ASCII:将RTU报文的每个字节转换为两个ASCII字符进行传输,以":"(0x3A)开始,以回车换行(0x0D 0x0A)结束,使用LRC校验。

Modbus TCP:在RTU的PDU(协议数据单元)前添加了MBAP报文头(包含事务标识符、协议标识、长度和单元标识符),去除了CRC校验,依赖TCP/IP协议保证可靠性。

协议细节与异常处理:

数据异常:需要注意大小端(字节序)​ 问题,即数据在内存中的存储顺序。

异常响应:从站处理异常时,会将响应报文中的功能码最高位置1(即原功能码+0x80),并在数据区返回异常代码。

通信时序:RTU模式对报文间隔有时间要求(如3.5个字符时间)。

一句话解释:什么是大端,什么是小端

大端模式就像我们写数字"1234"时从左到右先写千位"1"(高位),小端模式则像倒着写从右到左先写个位"4"(低位)。

简单说:大端是"高位在前",小端是"低位在前",这是计算机存储多字节数据(如整数、浮点数)时的两种不同顺序。

一句话解释: 如果设备方是小端处理

csharp 复制代码
ushort transValue = BitConverter.ToUInt16(bs);
byte[] bytes = BitConverter.GetBytes(transValue);
// 向设备请求时候   C#类型-》Byte[]
// 从设备响应进行数据解析   byte[]->C#类型

这段代码就像一个"数据翻译官":当设备使用小端模式存储数据时,它负责把从设备收到的字节数组(byte[])翻译成C#能理解的数字(如ushort),或者在发送请求前,把C#的数字翻译成设备能理解的字节数组,确保双方"说同一种语言"。

一句话解释:双字操作

双字操作就是处理一个由4个字节(32位)拼成的"数据块",比如用来表示整数、无符号整数或单精度浮点数,在编程中需要特别注意这些字节的排列顺序(大端或小端)来进行正确的读写转换。

这段注释是在说明:一个4字节的"数据块"就像一个可以装不同"货物"的集装箱,它既能表示整数(如int),也能表示浮点数(如float),但在与设备通信时,必须搞清楚这些字节在集装箱里的摆放顺序(大端或小端),否则解析出的数据会完全错误,同时也要注意某些"货物"(如浮点数)本身就有特殊的装箱规则(IEEE754标准)和可能的"损耗"(精度丢失)。

按位与

假设我们从设备读回一个字节(8个位)的数据 statusByte = 0b10110110(二进制表示,相当于十进制的182)。

这8个位可能代表设备的8个不同状态,比如:

位0: 电机故障

位1: 温度过高

位2: 门已关闭

......

位3: 报警状态​ ← 我们现在只关心这个

我们的目标:判断第3位(从第0位开始数)是不是1(即是否报警)。

步骤就像您看开关面板:

制作一个"窥视镜":我们创建一个字节,只有第3位是 1,其他位全是 0。

二进制就是 00001000。

如何得到它?1 << 3。意思是把数字1(00000001)向左移动3位,变成 00001000。这个操作叫"左移"。

用"窥视镜"去看状态字节:

进行 按位与​ 操作:statusByte & (1 << 3)

csharp 复制代码
设备状态(statusByte):  1 0 1 1 0 1 1 0
  我们的窥视镜(1<<3):    0 0 0 0 1 0 0 0
  -------------------------------  【按位与】
  结果:                0 0 0 0 0 0 0 0

slave bus





通信方式与分类:

分析了两种主要的Modbus通信方式:

串口通信(RS485):一主多从架构,支持Modbus ASCII和Modbus RTU报文格式

以太网通信:点对点模式,包括Modbus TCP和Modbus UDP,采用RTU over TCP方式传输

Modbus通信方式

第一,串口通信,通常基于RS485电气接口,采用一主多从的总线型网络拓扑。

第二,在该方式下,主要使用Modbus RTU和Modbus ASCII两种报文格式,它们定义了数据在串行链路上的封装规则。

第三,Modbus ASCII格式将每个字节转换为两个可打印的ASCII字符进行传输,易于调试但效率较低;而Modbus RTU格式则直接传输二进制数据,更为紧凑高效。

第四,以太网通信,基于TCP/IP协议栈,实现了设备间的点对点通信,其标准协议为Modbus TCP,有时也采用Modbus UDP。

第五,Modbus TCP本质上采用了RTU over TCP的方式,即在TCP数据帧中承载与RTU格式相同的协议数据单元(PDU),从而在继承串口协议功能的同时适应了网络环境。

协议报文格式:

详细描述了各种Modbus功能码(0x01、0x02、0x03、0x04)的用途,以及RTU、ASCII和TCP三种报文格式的特点和处理方式。

协议报文格式部分的核心要点可总结如下:

第一,功能码定义了操作类型,例如0x01和0x02用于读取线圈(位)状态,0x03和0x04用于读取寄存器(字)数据。

第二,Modbus RTU格式采用紧凑的二进制字节流直接传输,通信效率高,是常用的格式。

第三,Modbus ASCII格式将RTU报文的每个字节转换为两个ASCII字符进行传输,增加了可读性但降低了效率,并使用LRC校验。

第四,Modbus TCP格式则在RTU报文的基础上,添加了MBAP报文头(包含事务标识、协议标识和长度字段),以便在以太网上传输。

第五,这三种格式的PDU(协议数据单元)核心实质相同,主要差异在于帧头、帧尾的封装方式以及适用的物理链路层。

一句话解释

csharp 复制代码
serialPort.DataReceived += (se, ev) =>
                //{
                //    byte[] data = new byte[1024];
                //    serialPort.Read(data, 0, data.Length);
                //    Console.WriteLine(Encoding.UTF8.GetString(data.ToArray()));
                //};

这行代码就像给串口这个"邮筒"安装了一个自动感应铃:一旦有数据送达(DataReceived事件触发),就立即执行括号里的操作------读取数据并打印出来,全程无需你手动轮询检查。

poll





一句话

csharp 复制代码
 static List<byte> CRC16(List<byte> value, ushort poly = 0xA001, ushort crcInit = 0xFFFF)
 {
     //throw new NotImplementedException();
     if (value == null || !value.Any())

         throw new ArgumentException("");

     ushort crc = crcInit;
     for (int i = 0; i < value.Count; i++) {
         crc = (ushort)(crc ^ (value[i]));
     for(int j = 0; j < 8; j++)
         {
             crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ poly) : (ushort)(crc >> 1);
         }
     }
     byte hi = (byte)((crc & 0xFF00) >> 8);
     byte lo = (byte)(crc & 0x00FF);

     List<byte> buffer=new List<byte>();
     buffer.AddRange(value);
     buffer.Add(lo);
     buffer.Add(hi);
     return buffer;
 }

简单来说,这个函数的作用就是:​ 确保你的数据在传输过程中,如果出现任何意外错误(比如电磁干扰导致某个比特位翻转了),接收方用同样的算法再算一次"指纹",就会发现对不上,从而知道数据有误,要求重发。这是工业通信协议中保证数据可靠性的一个非常关键的环节。

一句话解释

这就像您亲自动手,一步步地写一封格式非常严格的"询问信"发给一台设备。具体内容是:

"喂,地址是 1号 的设备(地址1),我要用 '读保持寄存器' 这个指令(功能码03),从你内部的 第4号存储位置 开始(保持寄存器地址4),连续读2个位置(读取2个寄存器)的数据给我。"

整个过程可以这样想象:

  1. 确定收信人:先在纸上写下"1"(设备地址)。
  2. 写明意图:接着写下"03"(代表"读保持寄存器"这个动作)。
  3. 说明从哪里开始读:然后写下"00 04"(这是数字4用两个字节的表示,告诉设备起始位置)。
  4. 说明读多少:再写下"00 02"(这是数字2用两个字节的表示,告诉设备读两个单位)。
  5. 加上防伪码:最后根据前面所有内容计算一个"校验码"(CRC)附在最后,防止信息在传输中出错。

把这几个部分按顺序拼接起来,就得到了一个完整的、设备能听懂的指令(报文):01 03 00 04 00 02 [CRC码]。将这个指令通过串口发送出去,就是一次"手动组装的Modbus RTU读指令"操作。

csharp 复制代码
    serialPort.Write(bytes.ToArray(), 0, bytes.Count);

在这段代码执行前,您已经像写一封详细的"操作指令信"一样,把设备地址、功能码、寄存器地址、要读取的数量等信息,按照Modbus协议规定的格式,一个一个字节地写进了 bytes这个列表里,并计算好了CRC校验码。这行代码就是最终把这封"信"(字节数组)通过串口(COM2)投递出去的临门一脚。

简单总结:

serialPort.Write(bytes.ToArray(), 0, bytes.Count);= "把 bytes里从开头到结尾的所有数据,通过串口完整地发送出去。"​ 这是上位机与设备进行通信时,将指令从软件"送达"硬件的关键一步。

一句话解释:

csharp 复制代码
 byte[] data = new byte[len * 2 + 5];

根据要读取的寄存器数量,计算并准备好一个大小刚好的"回信箱"(字节数组),用来完整接收设备返回的Modbus RTU响应数据。其中 len * 2是预期的有效数据长度(每个寄存器值占2字节),+5是响应报文固定附加的字节数(设备地址1字节 + 功能码1字节 + 字节数1字节 + CRC校验码2字节)。

csharp 复制代码
    serialPort.Read(data, 0, data.Length);
    for (int i = 3; i < data.Length - 2; i += 4)
    {
        byte[] vb = new byte[4]
        {
            data[i+2],
                data[i+3],
                data[i],
                data[i+1]
        };
        float v = BitConverter.ToSingle(vb);
        Console.WriteLine(v);
    }
}

提取并重组数据:接下来的 for循环负责从原始数据中提取出有效的数值部分。

从哪开始?​ i = 3表示我们跳过了回复数据的前3个字节。这就像看一封信时跳过"收信人、发信人、日期"等固定格式的开头,直接看正文。在Modbus协议中,前3个字节通常是设备地址、功能码和后续数据的长度。

怎么提取?​ i += 4表示我们每4个字节作为一组来处理。这是因为在您之前的请求中,您要求读取2个寄存器,而每个寄存器是2个字节,合起来正好4个字节,可以表示一个浮点数(如温度、压力值)。

为什么重组?​ byte[] vb = new byte[4] { data[i+2], data[i+3], data[i], data[i+1] };这行是最关键的一步,它调整了字节的顺序。设备发送数据的习惯(比如顺序是 A, B, C, D)可能和计算机处理浮点数的习惯(需要 D, C, B, A或 C, D, A, B等)不同。这里采用的 { data[i+2], data[i+3], data[i], data[i+1] }是一种常见的调整方式,相当于把前两个字节和后两个字节交换了位置。这样重组后,字节顺序才能被正确解读。

一句话解释:可读写的开关量

一句话解释:协议报文格式

协议报文格式就像是通信双方约定好的一张"快递单模板",它严格规定了数据包中每个部分的位置、顺序和含义,确保发送方能把信息准确打包,接收方能按同样的规则正确拆解。

以您学过的Modbus为例:

• Modbus RTU格式就像一张简洁的国内快递单,主要包含:收件人(从站地址)+ 物品类型(功能码)+ 具体内容(数据)+ 防伪码(CRC校验)。

• Modbus TCP格式则像一张国际快递单,在RTU的"物品信息"前,还加上了运单号(事务标识)+ 快递公司(协议标识)+ 包裹总长度等网络传输所需的额外信息。

无论哪种格式,核心目的都是让通信双方(上位机和设备)能毫无歧义地理解:"谁发的、要干什么、数据是什么、数据有没有传错"。

一句话解释 modbus功能码

Modbus功能码就是告诉设备"要干什么活"的指令编号,比如用 03 表示"读保持寄存器",用 06 表示"写单个保持寄存器",主站通过它来指挥从站进行具体的读写操作。

多写保持型寄存器

csharp 复制代码
List<byte> datas = new List<byte>();
 // 创建一个空的字节列表
datas.Add(0x01);  // 设备地址
datas.Add(0x10);  // 功能码:写多个寄存器
ushort addr = 16;  // 起始寄存器地址
datas.Add((byte)(addr / 256));  // 地址高字节
datas.Add((byte)(addr % 256));  // 地址低字节
// 写入寄存器数量
datas.Add((byte)(values.Count / 256));  // 数量高字节:4/256 = 0
datas.Add((byte)(values.Count % 256));  // 数量低字节:4%256 = 4
// 结果是:0x00, 0x04(表示写4个寄存器)

// 需要写入的数据字节数
datas.Add((byte)(values.Count * 2));  // 字节数:4×2=8,即0x08
// 注:因为每个寄存器是16位(2字节),所以总字节数=寄存器数×2

for (int i = 0; i < values.Count; i++)
{
    // 大端模式:先高字节,后低字节
    datas.Add(BitConverter.GetBytes(values[i])[1]); // 高字节
    datas.Add(BitConverter.GetBytes(values[i])[0]); // 低字节
}
csharp 复制代码
List<float> values = new List<float>();
values.Add(1.2f);
values.Add(2.3f);
values.Add(4.5f);

// 寄存器数量:每个浮点数占2个寄存器,所以是 values.Count * 2
ushort registerCount = (ushort)(values.Count * 2);
datas.Add((byte)(registerCount / 256));  // 数量高字节
datas.Add((byte)(registerCount % 256));  // 数量低字节

// 数据字节数:每个浮点数4字节,所以是 values.Count * 4
byte byteCount = (byte)(values.Count * 4);
datas.Add(byteCount);

// 写入每个浮点数
for (int i = 0; i < values.Count; i++)
{
    // 获取浮点数的字节表示
    byte[] floatBytes = BitConverter.GetBytes(values[i]);
    
    // 关键:按照设备要求的字节顺序添加
    // 你的注释是DCBA(完全反向),但不一定正确
    // 需要查阅设备手册确定字节顺序
    datas.Add(floatBytes[3]); // D - 最高字节
    datas.Add(floatBytes[2]); // C
    datas.Add(floatBytes[1]); // B
    datas.Add(floatBytes[0]); // A - 最低字节
}
csharp 复制代码
// 1. 使用具体类型,而不是dynamic
List<object> values2 = new List<object>();
values2.Add((ushort)123);  // 明确类型
values2.Add(36.5f);        // float类型

List<byte> temp = new List<byte>();
int totalRegisters = 0;

for (int i = 0; i < values2.Count; i++)
{
    if (values2[i] is ushort ushortValue)
    {
        // ushort: 大端字节序
        temp.Add((byte)(ushortValue >> 8));
        temp.Add((byte)(ushortValue & 0xFF));
        totalRegisters += 1;
    }
    else if (values2[i] is float floatValue)
    {
        // float: 根据设备要求选择字节顺序
        // 这里假设使用ABCD顺序
        byte[] floatBytes = BitConverter.GetBytes(floatValue);
        temp.Add(floatBytes[3]);  // A
        temp.Add(floatBytes[2]);  // B
        temp.Add(floatBytes[1]);  // C
        temp.Add(floatBytes[0]);  // D
        totalRegisters += 2;
    }
    else
    {
        throw new InvalidOperationException($"不支持的类型: {values2[i].GetType()}");
    }
}

// 2. 正确的寄存器数量计算
datas.Add((byte)(totalRegisters >> 8));    // 高字节
datas.Add((byte)(totalRegisters & 0xFF));  // 低字节

// 3. 正确的字节数
datas.Add((byte)temp.Count);  // 直接使用temp的字节数

// 4. 拼接数据
datas.AddRange(temp);

List datas = new List(); // 创建一个空的字节列表

在Modbus RTU中的作用

在Modbus RTU通信中,所有信息都必须转换为字节序列进行传输。这个datas列表就像一个"容器"或"组装线",用来逐步构建完整的Modbus命令帧:

csharp 复制代码
[设备地址][功能码][起始地址高][起始地址低][寄存器数量高][寄存器数量低][字节数][数据...][CRC低][CRC高]
 1字节    1字节     1字节       1字节        1字节        1字节       1字节   N字节    1字节   1字节

一句话解释:什么是ModBUSRTU

ModBUSRTU是工业设备之间通过串口(如RS485)进行通信的一种二进制协议,它采用简洁的主从问答模式,并使用CRC校验来确保数据在嘈杂工业环境中的可靠传输。

什么是ModbusRTU和读线圈状态?

ModbusRTU是一种工业通信协议,常用于PLC、传感器等设备通过串口(如COM口)交换数据。简单来说,就像一套固定的"对话规则",让不同设备能互相听懂。

读线圈状态是Modbus的一种功能------线圈可以理解为开关(比如继电器),状态只有开(1)或关(0)。这个功能就是向设备询问一批开关的当前状态。

csharp 复制代码
            #region ModbusRTU 读线圈状态报文处理
            if (flag == 4)
            {
                List<byte> datas = new List<byte>();
                datas.Add(0x01);//设备地址设为1(0x01是十六进制,等于十进制1)。这就像收件人姓名,告诉设备"这条消息是发给你的"。
                datas.Add(0x01);//功能码设为0x01,代表"读线圈状态"这个操作。
                ushort addr = 0;
                datas.Add(0x00);
                datas.Add(0x00);//起始地址设为0。意思是"从第0号开关开始读"。注意:地址用两个字节表示(高字节在前),所以添加了两次0x00。
                ushort len = 10;
                datas.Add(0x00);
                datas.Add(0x04);//代码中len=10表示想读10个开关,但添加到报文的却是0x00和0x04(十六进制0x0004,等于十进制4)。这会导致设备只返回前4个开关的状态,而不是10个

                datas = CRC16(datas);//计算CRC校验码并添加到报文末尾。
                                     //CRC是一种错误检查机制,就像快递单上的校验码,确保数据在传输中没出错。ModbusRTU要求报文以CRC结束。
                SerialPort serialPort = new SerialPort("COM2", 9600, Parity.None, 8, StopBits.One);
                //代码打开串口COM2(波特率9600等参数是串口常见设置),然后发送组装好的报文。
                serialPort.Open();//代码打开串口COM2(波特率9600等参数是串口常见设置),然后发送组装好的报文。
                serialPort.Write(datas.ToArray(), 0, datas.Count);//将组装好的请求报文通过串口发送给设备。。
                byte[] data = new byte[(int)(Math.Ceiling(len * 1.0 / 8) + 5)];
                //创建一个字节数组来接收设备的响应。
                //代码预先计算了响应长度并读取:
                // 响应长度公式(Math.Ceiling(len * 1.0 / 8) + 5)是基于ModbusRTU响应格式算的。
                // 对于读线圈,响应包括:设备地址(1字节)+功能码(1字节)+字节计数(1字节)+数据区(每字节存8个开关状态,所以10个开关需要2字节)+CRC(2字节),总共7字节。公式中len = 10,10 / 8 = 1.25向上取整得2字节数据,加5得7字节,计算正确。
                serialPort.Read(data, 0, data.Length);
                //从串口读取设备返回的数据,存到data数组中。这是个阻塞调用------程序会一直等待,直到收到指定长度的数据。
                List<byte> respBytes = new List<byte>();
                for (int i = 3; i < data.Length && respBytes.Count < Math.Ceiling(len * 1.0 / 8); i++)
                {
                    respBytes.Add(data[i]);//跳过响应报文的头部,只提取真正的线圈数据:
                }
                int count = 0;
                for (int i = 0; i < respBytes.Count; i++)
                {
                    for (int k = 0; k < 8; k++)
                    {
                        byte temp = (byte)(1 << k);
                        Console.WriteLine((respBytes[i] & temp) != 0);
                        count++;
                        if (count == len)
                            break;
                    }
                    //但每个开关的状态是一个二进制位(0或1),所以需要逐位解析:
                   // for (int i = 3; ...):从响应数据的第4个字节(索引3)开始提取,因为前3字节是头部信息(地址、功能码、字节计数)。
//内层循环遍历每个字节的8个位:(respBytes[i] & (1 << k)) != 0是位运算,检查第k位是否为1。如果是1,输出True(开);否则False(关)。
//count变量控制只输出len个状态(这里是10个),避免多读
                }
                #endregion
            }

写单线圈

一、什么是"写单线圈"?

ModbusRTU 写单线圈功能的实现

写单线圈是Modbus协议中的一个重要功能,用于控制单个开关/继电器的状态。比如:

打开某个指示灯

启动某个电机

关闭某个阀门

csharp 复制代码
List<byte> datas = new List<byte>();
// 创建一个空的字节列表
datas.Add(0x01);     // 设备地址:1号设备
datas.Add(0x05);     // 功能码:05代表"写单线圈"
ushort addr = 11;    // 要控制的线圈地址:第11号线圈
datas.Add((byte)(addr/256));   // 地址高字节:11/256=0
datas.Add((byte)(addr%256));   // 地址低字节:11%256=11
//addr = 11:表示要控制第11号线圈(Modbus地址从0开始,实际设备可能从1开始编址)
//地址拆分:Modbus要求用两个字节表示地址
//addr/256= 0(高字节)
//addr%256= 11(低字节)
//最终地址字节:0x00 0x0B(十六进制)
datas.Add(0x00);     // 状态值高字节
datas.Add(0x00);     // 状态值低字节:0x0000表示OFF(关闭)
//关键点:写单线圈的状态值必须是以下两种格式之一:
//关闭线圈:0x0000(所有位都是0)
//打开线圈:0xFF00(高字节FF,低字节00)
datas = CRC16(datas);  // 计算并添加CRC校验码
SerialPort serialPort = new SerialPort("COM2",9600,Parity.None,8,StopBits.One);
serialPort.Open();
serialPort.Write(datas.ToArray(),0, datas.Count);  // 发送请求

示例

01 05 00 0B 00 00 CRC

写多线圈

csharp 复制代码
#region 写多线圈
if (flag == 6)
{//它在通过串口,用Modbus RTU协议向地址为1的设备发送一条命令:"请从第10号线圈开始,连续设置10个线圈的状态。具体状态是:只有第1个(10号)打开,
 //后面的9个(11-19号)全部关闭。"
    List<byte> datas=new List<byte>();
    datas.Add(0x01);//收件人地址​ (0x01): 告诉串口,这条命令是发给1号设备的。
    datas.Add(0x0F);//要做的事​ (0x0F): 功能码 0x0F就是"写多个线圈"的指令代码 
    ushort addr = 10;
    datas.Add((byte)(addr/256));
    datas.Add((byte)(addr % 256));
    //你要从第10号线圈(开关)开始写。代码里 addr/256和 addr%256是把10这个数拆成高位和低位两个字节,是Modbus的标准格式。
    List<bool> status = new List<bool>()
    {
        true,false, false, false, false, false,false,false,false,false
    };
    datas.Add((byte)(status.Count / 256));
    datas.Add((byte)(status.Count % 256));
    //要操作几个开关​ (status.Count = 10): 你要一口气设置10个线圈的状态。
    //同样,把数字10拆成两个字节。
    List<byte> vbs=new List<byte>();
     // 准备一个空盒子,用来装打包好的字节
    int index = 0;
     // 当前正在操作的字节在盒子里的位置
    for (int i = 0; i < status.Count; i++) {
        //2. 循环处理每个开关
        if (i % 8 == 0)
            //每8个开关开一个新字节
            vbs.Add(0x00);
        // 就往盒子里放一个新的空字节(初始全是0)
        index = vbs.Count - 1;
        //当前操作的是盒子里最后一个字节
        if (status[i])//如果开关要打开,就在对应位置写1
        {
            byte temp=(byte)(1<<(i%8));
            //// 计算要写在哪一位
            //把1左移这么多位
            vbs[index] |= temp;
            //按位或运算,只把指定的位改成1,不影响其他位
        }
        else
        {

        }
    
    }
    //因为一个字节有8位,所以每8个开关状态被"压缩"成1个字节(8位二进制数)
    //。你的10个开关,需要2个字节来装。
    datas.Add((byte)vbs.Count);
    //告诉设备压缩包有多大​ ((byte)vbs.Count): 告诉设备,
    //后面紧跟的"状态压缩包"有 2个字节。
    datas.AddRange(vbs);
    datas = CRC16(datas);
    SerialPort serialPort= new SerialPort("COM2",9600,Parity.None,8,StopBits.One);
    serialPort.Open();
    serialPort.Write(datas.ToArray(),0,datas.Count);
}
#endregion

异常响应

"这是一个Modbus RTU主站的监控程序,它像雷达一样不停扫描设备,专门捕捉设备报错时的异常信号。"

csharp 复制代码
List<byte> bytes = new List<byte>();
bytes.Add(1);           // 设备地址:1号设备
bytes.Add(0x03);        // 功能码:0x03 = 读保持寄存器
ushort addr = 5;        // 从第5号寄存器开始读
bytes.Add((byte)(addr/256));    // 地址高字节
bytes.Add((byte)(addr % 256));  // 地址低字节
ushort len = 4;         // 要读4个寄存器(每个寄存器2字节)
bytes.Add((byte)(len/256));     // 数量高字节
bytes.Add((byte)(len%256));     // 数量低字节
bytes = CRC16(bytes);   // 加上校验码
//翻译成白话:"嘿,1号设备!我想从你的第5号寄存器开始,连续读取4个寄存器的值。"
while (true) {
    Thread.Sleep(50);  // 每次请求间隔50毫秒
    serialPort.DiscardOutBuffer();  // 清空输出缓存
    serialPort.Write(bytes.ToArray(),0,bytes.Count);  // 发送请求
    //这是主站在主动询问:每隔50ms就问一次设备:"你那4个寄存器的值是多少?"
    List<byte> respBytes = new List<byte>();

// 等待完整响应:正常响应应该是 5 + 4×2 = 13 字节
while (respBytes.Count < len * 2 + 5)  // len=4, 4×2+5=13
{
    respBytes.Add((byte)serialPort.ReadByte());  // 一个字节一个字节地读
    
    // 🔴 异常检测关键代码!
    if(respBytes.Count == 5 && respBytes[1] > 0x80)
    {
        break;  // 发现异常响应,立即停止读取
    }
    Console.WriteLine(respBytes.Count);  // 打印当前已接收字节数
}

0x83= 0x03+ 0x80(功能码加0x80表示异常)

异常码示例:

0x01:非法功能码(设备不支持此功能)

0x02:非法数据地址(寄存器5不存在)

0x03:非法数据值(请求的数据数量不对)

csharp 复制代码
开始循环
    ↓
等待50ms
    ↓
清空输出缓冲区
    ↓
发送读取请求(13字节)
    ↓
开始接收响应
    ↓
收到第5个字节时检查是否为异常响应
    ├── 如果是异常(0x83等)→ 立即停止接收
    └── 如果是正常(0x03)→ 继续收到13字节
    ↓
打印接收到的字节数
    ↓
清空输入缓冲区
    ↓
回到循环开始

Modbus ASCII

csharp 复制代码
二进制数据 → ASCII字符串 → 发送 → 接收 → ASCII字符串 → 二进制数据 → 解析值
csharp 复制代码
//这段代码使用 Modbus ASCII 协议通过串口向 1 号设备发送读取命令,要求从第 5 号寄存器开始连续读取 4 个寄存器的值,接收响应后将每个寄存器的 16 位数据解析为十进制数输出到控制台。
bytes.Add(0x01);        // 设备地址
bytes.Add(0x03);        // 功能码(读保持寄存器)
 ushort addr = 5;
bytes.Add((byte)(addr / 256));  // 地址高字节
bytes.Add((byte)(addr % 256));  // 地址低字节                
ushort len = 4;
bytes.Add((byte)(len/256));     // 数量高字节
bytes.Add((byte)(len%256));     // 数量低字节
//此时的二进制数据:[0x01, 0x03, 0x00, 0x05, 0x00, 0x04]
// 将每个字节转换为两个十六进制字符
var bytesStrArray = bytes.Select(b => b.ToString("x2")).ToList();
// 结果为:["01", "03", "00", "05", "00", "04", "f3"]
//原始字节: [0x01, 0x03, 0x00, 0x05, 0x00, 0x04, 0xF3]
//转换为:   ["01", "03", "00", "05", "00", "04", "f3"]

// 拼接成完整的ASCII帧格式
string bytesStr = ":" + string.Join("", bytesStrArray) + "\r\n";
// 结果为:";010300050004f3\r\n"
//:     01   03  00      05    00    04   f3  \r\n
//↑     ↑    ↑   ↑       ↑     ↑      ↑   ↑
//开始 地址 功能 地址高 地址低 数量高 数量低 LRC 结束
//符    码                  校验   符
// 将字符串转换为ASCII编码的字节
byte[] asciiBytes = Encoding.ASCII.GetBytes(bytesStr);
serialPort.Write(asciiBytes.ToArray(), 0, asciiBytes.Length);
//字符: ':'  '0'  '1'  '0'  '3'  ...  '\r' '\n'
//ASCII: 0x3A 0x30 0x31 0x30 0x33 ... 0x0D 0x0A

// 接收响应:提前计算好响应长度
byte[] data = new byte[len * 4 + 11];
serialPort.Read(data, 0, data.Length);
//每个寄存器值:2字节 → 4个ASCII字符 = 4×4=16
//固定开销:地址(2)+功能码(2)+字节数(2)+LRC(2)+起始符(1)+结束符(2) = 11
string asciiStr = Encoding.ASCII.GetString(data, 0, data.Length);
// 假设接收的字节数组 data 包含:0x3A 0x30 0x31 0x30 0x33 ...
// 转换为字符串:":010308000A0014001E0028F2\r\n"
List<byte> dataBytes = new List<byte>();
//🔄 ASCII字符串转二进制数据
for (int i = 1; i < asciiStr.Length - 2; i += 2)
{//// i = 1:跳过 ':'(索引0)
// asciiStr.Length - 2:跳过 \r\n(最后两个字符)
    string temp = asciiStr[i].ToString() + asciiStr[i + 1].ToString();
    dataBytes.Add(Convert.ToByte(temp, 16));
    //两字符组合转换为字节:
    // 第一次循环:i=1
// temp = asciiStr[1] + asciiStr[2] = '0' + '1' = "01"
// Convert.ToByte("01", 16) → 0x01

// 第二次循环:i=3
// temp = asciiStr[3] + asciiStr[4] = '0' + '3' = "03"
// Convert.ToByte("03", 16) → 0x03
 for(int i = 3; i < dataBytes.Count-2; i += 2)
 {
     byte[] vb = new byte[2] { dataBytes[i + 1], dataBytes[i] };
     //2. 字节交换与合并
     //为什么交换?​ Modbus使用大端序(高位在前),而C#的BitConverter默认使用小端序(低位在前),交换后兼容C#处理
     ushort v = BitConverter.ToUInt16(vb);
     //将2字节数组转换为16位无符号整数
     Console.WriteLine(v);
     //打印每个寄存器的十进制值
 }

}
csharp 复制代码
static List<byte> LRC(List<byte> value)
{//2. 计算LRC校验(不同于RTU的CRC)
    int sum = 0;
    for (int i = 0; i < value.Count; i++)
    {
        sum += value[i];  // 将所有字节相加
    }
    sum = sum % 256;      // 取模256
    sum = 256 - sum;      // 用256减去这个值
    value.Add((byte)sum); // 添加为校验码
    return value;
}

代码执行流程

构建请求帧:

向地址为 1的设备发送 0x03(读保持寄存器)命令

从寄存器地址 5开始,读取 4个寄存器的值

计算LRC校验码并添加到数据末尾

转换为ASCII格式:

将二进制数据转换为十六进制字符串(例如 0x01→ "01")

添加起始符 :和结束符 \r\n,形成完整ASCII帧

发送请求:

打开COM2串口(波特率9600,无校验,8数据位,1停止位)

将ASCII字符串编码为字节并发送

接收响应:

预计算响应长度:4个寄存器 × 4字符 + 11固定字符 = 27字节

读取完整响应并转换为ASCII字符串

解析响应:

跳过起始符 :,忽略结束符 \r\n

每两个字符转换为一个字节(例如 "01"→ 0x01)

从第4个字节开始,每2字节为一个寄存器值

交换字节顺序(Modbus大端序 → C#小端序)并转换为十进制数输出

Modbus Tcp

这段代码试图通过 TCP/IP 协议​ 与 Modbus TCP 设备通信,发送读取寄存器请求。

csharp 复制代码
List<byte> dataBytes = new List<byte>();
dataBytes.Add(0x01);  // 单元标识符(类似串口模式中的设备地址)
dataBytes.Add(0x03);  // 功能码:读保持寄存器
dataBytes.Add((byte)(addr / 256));  // 起始地址高字节
dataBytes.Add((byte)(addr % 256));  // 起始地址低字节
dataBytes.Add((byte)(len / 256));   // 寄存器数量高字节
dataBytes.Add((byte)(len % 256));   // 寄存器数量低字节
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.Connect("127.0.0.1", 502);  // 连接本地的Modbus TCP服务器(端口502)

//构建 Modbus TCP MBAP 头(
while (true)
{
    tid++;
    headerBytes.Add((byte)(tid / 256));
    headerBytes.Add((byte)(tid % 256));  // 
    headerBytes.Add(0x00);
    headerBytes.Add(0x00);  // 协议标识符:0=Modbus协议
    headerBytes.Add((byte)(dataBytes.Count / 256));
    headerBytes.Add((byte)(dataBytes.Count % 256));  // 长度:后续字节数
    headerBytes.AddRange(dataBytes);  // 添加PDU数据
    socket.Send(headerBytes.ToArray());
}

正确的 Modbus TCP 通信流程:

构建PDU(包含单元标识符、功能码、地址、数量)

构建MBAP头(事务标识符、协议标识符、长度)

发送完整的请求帧

接收响应

解析MBAP头和PDU数据

处理寄存器值或异常响应

是泛型类型参数,它表示这个类可以处理任意类型的数据,使用时用实际类型(如int、string等)替换T,使该类具有类型安全和代码复用的特性。

属性是对外暴露的数据接口,通过get/set访问器封装对私有字段的安全访问,可以添加逻辑控制。

构造方法是在使用 new 关键字创建对象时自动调用的专属初始化方法,其方法名必须与类名完全相同,用于设置对象的初始状态。

对象的初始状态指的是对象在内存中被创建后、执行任何自定义初始化代码前,其属性根据类型自动获得的默认值(如数字为0、布尔为false、引用为null),或是通过构造方法、初始化块显式赋予的初始值。

构造方法的核心作用是在使用 new 关键字创建对象时,自动执行,为这个新对象进行初始化工作,确保对象在诞生时就拥有一个确定的、合理的初始状态。

基方法是基类中定义的、供子类直接调用的通用实现;虚方法则是基类中声明但允许子类按需重写的、具有默认行为的方法;重写就是子类用override关键字覆盖基类虚方法的实现,以提供特定于子类的行为。

相关推荐
Never_Satisfied14 小时前
在c#中,使用windows自带功能将文件夹打包为ZIP
开发语言·windows·c#
Never_Satisfied16 小时前
在c#中,string.replace会替换所有满足条件的子字符串,如何只替换一次
开发语言·c#
观无18 小时前
visionpro的dll导入
c#
Desirediscipline19 小时前
#define _CRT_SECURE_NO_WARNINGS 1
开发语言·数据结构·c++·算法·c#·github·visual studio
蚊子码农21 小时前
算法题解记录-2452距离字典两次编辑以内的单词
开发语言·算法·c#
专注VB编程开发20年21 小时前
c# vb.net Redis 左侧添加,右侧添加(添加到头部,添加到尾部)
redis·c#·.net
kylezhao201921 小时前
C#异步和并发在IO密集场景的典型应用 async/await
开发语言·数据库·c#
游乐码1 天前
c#索引器
开发语言·c#
Never_Satisfied1 天前
在c#中,实现把图片文件拖动到pictureBox控件上
开发语言·c#