技术学习··52 分钟阅读

C# 上位机通讯实战之一:Modbus

Modbus 协议详解

上位机通讯Modbus

 

内容要点:

​ 梳理 Modbus 的历史,Modbus RTU 和 TCP 的区别,还有技术要点。

 
 

上一篇文章给上位机通讯画了一份地图,了解了各种物理接口、通讯协议。

这篇文章,将挑选几个最重要的通讯协议,深入学习。

 

第一关:Modbus协议

 

入门必学。不管你是连温控器、变频器还是低端 PLC,它是唯一的通用语言。

 

Modbus 历史

 

1979 年(比 HTTP、TCP/IP 都要老,真是爷爷辈的), Modicon 公司(现在的施耐德电气),发明了世界上第一台 PLC,为了让 PLC 能和电脑说话,他们发明了这个协议。并且完全免费、公开发布这个协议的标准。任何人都可以拿去用,不需要交专利费。

 

经过 40 多年的积累,市场上已经有几十亿台设备支持它。同时它的规则也简单,加上免费开源,Modbus 已经成为了工业界最流行的协议。

 

Modbus RTU (Remote Terminal Unit 远程终端单元)。一开始(70-80年代),设备少,仅靠串口就可以连接所有设备,进行通讯。这一阶段的 Modbus 协议都是 Modbus RTU 。

 

Modbus TCP (Transmission Control Protocol 远程终端单元)。后来(90年代-现在), 互联网爆发,设备越来越多,为了快、为了远程联网,换成网线连接。协议包了一层壳,变成了 Modbus TCP 。

 

现在,Modbus RTU 、TCP,都是市场上广泛使用的协议,因为便宜,前者并没有被淘汰。也可总结为,“网口就是 TCP,串口就是 RTU”。

 

Modbus 优势

  • 门槛极低: 报文结构非常清晰,容易抓包调试。
  • 硬件便宜: 几块钱的芯片就能跑。
  • 极其稳定: 经过了 40 年工厂恶劣环境的考验,它是最皮实的。
  • 跨物理层: 它可以跑在两根铜线上(485),也可以跑在网线上(TCP),甚至可以跑在光纤、无线电上。

 

Modbus 是什么

 

Modbus 是一种“一问一答”的对话规则。

 

角色分工:

  • Master(主站): 通常是上位机。永远由主站发起对话,“你,把温度告诉我!”或者“你,把开关打开!”。
  • Slave(从站): 通常是 PLC、温控器、仪表。永远不会主动说话,只有问了,才回答。

 

对话内容:

  • 极其简单,只有两种事:读数据(Read)和 写数据(Write)。
  • 数据存在哪里?存在设备的**寄存器(Register)**里,就像是一个个标了号的“抽屉”。

 

Modbus 协议

 

1. 四大存储区(数据存在哪?)

Modbus 设备里面有四个存储数据的地方:

在 C# 编程中,用得最多的就是 保持寄存器 (Holding Registers, 4区)。因为现代设备为了方便,经常把“只读数据”也映射到这个区里,让你读又能写,省得换区。

区域名称英文代号俗称读写权限数据类型典型用途备注
线圈Coils (0x)DO (输出)读 / 写Bit (位) 只有 ON/OFF控制开关: 启动电机、打开阀门、亮红灯。最常用 地址通常以 0 开头 (如 00001)
离散输入Discrete Inputs (1x)DI (输入)只读Bit (位) 只有 ON/OFF读取状态: 按钮按下没?光电开关遮挡没?地址通常以 1 开头 (如 10001)
输入寄存器Input Registers (3x)AI (模拟量入)只读16位 Word (字) 数值 (0~65535)读取数值: 温度传感器读数、流量计读数。地址通常以 3 开头 (如 30001)
保持寄存器Holding Registers (4x)AO/参数读 / 写16位 Word (字) 数值 (0~65535)设定参数: 设定温度 100℃、设定转速。由它统治世界! 绝大多数数据都放这。 地址通常以 4 开头 (如 40001)

 

2. 功能码(怎么操作?)

 

上位机发给设备的指令里,有一个字节专门告诉设备“我要干什么”,这就是功能码。 虽然标准定义了很多,但工业现场90% 的情况只用这 4 个

 

