Modbus通信协议
什么是Modbus?
顾名思义,它是一个Bus,即总线协议。比如串口协议、IIC协议、SPI都是通信协议。你接触到这种协议,相信你所处的行业是工业电子方面或者你的产品用于工业。
好了,现在知道了大概知道了,这是一个总线协议,是一个Mod什么的公司发表的一个通信协议。那为什么要用这个呢? 答案就是他们都在用,你就得学
正经的说,它被工业领域所接受的原因是它具备一下三个优点
- 公开发表并且无版权要求
- 易于部署和维护
- 对供应商来说,修改移动本地的比特或字节没有很多限制
简单的概括,就是免费+简单+方便修改!
归纳:Modbus就是一种用在工业上的简单协议!
Modbus的内容是什么?
大致分为以下几种:
- Modbus-RTU
- Modbus-ASCII
- Modbus-TCP
以上三种协议,一个设备只会有一种协议,如果你的设备使用的是Modbus-RTU,只需查看以下对应部分,一般来说大部分的设备都是Modbus-RTU协议的。
通讯过程
Modbus是主从方式通信,也就是说,不能同步进行通信,总线上每次只有一个数据进行传输,即主机发送,从机应答,主机不发送,总线上就没有数据通信。(所以说,这也算是一个缺点了)
举例1: 一个总线上有一个主机,多个从机,主机查询其中一个从机,首先你必须得这些从机分配地址(这样才能知道哪个从机,而且每个地址必须唯一),分配好地址后,主机要查询,然后数据下发(数据内容下面会介绍),从机得到主机发送的数据,然后对应地址的从机回复,主机得到从机数据,这样就是一个主机到从机的通信过程,是不是很简单呢
?
举例2: 就像打电话,你得知道对方的电话(这就是唯一地址),然后你打电话过去,相当于主机查找从机,然后对方接通电话,给你回复(返回数据),正常是这样的。
如果这时候,对方正在打电话,你应该听到的是"sorry,you…"这一串英文,说明对方忙,但是Modbus总线不能判断对方是否忙,也没有对应的仲裁机制,好了你又知道了一个缺点了!但是,你可以用软件的办法进行适当的处理数据!
Modbus-RTU协议
设备必须要有RTU协议!这是Modbus协议上规定的,且默认模式必须是RTU,ASCII作为选项。(也就是说,一般的设备只有RTU这个协议,ASCII一般很少)所以说,一般学习Modbus协议,只需要了解RTU的协议,ASCII作为学习的了解就足够了。
帧结构
帧结构 = 地址 + 功能码+ 数据 + 校验
- 地址: 占用一个字节,范围0-255,其中有效范围是1-247,其他有特殊用途,比如255是广播地址(广播地址就是应答所有地址,正常的需要两个设备的地址一样才能进行查询和回复)。
- 功能码:占用一个字节,功能码的意义就是,知道这个指令是干啥的,比如你可以查询从机的数据,也可以修改数据,所以不同功能码对应不同功能。可以类比一下RESTful的各种GET POST PUT...这样。
- 数据:根据功能码不同,有不同结构,在下面的实例中有说明。就像json xx-form一样。
- 校验:为了保证数据不错误,增加这个,然后再把前面的数据进行计算看数据是否一致,如果一致,就说明这帧数据是正确的,我再回复;如果不一样,说明你这个数据在传输的时候出了问题,数据不对的,所以就抛弃了。
实战
只谈理论大家可能不太明白,下面举一个例子。Modbus-RTU协议一般我们用的最多功能码就是03和06,大部分都是用modbus来查询传感器上的信息用03查询功能码,如果需要修改传感器寄存器的值就用06修改功能码。

回复数据解析

