你用 Python 寫好 pymodbus 腳本,連上 Schneider M221,讀取一對你明明知道溫度是 25.5°C 的 holding register,結果拿到的是:
1.401298464324817e-45
不是 25.5。連邊都沒沾到。一個極小到接近 0 的非正規化浮點數(denormal float)。
歡迎來到 Modbus float32 byte-swap 陷阱——這是工程師最常以為「pymodbus 壞掉」但其實沒壞的問題。一旦你搞懂發生了什麼事,修正只要 5 行程式碼。這篇文章就是要把這件事講清楚。
Modbus 規範把 register 定義成 16-bit unsigned integer。就這樣。沒了。這個規範是 1979 年寫的,當時這就夠用了。
但現代 PLC 需要傳 float、32-bit integer、double、字串。業界採用的「解法」是:把比較大的值拆開,跨好幾個 16-bit register 存。
問題是?Modbus 規範對拆開後的順序沒有任何規定。
每家 PLC 廠商各自決定要把高字組(high word)放前面還是低字組(low word)放前面、要不要在 word 裡面交換 bytes、要不要全部翻過來。結果就是同一個 float 有 4 種不同的編碼方式,而且沒辦法從封包本身判斷你拿到的是哪一種。
這就是為什麼你 PLC 軟體上明明顯示 25.5°C,但 Python 腳本讀到 1.4e-41——同樣的 bytes,不同的解碼順序。
IEEE-754 的 32-bit float 25.5 對應的 byte sequence 是:
0x41 0xCC 0x00 0x00
現在來看不同 PLC 把這 4 個 bytes 塞進 2 個 Modbus register 時會發生什麼:
| 順序 | Register N | Register N+1 | 常見於 |
|---|---|---|---|
| ABCD(big-endian,無 swap) | 0x41CC |
0x0000 |
Allen-Bradley、ABB、實作乾淨的設備 |
| CDAB(word-swap) | 0x0000 |
0x41CC |
Schneider M221/M241、部分 Siemens |
| BADC(byte-swap) | 0xCC41 |
0x0000 |
少見——某些舊型 controller |
| DCBA(byte + word swap) | 0x0000 |
0xCC41 |
多半是 legacy 硬體 |
如果裝置送的是 CDAB 但你用 ABCD 解碼,結果會是極小的 denormal float(你看到的 1.4e-41)、或 nan、或 inf、或一堆毫無意義的數字。
Schneider 的 CDAB(word-swap)是最坑大多數人的陷阱。
純 stdlib,不需要任何依賴。直接拿去用:
import struct
def decode_float32(reg_high: int, reg_low: int, byte_order: str = "ABCD") -> float:
"""把兩個 16-bit Modbus register 解碼成 float32。
byte_order:
ABCD - big-endian,無 swap(IEEE-754 預設)
CDAB - word-swap(Schneider、部分 Siemens)
BADC - 每個 word 內部 byte-swap
DCBA - byte + word 同時 swap
"""
# 把兩個 register 打包成 4 bytes(big-endian)
raw = struct.pack(">HH", reg_high, reg_low)
if byte_order == "ABCD":
return struct.unpack(">f", raw)[0]
elif byte_order == "CDAB":
# 交換兩個 16-bit word
return struct.unpack(">f", raw[2:4] + raw[0:2])[0]
elif byte_order == "BADC":
# 每個 word 內部交換 bytes
return struct.unpack(">f", raw[1:2] + raw[0:1] + raw[3:4] + raw[2:3])[0]
elif byte_order == "DCBA":
# 4 bytes 全部反過來
return struct.unpack(">f", raw[::-1])[0]
else:
raise ValueError(f"Unknown byte order: {byte_order}")
# 範例:用 pymodbus 讀值,4 種順序全部跑過一次
from pymodbus.client import ModbusTcpClient
client = ModbusTcpClient("192.168.1.10")
client.connect()
response = client.read_holding_registers(address=100, count=2)
reg_high, reg_low = response.registers[0], response.registers[1]
print(f"ABCD: {decode_float32(reg_high, reg_low, 'ABCD')}")
print(f"CDAB: {decode_float32(reg_high, reg_low, 'CDAB')}")
print(f"BADC: {decode_float32(reg_high, reg_low, 'BADC')}")
print(f"DCBA: {decode_float32(reg_high, reg_low, 'DCBA')}")
訣竅:每次接新裝置時,4 種全部印出來。其中唯一一個給你合理數值(例如你知道溫度是 25.5,就會看到 25.5)的那個就是該裝置的編碼方式。確認後鎖定該裝置用那一種。
整合過這麼多年的 PLC,我看到的 byte order 大致是這樣:
教訓:永遠不要假設。即使同一個廠商,不同產品線都可能挑不同順序。新裝置一律先做「4 種全部印」測試。
pymodbus 內建的 BinaryPayloadDecoder 讓你指定 byteorder=Endian.BIG, wordorder=Endian.LITTLE(也就是 CDAB)。但這個 API 真的混亂到我看過有人兩個都設成 BIG 然後 debug 好幾個小時。直接用 struct 手動解碼比較清楚。
如果你解碼出來的值是 -1.5e+38 而你期待的是 25.5,幾乎可以確定 byte order 抓錯。在任何正常 scaling 下,個位數的正溫度不會意外被編碼成超大負數。
這篇文章聚焦在 float32 因為它最痛,但所有跨 register 的型別都有這個問題。int32、uint32、int64、double——全部都會跨 register 拆開存,全部都需要做 byte-order 處理。
很煩但真的有:少數高階 PLC 讓使用者自己設定 endianness。跑測試程式前,先翻一下手冊有沒有「byte order」或「word order」這種參數。
Modbus float32 byte-swap 屬於那種「看起來像你程式碼有 bug,其實是協定歷史遺留」的問題。一旦你知道要 4 種都試,新裝置的整合時間會從幾個小時壓縮到幾分鐘。
希望這篇有幫到你少踩一個坑。
如果你想看更多工業 Python 跟協定底層相關的文章,可以追一下我的 GitHub:github.com/PhilYeh1212,有興趣可以聊聊。
raw[1:2] + raw[0:1] + raw[3:4] + raw[2:3]
這段寫法 可以更精簡
沒錯的喔!!
每個人寫法習慣不同,就看自己順手