
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
好的,作为一名初学者,我用一个"通信工具箱"的比喻,帮你一句话理清这些概念:
-
Socket(套接字):编程世界里用来网络通信的"万能插座"或"对讲机"。它本身不是具体的通信方式,而是一个标准的编程接口(工具),让你能用代码通过网线或无线进行数据收发。你在代码中写的 new Socket(...) 就是在创建这个工具。
-
Socket TCP:使用"打电话"模式的Socket。当你创建Socket时指定了 ProtocolType.Tcp,就意味着你准备用这个Socket工具,按照可靠、有连接的"打电话"规则来通信。使用前需要先 Connect 拨号建立连接,确保对方在线并能按顺序、不丢包地对话。
-
Socket UDP:使用"发短信/广播"模式的Socket。当你创建Socket时指定了 ProtocolType.Udp,就意味着你准备用这个Socket工具,按照快速、无连接的"发短信"规则来通信。无需提前连接,直接 SendTo 把数据包发出去,但不保证对方一定能收到,也不保证按发送顺序到达。
-
TCP(传输控制协议):"打电话"的通信规则本身。它规定了如何先"三次握手"建立可靠连接、如何保证数据不丢失不乱序、如何"四次挥手"礼貌挂断。Socket TCP 是具体实现这套规则的工具。
-
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"(设备地址)。
- 写明意图:接着写下"03"(代表"读保持寄存器"这个动作)。
- 说明从哪里开始读:然后写下"00 04"(这是数字4用两个字节的表示,告诉设备起始位置)。
- 说明读多少:再写下"00 02"(这是数字2用两个字节的表示,告诉设备读两个单位)。
- 加上防伪码:最后根据前面所有内容计算一个"校验码"(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关键字覆盖基类虚方法的实现,以提供特定于子类的行为。