Chapter 14 ModbusTcp 驱动专题

ModbusTcp 驱动的目标:把"一组线圈、一段寄存器"变成和其它驱动完全一样的测点。你不需要碰功能码、PDU 长度限制、事务号——通道负责连接与分批请求,组合(TagCbnt)负责把一段连续地址一次读进来,再按偏移切给各个子测点。

用法三步,和别的驱动没有区别:

  1. 配通道:XML 里写一个 driver="ModbusTcp" 的 <Channel>,填 IP 与端口(见 14.1);
  2. 配测点:<Tag address="40001" type="INT16">,或者放进 <TagCbnt> 里批量读写(见 14.2);
  3. 注册 builder.AddModbusTcpSupport()(见 14.4)。

配套包:StdUnit.Tags.ModbusTcp。

本系列文档只讲"怎么用、为什么这样设计、容易踩什么"。地址表、字段默认值、API 清单请查 AI 参考手册 的 §5.2;两者冲突时以手册为准。

14.1 通道配置

<Project xmlns:mb="tags:modbustcp"
         xsi:noNamespaceSchemaLocation="Schemas/tagsproject.xsd"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <Channel name="ModbusTcp-2" driver="ModbusTcp">
    <mb:IpAddr>192.168.1.10</mb:IpAddr>              <!-- 默认 localhost -->
    <mb:Port>502</mb:Port>                           <!-- 默认 502 -->
    <mb:MaxReadRegisters>100</mb:MaxReadRegisters>   <!-- 不写 = 协议上限 125 -->
    <mb:MaxReadBits>1000</mb:MaxReadBits>            <!-- 不写 = 协议上限 2000 -->
    <mb:MaxWriteRegisters>100</mb:MaxWriteRegisters> <!-- 不写 = 协议上限 123 -->
  </Channel>
</Project>
字段默认含义
IpAddrlocalhost设备或网关地址
Port502端口
MaxReadRegisters协议上限 125单帧读寄存器的上限(FC03/FC04)
MaxReadBits协议上限 2000单帧读位的上限(FC01/FC02)
MaxWriteRegisters协议上限 123单帧写寄存器的上限(FC16)

三点值得知道:

  • 命名空间前缀可以省:通道子元素按本地名匹配(IpAddr 就是 IpAddr),所以不声明 mb: 前缀也能工作。但如果你开了 XSD 校验(EnableXmlSchemaValidation()),就要按 Schemas/tagsproject.xsd 的导入声明前缀,否则校验会失败。
  • Port 在 XSD 里声明为字符串,为的是兼容串口类驱动那种 COM1 形态的取值;运行期按整数解析,写 abc 会在加载期抛 TagsProjectXmlException(消息形如 配置的端口号不是整数:abc,并带 Channel(名))。
  • 三个上限只在"设备的单帧上限比协议值更小"时才需要写。超过协议上限不能凭空放大能力,所以配置比协议值更大时会直接在加载期抛异常,例如 MaxReadRegisters 配置(126)超过协议上限(125);写 0 同样报错。反过来说,协议能支撑的量永远读得到——超上限只是被拆成多个请求,不是读不到。

连接/读写超时目前是固定默认值(连接 1 秒、读写各 10 秒),XML 里没有对应字段,通道也没有暴露可配置入口。确实需要更长的超时时,只能自己实现通道(IModbusBitsChannel / IModbusRegisterChannel)并注册自己的 ITagChannelFactory。

14.2 地址怎么写

地址语法:

[<slave>~]<area><reference>[.<bit>]

area 决定区域与读写权限:

area区域参考号起点读/写
0线圈 Coils00001读、写
1离散输入 Discrete Inputs10001只读
3输入寄存器 Input Registers30001只读
4保持寄存器 Holding Registers40001读、写