- 发送:从机的地址+我要干嘛的功能码+我要查的寄存器的地址+我要查的寄存器地址的个数+校验码
- 回复:从机的地址+主机发我的功能码+要发送给主机数据的字节数+数据+校验码
归纳
Modbus-RTU协议只需要看懂功能码0x03、0x06、0x10这三个基本的就已经足够了,分别回想下其数据域部分:
0x03—主机需要发送起始地址+寄存器数量,从机回复总字节数+数据;
0x06—主机发送起始地址+数据内容(因为你只需要修改一个,所以起始地址就是所要修改的地址),从机返回起始地址+数据内容(发现居然一样!)
0x10—主机发送起始地址+寄存器个数+总字节数+数据,从机返回起始地址+寄存器数量
Modbus-ACSII协议
一般只需要了解RTU协议,因为前面有说过,必须要有RTU协议的,所以只需要了解了RTU协议,就可以读出设备信息了,至于ACSII协议,做个大概了解即可。
帧形式
对于RTU协议,比如RTU发送一个字节:0x12;ASCII协议则需要发送2个字节:一个字节代表ASCII码1,一个代表ASCII码2,即0x31和0x32,才能代表0x12。所以,ASCII协议的效率比较低。但是ASCII更符合串口打印查看,因为串口发送的数据一般都是文本模式(ASCII)。
比如用RTU方式,也叫16进制方式,要发0x03数据,RTU方式就发送00000011。用ASCII发送0x03,就要发送0的ASCII码0x30和3的ASCII码0x33,对应到2进制也就是发送00110000和00110011。很显然RTU方式只需要发送8位就可以了(加上起始位和停止位就是10位数据)。那么ASCII码方式发送就需要两个8位(每个8位分别加上起始位和停止位就是20位数据)。也就是说ASCII码发送数据量是RTU方式的2倍,所以ASCII码效率更低。
那么ASCII码效率更低,数据发送量大为啥还采用这种方式呢?
因为假如你要发送数据0x03,采用RTU方式(16进制发送),计算机中端设备接收到0x03后是不可以显示的,就是不能把0x03打印出来。因为可见字符的ASCII码是从32—126,不是这个范围以外的显示屏上都看不到,会出现乱码,如果是串口助手的话就会显示□□□□。如果采用ASCII方式(文本模式发送),就不会出现不可显示和乱码的情况,因为文本模式发送0x03,就是发送ASCII码0和ASCII码3,也就是0x30和0x33,是可以正常显示在计算机中端的。所以现在知道为什么还要使用ASCII效率比较低的方式发送了吧,只是为了方便调试显示而已。

从上面的图中,看出:
1)比RTU多了起始段:,多个结束符CR,LF
2)地址和功能都变成了2个字节;
3)数据部分更加繁琐,但是更符合人们的查看;
归纳
由于Modbus-RTU和Modbus-ACSII都是基于232和485链路的,所以其通讯模式半双工,一般是主机和从机的模式。其差别就是其字节的格式不同,一个是16进制的数据,一个是ASCII数据。ASCII多了帧头和帧尾,也就是说可以有用这个头尾判断一帧字节来判断是否结束;而RTU没有帧头和帧尾,所以协议里明确两帧之间要大于3.5个字节时间间隔,作为一帧结束的判断依据。对于RS485来说,总线上一般允许最大32个设备。
常见的功能码
Modbus 四大数据区回顾
首先,我们需要理解 Modbus 的世界主要围绕着四种基本的数据区(或称数据类型)构建。您的上位机程序就是通过不同的功能码来读写这些区域。
| 数据区 | 寄存器地址范围 | 数据类型 | 可写性 | 通俗比喻 |
|---|---|---|---|---|
| 线圈 (Coils) | 0xxxx | 1-bit (开关量) | 可读可写 | 继电器、指示灯、电磁阀的控制开关 |
| 离散输入 (Discrete Inputs) | 1xxxx | 1-bit (开关量) | 只读 | 物理按钮、传感器、行程开关的状态指示灯 |
| 输入寄存器 (Input Registers) | 3xxxx | 16-bit (模拟量) | 只读 | 温度/压力传感器的当前读数 |
| 保持寄存器 (Holding Registers) | 4xxxx | 16-bit (模拟量) | 可读可写 | 温控器的温度设定值 |
下面这张表包含了您在 99% 的工控应用开发中会遇到的核心功能码。
| 功能码 | 名称 (英文) | 作用 | 操作对象 | 读/写 |
|---|---|---|---|---|
01 (0x01) | Read Coils | 读取一个或多个线圈的状态 | 线圈 (Coils) | 读 |
02 (0x02) | Read Discrete Inputs | 读取一个或多个离散输入的状态 | 离散输入 | 读 |
03 (0x03) | Read Holding Registers | 读取一个或多个保持寄存器的值 | 保持寄存器 | 读 |
04 (0x04) | Read Input Registers | 读取一个或多个输入寄存器的值 | 输入寄存器 | 读 |
05 (0x05) | Write Single Coil | 写入单个线圈的状态(置 ON 或 OFF) | 线圈 (Coils) | 写 |
06 (0x06) | Write Single Holding Register | 写入单个保持寄存器的值 | 保持寄存器 | 写 |
15 (0x0F) | Write Multiple Coils | 写入多个连续线圈的状态 | 线圈 (Coils) | 写 |
16 (0x10) | Write Multiple Holding Registers | 写入多个连续保持寄存器的值 | 保持寄存器 | 写 |
Modbus TCP
Modbus TCP是Modbus协议在以太网上的一种实现,它保留了Modbus协议的核心功能和数据模型,但对消息封装进行了调整,使其符合TCP/IP的要求。在进行下一步之前我们首先对Modbus中的一些概念做一个了解。
存储区存储的数据类型分为布尔量和寄存器,布尔量如水阀的开关、灯的开关等数据。寄存器 如流速、导电率、温湿度、空气浓度等数据。
Modbus规定了4个存储区,分别是0、1、3、4,对应下面表格内容,具体为:
0区:可读可写的布尔量
1区:只读布尔量
3区:只读寄存器
4区:可读可写的寄存器

