ModbusTcp协议是基于tcp的,但不是说一定要通过tcp协议才能传输,只要能传输二进制的地方都可以。比如WebSocket协议。
但由于目前我只有tcp上面的modbus服务器实现,所以我必须先用tcp连接借助已有工具来验证我的服务器是否写正确。
效果


ModBusTCP协议报文
ModBusTCP协议报文比较复杂,主要是区分了3组类型
- 请求和响应
- 读和写
- 多个数据和单个数据
理论上将报文格式的种类从1种变成了8种。
但是在响应读数据时,没有区分数据量,而是到请求中去查找,听说是为了减少传输量。因此实际格式种类是3组6种。
-
请求读数据报文
事务标识符 协议标识符 报文长度 单元标识符 功能码 起始地址 读取数量 字段长度 2 byte 2 byte 2 byte 1 byte 1 byte 2 byte 2 byte 数据类型 ushort ushort ushort byte byte ushort ushort 自增 一般为0 6,从单元标识符开始算 一般为0 链接 -
响应读数据报文
事务标识符 协议标识符 报文长度 单元标识符 功能码 数据字节数 数据 2 byte 2 byte 2 byte 1 byte 1 byte 1 byte n byte 值得注意的是,响应报文中没有包含请求的数据数量,只能从请求中获得。比如是7个线圈还是8个线圈是看不出来的。
这其实就限制了程序结构,应该在同一个上下文中声明请求和响应变量。 -
请求写单个数据报文
事务标识符 协议标识符 报文长度 单元标识符 功能码 起始地址 数据 2 byte 2 byte 2 byte 1 byte 1 byte 2 byte 2 byte -
响应写单个数据报文(不需要响应)事务标识符 协议标识符 报文长度 单元标识符 功能码 起始地址 数据 2 byte 2 byte 2 byte 1 byte 1 byte 2 byte 2 byte 请求写单个数据的响应实际上是原样返回请求。
写1位线圈和写16位寄存器都传输了2个字节,这很奇怪。
原来是ModBusTCP定义写单个线圈用0xff00表示1,0x0000表示0。 -
请求写多个数据报文
事务标识符 协议标识符 报文长度 单元标识符 功能码 起始地址 数量 字节数 数据 2 byte 2 byte 2 byte 1 byte 1 byte 2 byte 2 byte 1 byte n byte 请求写多个数据时,如果是线圈,现在又变成了一位代表一个数据,而不是原来的2字节代表单个数据
还需要注意的是modbus采用大端传输,意味着一个字节中如果写了四个线圈,要从右向左数。 -
响应写多个数据报文(不需要响应)事务标识符 协议标识符 报文长度 单元标识符 功能码 起始地址 数量 2 byte 2 byte 2 byte 1 byte 1 byte 2 byte 2 byte
报文类定义
从功能码字段往后,字段不确定,所以统一用Data表示。
在各个响应方法里根据不同功能码对数据进行处理。
//WebModbus.cs // Modbus报文 public class ADUMessage { // 事务标识符 public ushort Transaction { get; set; } // 协议标识符 public ushort Protocol { get; set; } // 报文长度 public ushort Length { get; set; } // 单元标识符 public byte Unit { get; set; } // 功能码 public byte FunctionCode { get; set; } // 数据 public byte[] Data { get; set; } public static ADUMessage Deserialize(byte[] buffer){} public static byte[] Serialze(ADUMessage message){} }
数据模型
modbusTCP协议规定了服务器实现线圈、寄存器2种数据模型,每种又分为只读、可读可写。于是服务器一共要提供4个存储栈。
因为报文起始地址字段有2个字节,所以寻址范围是0-65535
| 栈地址 | 线圈栈 | 离散量输入栈 | 只读寄存器栈 | 读写寄存器栈 |
|---|---|---|---|---|
| 65535 | 0 | 0 | 0xFF C1 | 0x00 00 |
| 65534 | 0 | 1 | 0x00 00 | 0x00 00 |
| ... | 1 | 0 | 0x00 00 | 0x00 00 |
| 1 | 0 | 0 | 0x00 00 | 0x08 00 |
| 0 | 0 | 0 | 0x00 00 | 0x00 1A |
其中线圈栈和离散量输入栈是bool类型的,实际实现的时候可以是数字0、1,也可以是布尔true,false,还可以是字符串"on","off"
如何存储,存储的数据类型是什么?这不重要。重要的是传输的时候采用规定格式就行。
- 传输格式
-
采用第3种报文写单线圈时,如果要传输true,要使用0xFF 00。传输false,要使用0x00 00
-
其它时候都用一个bit位表示true和false
比如线圈栈,从地址0开始读9个数据。就是小端字节序0x0f 00。0 1 2 3 4 5 6 7 8 ... 1 1 1 1 0 0 0 0 0 0 当然,要从请求报文中知道截取取响应数据的哪几位。
-
- 模型类定义
//WebModbus.cs // 数据仓库,144KB public class DataStore { // 读写16位寄存器,64KB public ushort[] HoldingRegisters; // 只读16位寄存器,64KB public ushort[] InputRegisters; // 读写1位线圈,8KB public bool[] CoilDiscretes; // 只读1位线圈,8KB public bool[] CoilInputs; public DataStore() { HoldingRegisters = new ushort[65536]; InputRegisters = new ushort[65536]; CoilDiscretes = new bool[65536]; CoilInputs = new bool[65536]; } // 读 读写16位寄存器 public ushort[] ReadHoldingRegisters(ushort startIndex, ushort length){} // 读 只读16位寄存器 public ushort[] ReadInputRegisters(ushort startIndex, ushort length){} // 读 读写1位线圈 public bool[] ReadCoilDiscretes(ushort startIndex, ushort length){} // 读 只读1位线圈 public bool[] ReadCoilInputs(ushort startIndex, ushort length){} // 写 读写16位寄存器 public void WriteHoldingRegisters(ushort startIndex, ushort[] data){} //写 读写1位线圈 public void WriteCoilDiscretes(ushort startIndex, bool[] data){} }
功能码
功能码主要用来操作这4个栈,因为栈分读写和只读,写入还分为多个数据和单个数据。所以主要有8种功能码
- 0x01 读线圈栈
- 0x02 读离散量输入栈
- 0x03 读只读寄存器栈
- 0x04 读读写寄存器栈
- 0x05 写一个数据到线圈栈
- 0x06 写一个数据到读写寄存器栈
- 0x0F 写多个数据到线圈栈
- 0x10 写多个数据到读写寄存器栈
- 当然还有其它的,但不影响主要功能,这里就不说明了
相应的
字节序大小端
字节序这个东西还是当初上操作系统课时老师讲过,以后再没接触。不过要自己处理字节流时又面临这个问题了。
modbusTcp主要采用大端字节序方式传输数据。
为什么说主要呢,因为唯独线圈数据是采用小端的方式传输的,其它数据都是大端传输。这要特别注意。
- C#实现时可以使用
BinaryReader和BinaryWriter来处理接收到的和要传输的字节流。
但这两个类默认都是按照小端字节序来处理数据。比如reader.ReadUInt16(),如果我们传的字节流是0x00 01,它会以为是ushort 256,而不是ushort 1。
所以需要我们重载override这个方法,翻转字节数组,按照大端方式读取。
//BigEndianBinaryReader.cs public override short ReadInt16() { var data = base.ReadBytes(2); Array.Reverse(data); return BitConverter.ToInt16(data, 0); }
服务器处理流程
modbus服务器要做的就是
-
接收消息,反序列化为消息对象
-
然后判断怎么响应
-
读取数据栈,构造消息
-
序列化后响应
-
核心方法
//WebModbus.cs public ADUMessage HandleRequest(byte[] buffer) { ADUMessage request = ADUMessage.Deserialize(buffer); switch (request.FunctionCode) { //读 读写线圈 case 0x01: return Response_01(request); //读 只读线圈 case 0x02: return Response_02(request); //读 读写寄存器 case 0x03: return Response_03(request); //读 只读寄存器 case 0x04: return Response_04(request); //写 读写一个线圈 case 0x05: return Response_05(request); //写 读写一个寄存器 case 0x06: return Response_06(request); //写 读写多个线圈 case 0x0f: return Response_0f(request); //写 读写多个寄存器 case 0x10: return Response_10(request); default: return Response_01(request); } }
完整代码
程序所需命令行参数的一种方式是在项目文件种指定,这在调试时比较方便
<PropertyGroup> <StartArguments>5234</StartArguments> </PropertyGroup>
WebModbus.cs
/// <summary> /// 数据仓库,144KB /// </summary> public class DataStore { /// <summary> /// 读写16位寄存器,64KB /// </summary> public ushort[] HoldingRegisters; /// <summary> /// 只读16位寄存器,64KB /// </summary> public ushort[] InputRegisters; /// <summary> /// 读写1位线圈,8KB /// </summary> public bool[] CoilDiscretes; /// <summary> /// 只读1位线圈,8KB /// </summary> public bool[] CoilInputs; public DataStore() { HoldingRegisters = new ushort[65536]; InputRegisters = new ushort[65536]; CoilDiscretes = new bool[65536]; CoilInputs = new bool[65536]; } /// <summary> /// 读 读写16位寄存器 /// </summary> /// <param name="startIndex"></param> /// <param name="length"></param> /// <returns></returns> public ushort[] ReadHoldingRegisters(ushort startIndex, ushort length) { return HoldingRegisters.Take(new Range(new Index(startIndex), new Index(startIndex + length))).ToArray(); } /// <summary> /// 读 只读16位寄存器 /// </summary> /// <param name="startIndex"></param> /// <param name="length"></param> /// <returns></returns> public ushort[] ReadInputRegisters(ushort startIndex, ushort length) { return InputRegisters.Take(new Range(new Index(startIndex), new Index(startIndex + length))).ToArray(); } /// <summary> /// 读 读写1位线圈 /// </summary> /// <param name="startIndex"></param> /// <param name="length"></param> /// <returns></returns> public bool[] ReadCoilDiscretes(ushort startIndex, ushort length) { return CoilDiscretes.Take(new Range(new Index(startIndex), new Index(startIndex + length))).ToArray(); } /// <summary> /// 读 只读1位线圈 /// </summary> /// <param name="startIndex"></param> /// <param name="length"></param> /// <returns></returns> public bool[] ReadCoilInputs(ushort startIndex, ushort length) { return CoilInputs.Take(new Range(new Index(startIndex), new Index(startIndex + length))).ToArray(); } /// <summary> /// 写 读写16位寄存器 /// </summary> /// <param name="startIndex"></param> /// <param name="data"></param> public void WriteHoldingRegisters(ushort startIndex, ushort[] data) { for (int i = 0; i < data.Length; i++) { if (startIndex+i < 65536) { HoldingRegisters[startIndex + i] = data[i]; } } } /// <summary> /// 写 读写1位线圈 /// </summary> /// <param name="startIndex"></param> /// <param name="data"></param> public void WriteCoilDiscretes(ushort startIndex, bool[] data) { for (int i = 0; i < data.Length; i++) { if (startIndex + i < 65536) { CoilDiscretes[startIndex + i] = data[i]; } } } } /// <summary> /// Modbus报文 /// </summary> public class ADUMessage { /// <summary> /// 事务标识符 /// </summary> public ushort Transaction { get; set; } /// <summary> /// 协议标识符 /// </summary> public ushort Protocol { get; set; } /// <summary> /// 报文长度 /// </summary> public ushort Length { get; set; } /// <summary> /// 单元标识符 /// </summary> public byte Unit { get; set; } /// <summary> /// 功能码 /// </summary> public byte FunctionCode { get; set; } /// <summary> /// 数据 /// </summary> public byte[] Data { get; set; } public static ADUMessage Deserialize(byte[] buffer) { //BinaryReader读取方式是小端(右边是高字节),而modbus是大端传输(左边是高字节) BinaryReader reader = new BigEndianBinaryReader(new MemoryStream(buffer)); ADUMessage adu = new ADUMessage() { Transaction = reader.ReadUInt16(), Protocol = reader.ReadUInt16(), Length = reader.ReadUInt16(), Unit = reader.ReadByte(), FunctionCode = reader.ReadByte(), Data = reader.ReadBytes(buffer.Length - 8) }; return adu; } public static byte[] Serialze(ADUMessage message) { using (MemoryStream ms=new MemoryStream()) { BinaryWriter writer = new BigEndianBinaryWriter(ms); writer.Write(message.Transaction); writer.Write(message.Protocol); writer.Write(message.Length); writer.Write(message.Unit); writer.Write(message.FunctionCode); writer.Write(message.Data); return ms.ToArray(); } } } public class WebModbusServer { public DataStore store = new DataStore(); public ADUMessage HandleRequest(byte[] buffer) { ADUMessage request = ADUMessage.Deserialize(buffer); switch (request.FunctionCode) { //读 读写线圈 case 0x01: return Response_01(request); //读 只读线圈 case 0x02: return Response_02(request); //读 读写寄存器 case 0x03: return Response_03(request); //读 只读寄存器 case 0x04: return Response_04(request); //写 读写一个线圈 case 0x05: return Response_05(request); //写 读写一个寄存器 case 0x06: return Response_06(request); //写 读写多个线圈 case 0x0f: return Response_0f(request); //写 读写多个寄存器 case 0x10: return Response_10(request); default: return Response_01(request); } } public byte[] CoilToBytes(bool[] bools) { int byteCount = (bools.Length + 7) / 8; // 计算所需的字节数 byte[] bytes = new byte[byteCount]; for (int i = 0; i < bools.Length; i++) { int byteIndex = i / 8; // 计算当前布尔值应该存储在哪个字节中 int bitIndex = i % 8; // 计算当前布尔值应该存储在字节的哪个位上 if (bools[i]) { // 设置对应位为 1 bytes[byteIndex] |= (byte)(1 << bitIndex); } else { // 对应位保持为 0,无需额外操作 } } return bytes; } /// <summary> /// 读 读写线圈 /// </summary> /// <param name="request"></param> /// <returns></returns> private ADUMessage Response_01(ADUMessage request) { BinaryReader reader = new BigEndianBinaryReader(new MemoryStream(request.Data)); BinaryWriter writer; ushort StartAddress, DataNumber; StartAddress = reader.ReadUInt16(); DataNumber = reader.ReadUInt16(); bool[] data = store.ReadCoilDiscretes(StartAddress, DataNumber); byte[] coilBytes = CoilToBytes(data); byte[] dataBytes = new byte[coilBytes.Length + 1]; writer = new BinaryWriter(new MemoryStream(dataBytes)); writer.Write((byte)coilBytes.Length); writer.Write(coilBytes); ADUMessage response = new ADUMessage() { Transaction = request.Transaction, Protocol = request.Protocol, Length = (ushort)(dataBytes.Length + 2), Unit = request.Unit, FunctionCode = request.FunctionCode, Data = dataBytes, }; return response; } /// <summary> /// 读 只读线圈 /// </summary> /// <param name="request"></param> /// <returns></returns> private ADUMessage Response_02(ADUMessage request) { BinaryReader reader = new BigEndianBinaryReader(new MemoryStream(request.Data)); BinaryWriter writer; ushort StartAddress, DataNumber; StartAddress = reader.ReadUInt16(); DataNumber = reader.ReadUInt16(); bool[] data = store.ReadCoilInputs(StartAddress, DataNumber); byte[] coilBytes = CoilToBytes(data); byte[] dataBytes = new byte[coilBytes.Length + 1]; writer = new BinaryWriter(new MemoryStream(dataBytes)); writer.Write((byte)coilBytes.Length); writer.Write(coilBytes); ADUMessage response = new ADUMessage() { Transaction = request.Transaction, Protocol = request.Protocol, Length = (ushort)(dataBytes.Length + 2), Unit = request.Unit, FunctionCode = request.FunctionCode, Data = dataBytes, }; return response; } /// <summary> /// 读 读写寄存器 /// </summary> /// <param name="request"></param> /// <returns></returns> private ADUMessage Response_03(ADUMessage request) { BinaryReader reader = new BigEndianBinaryReader(new MemoryStream(request.Data)); BinaryWriter writer; ushort StartAddress, DataNumber; StartAddress = reader.ReadUInt16(); DataNumber = reader.ReadUInt16(); ushort[] data = store.ReadHoldingRegisters(StartAddress, DataNumber); byte[] dataBytes = new byte[data.Length * 2 + 1]; writer = new BigEndianBinaryWriter(new MemoryStream(dataBytes)); writer.Write((byte)(data.Length * 2)); foreach (ushort value in data) { writer.Write(value); } Array.Resize(ref dataBytes, dataBytes.Length + 1); ADUMessage response = new ADUMessage() { Transaction = request.Transaction, Protocol = request.Protocol, Length = (ushort)(dataBytes.Length + 2), Unit = request.Unit, FunctionCode = request.FunctionCode, Data = dataBytes, }; return response; } /// <summary> /// 读 只读寄存器 /// </summary> /// <param name="request"></param> /// <returns></returns> private ADUMessage Response_04(ADUMessage request) { BinaryReader reader = new BigEndianBinaryReader(new MemoryStream(request.Data)); BinaryWriter writer; ushort StartAddress, DataNumber; StartAddress = reader.ReadUInt16(); DataNumber = reader.ReadUInt16(); ushort[] data = store.ReadInputRegisters(StartAddress, DataNumber); byte[] dataBytes = new byte[data.Length * 2 + 1]; writer = new BigEndianBinaryWriter(new MemoryStream(dataBytes)); writer.Write((byte)(data.Length * 2)); foreach (ushort value in data) { writer.Write(value); } Array.Resize(ref dataBytes, dataBytes.Length + 1); ADUMessage response = new ADUMessage() { Transaction = request.Transaction, Protocol = request.Protocol, Length = (ushort)(dataBytes.Length + 2), Unit = request.Unit, FunctionCode = request.FunctionCode, Data = dataBytes, }; return response; } /// <summary> /// 写 读写一个线圈 /// </summary> /// <param name="request"></param> /// <returns></returns> private ADUMessage Response_05(ADUMessage request) { BinaryReader reader = new BigEndianBinaryReader(new MemoryStream(request.Data)); ushort StartAddress, coli; StartAddress = reader.ReadUInt16(); coli = reader.ReadUInt16(); store.WriteCoilDiscretes(StartAddress, new bool[] { coli ==0xff00?true:false}); return request; } /// <summary> /// 写 读写一个寄存器 /// </summary> /// <param name="request"></param> /// <returns></returns> private ADUMessage Response_06(ADUMessage request) { BinaryReader reader = new BigEndianBinaryReader(new MemoryStream(request.Data)); ushort StartAddress, register; StartAddress = reader.ReadUInt16(); register = reader.ReadUInt16(); store.WriteHoldingRegisters(StartAddress, new ushort[] { register }); return request; } /// <summary> /// 写 读写多个线圈 /// </summary> /// <param name="request"></param> /// <returns></returns> private ADUMessage Response_0f(ADUMessage request) { BinaryReader reader = new BigEndianBinaryReader(new MemoryStream(request.Data)); ushort StartAddress, DataNumber; StartAddress = reader.ReadUInt16(); DataNumber = reader.ReadUInt16(); byte byteNumber = reader.ReadByte(); //线圈是小端传输 byte[] bytes = reader.ReadBytes(byteNumber); bool[] data=new bool[DataNumber]; byte index = 0; foreach (var item in bytes) { //1000 0000 byte rr = (byte)0x01; for (int i = 0; i < 8; i++) { if (index< DataNumber) { var result = rr & item; if (result > 0) { data[index] = true; } else { data[index] = false; } //0100 0000 rr <<= 1; index++; } else { break; } } } store.WriteCoilDiscretes(StartAddress, data); return request; } /// <summary> /// 写 读写多个寄存器 /// </summary> /// <param name="request"></param> /// <returns></returns> private ADUMessage Response_10(ADUMessage request) { //寄存器是大端传输 BinaryReader reader = new BigEndianBinaryReader(new MemoryStream(request.Data)); ushort StartAddress, DataNumber; StartAddress = reader.ReadUInt16(); DataNumber = reader.ReadUInt16(); byte byteNumber = reader.ReadByte(); ushort[] data = new ushort[byteNumber / 2]; for (int i = 0; i < data.Length; i++) { data[i] = reader.ReadUInt16(); } store.WriteHoldingRegisters(StartAddress, data); return request; } }
BigEndianBinaryReader.cs
public class BigEndianBinaryReader : BinaryReader { public BigEndianBinaryReader(Stream input) : base(input) { } public override short ReadInt16() { var data = base.ReadBytes(2); Array.Reverse(data); return BitConverter.ToInt16(data, 0); } public override ushort ReadUInt16() { var data = base.ReadBytes(2); Array.Reverse(data); return BitConverter.ToUInt16(data, 0); } public override int ReadInt32() { var data = base.ReadBytes(4); Array.Reverse(data); return BitConverter.ToInt32(data, 0); } public override uint ReadUInt32() { var data = base.ReadBytes(4); Array.Reverse(data); return BitConverter.ToUInt32(data, 0); } public override long ReadInt64() { var data = base.ReadBytes(8); Array.Reverse(data); return BitConverter.ToInt64(data, 0); } public override float ReadSingle() { var data = base.ReadBytes(4); Array.Reverse(data); return BitConverter.ToSingle(data, 0); } public override double ReadDouble() { var data = base.ReadBytes(8); Array.Reverse(data); return BitConverter.ToDouble(data, 0); } // 可以继续添加其他方法来支持更多数据类型的大端读取 } public class BigEndianBinaryWriter : BinaryWriter { public BigEndianBinaryWriter(Stream input) : base(input) { } public override void Write(ushort value) { var bytes = BitConverter.GetBytes(value); Array.Reverse(bytes); base.Write(bytes); } public override void Write(short value) { var bytes = BitConverter.GetBytes(value); Array.Reverse(bytes); base.Write(bytes); } public override void Write(uint value) { var bytes = BitConverter.GetBytes(value); Array.Reverse(bytes); base.Write(bytes); } public override void Write(int value) { var bytes = BitConverter.GetBytes(value); Array.Reverse(bytes); base.Write(bytes); } public override void Write(ulong value) { var bytes = BitConverter.GetBytes(value); Array.Reverse(bytes); base.Write(bytes); } public override void Write(long value) { var bytes = BitConverter.GetBytes(value); Array.Reverse(bytes); base.Write(bytes); } // 可以继续添加其他方法来支持更多数据类型的大端写入 }
Program.cs
internal class Program { static WebModbusServer webModbusServer; static void Main(string[] args) { webModbusServer = new WebModbusServer(); //服务器 if (args.Length == 1) { //webModbusServer.store.WriteCoilDiscretes(0, new bool[] { true, true }); //webModbusServer.store.CoilInputs[0] = true; //webModbusServer.store.CoilInputs[1] = true; StartServer(args[0]); } } private static void StartServer(string args) { int serverPort = Convert.ToInt32(args); var server = new TcpListener(IPAddress.Parse("127.0.0.1"), serverPort); Console.WriteLine($"TCP服务器 127.0.0.1:{serverPort}"); server.Start(); int cnt = 0; Task.Run(async () => { List<TcpClient> clients = new List<TcpClient>(); while (true) { TcpClient client = await server.AcceptTcpClientAsync(); clients.Add(client); cnt++; var ep = client.Client.RemoteEndPoint as IPEndPoint; Console.WriteLine($"TCP客户端_{cnt} {ep.Address}:{ep.Port}"); //给这个客户端开一个聊天线程 //操作系统将会根据游客端口对应表将控制权交给对应游客线程 StartModbus(client); } }).Wait(); } public static async Task StartModbus(TcpClient client) { var buffer = new byte[1024 * 4]; while (client.Connected) { int msgLength = await client.Client.ReceiveAsync(new ArraySegment<byte>(buffer)); //关闭连接时会接收到一次空消息,不知道为什么 if (msgLength>0) { PrintBytes(buffer.Take(msgLength).ToArray(), "请求 "); ADUMessage response = webModbusServer.HandleRequest(buffer.Take(msgLength).ToArray()); await client.Client.SendAsync(ADUMessage.Serialze(response)); PrintBytes(ADUMessage.Serialze(response), "响应 "); } } } public static void PrintBytes(byte[] bytes,string prefix="") { Console.Write(prefix); for (int i = 0; i < bytes.Length; i++) { if (i < 2) { Console.ForegroundColor = ConsoleColor.Red; } else if(i<4) { Console.ForegroundColor = ConsoleColor.Green; } else if(i<6) { Console.ForegroundColor= ConsoleColor.Blue; } else if (i < 7) { Console.ForegroundColor = ConsoleColor.Yellow; } else if (i<8) { Console.ForegroundColor = ConsoleColor.DarkCyan; } else { Console.ForegroundColor = ConsoleColor.White; } Console.Write(bytes[i].ToString("X2") + " "); } Console.WriteLine(); } }