细节:

  • slave 省略时默认 1。40001 与 1~40001 等价。

  • reference 是 1 起算的"参考号",注意,不同厂家的设备,语义并不一样!许多设备厂家的开发工程师是半吊子,或者是个优秀的工程师却被迫只能照抄上古遗留的代码,结果导致弄出来的地址五花八门,有的是0-based,有的是1-based,还有的甚至混进了寄存器area编号。所以你在设备手册里看到的编号地址,不一定就是真正合规的Modbus地址。我们采纳的语义是:40001 指保持寄存器的首个寄存器,解析后内部偏移是 0。所以不要自己减 1变成0-based。

  • bit 是寄存器内的位序号,取值 0~15(如 4~40010.3 表示保持寄存器第 10 个的第 3 位)。超出范围会抛 NthBit范围非法。

  • 地址匹配不上时,错误消息会把期望的语法一并给出:

    非法的Modbus地址 'x4001':未能匹配模式 <area><start>的模式(期望 [<slave>~]<area><start>[.<nth>],area 取 0/1/3/4,如 '1~40001.0')
    
  • 从站号写在地址里(2~10001),也可以写在 TagCbnt 的 slave 属性上(slave="2"),两者等价、写了属性就以属性为准。一次组合读写的从站号由组合决定,子测点地址里的 2~ 前缀只用来算偏移,不会切换从站。

14.3 类型与端序

Modbus 有两套互斥的组合空间,由 TagCbnt 起始地址落在哪个区域自动分流——位空间用 ModbusBitTagCbntBuilder,寄存器空间用 ModbusRegisterTagCbntBuilder,两个构建器在注册时就已经按区域分好了谓词,你不需要手动选:

组合空间起始区域缓存组合内 type 可用值TagSize
位空间0x / 1xbool[]DI(必须落 1x)、DO(必须落 0x)1
寄存器空间3x / 4xushort[]BIT BYTE INT16 UINT16 INT32 UINT32 FLOAT INT64 UINT642 / 4 / 8(字节)

两点容易搞混的地方:

  • DI / DO 是位空间专有,BIT / BYTE / 各数值类型是寄存器空间专有,写错区域会在加载期抛异常,例如把 DI 放在 0x 会得到 地址区域OutputCoils不可作为DI测点(DI 必须使用离散输入 1x 区域)。
  • 寄存器空间的测点,偏移一律按字节算:TagSize 要写 2(16 位)、4(32 位)、8(64 位)。不写时由类型自动补齐(BIT/BYTE/INT16/UINT16 是 2,INT32/UINT32/FLOAT 是 4,INT64/UINT64 是 8)。

端序:endian 管"每个 16 位单元内部两个字节",interpret 管"这些单元之间的顺序"

先按标准设备(Modbus 规范的惯例)写,通常就是一句话:

<Tag name="int16" address="40026" type="INT16" endian="BigEndian"></Tag>
<Tag name="float" address="40024" type="FLOAT" endian="BigEndian"></Tag>

endian 在 Modbus 与 S7 同名同义:描述每个 16 位单元(寄存器)内部那两个字节的先后。协议规定标准设备是大端(高字节在前),所以标准设备写 BigEndian;被字节序镜像过的网关会把这两个字节反着放,写 LittleEndian 让本库换回来。不写时描述符默认 LittleEndian(低位在前)——对标准设备来说数值看着就是错的,但不会报错:16 位会交换寄存器内两字节。

一个 32/64 位数值由多个 16 位单元拼成,于是还有第二个自由度:这些单元之间的先后。这由可选属性 interpret 表达——一串大写字母,A 是数值的最高字节,从左到右就是这些字节在设备端(也就是设备寄存器里那串字节)出现的先后:

<Tag name="float" address="40024" type="FLOAT" endian="BigEndian" interpret="CDAB"></Tag>

以 32 位物理值 0x12345678 为例(A=0x12、B=0x34、C=0x56、D=0x78),四种排布各有一个唯一写法:

endianinterpret设备端字节说明
BigEndian不写(等价 ABCD)12 34 56 78完全大端,标准设备
BigEndianCDAB56 78 12 34字交换(两个 16 位单元顺序相反)
LittleEndianBADC34 12 78 56字节交换(每个单元内部两字节相反)
LittleEndian不写(等价 DCBA)78 56 34 12完全小端

interpret 的字符数必须等于该数值的字节数:32 位 4 个字符(AD)、64 位 8 个字符(AH,例如 GHEFCDAB / BADCFEHG / HGFEDCBA 分别对应 32 位的 CDAB / BADC / DCBA)。因为 endian 已经确定了每个单元内部两字节的先后,interpret 里每两个连续字符必须是同一个单元的两个字节,且先后与 endian 一致——BigEndian 只接受 AB/CD…,LittleEndian 只接受 BA/DC…——所以同一件事只有唯一写法。