代码 (Hex)功能名称针对哪个存储区?作用描述C# 方法 (NModbus4示例)
01Read Coils线圈 (0x)读开关状态ReadCoils()
02Read Discrete Inputs离散输入 (1x)读输入状态ReadInputs()
03Read Holding Registers保持寄存器 (4x)读数值ReadHoldingRegisters() (这是最常用的指令,没有之一)
06Write Single Register保持寄存器 (4x)写一个数值WriteSingleRegister()
10 (16)Write Multiple Registers保持寄存器 (4x)写多个数值WriteMultipleRegisters()

 

3. 报文结构(数据包长什么样?)

 

Modbus RTU 为例,看一个最经典的交互过程:

场景: 上位机(主站 01)想读取 变频器(从站 01)的输出频率(假设在寄存器 2000 处,读 1 个)。

 

发送 (Request):上位机 -> 设备

报文:01 03 07 D0 00 01 85 CF

字节含义解释
01从站地址喂,1号设备,我在跟你说话。
03功能码我要读保持寄存器
07 D0起始地址从第 2000 号地址开始读 (0x07D0 = 2000)。
00 01数量我只要读 1 个寄存器。
85 CFCRC 校验这是为了防止数据传输出错算出来的校验码。

 

接收 (Response):设备 -> 上位机

报文:01 03 02 13 88 B5 12

字节含义解释
01从站地址我是1号,我回话了。
03功能码这是针对你刚才“读寄存器”的回复。
02字节数后面跟着 2 个字节的数据(因为1个寄存器=2字节)。
13 88数据内容0x1388 = 十进制 5000 (代表 50.00 Hz)。
B5 12CRC 校验校验码。

 

Modbus RTU 和 TCP 的区别

 

发送同样的数据包内容,RTU 和 TCP 的区别如下:

组成部分1. TCP 专用报文头 (MBAP)2. 从站 ID / 单元标识符3. PDU (功能码 + 数据) (完全一致的核心)4. RTU 专用 校验尾巴(CRC)
Modbus TCP00 01 00 00 00 060103 00 00 00 01(无)
Modbus RTU(无)0103 00 00 00 0184 0A
加头: 也就是 MBAP 头。 用来在网络上管理这包数据。身份: 我是 1 号设备。 (TCP里叫 Unit ID,RTU里叫 Slave ID)核心指令: 03: 读保持寄存器 00 00: 从地址 0 开始 00 01: 读 1 个去尾: TCP 底层自带校验, 所以把 CRC 扔了。

 

Modbus RTU(经典款)

 

跑在串口上 (RS-485 / RS-232),带宽不大,数据是紧凑的十六进制 (Hex)

结构: [从站ID] + [PDU] + [CRC校验]

特点:

  • 时间间隔敏感: 它靠“时间停顿”来判断一句话说没说完(3.5个字符时间)。

  • 校验强: 使用 CRC (循环冗余校验),算错一位数就丢弃。

  • 场景: 也就是我们说的“串口派”。连接温控器、变频器等低成本设备。

 

Modbus TCP(现代款)

 

跑在网口上,带宽大,给 RTU 的数据穿了一层 TCP 的马甲(MBAP)

结构: [MBAP 报文头] + [PDU]

特点:

  • 去掉了 CRC: 因为 TCP 协议本身就保证数据不会错,Modbus 就不操心校验了。
  • 加了 MBAP 头: 用来标记事务 ID(比如这是第几号指令)。

场景: 也就是我们说的“网口派”。连接 PLC、远程 IO 模块。

 

通讯实战

 

通讯建立的三部曲

 

第一步:配置参数

 

这是新手最容易忽略,也是最容易导致“连不上”的原因。两边的设置必须一模一样!

 

如果是串口 (RS-485):

  • (上位机)和(设备)必须约定好配置参数。
  • 波特率 (Baud Rate): 比如都设为 9600。如果一个是 9600 一个是 19200,那就是鸡同鸭讲。
  • 数据位/停止位/校验位: 通常是 8-N-1(8数据位,无校验,1停止位)。
  • 站号 (Slave ID): 设备说“我是 1 号”,你代码里就必须呼叫“1 号”。

 

