一面向寄存器的报文ModBus
1.1目的
为了让外面的电脑知道设备内部的情况,或者去控制设备,通信协议就会规定:"我发一段报文,直接去读/写你某个编号的抽屉。" 这就诞生了面向寄存器的通信报文
1.2结构
发送端:01 03 00 02 00 01 25 CA
| 字节数据 (Hex) | 报文里的角色 | 通俗含义(潜台词) |
|---|---|---|
01 |
设备地址 (Slave ID) | "1号传感器,叫你呢,听电话。" |
03 |
功能码 (Function Code) | "我要读取保持寄存器 的内容。" (03是Modbus固定的读指令) |
00 02 |
寄存器起始地址 | "我要看你编号为 00 02 的那个小抽屉。" |
00 01 |
寄存器数量 | "我只读 1 个抽屉(即16位数据)。" |
25 CA |
CRC校验码 (Trailer) | 报尾。用来确保上面这串数字在网线传输里没被干扰出错。 |
接收端:
1号传感器收到上面的命令后,发现自己 00 02 号抽屉里现在的温度数值是 00 14(十六进制,转成十进制是 20,代表 20℃)。
它就会立马回一个响应报文给电脑:
01 03 02 00 14 B9 8F
-
01 03:我是1号,回应你的03读指令。 -
02:后面跟着的数据一共有 2 个字节。 -
00 14:这就是你要的寄存器里装的内容!(温度 20 度的二进制数据)。 -
B9 8F:校验码。
1.3和普通网络报文的区别
-
普通网络报文(如 HTTP/TCP): 它是面向"资源/文件/字节流"的。你找服务器要的是一个网页、一张图片,报文里装的是网页代码。
-
寄存器报文(如 Modbus/CANopen): 它是面向"硬件物理地址"的。报文非常底层、非常短,它的核心目的就是"去改硬件芯片里的某个内存数据"。
二Modbus在代码中的作用
2.1目的
在 C# 中操作 Modbus,其实就是用代码去组装前面提到的"寄存器报文",通过串口(SerialPort)或网络(TcpClient)发给硬件,然后再把硬件回传的报文解析成人类能看懂的数字。
从站地址1B\] \[功能码1B\] \[数据N字节\] \[CRC16 2字节
2..2寄存器分类
硬件里有四种不同类型的"寄存器抽屉"。Modbus 给它们编了号:
| 寄存器类型 | 英文名 | 读写权限 | 数据类型 | 常见物理含义 | Modbus功能码 |
|---|---|---|---|---|---|
| 线圈 | Coils | 读/写 | 1位 (Boolean) | 控制继电器开/关、指示灯亮/灭 | 01(读), 05(写) |
| 离散输入 | Discrete Inputs | 只读 | 1位 (Boolean) | 开关传感器状态、限位开关是否触发 | 02(读) |
| 输入寄存器 | Input Registers | 只读 | 16位 (Ushhort) | 传感器的只读数值,如当前温度、湿度 | 04(读) |
| 保持寄存器 | Holding Registers | 读/写 | 16位 (Ushhort) | 配置参数(如报警上限)、控制输出值 | 03(读), 06/16(写) |
2.3C#实战代码
2.3.1NuGet 导入
NModbus (或者 NModbus4)
2.3.2连接设备与读取数据(Master/客户端)
以TCP网线为例
假设硬件 PLC 的 IP 是 192.168.1.100,端口默认是 502,我们要读取保持寄存器(从地址 0 开始的 2 个寄存器)。
cs
using System;
using System.Net.Sockets;
using NModbus;
class Program
{
static void Main(string[] args)
{
// 1. 建立标准的 TCP 连接
using (TcpClient client = new TcpClient("192.168.1.100", 502))
{
// 2. 创建 Modbus 工厂并实例化 Master (主站/客户端)
var factory = new ModbusFactory();
IModbusMaster master = factory.CreateMaster(client);
byte slaveId = 1; // 从站设备地址(通常为1)
ushort startAddress = 0; // 起始寄存器地址
ushort numberOfPoints = 2; // 连续读取几个寄存器
try
{
// 3. 发送读取"保持寄存器"的请求 (功能码 03)
// 内部会自动组装报文并发送,等待硬件回应
ushort[] registers = master.ReadHoldingRegisters(slaveId, startAddress, numberOfPoints);
// 4. 打印读取到的数据
for (int i = 0; i < registers.Length; i++)
{
Console.WriteLine($"寄存器 [{startAddress + i}] 的当前数值 = {registers[i]}");
}
}
catch (Exception ex)
{
Console.WriteLine($"通信失败: {ex.Message}");
}
}
}
}
2.3.3向硬件写入数据(控制设备)
如果你想修改硬件里的参数,比如把 0 号保持寄存器的值改成 123,或者把 5 号线圈(灯)打开:
cs
// 写单个保持寄存器 (将地址0的值改为 123)
master.WriteSingleRegister(slaveId, 0, 123);
// 写单个线圈 (将地址5的线圈设为 true,即开灯)
master.WriteSingleCoil(slaveId, 5, true);
// 批量写多个保持寄存器
ushort[] dataToWrite = new ushort[] { 10, 20, 30 };
master.WriteMultipleRegisters(slaveId, startAddress, dataToWrite);
2.3.4数据类型的"避坑指南"
Modbus 的寄存器非常原始,一个寄存器只能装 16 位(2个字节)的无符号整数(ushort) 。 但在 C# 开发中,我们经常遇到小数(Float)或者大整数(Int32) ,它们占 32 位(4个字节),需要占用 2 个连续的 Modbus 寄存器。
这时候就需要我们在 C# 中进行数据转换(字节拼接)。
示例:如何读取硬件里的浮点数(Float,占2个寄存器)
cs
// 1. 读取两个连续寄存器
ushort[] rawData = master.ReadHoldingRegisters(slaveId, 0, 2);
// 2. 将 2 个 ushort 转换为字节数组(共4个字节)
byte[] lowBytes = BitConverter.GetBytes(rawData[0]);
byte[] highBytes = BitConverter.GetBytes(rawData[1]);
// 3. 把它们拼成一个 4 字节的数组
byte[] resultBytes = new byte[4];
Array.Copy(lowBytes, 0, resultBytes, 0, 2);
Array.Copy(highBytes, 0, resultBytes, 2, 2);
// ⚠️注意:工业上经常遇到"大小端/高低字节对调"的问题!
// 如果读出来的数字是天文数字,可能需要调换字节顺序: Array.Reverse(resultBytes);
// 4. 最终转换成 C# 的 float 小数
float temperature = BitConverter.ToSingle(resultBytes, 0);
Console.WriteLine($"实际温度小数: {temperature} ℃");
2.3.5串口(Modbus RTU)
如果你的硬件是用 RS485 网线或者串口连接电脑的,C# 代码只有初始化部分不同,读写函数的用法完全一模一样:
cs
using System.IO.Ports; // 需要引入串口命名空间
// 1. 初始化串口参数
using (SerialPort port = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One))
{
port.Open(); // 打开串口
// 2. 创建 Modbus RTU 主站
var factory = new ModbusFactory();
IModbusMaster master = factory.CreateRtuMaster(port);
// 3. 后面的 master.ReadHoldingRegisters 等操作和上面 TCP 完全一致!
}
三自定义报文
3.1意义
简单来说:HTTP 是通用普通话,Modbus 是行业术语,而自定义报文,就是你和硬件设备之间私下约定的"接头暗号"或"方言"。
当现有的标准协议(如 HTTP 太重、Modbus 太死板)无法满足特定硬件的需求、或是为了追求极致的传输效率和安全性时,开发人员就会自己定义一套字节规则。
设计自定义报文时绝对不能瞎写,必须要遵循一套经典的"通信帧(Frame)"结构
| 组成部分 | 常见长度 | 作用 | 举例 |
|---|---|---|---|
| 帧头 (Header) | 1~2 字节 | 标志着一条新报文的开始。用于在杂乱的信号中"对齐"数据。 | 0x55 0xAA 或 0x7E |
| 长度 (Length) | 1~2 字节 | 记录后面还有多少个字节。这是解决网络"粘包"的核心! | 0x00 0x0C (代表后面还有12字节) |
| 命令码 (CMD) | 1 字节 | 告诉对方你要干什么(如:登录、上传数据、控制开关)。 | 0x01:心跳包, 0x02:上报温度 |
| 数据域 (Payload) | N 字节 | 真正要传输的核心内容(如温度值、GPS坐标、设备状态)。 | 0x1A 0x2B |
| 校验码 (Check) | 1~2 字节 | 常见的有累加和(CheckSum)或 CRC16,防止传输中数据丢位、出错。 | 0xDE 0xAD |
| 帧尾 (Tail) | 0~2 字节 | 可选。标志着报文的绝对结束。 | 0x0D 0x0A (即 \r\n) |
3.2组包(发送时:把 C# 数据变成字节流)
因为是"自定义",市面上没有现成的第三方库能直接用。你需要直接使用 C# 的 Socket、TcpClient 或 SerialPort,手写代码去进行"组包(打包)"与"解包(拆包)"。
假设协议规定:帧头是 0xAA,命令码是 0x01(写温度),数据是两个字节的整数。我们要把温度 25 填进去,最后加上校验和(前面所有字节相加)。
cs
public byte[] PackTemperaturePacket(ushort temperature)
{
List<byte> packet = new List<byte>();
// 1. 添加帧头
packet.Add(0xAA);
// 2. 添加命令码
packet.Add(0x01);
// 3. 添加数据(注意:网络传输通常用大端序 BigEndian,而Windows是小端序)
byte[] tempBytes = BitConverter.GetBytes(temperature);
if (BitConverter.IsLittleEndian)
{
Array.Reverse(tempBytes); // 翻转字节,确保高位在前
}
packet.AddRange(tempBytes);
// 4. 计算校验和(把目前所有的字节加起来,取低8位)
byte checksum = 0;
foreach (byte b in packet)
{
checksum += b;
}
packet.Add(checksum); // 写入校验码
return packet.ToArray(); // 最终拿到了可以直接发送的 byte[]
}
3.3解包(接收时:从混乱的字节流中剥离出完整报文)
接收自定义报文是整个网络编程里最容易翻车的地方。因为 TCP 是面向字节流 的,设备发了两次报文,你的电脑可能会一次性收到(粘包 ),或者只收到半个报文(拆包)。
我们需要建立一个接收缓冲区,像剥洋葱一样去解析数据:
cs
// 全局接收缓冲区
List<byte> receiveBuffer = new List<byte>();
void OnDataReceived(byte[] chunk)
{
// 把新收到的无规律字节塞进缓存
receiveBuffer.AddRange(chunk);
// 循环解析,直到缓存里的数据不够一条完整的报文
while (receiveBuffer.Count >= 5) // 假设我们最短的报文也得5个字节
{
// 1. 寻找帧头
if (receiveBuffer[0] != 0xAA)
{
receiveBuffer.RemoveAt(0); // 不是帧头,视为垃圾数据,扔掉,继续找
continue;
}
// 2. 假设根据协议,第 2 个字节代表整条报文的总长度
int packetLength = receiveBuffer[1];
// 3. 检查缓存里的数据够不够这一条报文的指定长度
if (receiveBuffer.Count < packetLength)
{
break; // 数据还没收全,退出循环,等下一次数据接收事件来拼接
}
// 4. 说明已经有至少一条完整报文了,提取出来
byte[] completePacket = receiveBuffer.GetRange(0, packetLength).ToArray();
receiveBuffer.RemoveRange(0, packetLength); // 从缓存中移除已提取的部分
// 5. 验证校验和并解析
if (VerifyChecksum(completePacket))
{
ParseData(completePacket); // 校验通过,提取里面的核心数据
}
}
}
3.4什么时候用自定义报文
-
硬件极度受限:比如单片机、蓝牙手环,内存只有几 KB。跑不起 HTTP 这种动辄几百字节的文本。自定义二进制报文可以精简到 5 个字节,极度省电、省带宽。
-
极致的性能与速度:在超高速工业控制、实时网络游戏中,自定义二进制报文的解析速度比 JSON/XML 快成百上千倍。
-
高保密性 :如果没有公开的协议文档,外人即使抓到了你的网包,也只是一串毫无规律的
0xAA 0x01...,极难破解。
无论是标准 Modbus 还是自定义报文,在 C# 眼里,底层全是 byte[](字节数组) 。Modbus 是别人帮你定好了格式写好了库;而自定义报文需要你既当设计师(定规则),又当搬砖工(手写拼接和拆解)
3.5自定义报文底层原理
需要发的
站号\] \[功能码
起始地址高8位\] \[起始地址低8位\] \[数量高8位\] \[数量低8位
CRC低8位\] \[CRC高8位
需求:
读 1号设备 功能码 03(读保持寄存器) 起始地址:0x07D1 读 4 个寄存器
我们要手动把它变成报文 : 01 03 07 D1 00 04 40 02
步骤
1.拆地址
把 16 位地址 → 拆成 高 8 位 + 低 8 位
为什么要拆?
因为 串口一次只能发 8 位(1 字节) 但 Modbus 地址是 16 位 所以必须拆成两个字节发。
怎么拆:0x07D1
1.1取高8位
原理:向右移 8 位,高 8 位移到低 8 位位置,原来的低 8 位丢掉
0x07D1 → 二进制:00000111 11010001
右移8位 → 00000000 00000111 → 0x07
cs
高8位 = (byte)(地址 >> 8);
1.2取低8位
原理:和 0x00FF 做按位与,只保留低 8 位,高 8 位清零。
0x07D1 & 0x00FF → 00000000 11010001 → 0xD1
cs
低8位 = (byte)(地址 & 0xFF);
2.拆寄存器数量
把 "寄存器数量" 也拆成高低 8 位
数量:4 → 0x0004
高 8 位:00 低 8 位:04
3.加CRC16校验
crc 初始值:
0xFFFF 第1轮 → crc 变成新值
第2轮 → crc 再更新
第3轮 → 继续更新
...
第8轮 → 得到这个字节最终的 crc
3.1初始值
crc = 0xFFFF;
3.2每个字节和 crc 低 8 位 异或(^)
crc = crc ^ 当前字节;
3.3循环 8 次(处理 8 位)
每一位:
lsb = crc & 1:拿最低位crc >>= 1:把最低位移出去丢掉if (lsb==1) crc ^= 0xA001: 丢掉的位是 1 ⇒ 用【右移后的 crc】异或【多项式 0xA001】,结果放回 crc
3.4多项式固定
0xA001(Modbus 标准)