长度不符、字符不是大写字母、字符重复、与 endian 冲突,都在加载期报错;16 位、BYTE、BIT、位空间(DI/DO)以及 TagCbnt 本身写 interpret 也会在加载期报错(它们没有"多个单元"可排)。

类型占几个寄存器endian 管什么interpret
INT16 UINT161寄存器内两字节的顺序不允许
INT32 UINT32 FLOAT2每个寄存器内两字节的顺序可选(4 个字符)
INT64 UINT644每个寄存器内两字节的顺序可选(8 个字符)
BYTE半个取寄存器的高字节还是低字节不允许
BIT DI DO一个位无不允许

取值大小写敏感:endian 是 BigEndian / LittleEndian,interpret 是 ABCD / CDAB 这类大写记法,写别的会在加载期报错。

顺带说清一个常见的误解:这里与"Intel 是小端"没有关系。NModbus 返回的 ushort[] 里存的是数值,不是字节容器——它在解析响应时用 NetworkToHostOrder 把线上两个字节还原成数值(线上 12 34 → 数值 0x1234,也就是 4660),在 x86 上这一步会做一次字节交换、在大端主机上不做,两种主机拿到的数值完全一样;写方向对称地做一次 HostToNetworkOrder。也就是说主机端序在这一层已经被消化掉了,endian 与 interpret 只负责表达"设备里这个数值怎么摆"(描述它的坐标叫设备端字节:从寄存器数值重建出的设备侧字节序列,与真机上那几个寄存器的线上字节逐字节相同)。只有当有人把 ushort[] 重新当成字节流去看(MemoryMarshal.AsBytes、序列化到文件)时主机端序才会渗进来——本库没有任何这种操作。

写在哪儿都一样。 这些语义与"测点挂在 <TagGrp> 下(直接测点)还是挂在 <TagCbnt> 下(组合成员)"无关——同一份 XML 放在两处得到同一个物理值,这一点有专门的回归测试钉着(ModbusEndianConsistencyTests、ModbusInterpretTests)。

  • BIT、DI、DO 与端序无关(位号 0 就是最低位),BYTE 只是借 endian 选择取哪一半。
  • interpret 是 Modbus 驱动的私有属性,其它驱动不认识它(写上去会被当作普通自定义属性而忽略)。
<Tag name="int32" address="40022" type="INT32" endian="BigEndian"></Tag>                                <!-- 标准设备 -->
<Tag name="u32-swapped" address="40028" type="UINT32" endian="BigEndian" interpret="CDAB"></Tag>        <!-- 字交换设备 -->
<Tag name="u32-le" address="40030" type="UINT32" endian="LittleEndian"></Tag>                           <!-- 完全小端设备 -->

14.4 注册与运行

using StdUnit.Tags.ModbusTcp;

var services = new ServiceCollection();
services.AddLogging();
services.AddTagsProject(builder =>
{
    builder.AddModbusTcpSupport();   // 通道工厂 + 位空间组合 + 寄存器空间组合 + 直连测点 + schema
});

AddModbusTcpSupport() 是四个细粒度注册的组合:AddModbusTcpChannel()、AddModbusBitTagCbntBuilder()、AddModbusRegisterTagCbntBuilder()、AddModbusTcpDirectTagBuilder()(另外注册了驱动自带的 XSD)。

想要更精细的控制,可以手动注册上面这些扩展方法自由组合。

一份完整的配置长这样——位空间管开关量,寄存器空间管采集:

<TagGrp name="g3" isEntry="true" isEnabled="true" channel="ModbusTcp-2" scanInterval="10">
  <TagCbnt name="输入" address="10001" access="RO">
    <Tag name="放行按钮闭合状态" address="10001" type="DI"></Tag>
    <Tag name="手动" address="10002" type="DI"></Tag>
  </TagCbnt>

  <TagCbnt name="输出" address="00001" access="R1W">
    <Tag name="绿灯" address="00020" type="DO"></Tag>
    <Tag name="红灯" address="00021" type="DO"></Tag>
  </TagCbnt>

  <TagCbnt name="采集" address="40001" access="RO">
    <Tag name="byte" address="40020" type="BYTE"></Tag>
    <Tag name="int16" address="40021" type="INT16"></Tag>
    <Tag name="int32" address="40022" type="INT32" endian="BigEndian"></Tag>
    <Tag name="float" address="40024" type="FLOAT" endian="BigEndian"></Tag>
  </TagCbnt>
