# Modbus TCP

> TCP/IP 上的 Modbus 寄存器和线圈，带定型寻址、字节序控制，以及一次有边界的网络扫描。

Modbus TCP 驱动通过 TCP/IP 读写 Modbus 寄存器和线圈，对象是 PLC、仪表、网关，以及任何说 Modbus/TCP 的服务器。寻址是完全定型的：你挑一个寄存器类别和一个从 0 起的寄存器号，应用把总线请求拼出来；没有自由文本地址可以打错。

## 设备 {#device}

| 字段 | 含义 | 默认值 |
| --- | --- | --- |
| **主机** | Modbus 服务器或网关的 DNS 名称、IPv4 或 IPv6 地址。 | 空：填写之前设备不会被连接 |
| **端口** | TCP 端口，1 到 65535。框留空时 502 以灰色显示，设备就在这个端口上被拨号连接；以 0 保存的设备也是这样显示的。 | 502 |
| **单元 ID** | 每一次请求都带着的 Modbus 从站 id，网关靠它把报文路由到它后面那台设备。0 到 255。 | 1 |
| **帧格式** | 每个请求在连接上以什么帧传送：**MBAP** 在每个请求前带 Modbus TCP 报头；**RTU** 把带 CRC 的 RTU 帧原样发送，用于工作在透传模式的串口转以太网转换器（见 [TCP 上的 RTU](#rtu-over-tcp)）。主机、端口、单元 ID 和点位两种情况都一样。 | MBAP |
| **最小事务间隔（ms）** | 兼容性节奏：一次事务完成之后、下一次开始之前的最小静默，0 到 1000。填零不加任何人为延时。 | 0 |
| **轮询间隔（ms）** | 没有自己另作规定的点位所用的默认轮询速率。 | 1000 |

面板标题栏里显示的基础 URI 是 `modbus+tcp://host:port/unitID`，设了间隔时再加上 `?minimumTransactionIntervalMs=n`。

共用同一主机和端口的设备也共用一道事务闸门：发往同一个 endpoint 的请求绝不重叠，而这个 endpoint 上那些已启用设备中配置得**最大**的那个最小事务间隔，替所有设备定节奏。别的设备把实际生效的间隔抬到高于这里配置的值时，面板会说出来。这个间隔只管本应用发出去的流量：它是给慢服务器或慢网关的兼容性辅助手段，不是用来修网络故障的。

连接最多等 5 秒；一次应答最多等 10 秒；协议层没有重试。重新连接由后台的重试循环负责，所以一台关着的设备除了一次质量不可用的读数之外不花什么代价。

## TCP 上的 RTU {#rtu-over-tcp}

工作在透传模式的串口转以太网转换器自己不说 Modbus TCP：套接字上收到什么，就原样发到它的串行总线上，总线回答什么，就送回来。它后面的设备，就是一台把**帧格式**设为 **RTU** 的 Modbus TCP 设备：工作站发送不带 MBAP 报头的 RTU 帧（单元 ID、请求和 CRC），只有 CRC 校验通过的应答才被采信。这时基础 URI 的查询里带上 `framing=rtu`，例如 `modbus+tcp://192.168.0.20:502/1?framing=rtu`；设为 **MBAP** 的设备保留它一直以来的地址。本页其余内容一律照旧：寄存器类别、功能码、写入队列、超时和事务间隔都一样。

- **单元 ID** 是设备在转换器后面那条串行总线上的地址，1 到 247。
- 同一台转换器后面的设备共用它的主机和端口，也就共用它的事务关口：一次只有一个请求，这也正是一条串行总线所能承载的全部。转换器如果需要帧与帧之间停顿一下，就调大**最小事务间隔（ms）**。
- 转换器自身的串口设置（波特率、格式）在转换器上配置；工作站看不到它们。
- RTU 帧不带事务编号，所以每次请求之前，工作站先丢掉转换器已经发来的内容：请求超时之后才到的应答，绝不会被当成下一个请求的应答。

## 点位寻址 {#tag-addressing}

| 字段 | 含义 | 默认值 |
| --- | --- | --- |
| **寄存器类别** | 这个点位寻的是哪一种 Modbus 实体：线圈（0xxxx）、离散输入（1xxxx）、输入寄存器（3xxxx）或保持寄存器（4xxxx）。每一类都带着手册地址给它的首位数字，所以只印了 30001 的手册指的是输入寄存器。 | 由类型和读写推出来：布尔点位取离散输入（只读）或线圈（可写）；其余每一种类型取输入寄存器（只读）或保持寄存器（可写）。 |
| **寄存器编号（从 0 开始）** | 协议里那个原始地址。它是总线上从 0 起的地址，不是手册的记法：保持寄存器 40001 就是寄存器号 0。在这里输入 30001 不会选定类别，因为确实有设备的寄存器超过 30000：类别在上面选。寄存器号在总线上以一个 16 位数传输，所以这个字段接受 0 到 65535，更宽的值在打字的地方就被拒绝。 | 0 |
| **数量（寄存器，留空为自动）** | 这个值占多少个 16 位寄存器。这个框只在文本点位上画出来：其余每一种类型的宽度都是定死的，没有什么可覆盖。 | 按类型：布尔、Int16 和 UInt16 1，Int32 和 Float 2，文本 4 |
| **高字节在前（MSB first，大端序）** | 每个 16 位寄存器里哪一个字节先上总线；括号里是手册对这一项的写法。数值点位有，放在保持寄存器或输入寄存器里的布尔点位也有；线圈或离散输入里的布尔值只是一个位，没有字节可排，所以那里不画这个框。 | 开（Modbus 规范要求的那样） |
| **高字在前（high word first）** | 两个寄存器组成的值里，哪一个寄存器带高半部分。只对 Int32 和 Float 点位有：只占一个寄存器的值（Boolean、Int16、UInt16）没有第二个寄存器，所以那里不画这个框。两个框下面的**数值的字节顺序**用手册的字母说出两个框合起来的排布（[字节序与字序](#byte-and-word-order)）。 | 开 |

“信号源”卡片的末尾是**手册中的地址**，即点位在设备手册所印记法里的位置：类别的首位数字后接从 1 起数的寄存器。输入寄存器 0 读作 30001，保持寄存器 100 上的 Float 读作 40101 到 40102，超过 9998 的寄存器用六位（保持寄存器 9999 读作 410000），所以面板可以和手册逐行对照。

### 数值类型与数量 {#value-types-and-quantities}

提供六种数值类型，每一种在协议上的宽度都是定死的：

| 数据类型 | 寄存器数 | 总线形式 |
| --- | --- | --- |
| Boolean | 1 | 在寄存器类别上：任何非零寄存器读作 true，写入发 1 或 0。在 `Coil`/`DiscreteInput` 上：就是那个位本身。 |
| Int16 | 1 | 放在单个寄存器里的 16 位有符号整数，-32768 到 32767。 |
| UInt16 | 1 | 放在单个寄存器里的 16 位无符号整数，0 到 65535。同一个寄存器，按 UInt16 读是 65535，按 Int16 读是 -1：符号由所选的数据类型决定，而不是由寄存器决定。 |
| Int32 | 2 | 跨一对寄存器的 32 位有符号整数。 |
| Float | 2 | 跨一对寄存器的 IEEE 754 单精度。 |
| String | 1 或更多（默认 4） | UTF-8 字节，每个寄存器装两个。读取在第一个 NUL 字节处停下；写入必须装得进声明的数量（每寄存器 2 字节），不足处补零。 |

文本点位按设备存放字节的顺序读和写，而写入总是发 UTF-8。不是合法 UTF-8 的字节，也就是设备从未初始化过的一段区域、或者用别的编码写进去的文本看起来的样子，读作 Unicode 替换字符而不是报错：这个点位显出明显不对的东西，而那台设备上其余每个点位照读不误。

**数量（寄存器，留空为自动）**只对文本点位询问。布尔、Int16、UInt16、Int32 和 Float 的宽度是定死的，所以那个框在这些点上不画出来，点位面板里和点位表格里都不画；这个覆盖项的存在，就是为了给文本点位定尺寸。

### 字节序与字序 {#byte-and-word-order}

字节序框画在数值点位和放在寄存器里的布尔点位上，字序框只画在占两个寄存器的点位（Int32、Float）上。它们是两个互不相干的问题：每个寄存器内部哪个字节在前，以及一对寄存器之间哪个寄存器在前。Modbus 对文本两者都没有定义，所以文本点位不提供它们：它的字节按设备存放的顺序传输。以值 `0x01020304` 为例，把最高位字节记作 `A`，四种排布是：

| 高字节在前 | 高字在前 | 总线上的字节 |
| --- | --- | --- |
| 开 | 开 | `AB CD`（规范默认） |
| 开 | 关 | `CD AB`（经典的字交换） |
| 关 | 开 | `BA DC` |
| 关 | 关 | `DC BA` |

读和写按同样的顺序应用这同样两个答案，所以一次写入放下的字节，恰好就是一次读取会取回来的那些。框下面的**数值的字节顺序**像手册一样用一个词说出这个排布（ABCD、CDAB、BADC 或 DCBA）。

Int16 或 UInt16 的值只是一个寄存器，所以只有字节序对它起作用：对 `0x1234`，高字节在前发出 `12 34`，关掉则发出 `34 12`。没有第二个寄存器可排，所以不画字序框。

### 功能码 {#function-codes}

| 寄存器类别 | 读 | 写 |
| --- | --- | --- |
| 保持寄存器（4xxxx） | 3（读保持寄存器） | 6（写单个寄存器）或 16（写多个寄存器） |
| 输入寄存器（3xxxx） | 4（读输入寄存器） | 只读；写入被拒绝 |
| 线圈（0xxxx） | 1（读线圈） | 5（强制单线圈） |
| 离散输入（1xxxx） | 2（读离散输入） | 只读；写入被拒绝 |

只有当服务器的协议应答确认接受时，一次写入才算成功；缓存的值也是到那时才往前走。

## 设备发现 {#discovery}

这个扫描是明确的、有边界的：应用从不推断、也从不横扫你的本地子网。范围表单接收：

| 字段 | 含义 | 默认值 |
| --- | --- | --- |
| **IPv4 主机或 CIDR** | 一个地址（`192.168.1.10`），或者一段不宽于 /24 的子网（`192.168.1.0/24`）。网络地址和广播地址会被跳过。 | 必填 |
| **端口** | 要探测的端口。 | 502 |
| **单元 ID** | 单个值、逗号分隔的列表或者递增的区间：`1`、`1,5,9`、`1, 3, 8-16`（0 到 255）。 | 1 |
| **帧格式** | 每次探测所用的帧格式。透传模式的转换器只回应 RTU 帧，所以要用 RTU 扫描。 | MBAP |
| **最小事务间隔（ms）** | 同一台主机上两次完成的探测之间的节奏，0 到 1000。 | 0 |

扫描用所选的**帧格式**发送每一次探测。选 **RTU** 时，每次探测都是裸的 RTU 帧，只有单元 ID 对得上、CRC 校验通过的应答才算数，所以透传模式转换器后面的从站也能像其他从站一样被找到。找到的从站在添加时沿用这次扫描的帧格式。

一次扫描最多发 **256 次探测**（主机数 × 从站 id 数）；计划更大的扫描在任何套接字打开之前就被拒绝。唯一发出去的请求是**读取基本设备识别**（功能码 43/14）：设备发现从不读寄存器，也从不去猜寄存器映射。一个完全合法的 Modbus 异常应答足以证明有这台从站，只是说不出它的身份；TCP 可达、超时和畸形帧都证明不了。同时最多并行扫四台主机，每台主机内部按顺序来，连接和探测的超时都是 750 毫秒。

结果带着完整的 endpoint，以及在设备报告了厂商和产品代码时由它们拼出来的一个建议名称。候选项的点位数为零：寄存器是在**添加**之后再配置的。已经配置过的从站会被标出来，并提供**打开**，而不是造一个重复的。

## 值得知道的限制 {#limits-worth-knowing}

- 不提供其他宽度的整数类型（SByte、Byte、UInt32、Int64、UInt64）和 Double：总线编解码只能原样往返上面那六种类型。
- 每个 endpoint 同一时刻只有一个请求在途；一个网关后面的许多设备按设计共用这一层串行化。
- 写入一次只寻一个线圈或一段连续寄存器；没有多线圈写入。