如果是网口 (TCP):

  • IP 地址: 必须在同一个网段。比如设备是 192.168.1.5,你的电脑必须是 192.168.1.X
  • 端口号 (Port): Modbus TCP 默认永远是 502

 

第二步:敲门(发起请求)

 

Modbus 是**“主从模式”**,如果上位机不发送内容,设备不会回复。

 

发起了第一次“读写指令”。

 

在 C# 代码里,这一步就是:

  1. 打开端口: serialPort.Open()client.Connect()

  2. 发送指令: master.ReadHoldingRegisters(...)

 

第三步:验证

 

验证是否发送成功:

 

看代码返回值

  • 成功: 你的 C# 方法顺利返回了一个数组,里面的数字不是 0(或者是你预期的 0)。没有报错(Exception)。

  • 失败: 程序直接崩了,抛出 TimeoutException(超时异常)。

 

看硬件指示灯)

  • 几乎所有工业设备的网口或串口旁都有两个小灯:Tx (发送)Rx (接收)
  • Tx 闪: 说明你的电脑指令发过去了(路通了)。
  • Rx 闪: 说明设备收到指令并回话了(暗号对了)。
  • 如果不亮: 线没接好。
  • 如果只 Tx 亮,Rx 不亮: 线通了,但波特率/站号/ID设置错了,设备听不懂,拒绝回答。

 

用“替身”测试

在写代码前,先用现成的软件(Modbus Poll)连一下。

  • 如果 Modbus Poll 显示 Timeout Error,先去查线和参数。
  • 如果 Modbus Poll 能看到数字在跳,说明硬件和参数没问题。

 

c# 实战

 

新建项目: Visual Studio -> 新建控制台应用 (.NET Framework 或 .NET 6/7/8 均可)。

安装库: 在“解决方案资源管理器” -> 右键项目 -> 管理 NuGet 程序包 -> 搜索并安装 NModbus4

注:NModbus4 是最经典的开源库,虽然好久没更新,但极其稳定,适合教学。

 

1️⃣ Modbus RTU (串口) 完整封装类

适用场景: USB转485 连接温控器、传感器、变频器。

 

using System;
using System.IO.Ports;
using Modbus.Device; // 核心命名空间,来自 NModbus4

public class ModbusRtuService
{
    private SerialPort _serialPort;
    private IModbusSerialMaster _master;

    /// <summary>
    /// 初始化串口连接
    /// </summary>
    /// <param name="portName">COM口名称,如 "COM3"</param>
    /// <param name="baudRate">波特率,如 9600</param>
    public void Connect(string portName, int baudRate)
    {
        try
        {
            // 1. 配置物理层 (SerialPort)
            _serialPort = new SerialPort(portName)
            {
                BaudRate = baudRate,
                DataBits = 8,
                Parity = Parity.None,
                StopBits = StopBits.One
            };
            
            _serialPort.Open(); // 打开串口

            // 2. 创建协议层 (Modbus Master)
            var factory = new ModbusFactory();
            _master = factory.CreateRtuMaster(_serialPort);

            // 设置读取超时时间 (重要!防止设备掉线后程序卡死)
            _master.Transport.ReadTimeout = 1000; // 1秒超时
            
            Console.WriteLine($"[RTU] 串口 {portName} 打开成功!");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[RTU] 连接失败: {ex.Message}");
        }
    }

    /// <summary>
    /// 读取保持寄存器 (功能码 03)
    /// </summary>
    /// <param name="slaveId">从站地址 (1-247)</param>
    /// <param name="startAddress">起始地址 (说明书地址 - 1)</param>
    /// <param name="numRegisters">读取数量</param>
    public ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddress, ushort numRegisters)
    {
        try
        {
            if (_master == null) return null;

            // 发送指令并等待返回
            // 参数:站号, 起始地址, 数量
            return _master.ReadHoldingRegisters(slaveId, startAddress, numRegisters);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[RTU] 读取失败: {ex.Message}");
            return null;
        }
    }

    /// <summary>
    /// 写入单个寄存器 (功能码 06)
    /// </summary>
    public void WriteSingleRegister(byte slaveId, ushort address, ushort value)
    {
        try
        {
            _master?.WriteSingleRegister(slaveId, address, value);
            Console.WriteLine($"[RTU] 写入成功: 地址 {address} -> 值 {value}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[RTU] 写入失败: {ex.Message}");
        }
    }

    /// <summary>
    /// 关闭连接
    /// </summary>
    public void Disconnect()
    {
        _serialPort?.Close();
        _master?.Dispose();
        Console.WriteLine("[RTU] 连接已关闭");
    }
}

 

