通信【报文】

一面向寄存器的报文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 0xAA0x7E
长度 (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# 的 SocketTcpClientSerialPort,手写代码去进行"组包(打包)""解包(拆包)"。

假设协议规定:帧头是 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 标准)

3.5最后把 16 位 CRC 拆成 低字节在前
相关推荐
志栋智能1 小时前
小步快跑:从单一场景开启超自动化巡检之旅
运维·网络·人工智能·自动化
XINERTEL1 小时前
视频卡顿花屏?专业视频质量评估测试让画质从“凭感觉”到“数据说话”
网络·测试工具·音视频·丢包
AugustRed2 小时前
Linux 运维常用命令大全(超全速查表)
运维·网络·php
正在走向自律2 小时前
远程控制软件安全对比2026:ToDesk vs 向日葵 vs TeamViewer,你的电脑钥匙交给谁更放心
网络·远程办公·远程服务
胡楚昊2 小时前
Vulnhub靶场 Tr0ll打靶(上)
网络
gs801403 小时前
网络隐形杀手:从 Could not connect to SMTP host 报错深度剖析 Docker MTU 黑洞理论与实战
网络·docker·容器
wanhengidc3 小时前
云手机搬砖 像僵尸开炮
运维·网络·智能手机·云计算
星恒讯工业路由器4 小时前
5G‑A大上行:七大技术补短板
网络·信息与通信·6g·5g‑a·5g-a大上行