Chapter 14 ModbusTcp 驱动专题
ModbusTcp 驱动的目标:把"一组线圈、一段寄存器"变成和其它驱动完全一样的测点。你不需要碰功能码、PDU 长度限制、事务号——通道负责连接与分批请求,组合(TagCbnt)负责把一段连续地址一次读进来,再按偏移切给各个子测点。
用法三步,和别的驱动没有区别:
- 配通道:XML 里写一个
driver="ModbusTcp"的<Channel>,填 IP 与端口(见 14.1); - 配测点:
<Tag address="40001" type="INT16">,或者放进<TagCbnt>里批量读写(见 14.2); - 注册
builder.AddModbusTcpSupport()(见 14.4)。
本系列文档只讲"怎么用、为什么这样设计、容易踩什么"。地址表、字段默认值、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>
| 字段 | 默认 | 含义 |
|---|---|---|
IpAddr | localhost | 设备或网关地址 |
Port | 502 | 端口 |
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 | 线圈 Coils | 00001 | 读、写 |
1 | 离散输入 Discrete Inputs | 10001 | 只读 |
3 | 输入寄存器 Input Registers | 30001 | 只读 |
4 | 保持寄存器 Holding Registers | 40001 | 读、写 |
细节:
-
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 / 1x | bool[] | DI(必须落 1x)、DO(必须落 0x) | 1 |
| 寄存器空间 | 3x / 4x | ushort[] | BIT BYTE INT16 UINT16 INT32 UINT32 FLOAT INT64 UINT64 | 2 / 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),四种排布各有一个唯一写法:
endian | interpret | 设备端字节 | 说明 |
|---|---|---|---|
BigEndian | 不写(等价 ABCD) | 12 34 56 78 | 完全大端,标准设备 |
BigEndian | CDAB | 56 78 12 34 | 字交换(两个 16 位单元顺序相反) |
LittleEndian | BADC | 34 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 UINT16 | 1 | 寄存器内两字节的顺序 | 不允许 |
INT32 UINT32 FLOAT | 2 | 每个寄存器内两字节的顺序 | 可选(4 个字符) |
INT64 UINT64 | 4 | 每个寄存器内两字节的顺序 | 可选(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) | 配置值超过协议上限,只能往小配 |
加载期报 配置的端口号不是整数:COM1 | Port 字段填了非数字;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、设备是否允许多客户端 |