2️⃣ Modbus TCP (网口) 完整封装类

适用场景: 网线连接 PLC、远程 IO 模块。

using System;
using System.Net.Sockets;
using Modbus.Device;

public class ModbusTcpService
{
    private TcpClient _tcpClient;
    private ModbusIpMaster _master;

    /// <summary>
    /// 连接到 Modbus TCP 设备
    /// </summary>
    /// <param name="ip">设备的 IP 地址</param>
    /// <param name="port">端口号,默认 502</param>
    public void Connect(string ip, int port = 502)
    {
        try
        {
            // 1. 配置物理层 (TcpClient)
            _tcpClient = new TcpClient();
            _tcpClient.Connect(ip, port); // 建立 TCP 连接

            // 2. 创建协议层 (Modbus Master)
            var factory = new ModbusFactory();
            _master = factory.CreateMaster(_tcpClient);
            
            // 设置超时
            _master.Transport.ReadTimeout = 2000;

            Console.WriteLine($"[TCP] 连接 {ip}:{port} 成功!");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[TCP] 连接失败: {ex.Message}");
        }
    }

    /// <summary>
    /// 读取保持寄存器 (功能码 03)
    /// </summary>
    public ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddress, ushort numRegisters)
    {
        try
        {
            // TCP 模式下,slaveId 对应报文里的 Unit Identifier
            // 如果直连 PLC,通常填 1 或 0 都可以
            return _master?.ReadHoldingRegisters(slaveId, startAddress, numRegisters);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[TCP] 读取失败: {ex.Message}");
            return null;
        }
    }

    public void Disconnect()
    {
        _tcpClient?.Close();
        _master?.Dispose();
        Console.WriteLine("[TCP] 连接已关闭");
    }
}

 

3️⃣ 进阶:如何处理“小数” (Float/Real)?

Modbus 读回来的是 ushort[] (16位整数数组)。如果你要读温度 25.5,设备通常是用 两个寄存器 拼成一个 32位浮点数 存的。

public static class ModbusUtils
{
    /// <summary>
    /// 将两个寄存器 (ushort) 转换为 浮点数 (float)
    /// </summary>
    /// <param name="registers">Modbus读回来的数组 (至少2个长度)</param>
    /// <returns>转换后的浮点数</returns>
    public static float ConvertToFloat(ushort[] registers)
    {
        if (registers == null || registers.Length < 2) return 0.0f;

        // 1. 拿到高位和低位 (注意:这里可能有 ABCD 或 CDAB 的字节序问题)
        // 假设顺序是 CDAB (低位寄存器在前,高位在后) -> 这是最常见的工控格式
        ushort low = registers[0];
        ushort high = registers[1];

        // 2. 转换成字节数组
        byte[] lowBytes = BitConverter.GetBytes(low);
        byte[] highBytes = BitConverter.GetBytes(high);

        // 3. 拼成一个 4 字节数组 (float 是 4 字节)
        byte[] floatBytes = new byte[4];
        
        // 这里的顺序决定了是 ABCD 还是 CDAB,如果读数不对,就调整这里的赋值顺序
        floatBytes[0] = lowBytes[0];
        floatBytes[1] = lowBytes[1];
        floatBytes[2] = highBytes[0];
        floatBytes[3] = highBytes[1];

        // 4. 转换成 float
        return BitConverter.ToSingle(floatBytes, 0);
    }
}

 

4️⃣ 主程序 (Main):让代码跑起来

连接一个串口设备,读取地址 40001 (温度) 和 40002 (湿度)。