功能码
Modbus定义了一系列的功能码,这些功能码表示了主站请求从站执行的具体操作,下面表格是一些常见的功能码,具体为:
1.4.1读操作
0x01:读输出线圈状态。
0x02:读输入线圈状态。
0x03:读输出寄存器的值。
0x04:读输入寄存器的值。
1.4.2写操作
0x05:写单个线圈状态。
0x06:写单个寄存器的值。
0x0F:写多个线圈状态。
0x10:写多个寄存器的值。

协议报文
Modbus TCP/IP的报文被封装在一个标准的TCP数据段内,而不是像串行通信那样直接在物理层上发送。TCP报文提供了端到端的可靠传输,包括数据分段、重传、流量控制和拥塞控制等机制。
Modbus TCP/IP的报文帧主要包括:
MBAP头:包含事务标识符、协议标识符、消息长度、设备地址
Modbus PDU:原始Modbus协议的数据部分,包括功能码、数据地址和数据值。

下面我们用一个表格来根据一段报文解释这两部分的具体内容 主站发送报文:

从站响应报文:

从上面的报文中我们可以知道:
事务标识符:占2个字节,一次通信的过程中主站和从站的报文帧的事务标识符是一致的。
协议标识符:占2个字节,00 00表示Modbus TCP协议。
后面报文的长度:占2个字节,表示后面报文的长度。
从站地址:占1个字节,表示设备的地址,也就是Salve ID的值。
功能码:占1个字节,例子中的03表示的是读输出寄存器的值。
起始地址:占2个字节,表示从该设备的哪个位置开始读。
读取数量:占2个字节,表示从起始位置往后读的数量。
长度:占2个字节,表示后面还报文的长度。
对应的值:根据不同的存储类型所占的字节不同。
———————————————— 版权声明:本文为CSDN博主「上海研博数据」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.csdn.net/yanboshuju/article/details/142519763
实战:
接下来我们尝试来写一个modbus tcp通信的从机。
using NModbus;
using NModbus.Data;
using NModbus.Device;
using NModbus.Serial;
using System.IO.Ports;
using System.Net;
using System.Net.Sockets;
namespace modbus_slave;
class Program
{
static async Task Main(string[] args)
{
// --- 1. 定义从站和TCP参数 ---
byte slaveId = 1;
int port = 502; // Modbus TCP 默认端口
// IPAddress.Any (0.0.0.0) 表示监听容器内所有可用的网络接口
var ipAddress = IPAddress.Any;
Console.WriteLine($"准备在 {ipAddress}:{port} 上启动 Modbus TCP 从站 (ID: {slaveId})...");
// --- 2. 创建监听器 ---
var listener = new TcpListener(ipAddress, port);
listener.Start();
// --- 3. 创建数据存储区 (与之前完全一样) ---
var dataStore = new SlaveDataStore();
dataStore.HoldingRegisters.WritePoints(1, new ushort[] { 10, 20, 30 }); // 写入保持寄存器 (Holding Registers)。 这里是从地址1开始,也就是40001
dataStore.InputRegisters.WritePoints(1, new ushort[] { 100, 200, 300 }); // 写入输入寄存器 (Input Registers)。从地址 1 开始写入数据,也就是30001这指定了我们要操作的是“线圈”区(地址范围 00001 起始),它是一种开关量(bool 类型)。这是可读可写的开关量数据区,通常用于控制继电器或指示灯。
dataStore.CoilDiscretes.WritePoints(1, new bool[] { true });
// 写入线圈 (Coils)。
Console.WriteLine("数据存储区已初始化。");
// --- 4. 创建 Modbus 从站网络 ---
var factory = new ModbusFactory();
var network = factory.CreateSlaveNetwork(listener);
var slave = factory.CreateSlave(slaveId, dataStore);
network.AddSlave(slave);
// --- 5. 开始监听 ---
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"从站已在 TCP 端口 {port} 上开始监听... 按 Ctrl+C 退出。");
Console.ResetColor();
await network.ListenAsync();
}
}
首先我们要关注的就是数据存储器的代码,SlaveDataStore 的作用是作为您的 Modbus 从站设备的内存数据库或数据中心。
您可以把它想象成一个模拟设备(比如 PLC 或传感器)的**“大脑”或“内存条”。当外部的主站(Master)设备发来一个请求,比如“读取3号寄存器的值”时,NModbus 框架负责处理通信协议的细节(接收数据、校验CRC、解析功能码),但它需要一个地方来查找“3号寄存器”的真实数值**。这个地方,就是您所创建和管理的 SlaveDataStore。
数据存储区的作用
整个工作流程是这样的:
- 一个 Modbus 主站(例如测试软件)发送一个请求(比如:读取功能码
03,地址1,数量3)。 - 您的 C# 从站程序的
ModbusSlaveNetwork在串口或 TCP 端口上接收到这个请求并解析它。 - 它理解了这是一个“读取保持寄存器”的指令。
- 然后,它会转向您提供的
SlaveDataStore对象,并向它查询:“请从你的HoldingRegisters集合里,把地址从 1 开始的 3 个值给我。” SlaveDataStore从自己的内存中找到这些值(例如[10, 20, 30])并返回它们。- 最后,
ModbusSlaveNetwork将这些值打包成一个标准的 Modbus 响应帧,通过串口或 TCP 发送回主站。
然后就是从站网络,从站网络的作用是调度中心和交通枢纽。如果把您的整个从站程序比作一个公司,那么:
TcpListener/SerialPort: 是公司的总机电话或大门口,负责接收所有外部来电或访客。IModbusSlaveNetwork(您代码中的network): 是坐在大门口的前台或总机接线员。IModbusSlave(您代码中的slave): 是公司里的具体员工(例如,工号为1的“张三”)。SlaveDataStore: 是员工张三的个人文件柜,存放着他负责的所有数据。
ModbusFactory();这是一个遵循“工厂设计模式”的辅助类。它的作用是帮助我们创建所有符合 Modbus 规范的标准组件,避免我们手动 new 那些内部逻辑复杂的对象。您可以把它看作是公司的“行政部”,负责招聘员工和分配办公用品。
factory.CreateSlaveNetwork(listener);组成: 这一步创建了一个 IModbusSlaveNetwork 的实例。它的核心组成部分是一个传输层接口 (IModbusTransport),这个接口包裹了您传入的 listener (TCP监听器)。这行代码正在创建**“前台/总机接线员” (network)。您把公司的“总机电话” (listener)** 交给了它,并告诉它:“从现在开始,你要负责监听这个电话的所有来电”。此时状态: network 已经知道如何监听了,但它手头还没有任何员工的联系方式。如果现在有电话打进来,它不知道该转接给谁。
factory.CreateSlave(slaveId, dataStore);这一步创建了一个 IModbusSlave 的实例。它的核心组成是 从站ID (Unit ID) 和一个 数据存储区 (ISlaveDataStore)。
network.AddSlave(slave);这一步是在 IModbusSlaveNetwork 内部的一个字典(或集合)中,添加一条记录,将 slaveId 和 slave 对象关联起来。可以多次调用 network.AddSlave() 来添加更多具有不同 ID 的从站。例如,您可以再创建一个 slaveId = 2 的 slave2 并添加进去。这样,您的同一个程序就可以在同一个端口上模拟多个不同的 Modbus 设备。
现在我们可以来看看报文,应该可以更好地理解它的组成。

