把 1.5 这个数写进两个连续的保持寄存器,再读回来,你可能拿到 1.5,也可能拿到 1078984704;把 25.6 写进一个输入寄存器再读回来,可能还是 25.6,也可能变成 2560。
这不是设备坏了,是同一个寄存器在不同的配置下有四种读法。Modbus 从 1979 年活到今天,靠的是极简------一张功能码表、四块存储区、一个大端字节序。而恰恰是这份极简,把「读几个」「按什么顺序拼」「拼出来怎么解释」这三件事全推给了上位机。现场九成的「采到的数不对」,最后都落在本文要讲的这几处。
本文的技术细节取自 SagooIoT 的发行版代码树:驱动插件在 plugins/modbus,点位表单链路在 pkg/plugins 与 internal/logic/product,采集成败的分支在插件内部与主机 Poller 之间。
一、先把四张表和四种功能码钉住
Modbus 的设备内部就是四块存储,每块有独立的功能码。这四块在平台上对应「寄存器类型」这个下拉框:
| 寄存器类型 | 功能码 | 位/字宽 | 可读 | 可写 | 现场常见用途 |
|---|---|---|---|---|---|
| 线圈 Coil | 1 | 位 | ✓ | ✓ | 继电器输出、启停命令 |
| 离散输入 Discrete | 2 | 位 | ✓ | ✗ | 限位、故障干接点 |
| 保持寄存器 Holding | 3 | 16 位 | ✓ | ✓ | 参数设定值、累积量 |
| 输入寄存器 Input | 4 | 16 位 | ✓ | ✗ | 实时测量值 |
插件里这个下拉框有第五个选项 custom,配一个 function 数字框(1~127)用来填非标功能码。这个选项能不能真正发出去,后面第十二节会有一个不太好看的答案。
读功能码的推导规则写在 resolveReadFunction 里:点位显式配了 function(或它的别名 func)就用它,否则按 registerType 推 ------coil→1、discrete→2、input→4,其余一律 3。
go
func resolveReadFunction(p driver.Point) byte {
p = resolveModbusPoint(p)
if p.Function != 0 {
return p.Function
}
switch strings.ToLower(strings.TrimSpace(fmt.Sprint(p.Config["registerType"]))) {
case "coil":
return 1
case "discrete":
return 2
case "input":
return 4
default:
return 3
}
}
注意那个 default: return 3。它意味着 registerType 填成 custom 但忘了填 function 时,读的是保持寄存器;也意味着这个下拉框里最危险的一种填错------后果不是报错,而是安静地去读保持寄存器。
寄存器地址则接受三个别名,优先级 address > registerAddr > addr。读路径和写路径共用同一个解析函数,之所以要共用,是因为早期两侧各写一份别名表,改了一边忘了另一边。
二、点位配置里的十个字段
属性配置弹窗里那些控件,字段不是产品经理拍的,是 Modbus 插件自己声明出来的。GetTSLConfigMeta() 返回一个字段描述数组,Modbus 吐了十个:
| 字段 | 控件 | 默认值 | 取值范围 |
|---|---|---|---|
| registerType | 下拉 | holding | coil / discrete / holding / input / custom |
| function | 数字 | 3 | 1 ~ 127 |
| registerAddr | 数字 | 空 | 0 ~ 65535 |
| dataType | 下拉 | uint16 | 十种,见第四节 |
| quantity | 数字 | 1 | 1 ~ 2000(经钳制) |
| dataCoef | 数字 | 1.0 | 任意 |
| offset | 数字 | 0.0 | 任意 |
| byteOrder | 下拉 | ABCD | ABCD / DCBA / BADC / CDAB |
| bitIndex | 数字 | 空 | 0 ~ 15 |
| pollGroup | 下拉 | 空(默认组) | 默认 / fast / slow |
这十个字段就是本文后面所有坑的来源。它们也是插件与主程序之间的唯一契约------字段级校验也由插件自己做 (ValidatePointConfig),主程序只做「protocol 非空」这种弱校验,然后把配置通过 RPC 丢回插件做字段级兜底。
校验里有一处比界面更宽:界面上的 registerType 只允许五项,但校验里额外承认 custom 配合显式 function------因为「自定义功能码」这条路径必须允许 function 缺省之外的组合。校验还接受一个界面上根本没有的字段 encoding,只认 raw 和 bcd 两个值。这个字段的命运,第十二节再说。
三、字节序:四种写法,和一段被纠正的旧实现
一个 32 位浮点要占两个 16 位寄存器。谁在高位、两个寄存器内部字节怎么排,Modbus 规范没规定,于是现场长出了四种:
| 写法 | 含义 | 对 4 字节 01 02 03 04 的结果 |
|---|---|---|
| ABCD | 大端,不动 | 01 02 03 04 |
| DCBA | 整体反转 | 04 03 02 01 |
| BADC | 每 2 字节内交换 | 02 01 04 03 |
| CDAB | 每 4 字节组内两个字交换 | 03 04 01 02 |
实现就在 convertByteOrder 里,几十行。值得说的是 CDAB 那段注释:
go
case "CDAB":
// 每 4 字节组内字交换,与旧 Poller applyByteOrder / 工业 CDAB 一致。
// (旧插件实现「BADC+整体反转」在 8 字节类型上结果不同,已纠正。)
for i := 0; i+3 < len(res); i += 4 {
res[i], res[i+1], res[i+2], res[i+3] = res[i+2], res[i+3], res[i], res[i+1]
}
旧实现是「先按 BADC 每 2 字节交换,再整体反转」。这两步在 4 字节上恰好等价于正确结果------所以 float32 用了很久都没出事。但到了 8 字节类型(int64 / uint64 / float64),BADC+反转等于把整 8 字节当作一个整体处理,而正确算法是每 4 字节独立处理,结果完全对不上。一个只在 64 位类型上暴露的 bug,可以安静地活很久。
这段实现还有两个防御细节:BADC 用 safeLen(偶数长度)防止奇数字节越界;len(data) < 2 直接原样返回,因为一个字节没有字节序可言。
字节序的作用域是两级 :设备连接配置里配一次作为默认,点位配置里可以单独配一次覆盖它。解析函数只有三行,但顺序要紧------点位上的空值要能被识别出来("" 和 <nil> 都算空),否则一次「清空点位配置」的操作会把设备级设置顶掉。
四、数据宽度:谁决定读几个寄存器
字节序解决「怎么拼」,宽度解决「拼几个」。宽度由 dataType 推:
| dataType | 占寄存器数 | 说明 |
|---|---|---|
| bool | 1 | 按位取,见第五节 |
| uint16 / int16 | 1 | |
| uint32 / int32 | 2 | |
| float32 | 2 | 最常见的现场类型 |
| uint64 / int64 | 4 | |
| float64 | 4 | |
| string | 1 | 这里有个坑 |
推导函数是 dataTypeQuantity,注释写得很直白:「merge 与点表校验共用,勿各自漂移」。合并读和点表校验用的是同一个宽度结论------如果两处各算一套,合并出的块边界和校验报的重叠就永远是两套说法。
string 只占 1 个寄存器,也就是 2 字节。解析时取到第一个 0 字节为止,所以默认只能读两个 ASCII 字符 。想读长字符串,得手动把 quantity 配大------这也是 quantity 这个字段真正的用途。
但 quantity 一旦填了,就会覆盖类型推导出的宽度:
go
func pointQuantity(point driver.Point) uint16 {
quantity := uint16(dataTypeQuantity(point.Type))
if point.Config != nil {
if val, ok := point.Config["quantity"]; ok {
if q := common.ToInt(val); q > 0 {
if q > 65535 { // 防 uint16 回绕;协议上限钳制见 clampQuantity
q = 65535
}
quantity = uint16(q)
}
}
}
return quantity
}
一个 float32 点位如果把 quantity 手滑填成 1,读取时按 2 字节切给解析器,而解析器需要 4 字节------直接报 insufficient data for float32。
关键在于这个错误发生在块读之后 :decodeRegisterBlock 逐点拆包,某个点宽度不够就记下错误继续拆下一个点,最后返回第一个错误;而 Read 里对它是这么处理的------
go
if err := decodeRegisterBlock(dev, block, raw, result); err != nil {
return nil, err
}
块读成功、一个点宽度配错,整次采集就失败了。 这不是设计失误,是在「静默丢点」和「整批失败」之间选了后者:少一个属性的数据,比一个属性带着错值进时序库要好。
宽度还有一道钳制在 clampQuantity:FC 1/2 上限 2000,FC 3/4 上限 125(这是 Modbus 协议本身的上限)。配超了不报错,直接钳到上限------避免发出一个设备必然拒绝的越界请求。
五、bool 的两条路径
同一个 bool 类型,解析路径有两条,靠数据长度分岔:
go
if len(orderedData) >= 2 {
// 寄存器位点:按字节序解出 uint16 后再取 bitIndex 位
reg := binary.BigEndian.Uint16(orderedData)
if bit < 0 || bit > 15 {
return nil, fmt.Errorf("bitIndex out of range: %d", bit)
}
value = ((reg >> uint(bit)) & 0x01) > 0
} else {
// 线圈/离散位打包:LSB 为首线圈;合并读时 bitIndex 为块内位偏移
byteIdx := bit / 8
value = ((orderedData[byteIdx] >> uint(bit%8)) & 0x01) > 0
}
- 两字节及以上 :这是「一个 16 位寄存器里的某一位」(比如状态字 Word 的 bit3)。先按字节序解出寄存器值,再右移取值。此时
byteOrder有意义------BADC 和 ABCD 解出的 uint16 不同,取到的位也可能不同。 - 单字节 :这是「线圈位打包流」。Modbus 读线圈返回的是位打包字节,字节内 LSB 是第一个线圈。取位方式完全不同,而且字节序在这条路上无意义。
现在看合并读里拆线圈块的那段,就能明白它为什么必须按字节切片再送进解析器:
go
// decodeCoilBlock 从位打包字节流按块内位偏移拆点。
// 必须按字节切片后再 ParseValue:整块 raw≥2 时 bool 会误走寄存器 Uint16 路径。
val, err := tempDevice.ParseValue(modelPoint, raw[byteIdx:byteIdx+1])
如果图省事把整块(比如 40 字节)直接传给解析器,len >= 2 就会命中第一条路径,把位打包流当成一个寄存器去解------读出来的 bool 全是错的,而且不报任何错。
还有一条容易忽略的规则:线圈和离散输入的单点读,恒取 bit0,忽略配置里的 bitIndex。
go
// 线圈/离散单点读恒取 bit0,config 上的 bitIndex 不适用(规格 5.4.2)
fn := resolveReadFunction(point)
if fn != 1 && fn != 2 {
if bit, ok := resolveBitIndex(point.Config); ok {
modelPoint.BitIndex = bit
modelPoint.HasBitIndex = true
}
}
原因很实在:FC1/FC2 一次读回多个线圈时,位打包流里的位置由地址 决定,不由 bitIndex 决定。给一个线圈点位配 bitIndex 在语义上是矛盾的,这行 fn != 1 && fn != 2 就是把矛盾挡住。
bitIndex 还接受别名 bitOffset,两者都校验 0~15。别名的存在说明这两个名字在历史上都被用过,与其统一(会打挂存量配置),不如都认。
六、系数与偏移:读和写是两条反向公式
现场的表常把值放大成整数再传,比如温度 25.6 存成 256,那就要靠系数还原。
读方向:
go
if point.Scale != 0 {
numValue = numValue * point.Scale
}
if point.Offset != 0 {
numValue = numValue + point.Offset
}
return roundFloat(numValue, 6), nil
写方向(encodeModbusWrite 里):
go
valFloat := toFloat64(req.Value)
if offset != 0 {
valFloat = valFloat - offset
}
if dataCoef != 0 {
valFloat = valFloat / dataCoef
}
两条公式互为逆运算,而且系数与偏移在两个方向上共用一个字段名,这样「改配置里的系数」只会同时影响读写两侧,不会出现「写下去 25.6、读回来 2560」这种不对称。
有两处防呆值得记:
- 读侧零值即跳过 ,
Scale != 0才乘。所以配置里系数是 0 时行为等同于 1------不会把读数清零。 - 写侧系数按字段名回退取 :先读
dataCoef,为 0 再读scale。注释写的是「与读侧 scale/dataCoef 双别名对称」。原因是 SDK 在解析物模型时把系数预填到point.Scale,而点位原始配置里那个键可能叫dataCoef。
最后一步 roundFloat(numValue, 6) 把结果收到 6 位小数。不做这一步,25.6 乘完系数会变成 25.599999999999998,写进时序库很难看。
七、写:功能码不看它原来是不是读功能码
下发指令时,平台传下来的 Function 很可能是读功能码(比如 3),因为它就是物模型里那一条的配置。如果照着发下去,就是给设备发了一条「读保持寄存器」还带着一个值。
所以写路径完全重新推导 功能码,只看 registerType 和 dataType:
| 条件 | 功能码 | 数量 |
|---|---|---|
| registerType=coil 或 dataType=bool,单个 | FC5 写单线圈 | 1 |
| 同上,多个(数组 / 位打包) | FC15 写多线圈 | n |
| int16 / uint16 | FC6 写单寄存器 | 1 |
| int32 / uint32 / float32 | FC16 写多寄存器 | 2 |
| int64 / uint64 / float64 | FC16 写多寄存器 | 4 |
FC15 的位打包和读侧呼应,同样 LSB 起始:
go
// packCoils Modbus 位打包:字节内 LSB 为起始线圈。
func packCoils(coils []bool) []byte {
n := (len(coils) + 7) / 8
out := make([]byte, n)
for i, on := range coils {
if on {
out[i/8] |= 1 << uint(i%8)
}
}
return out
}
有符号类型在写侧显式走一遍补码,注释是「避免负 float→uint16 的实现定义行为」:
go
case "int16":
out.Function = 6
out.Quantity = 1
// 显式补码:避免负 float→uint16 的实现定义行为
out.RegVal = uint16(int16(valFloat))
Go 里 float64 直接转无符号整数的行为在规范里是未定义的(不同平台不同),所以先转 int16 再转 uint16,把负数正确地变成补码。
写路径有两个拒绝分支,比功能码推导更值得说。
其一,缺地址不写。 三个别名一个都没有时直接返回错误。注释写得像一句事故复盘:
go
// 地址缺失时拒绝写入:静默写 0 号寄存器(合法地址)是最危险的故障形态。
// 显式配置 0 属合法,resolveModbusAddress 会返回 ok=true。
return encodedWrite{}, fmt.Errorf("modbus 写缺少寄存器地址(address/registerAddr/addr 三别名均未配置)")
关键在「显式配置 0 属合法」。地址 0 不是一个哨兵值,它是真实存在的第一个寄存器。所以不能用「地址等于 0 就当没配」这种判断,必须区分「显式 0」和「缺失」:代码先取三别名(显式 0 会命中并返回 ok=true),只有全缺时才走 if addr == 0 的补救分支。
其二,缺数据类型不写。
go
// 与旧 Poller 一致:缺 dataType 不猜测写入,避免误写 holding
if dataType == "" {
return encodedWrite{}, fmt.Errorf("%w: (empty)", common.ErrUnsupportedDataType)
}
一个没有数据类型的点位,往哪个寄存器上写、写几个字节全是猜。猜错的后果是把相邻点位的值覆盖掉,而且现场很难复盘。宁可不写。
有意思的是这个「拒绝」只在写侧。读侧的地址缺失是静默的 :读路径里地址回填失败时 p.Address 保持 0,然后照常发出「读 0 号寄存器」。同一个风险,写侧当危险挡住,读侧让它发生------因为读错的代价是数据不对,写错的代价是设备状态被改。
八、合并读:什么时候能并,什么时候必须分开
一条产线上的点位往往挨着:40001 到 40020 全是要采的。逐点读就是 20 次往返,在 9600 波特的串口上能把一个采集周期拉长到几秒。合并读就是把这些点合成一次块读再拆开。
算法在 mergeBlocks,先按功能码分组(线圈和寄存器不能混读),组内按地址升序,然后贪心扩块:
go
curEnd := int(cur.start) + int(cur.quantity) // 排他尾(用 int 防 uint16 回绕)
gap := int(p.src.Address) - curEnd
newEnd := int(p.src.Address) + int(p.quantity)
newQty := newEnd - int(cur.start)
// next.start <= cur.end+1+maxGap ⟺ gap <= maxGap
if gap <= int(maxGap) && newQty > 0 && newQty <= int(maxQuantity) {
cur.quantity = uint16(newQty)
cur.points = append(cur.points, p)
continue
}
两个条件同时成立才并入:
gap <= maxGap(默认 16):两个点之间允许的空洞。空洞里的寄存器会被读回来然后丢掉------用一点带宽换一次往返。newQty <= maxQuantity(默认 125,线圈用 2000):合并后的块总宽度不能超协议上限。
那个 // 用 int 防 uint16 回绕 的注释值一行。地址是 uint16,start + quantity 在地址接近 65535 时会回绕成小数字,于是 gap 变成一个负数,gap <= maxGap 恒成立,一个横跨半个地址空间的块就被合并出来了。转成 int 先算就不会。
失败策略只有一句,但它是明确的取舍:
go
// batch:块读失败 → 整次 Read 失败(规格 §2.3,与现网语义对齐)
块里任意一段读不到(设备返回异常码、超时),整个块作废,不做「剩下的部分算成功」。理由是现场的可解释性------一块读回来一半,上位机无法判断是设备没这段寄存器还是链路抖动,不如整批失败让上层看到明确的错误。
要看「到底哪个点读不到」,有开关:设备连接配置里的 collectionMode 改成 single,全部退化成逐点读。这个开关的定位就是排障对照,不是常态。
single 模式下的错误处理也做了分级:单点失败时,如果是连接类错误(broken pipe、i/o timeout、eof 等一列关键词)就立刻中止并把连接从池子里剔除;如果是设备返回的协议错误,记下第一个错误继续读其他点,最后如果一个点都没读成功才把错误抛出去。
九、点表校验:两个点位打到同一个地址
点位配错最容易的一种,是把两个属性配到同一段地址。一个 float32 点位占了 40001-40002,另一个 int32 也写 40001------两个都能读出数,都不会报错,但至少有一个的值是错的。
这类错误在单个点位上无法发现,必须看整张点表 。所以插件除了 ValidatePointConfig(单点)之外,还实现了可选的 PointTableValidator:
go
// validateModbusPointTable 跨属性点表校验:同区地址区间重叠、整寄存器与位点互斥、位点重复。
// 一次报全,不短路。
规则分四种:
| 情形 | 判定 |
|---|---|
| 同区同地址、同 bitIndex 的两个位点 | 报「位点重复」 |
| 同地址,一个整寄存器 + 一个位点 | 报「整寄存器与位点互斥」 |
| 同地址、不同 bitIndex 的两个位点 | 合法,不报 |
| 其他区间相交 | 报「地址区间重叠」 |
第二行那条值得解释:一个「读 40001 整个寄存器」的属性和一个「读 40001 的 bit3」的属性放在一起,前者会拿到后者的原始寄存器值,两者的口径是重叠的------要么删掉一个,要么把整寄存器那条挪到别处。第三行则相反:同地址的不同位是标准的「状态字取位」用法,必须放行。
校验的粒度按「寄存器语义宽度」算:位点按 1 个寄存器参与区间比较,而不是按 1 位。因为位点和整寄存器争的是同一个存储位置。
最后「一次报全,不短路」这五个字是有意写的。校验失败时点表通常错了好几处,报第一个就返回,用户要改一遍存一次,来回好几轮。
十、连接:池键、从站号,还有一个靠反射的健康检查
池键必须带全物理链路参数。 TCP 场景键是 modbus:tcp:<address>;串口场景是 modbus:rtu:<address>:<波特率>:<数据位>:<停止位>:<校验>。
go
func connectionPoolKey(mode string, config driver.ConnectConfig) string {
if mode == "tcp" {
return fmt.Sprintf("modbus:%s:%s", mode, config.Address)
}
return fmt.Sprintf("modbus:%s:%s:%s:%s:%s:%s", mode, config.Address,
config.Config["baudRate"], config.Config["dataBits"],
config.Config["stopBits"], config.Config["parity"])
}
串口参数进池键的原因写在注释里:「避免不同物理链路误复用同一个客户端」。同一个 /dev/ttyUSB0 上先用 9600 再改 19200,如果键里不含波特率,第二个设备会拿到按 9600 建好的串口句柄。
从站号是「动态切换」的。 Modbus TCP 上一个 socket 后面可以挂多个从站(RTU over TCP 网关、多从站串口服务器),所以插件复用同一个物理连接,每次读写前改一次 SlaveId:
go
client.Mu.Lock()
defer client.Mu.Unlock()
// 动态切换从站ID
// 这是一个非线程安全的操作,所以必须在 client.Mu 保护下进行
client.SetSlaveIDUnsafe(dev.SlaveID)
SlaveId 是 goburrow/modbus 里 handler 的一个公开字段,改它是线程不安全的,所以整个读写过程都在连接级互斥锁里。这也决定了同一物理连接上的采集是串行的。
健康检查靠反射读上游库的私有字段名。
go
switch h := c.Handler.(type) {
case *modbus.TCPClientHandler:
return hasNonNilField(reflect.ValueOf(h), "tcpTransporter", "conn")
case *modbus.RTUClientHandler:
return hasNonNilField(reflect.ValueOf(h), "rtuSerialTransporter", "serialPort", "port")
case *modbus.ASCIIClientHandler:
return hasNonNilField(reflect.ValueOf(h), "asciiSerialTransporter", "serialPort", "port")
default:
// 未知实现类型时不做激进判死,交给后续读写错误触发重连
return true
}
tcpTransporter、serialPort 这些是 goburrow/modbus 内部结构体的私有字段名。这份代码用反射去探它们是否为 nil,来判断底层连接是不是已经关了。
这是一次有意识的越界 :上游库没提供「连接是否有效」的公开方法,而池子需要一个复用的判据。代价是上游库一改字段名,这段检查就会静默失效------hasNonNilField 里 defer recover() 吞掉一切异常,字段找不到时 FieldByName 返回零值,最终返回 false,于是连接每次都被判死、每次重建。表现为「连接池好像没生效」,但不报错。
default: return true 那一支是防这个的:遇到不认识的 handler 类型时不做激进判死,让后续的真实 I/O 错误来触发重连。宁可多试一次,不要因为探不到就说连接坏了。
十一、表单是从哪儿来的
现在回答一个很反直觉的现象:为什么改了插件的表单字段,管理端界面上没变?
因为界面上那十个字段,是三次跳转之后的结果,其中两跳都不在插件里。
第一跳,插件声明。 GetTSLConfigMeta() 返回字段数组,SDK 把它包成 JsonRes 暴露成 RPC 方法。
第二跳,插件启用时同步落库。 这不是每次请求都走的实时 RPC,而是在插件启用/启动时异步做一次:
go
// syncPluginMetadata 异步同步插件配置元数据到数据库
// 启动后通过 RPC 探测插件就绪状态,获取 PluginConfig、InstanceConfig、TSLConfig 并写入 sys_plugins 表
func (m *Manager) syncPluginMetadata(pluginId string) {
// 仅设备驱动插件需要做驱动配置与TSL元数据同步,避免非驱动插件误断言
if m.Type != PluginType.Driver {
return
}
...
info := drv.Info()
tslRes := drv.GetTSLConfigMeta()
...
input := model.SysPluginsAddInput{
Types: m.Type, HandleType: info.HandleType, Name: info.Name,
...
TslConfig: tslConfigJson,
}
if err := service.SysPlugins().SaveSysPlugins(context.Background(), input); err != nil {
g.Log().Errorf(context.Background(), "保存插件 %s 配置失败: %v", pluginId, err)
}
}
触发点有两个:一是管理器启动时遍历已启用插件,二是从界面上点「启用」成功后(go m.syncPluginMetadata(id),异步)。同步前先等 RPC 就绪,等待超时 10 秒,退避重试。
这一步解释了三个现象:
- 改插件代码 → 重启进程才会刷新表单。 字段是启用那一刻的快照。
syncPluginMetadata开头就挡掉了非 driver 插件 ------protocol、notice类插件不走这条同步。- 插件拉起失败或
GetTSLConfigMeta返回非 0 时,落库的是空串 ,而不是保留旧值。代码里tslData为 nil 时tslConfigJson = "",然后照样 upsert 进库。
第三跳,前端读库。 主程序侧取表单已经不再走 RPC,注释写得很明白:
go
// 尝试从数据库获取配置元数据 (Refactored to use DB instead of RPC)
pluginName := strings.ToLower(protocol)
sysPlugin, err := service.SysPlugins().GetSysPluginsByName(ctx, pluginName)
if err == nil && sysPlugin != nil {
if sysPlugin.TslConfig != "" {
var tslData []map[string]any
if err := json.Unmarshal([]byte(sysPlugin.TslConfig), &tslData); err == nil {
if len(tslData) > 0 {
_ = cache.Instance().Set(ctx, cacheKey, tslData, time.Minute) // Short cache for checks
return tslData, nil
}
}
}
}
外面还包了一层 tsl_meta_schema_<protocol>_v2 的缓存,有效期一分钟 。缓存键里带 _v2 后缀,注释写的是「强制失效掉可能过期的旧缓存」------这是上次改结构时留下的痕迹,用改键名代替了清缓存。
binary 协议是唯一的例外,走内置 schema:
go
// protocol 插件不走 Driver GetTSLConfigMeta 同步;binary 内置 schema,供前端 editAttr 动态表单
if pluginName == "binary" {
data = binaryPropertyMeta()
_ = cache.Instance().Set(ctx, cacheKey, data, time.Minute)
return data, nil
}
而最值得注意的,是这个函数在几条失败路径上的返回值:插件查不到、TslConfig 为空、JSON 解析失败------三种情况都只写一行 Warning 日志,然后落到函数末尾返回 nil, nil。调用方拿到的是「没有错误、字段列表为空」。前端于是渲染出一个没有字段的配置表单,界面上不会有任何报错。
这条链路还有一处已经过期的文档。插件专题里那份配置架构说明(2026 年初写的)下过一个判断:
Critical Finding : The configuration form (Schema) seen in the frontend is HARDCODED in the host core, NOT dynamically provided by the plugin.
以及配套的最佳实践:
If a configuration option (like a new Register Type) is missing from the UI, it must be added in this file, not in the plugin.
这两句话在写下的那一刻是对的,现在已经不成立了:tsl_config.go 里那段硬编码的 Modbus schema 早被换成了「读库 + binary 兜底」,字段的真正来源变成了插件的 GetTSLConfigMeta()。文件名还叫 tsl_config,做的事已经完全不同。
留下这个反例的价值在于:它说明「表单从哪来」这件事在这套系统里改过一次方向,而文档停在了上一次。只看文档的人,会去改错文件。
十二、还在库里但没接线的东西
下面这些不是猜测,是在这份代码树里逐个确认过的。
八个高级功能码方法,零调用。
client.go 里实现了八个方法,覆盖 0x07 读异常状态、0x08 诊断、0x0B 通信事件计数器、0x0C 通信事件日志、0x11 报告服务器 ID、0x14 读文件记录、0x18 读 FIFO 队列、0x2B 读设备标识。全部手写 PDU 编码,全部走了完整的 Encode → Send → Decode 流程。
ReadExceptionStatus -> 调用点 0
Diagnostics -> 调用点 0
GetCommEventCounter -> 调用点 0
GetCommEventLog -> 调用点 0
ReportServerID -> 调用点 0
ReadFileRecord -> 调用点 0
ReadFIFOQueue -> 调用点 0
ReadDeviceIdentification-> 调用点 0
它们对应的读取分派只有一个 switch:
go
func readModbus(client *CustomModbusClient, function byte, address, quantity uint16) ([]byte, error) {
switch function {
case 1: return client.ReadCoils(address, quantity)
case 2: return client.ReadDiscreteInputs(address, quantity)
case 3: return client.ReadHoldingRegisters(address, quantity)
case 4: return client.ReadInputRegisters(address, quantity)
default:
return client.ReadHoldingRegisters(address, quantity)
}
}
default 那一行是要命的地方。 前面说过,界面上的 registerType 可以选 custom 再填一个 function(1~127),点位校验也承认这个组合。但就算把 function 填成 20,走到这里也只会在 switch 里找不到 20,于是落到 default,发出 FC3 读保持寄存器。自定义功能码这条路径在当前读分派下走不通,而且不报错------读回来的是保持寄存器的一段数据,被当成那个属性的值。
八个现成的方法、一个允许配置的 function 字段、一个 default 分支,三者拼在一起形成了这个缺口。
encoding 字段无人消费。
点位校验里明确承认 raw 和 bcd 两个值:
go
if encoding, ok := cfg["encoding"].(string); ok && encoding != "" {
switch encoding {
case "raw", "bcd":
default:
add("modbus.encoding 无效值: %s", encoding)
}
}
但翻遍解析路径,没有任何地方读 encoding。device.go 里倒是留着一个 BCD 转换函数:
go
// bcdToDecimal BCD to Decimal
func (d *Device) bcdToDecimal(bcd byte) int {
return int(((bcd >> 4) * 10) + (bcd & 0x0F))
}
零调用点。 电表、水表这类设备用 BCD 编码传数很常见,这个字段和这个函数都在等一条还没接上的线。目前配 encoding: bcd 不会有任何效果,数据会按原样当二进制整数解出来。
同一个文件里还有一个 evalFormula(用 govaluate 做公式计算),同样是零调用点。两个函数加一个字段,构成了一组「已经预见到的需求,还没接到主路径上」。
SQL 列注释抄错了。
sys_plugins 表在所有后端都有,但 tsl_config 这一列的注释在两个后端不一样:
| 后端 | 注释 |
|---|---|
| PostgreSQL | 物模型TSL配置(JSON) |
| MySQL | TLS/SSL安全连接配置 |
PostgreSQL 那句是对的。MySQL 那句把 tsl 当成了 TLS,和前面那个「插件类型列注释抄了 go-plugin 文档」的问题是同一种形态------建表脚本是在不同时期分批加的,注释没跟着改。
同一个插件有两处版本号。
info.json 里写 "version": "1.0.0",main.go 里硬编码 Version: "1.0.1"。运行时元数据以代码里那份 为准,同步进库时会覆盖。所以插件升级后,数据库里的版本会从 1.0.0 变成 1.0.1,而这个变更只在日志里留一行 Warning,不影响任何逻辑------normalizeRuntimePluginInfo 只对 Name 和 Types 不一致做硬报错:
| 字段 | 不一致时的行为 |
|---|---|
| Name 与插件 ID | 报错,跳过同步 |
| Types 与管理器类型 | 报错,跳过同步 |
| 数据库 Types 与运行时 Types | 报错,跳过同步 |
| HandleType 变化 | 日志 Warning,继续 |
| Version 变化 | 日志 Warning,继续 |
这条梯度是按「不一致会不会导致装错插件」设计的:Name 和 Types 错意味着找错了目标,必须停;HandleType 和 Version 变了说明是合法升级,记一笔就够了。
DTU 反连不经过插件的 Connect/Read。 这是插件 README 里明确写出来的一条约束:
不支持 在插件内 Listen。DTU 反连(设备连平台 TCP)由主机 Poller 在入站隧道上发 MBAP,有隧道时不会调用本插件的 Connect/Read。
原因是 go-plugin 的进程外模型没法把一条活的 net.Conn 交给插件。所以现场那种「4G DTU 主动连回来」的设备,走的是「网络组件的 TCP 服务器 + 注册包 + 主机侧隧道问答」,插件在这一轮采集里完全不参与------虽然配置上产品选的还是同一个 modbus 驱动,点位也还是同一套。
十三、小结
把这篇里的东西收成一张表,它们是现场排查时最快能定位的几处:
| 现象 | 先看这里 |
|---|---|
| 数量级差 2 的幂次 | byteOrder 两级(点位覆盖设备) |
| 值是整数、应为小数 | dataCoef / scale |
| 64 位类型只有 CDAB 不对 | 历史实现问题,当前版本已纠正 |
| 一个 bool 明明置位却读到 0 | 线圈取 bit0,bitIndex 在线圈上不生效 |
| 某个属性报 insufficient data | 该点 quantity 覆盖了类型宽度 |
| 采集整批失败但块读正常 | 块内某个点宽度不够,整块作废 |
| 下发指令没反应 | 该点缺 dataType 或地址,写路径主动拒绝 |
| 属性在设备上明明有值,读不到 | 点表校验拦下了重叠,或 registerType=custom 落到 FC3 |
| 改了插件表单字段,界面没变 | 表单是启用时的落库快照,要重新走一次同步 |
| 配置表单是空白的 | 同步时插件未就绪,落库空串,接口静默返回空列表 |
Modbus 的极简是它的生命力,也是它的成本------那三件被推给上位机的事(读几个、怎么拼、怎么解释),最后都变成了配置项、校验规则和错误分支。这套插件把这些分支基本都写清楚了:该拒绝的拒绝(缺地址、缺类型不写),该放弃的放弃(块读失败整批作废),该保守的保守(上层库没给判据就用反射探,探不到不算坏)。