class Program
{
    static void Main(string[] args)
    {
        // ---------------------------------------------
        // 场景 A: 测试 Modbus RTU (串口)
        // ---------------------------------------------
        ModbusRtuService rtu = new ModbusRtuService();
        
        // 1. 连接 (请去设备管理器看你的 COM 口号)
        rtu.Connect("COM3", 9600);

        // 2. 读取数据
        // 假设说明书说:温度在 40001,湿度在 40002
        // 代码里地址要减 1 -> 0, 1
        // 我们一次读 2 个
        byte slaveId = 1;
        ushort startAddr = 0;
        ushort count = 2;

        Console.WriteLine("\n正在读取数据...");
        var data = rtu.ReadHoldingRegisters(slaveId, startAddr, count);

        if (data != null)
        {
            Console.WriteLine($"[原始数据] 寄存器0: {data[0]}, 寄存器1: {data[1]}");

            // 假设厂家说:读出来的数除以 10 就是真实温度
            double temp = data[0] / 10.0;
            Console.WriteLine($"[解析后] 当前温度: {temp} ℃");
        }

        // 3. 写入数据
        // 假设 40010 是设定温度,我们要设为 100
        rtu.WriteSingleRegister(slaveId, 9, 100);

        // 4. 断开
        Console.ReadLine(); // 暂停看一下
        rtu.Disconnect();
    }
}

 

避坑指南

 

地址偏移坑

说明书上写“频率地址 40001”,你代码里读 40001,结果报错或者读到了错误的数据(比如读到了 40002 的值)。

 

PLC/文档地址(逻辑地址)通常是从 1 开始计数的。

协议/代码地址(物理地址)是从 0 开始计数的。

 

代码地址 = 说明书地址 - 1

  • 文档写 40001 -> 代码填 0
  • 文档写 40100 -> 代码填 99
  • 注意:如果文档直接写 Hex 地址(如 0x2000),通常不需要减 1,直接用即可。

 

大小端坑 (Endian)

 

读一个压力值,本来应该是 25.5,结果读出来是 1.34e-38 或者一个几十亿的巨大整数。

 

Modbus 寄存器是 16 位的(2字节)。

当你读 32 位数据(如 floatint)时,需要读 2 个寄存器(4字节)。

坑在于顺序: 设备发过来的 4 个字节是 A B C D 顺序,还是 C D A B 顺序?标准没有强制规定,各家厂家随便乱来。

 

先用调试软件(Modbus Poll)连上。

在软件设置里改 Byte Order(字节序),试 ABCDCDABBADC... 直到看到正常的数字为止。

然后在 C# 代码里做同样的字节交换处理。

 

 

接线反转坑:RS-232 vs RS-485

 

线插上了,死活连不上,RX 灯不闪。

232 和 485 的接法逻辑完全相反。

RS-232(交叉接): 电脑的 TX(发)要接设备的 RX(收),RXTX

RS-485(直连): AA(正接正),BB(负接负)。

 

硬件坑

通讯极其不稳定,发 10 次成功 5 次;或者运行一小时后电脑蓝屏/串口消失。

 

淘宝上那种 5 块钱蓝色的 USB 转 485,抗干扰能力极差,且容易因为发热导致电平漂移。

 

贵点,买个带 FTDI 芯片 或者 隔离保护 的转换器。

检查有没有接地线(GND)。485 虽然是两根线,但在强干扰环境下,把屏蔽层接地能救命。

 

轮询坑

 

程序刚运行挺好,过一会设备就掉线,或者界面卡死,或者设备报错。

 

while(true) 循环里读得太快了!

刚发完一条指令,设备还没来得及处理数据,下一条指令又过去了。

或者你在 UI 线程里直接写循环,导致界面卡死。

 

必须加延时: 每次读取之间,至少 Thread.Sleep(20)50 毫秒。给设备一点反应时间。

设置超时: 必须给串口或 TCP 设置 ReadTimeout(比如 1000ms),并用 try-catch 捕获超时异常,防止程序崩溃。

 

ID 冲突坑

 

485 总线上挂了 3 个传感器,单独连都正常,接在一起数据就乱跳,校验错误。

 

忘了修改站号(Slave ID),它们出厂默认都是 1 号。当你喊“1号”时,3 个设备同时在总线上向你回话,电信号就撞车了。

 

上机安装前,必须单独连接每一个设备,把 ID 改成不同的(1, 2, 3...),并在设备外壳上贴个标签写好 ID。