这里是一个主站master 程序在7789端口发往从站slave 502端口的一个指令。这个指令的具体意思是:“你好,请 ID 为 1 的从站设备,把你的、从第 1 个地址开始的、连续 3 个保持寄存器 (Holding Registers) 的值告诉我。”
Modbus - 应用层 (您最关心的“信件内容”)
这是数据包的核心,定义了本次通信的业务意图。对应您截图中间详情窗口的最后一部分。
Function Code: 3 (Read Holding Registers):- 功能码
03。这明确了本次操作的类型是“读取保持寄存器”。这是您在 C# 代码中调用_master.ReadHoldingRegistersAsync(...)所产生的结果。
- 功能码
Reference Number: 1:- 起始地址
1。这告诉从站要从哪个地址开始读取。
- 起始地址
Word Count: 3:- 读取数量
3。这告诉从站需要读取 3 个连续的寄存器(每个寄存器是 16-bit 的字)。
- 读取数量
Modbus/TCP - 应用层头部 (MBAP Header)
这是为了让 Modbus 协议能够在 TCP/IP 网络上传输而额外增加的一个“信封头”。
Transaction Identifier: 1:- 事务标识符
1。这是一个序列号,由主站为每个请求生成。从站的响应中会包含完全相同的标识符,这样主站就能将请求和响应一一对应起来,尤其是在异步通信中。
- 事务标识符
Protocol Identifier: 0:- 协议标识符
0。对于 Modbus 协议,这个值固定为 0。
- 协议标识符
Length: 6:- 长度
6。它指明了后面还跟着 6 个字节的数据。我们来数一下:Unit Identifier (1字节) + Function Code (1字节) + Reference Number (2字节) + Word Count (2字节) = 6 字节。完全正确。
- 长度
Unit Identifier: 1:- 单元标识符(从站ID)
1。即使在 TCP 通信中通常是一对一,但为了兼容 Modbus 网关等设备,这个字段依然被保留,用于指定最终要访问的从站设备地址。
- 单元标识符(从站ID)
TCP 和 IP 层 (传输层和网络层)
这部分是为 Modbus 数据提供可靠的、端到端的传输服务。
- Source Port (源端口):
7798- 这是您的主站程序(客户端)在发起连接时,由操作系统随机分配的一个临时端口。
- Destination Port (目标端口):
502- 这是 Modbus TCP 协议的标准服务端口,您的从站程序正在这个端口上监听请求。
数据包在列表中的上下文
在顶部的报文列表中,您可以看到:
在 #1528 这个 Query 包之前,有 [SYN], [SYN, ACK], [ACK] 等包,那是 TCP 协议在建立连接的过程。
SYN (Synchronize): 同步请求标志。发起方用它来表示“我想和你建立连接,并同步我们的通信序列号”。
ACK (Acknowledge): 确认标志。接收方用它来表示“我收到了你的消息”。