解碼 32-bit 浮點數與縮放值

本頁目錄

程式碼:examples/python/modbus_logger/decode.py

問題

Modbus 的暫存器只有一種型別:16-bit 無號整數。任何比它大的東西——32-bit 浮點數、32-bit 整數、64-bit——都是廠商自己把值拆成好幾個暫存器塞進去的。手冊上寫「Float32」,你讀到的是兩個看不懂的數字:

read_holding_registers(0, 2) → [17244, 19661]     # 這是 220.3 V

拼錯順序就會得到 1.07617e+08 這種一看就知道錯了的值,或更糟——看起來很合理但其實是錯的值

四種可能的順序

把兩個暫存器攤成 4 個位元組 A B C D(A 是最高位),四種常見排法就是它們的排列組合:

排法 別稱 常見於
ABCD Big-endian、High word first Modbus 標準,最常見
CDAB Word swap、Low word first、Mid-little 台系電表與 PLC 很常見
BADC Byte swap、Mid-big 少數 RTU 閘道器
DCBA Little-endian 罕見

沒有辦法從資料本身推斷,只能查手冊或試。

通用解碼函式

不要用 pymodbus 的 payload builder(3.x 各版本 API 一直變,而且已標記 deprecated)。直接用標準函式庫 struct:

import struct

# 值是「輸出的第 n 個位元組要取原始的第幾個」
_PERMUTATIONS = {
    "abcd": (0, 1, 2, 3),
    "cdab": (2, 3, 0, 1),
    "badc": (1, 0, 3, 2),
    "dcba": (3, 2, 1, 0),
}

_ALIASES = {
    "big": "abcd", "high_word_first": "abcd",
    "little": "dcba",
    "word_swap": "cdab", "low_word_first": "cdab",
    "byte_swap": "badc",
}


def _arrange(regs: list[int], order: str) -> bytes:
    """把暫存器攤成位元組後依 order 重排(每 4 位元組一組)。"""
    raw = struct.pack(f">{len(regs)}H", *regs)
    perm = _PERMUTATIONS[_ALIASES.get(order.lower(), order.lower())]
    out = bytearray(len(raw))
    for base in range(0, len(raw), 4):
        for i, src in enumerate(perm):
            out[base + i] = raw[base + src]
    return bytes(out)


def regs_to_float32(regs: list[int], order: str = "big") -> float:
    return struct.unpack(">f", _arrange(regs, order))[0]


def regs_to_int32(regs: list[int], order: str = "big", signed: bool = True) -> int:
    value = struct.unpack(">I", _arrange(regs, order))[0]
    if signed and value >= 0x8000_0000:
        value -= 0x1_0000_0000
    return value


def reg_to_int16(reg: int, signed: bool = True) -> int:
    return reg - 0x1_0000 if signed and reg >= 0x8000 else reg

先重排位元組、再統一用大端解讀,四種排法才會得到四個真正不同的結果。

struct 的大小端旗標去疊加 word swap 是很自然的寫法,但 little-endian 的解讀會把 word swap 抵消掉——你會發現 ABCD 和 DCBA 解出一模一樣的值,四種變兩種,然後在那邊懷疑人生。先排位元組再固定用 > 解讀就沒這個問題。

不確定順序時,四種都印出來

除錯神器:

def try_all(regs: list[int]) -> dict[str, float]:
    return {name: struct.unpack(">f", _arrange(regs, name))[0] for name in _PERMUTATIONS}
$ python decode.py 17244 19661
暫存器 [17244, 19661] 的四種 float32 解法:
  ABCD   =              220.3
  CDAB   =        1.07617e+08
  BADC   =        2.20453e+17
  DCBA   =       -2.14287e+08

一眼就知道是 ABCD。記得把答案寫回設定檔,不要每次現場試。

整數 + 縮放係數

比 float32 更常見的做法:用 16-bit 整數存,再乘一個係數。手冊會寫「Unit: 0.1V」。

voltage = reg_to_int16(regs[0]) * 0.1        # 2203 → 220.3

把型別與係數寫在設定裡而不是程式裡:

registers:
  - {name: 電壓, address: 8,  type: int16,   scale: 0.1,  unit: V}
  - {name: 電流, address: 12, type: float32, word_order: big, unit: A}
  - {name: 電能, address: 20, type: uint32,  word_order: big, scale: 0.01, unit: kWh}

對應的分派函式與「每種型別佔幾個暫存器」對照表:

REG_COUNT = {"int16": 1, "uint16": 1, "int32": 2, "uint32": 2, "float32": 2, "float64": 4}


def decode(regs: list[int], cfg: dict) -> float:
    kind, order = cfg["type"], cfg.get("word_order", "big")
    if kind == "int16":
        value = reg_to_int16(regs[0], signed=True)
    elif kind == "uint16":
        value = regs[0]
    elif kind in ("int32", "uint32"):
        value = regs_to_int32(regs, order, signed=(kind == "int32"))
    elif kind == "float32":
        value = regs_to_float32(regs, order)
    else:
        raise ValueError(f"未知型別:{kind}")
    return value * cfg.get("scale", 1.0)

REG_COUNT 讓輪詢端知道每個項目要讀幾個暫存器,設定檔就不用重複寫。

踩過的坑

scale 用乘法不要用除法。 手冊寫「0.1V」代表 讀值 × 0.1。但有些手冊寫「Scale: 10」,意思是 讀值 ÷ 10。設定檔統一存乘數,轉換在寫設定時做完,程式裡永遠是乘法——不然半年後你會忘記是哪一種。

負數不處理會變成六萬多。 溫度 -5.0°C 存成 int16 是 0xFFCE = 65486,乘 0.1 之後溫度圖表會出現 6548.6 度。看到「大概六萬多」的異常值,九成是漏了有號轉換。

32-bit 值跨兩個暫存器,要一次讀完。 分兩次 read_holding_registers(0, 1)(1, 1),中間值可能剛好變動,你會拼出一個從未存在過的數字。一定要 read_holding_registers(0, 2) 一次拿。

float32 精度只有約 7 位有效數字。 累積電能到了 12345678.9 kWh 時 float32 存不下,末位會跳動。這是裝置端的限制不是你的 bug——遇到就改讀廠商提供的 uint32 + scale 版本暫存器(通常會有)。

同一台裝置的不同欄位可能用不同順序。 真的遇過:電流是 ABCD,累積電能是 CDAB。所以 word_order 要能逐欄設定,不要做成全域參數。


回到頂端

MIT License — 範例程式可自由取用。

This site uses Just the Docs, a documentation theme for Jekyll.