Modbus
Zeus 的 Modbus 模块帮你读写线圈和寄存器,不需要手拼功能码、CRC、MBAP 头或 ASCII LRC。
先分清三个名字
| 名字 | 例子 | 含义 |
|---|---|---|
| 通道名 | bus | Zeus 里这条通信线路的名字,可以是串口、TCP、UDP 或虚拟通道 |
| 设备名 | oven | Zeus 里这台设备的名字 |
| 从站地址 | unitId: 1 | Modbus 协议里的从站地址 / 单元标识 |
很多问题来自把这三个混在一起。bus 不是 COM 口,oven 不是从站地址,unitId 也不是 Zeus 的设备名。
没硬件时先跑虚拟从站
var memory = new ModbusSlaveMemory();
memory.HoldingRegisters[0] = 185;
await using var app = ZeusHost.Create(builder =>
{
builder.AddVirtualChannel("bus", new ModbusSlaveResponder(1, ModbusTransport.Rtu, memory));
builder.AddModbusRtu("oven", "bus", unitId: 1, points: map =>
{
map.HoldingRegister("temperature", 0, 0.1);
});
});
await app.StartAsync();
var oven = app.Devices.Get<ModbusDevice>("oven");
var values = await oven.ReadHoldingRegistersAsync(0, 1);
var temperature = app.Points.Get<double>("temperature");
这段代码里:
| 代码 | 含义 |
|---|---|
ModbusSlaveMemory | 虚拟从站的寄存器内存 |
HoldingRegisters[0] = 185 | 地址 0 的保持寄存器值是 185 |
ModbusSlaveResponder(1, Rtu, memory) | 模拟一个地址为 1 的 RTU 从站;需要 ASCII 时改成 ModbusTransport.Ascii |
AddVirtualChannel("bus", responder) | 把虚拟从站挂到虚拟通道上 |
AddModbusRtu("oven", "bus", unitId: 1) | 在 bus 通道上登记一台名为 oven 的 Modbus RTU 设备 |
0.1 | 把原始寄存器值 185 转成 18.5;写回时按同一系数反算 |
可运行示例:samples/Zeus.Samples.Console.Modbus。
换成现场串口
虚拟从站跑通后,把通道换成真实串口:
builder.AddSerialPort("bus", "COM3", 9600);
builder.AddModbusRtu("oven", "bus", unitId: 1);
AddModbusRtu 的第二个参数仍然是 Zeus 通道名 bus,不是 COM3。
换成现场网口
Modbus TCP 通常端口是 502:
builder.AddTcpClient("bus", "192.168.1.10", 502);
builder.AddModbusTcp("oven", "bus", unitId: 1);
unitId 在 Modbus TCP 里仍然可能有用,尤其是网关转 RTU 总线时。
换成 Modbus ASCII
一些老仪表或串口网关使用 ASCII 帧:冒号起始、十六进制文本、LRC 校验、CRLF 结束。通道仍然是串口,设备注册换成 AddModbusAscii:
builder.AddSerialPort("bus", "COM3", 9600);
builder.AddModbusAscii("oven", "bus", unitId: 1);
虚拟从站也支持同一封装,便于先验证主站逻辑:
builder.AddVirtualChannel("bus", new ModbusSlaveResponder(1, ModbusTransport.Ascii));
builder.AddModbusAscii("oven", "bus", unitId: 1);
读写 API
除常规线圈/寄存器读写外,Zeus 也提供诊断和设备识别类功能码:异常状态 07、诊断回显 08、报告服务器 ID 11、读/写文件记录 14/15、掩码写 16、读写多个保持寄存器 17、读设备识别 2B/0E。
var oven = app.Devices.Get<ModbusDevice>("oven");
var holding = await oven.ReadHoldingRegistersAsync(address: 0, quantity: 2);
var input = await oven.ReadInputRegistersAsync(address: 0, quantity: 2);
var coils = await oven.ReadCoilsAsync(address: 0, quantity: 8);
var discrete = await oven.ReadDiscreteInputsAsync(address: 0, quantity: 8);
var exceptionStatus = await oven.ReadExceptionStatusAsync();
var echo = await oven.DiagnosticsReturnQueryDataAsync(0xA55A);
var serverId = await oven.ReportServerIdAsync();
var identity = await oven.ReadDeviceIdentificationAsync();
await oven.WriteFileRecordAsync(fileNumber: 4, recordNumber: 1, values: [11, 22]);
var record = await oven.ReadFileRecordAsync(fileNumber: 4, recordNumber: 1, recordLength: 2);
await oven.WriteSingleRegisterAsync(address: 1, value: 200);
await oven.WriteSingleCoilAsync(address: 2, value: true);
await oven.WriteMultipleRegistersAsync(address: 10, values: [100, 200]);
await oven.WriteMultipleCoilsAsync(address: 20, values: [true, false, true]);
await oven.MaskWriteRegisterAsync(address: 30, andMask: 0xFFF0, orMask: 0x0005);
var afterWrite = await oven.ReadWriteMultipleRegistersAsync(
readAddress: 10,
readQuantity: 2,
writeAddress: 20,
writeValues: [300, 400]);
| 方法 | 功能码 | 用途 |
|---|---|---|
ReadCoilsAsync | 01 | 读线圈 |
ReadDiscreteInputsAsync | 02 | 读离散输入 |
ReadHoldingRegistersAsync | 03 | 读保持寄存器 |
ReadInputRegistersAsync | 04 | 读输入寄存器 |
WriteSingleCoilAsync | 05 | 写单个线圈 |
WriteSingleRegisterAsync | 06 | 写单个保持寄存器 |
ReadExceptionStatusAsync | 07 | 读异常状态字节 |
DiagnosticsReturnQueryDataAsync | 08 | 诊断回显,用于确认从站诊断通路 |
WriteMultipleCoilsAsync | 0F | 写多个线圈 |
WriteMultipleRegistersAsync | 10 | 写多个保持寄存器 |
ReportServerIdAsync | 11 | 读取服务器 ID、运行状态和厂商附加数据 |
ReadFileRecordAsync | 14 | 读一条文件记录 |
WriteFileRecordAsync | 15 | 写一条文件记录 |
MaskWriteRegisterAsync | 16 | 按 AND / OR 掩码修改单个保持寄存器 |
ReadWriteMultipleRegistersAsync | 17 | 一次事务中先写多个保持寄存器,再读回保持寄存器 |
ReadDeviceIdentificationAsync | 2B/0E | 读取厂商名、产品代码和版本等识别对象 |
地址从 0 开始
Zeus 使用报文里的真实地址,从 0 开始。
| 设备文档写法 | Zeus 里通常写 |
|---|---|
40001 | address: 0 |
40002 | address: 1 |
30001 | address: 0,但用输入寄存器 API |
00001 | address: 0,但用线圈 API |
如果厂家文档明确写的是 0 基地址,就按文档来。遇到 非法数据地址 时,优先检查这里。
和点表采集配合
如果某些数据要周期读取,不要自己写 while 循环。直接声明点:
builder.AddAcquisition(TimeSpan.FromMilliseconds(500));
builder.AddModbusRtu("oven", "bus", unitId: 1, points: map =>
{
map.HoldingRegister("temperature", 0, 0.1);
map.HoldingRegister("setpoint", 1, 0.1).Writable("setpoint");
map.Coil("heater", 2).Writable("heater");
});
然后读取或写回点表:
var temperature = app.Points.Get<double>("temperature");
await app.Points.WriteAsync("setpoint", 80.0);
await app.Points.WriteAsync("heater", true);
WriteAsync 传入工程值。上例中 80.0 会按 scale: 0.1 反算成保持寄存器 800。一次性调试、批量写或手册上的特殊地址,仍可直接调用 ModbusDevice 的写方法。
常见问题
| 现象 | 最可能原因 | 处理 |
|---|---|---|
| 一直超时 | 从站地址、串口参数、RTU/TCP/ASCII 类型不对 | 先用虚拟从站确认主机逻辑,再查现场参数 |
非法数据地址 | 地址偏移错了,或数量超过范围 | 检查 40001 是否要换算成 0 |
| CRC/LRC 失败 | 波特率、校验位、停止位不一致,或线路干扰 | 对照设备手册和串口助手抓包 |
| TCP 事务号不匹配 | 同一 TCP 通道上并发发了多路请求 | 同一客户端串行请求,或拆分通道 |
| 读到的温度大 10 倍 | 没做比例换算 | 在点表里用 HoldingRegister(..., 0.1) |
| 写回被拒绝 | 点没标可写,或写了输入区 | 对保持寄存器/线圈调用 .Writable |
下一步:周期读取数据看 点表与采集。