</TagGrp>
  • TagCbnt 的 address 是必写的(不像 OpcUa 那样可以省):它既决定这块组合落在哪个空间、用哪个构建器,也决定批量读写的起始位置与从站号(从站号也可以另用 slave 属性指定)。
  • access 是通用的访问模式,与驱动无关:R1W 表示"先读一次,成功之后只写"(见 Chapter 11 的说明),RO 表示只读。寄存器空间里落在 3x 的测点还会自动变成只读,写入会抛 NotSupportedException。
  • 组合的价值在于"一次请求覆盖一段连续区间":TagCbnt 的缓存从它的起始地址开始,一直算到最后一个子测点结束,一次轮询里合并成一次(或按通道上限拆成几次)请求。所以起始地址要尽量贴近第一个真正用到的测点——起始地址离得越远、子测点之间越散,缓存里空转的寄存器就越多。
  • 一轮里是"先读整块,再逐点通知",逻辑组件看到的是同一轮的一致快照,不会读到半更新的缓存。
  • 同一个通道不能被多个入口共用(isEntry="true" 的组各占一个 ITagChannel 实例),这是全库通用的约束。

14.5 与其它驱动不一样的地方

关注点ModbusTcp
地址[<slave>~]<area><reference>[.<bit>],参考号是 1-based,比如40001就是保持寄存器的首个寄存器
区域与访问方式由地址里的 area 决定(0/1/3/4),1x 与 3x 天然只读
类型位空间只认 DI/DO;寄存器空间认 BIT/BYTE/INT16/UINT16/INT32/UINT32/FLOAT/INT64/UINT64
端序endian 描述每个 16 位单元内部两字节的顺序(与 S7 同名同义);32/64 位里单元之间的顺序由 interpret 表达(不写时 BigEndian=完全大端、LittleEndian=完全小端);BYTE 是取哪一半,BIT/DI/DO 与端序无关。写在 <TagGrp> 下与写在 <TagCbnt> 下语义一致
组合空间位空间与寄存器空间互斥,按 TagCbnt 起始地址区域自动分流
批量请求超过通道 MaxReadRegisters/MaxReadBits/MaxWriteRegisters 的区间自动拆成多个请求
多从站从站号写在地址里(2~10001),或用 TagCbnt 的 slave 属性指定;组合内的子测点不单独决定从站

14.6 排错清单

现象大概率原因
加载期报 非法的Modbus地址 ...参考号写成了 40000 这类 0 起算值,或区域数字不在 0/1/3/4 里
加载期报 地址区域X不可作为DI测点DI 放到了 0x 组合里,或 DO 放到了 1x 组合里
加载期报 MaxReadRegisters 配置(126)超过协议上限(125)配置值超过协议上限,只能往小配
加载期报 配置的端口号不是整数:COM1Port 字段填了非数字;ModbusTcp 只走 TCP 端口
INT32 / FLOAT 读出来的数值明显离谱漏写 endian="BigEndian"(默认 LittleEndian 会按完全小端解读),或设备是字交换/字节交换却只写了 endian,需要补 interpret="CDAB" / interpret="BADC"
直接测点 INT16 / UINT16 的数值像"字节颠倒"16 位测点默认 LittleEndian 会交换寄存器内两字节,标准设备要写 endian="BigEndian"
加载期报 interpret='...' 与 endian='...' 冲突每两个连续字符必须是同一个 16 位单元的两字节且先后与 endian 一致:LittleEndian 只接受 BA/DC…
加载期报 interpret='...' 的字符数(...)与测点字节数(...)不一致记法字符数必须等于该数值的字节数:32 位 4 个字符、64 位 8 个字符
写点位没有反应,也不报错该测点或所属组合的 access 是 RO:写入路径直接跳过
写入时抛 NotSupportedException地址落在只读区域(1x 离散输入 / 3x 输入寄存器),或给 DI 测点赋值
从站号改成 2 后仍在读 1 号站的点从站号写在组合上(TagCbnt 的 slave 属性或 2~ 地址前缀);写在子测点地址里不生效
单个测点读得对,组合里读得不对子测点地址与 TagCbnt 起始地址不在同一区域,或偏移算错(寄存器空间按字节算 TagSize)
连接建立失败通道名/IP/端口写在日志里,检查网关是否放通 502、设备是否允许多客户端