PMBus 系列約 4 分鐘

用 Python / Linux 讀取 PMBus 電源:i2c-tools、smbus2 與 hwmon 實作

在 Linux 上用 Python 讀取 PMBus 電源的完整實作:i2c-tools 快速驗證、smbus2 讀取 LINEAR11 / LINEAR16 量測值、STATUS_WORD 解碼、Block Read 讀取 MFR_ID、PEC 驗證與 QUERY,以及用核心 pmbus 驅動透過 hwmon / sensors 監控。

準備環境

硬體:Linux 上要有一個 I2C 介面

選項說明
伺服器 BMC直接存取主機板上連接電源的 I2C 匯流排
Raspberry Pi內建 I2C(/dev/i2c-1),適合實驗室測試
USB 轉 I2C例如 MCP2221(Linux 內建 hid-mcp2221 驅動),接在一般 PC 上

軟體

sudo apt install i2c-tools python3-pip
pip install smbus2

# 讓目前使用者不用 sudo 就能存取 I2C
sudo usermod -aG i2c $USER   # 重新登入後生效

第一步:用 i2c-tools 確認連線

# 列出所有 I2C 匯流排
i2cdetect -l

# 掃描 bus 1 上的裝置
i2cdetect -y 1

找到電源的位址後(以下以 0x58 為例),先用幾個唯讀命令確認通訊正常:

i2cget -y 1 0x58 0x98 b   # PMBUS_REVISION
i2cget -y 1 0x58 0x79 w   # STATUS_WORD
i2cget -y 1 0x58 0x88 w   # READ_VIN(LINEAR11 原始值)

第二步:Python 工具

以下程式用 smbus2 存取 I2C,換算方式都來自系列文章:

#!/usr/bin/env python3
"""pmbus_tool.py:在 Linux 上用 smbus2 讀取 PMBus 電源"""
import argparse
import time

from smbus2 import SMBus, i2c_msg

# PMBus 命令碼
CLEAR_FAULTS = 0x03
CAPABILITY = 0x19
QUERY = 0x1A
VOUT_MODE = 0x20
STATUS_WORD = 0x79
READ_VIN, READ_IIN, READ_VOUT, READ_IOUT = 0x88, 0x89, 0x8B, 0x8C
READ_TEMPERATURE_1 = 0x8D
READ_POUT, READ_PIN = 0x96, 0x97
PMBUS_REVISION = 0x98
MFR_ID, MFR_MODEL, MFR_SERIAL = 0x99, 0x9A, 0x9E

STATUS_WORD_BITS = [
    "NONE_OF_THE_ABOVE", "CML", "TEMPERATURE", "VIN_UV_FAULT",
    "IOUT_OC_FAULT", "VOUT_OV_FAULT", "OFF", "BUSY",
    "UNKNOWN", "OTHER", "FANS", "POWER_GOOD#",
    "MFR_SPECIFIC", "INPUT", "IOUT/POUT", "VOUT",
]


def linear11(raw):
    """LINEAR11:b[15:11] 指數 N、b[10:0] 尾數 Y,兩者都是二補數"""
    n = raw >> 11
    if n > 15:
        n -= 32
    y = raw & 0x7FF
    if y > 1023:
        y -= 2048
    return y * 2.0 ** n


def linear16(raw, vout_mode):
    """LINEAR16:16 位元無號尾數,指數在 VOUT_MODE 的 b[4:0]"""
    if vout_mode & 0xE0:
        raise ValueError(f"VOUT_MODE=0x{vout_mode:02X} 不是 Linear 格式")
    n = vout_mode & 0x1F
    if n > 15:
        n -= 32
    return raw * 2.0 ** n


def smbus_pec(data):
    """CRC-8/SMBUS:多項式 0x07、初始值 0"""
    crc = 0
    for b in data:
        crc ^= b
        for _ in range(8):
            crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
    return crc


def decode_status_word(sw):
    return [name for bit, name in enumerate(STATUS_WORD_BITS) if sw >> bit & 1]


class PMBusDevice:
    def __init__(self, bus, addr, pec=False):
        self.bus = bus
        self.addr = addr
        self.pec = pec
        self.bus.pec = pec        # Byte / Word 交易由驅動計算與檢查 PEC
        self._vout_mode = None

    # ---- Byte / Word ----
    def read_byte(self, cmd):
        return self.bus.read_byte_data(self.addr, cmd)

    def read_word(self, cmd):
        return self.bus.read_word_data(self.addr, cmd)

    def read_linear11(self, cmd):
        return linear11(self.read_word(cmd))

    def read_vout(self):
        if self._vout_mode is None:
            self._vout_mode = self.read_byte(VOUT_MODE)
        return linear16(self.read_word(READ_VOUT), self._vout_mode)

    def status_word(self):
        return self.read_word(STATUS_WORD)

    def clear_faults(self):
        self.bus.write_byte(self.addr, CLEAR_FAULTS)

    # ---- Block Read / Process Call ----
    def _block_transfer(self, write, max_len):
        """寫入 write 後 Repeated Start 讀回 Byte Count + 資料(+ PEC),自行驗證 PEC"""
        wr = i2c_msg.write(self.addr, write)
        rd = i2c_msg.read(self.addr, 1 + max_len + (1 if self.pec else 0))
        self.bus.i2c_rdwr(wr, rd)
        raw = list(rd)

        count = raw[0]
        if count > max_len:
            raise IOError(f"Byte Count {count} 超過 {max_len}")
        data = raw[1:1 + count]

        if self.pec:
            frame = [self.addr << 1, *write, self.addr << 1 | 1, count, *data]
            if smbus_pec(frame) != raw[1 + count]:
                raise IOError("PEC 錯誤")
        return data

    def read_block(self, cmd, max_len=32):
        return self._block_transfer([cmd], max_len)

    def read_string(self, cmd):
        return bytes(self.read_block(cmd)).decode("ascii", "replace").strip()

    def query(self, cmd):
        """QUERY (1Ah):回傳 None 表示不支援"""
        q = self._block_transfer([QUERY, 1, cmd], 1)[0]
        return q if q & 0x80 else None


def print_report(psu):
    for name, cmd in (("MFR_ID", MFR_ID), ("MFR_MODEL", MFR_MODEL), ("MFR_SERIAL", MFR_SERIAL)):
        try:
            print(f"{name:<11}: {psu.read_string(cmd)}")
        except OSError as e:          # 不支援的命令會 NACK;PEC 錯誤也會到這裡
            print(f"{name:<11}: (讀取失敗:{e})")

    print(f"VIN        : {psu.read_linear11(READ_VIN):8.2f} V")
    print(f"IIN        : {psu.read_linear11(READ_IIN):8.2f} A")
    print(f"VOUT       : {psu.read_vout():8.3f} V")
    print(f"IOUT       : {psu.read_linear11(READ_IOUT):8.2f} A")
    print(f"PIN        : {psu.read_linear11(READ_PIN):8.1f} W")
    print(f"POUT       : {psu.read_linear11(READ_POUT):8.1f} W")
    print(f"TEMP1      : {psu.read_linear11(READ_TEMPERATURE_1):8.1f} °C")

    sw = psu.status_word()
    print(f"STATUS_WORD: 0x{sw:04X} {' '.join(decode_status_word(sw)) or 'OK'}")


def main():
    ap = argparse.ArgumentParser(description="讀取 PMBus 電源")
    ap.add_argument("--bus", type=int, default=1, help="I2C bus 編號")
    ap.add_argument("--addr", type=lambda s: int(s, 0), default=0x58, help="PMBus 位址")
    ap.add_argument("--pec", action="store_true", help="啟用 PEC")
    ap.add_argument("--watch", type=float, metavar="SEC", help="每隔幾秒重複讀取")
    args = ap.parse_args()

    with SMBus(args.bus) as bus:
        psu = PMBusDevice(bus, args.addr, pec=args.pec)
        while True:
            print_report(psu)
            if not args.watch:
                break
            print("-" * 40)
            time.sleep(args.watch)


if __name__ == "__main__":
    main()

執行

python3 pmbus_tool.py --bus 1 --addr 0x58
python3 pmbus_tool.py --bus 1 --addr 0x58 --pec --watch 2   # 啟用 PEC,每 2 秒更新

輸出範例(數值依實際電源而定):

MFR_ID     : ACME
MFR_MODEL  : CRPS-1600
MFR_SERIAL : SN12345678
VIN        :   230.00 V
IIN        :     2.20 A
VOUT       :   12.000 V
IOUT       :    41.50 A
PIN        :    508.0 W
POUT       :    498.0 W
TEMP1      :     35.2 °C
STATUS_WORD: 0x0000 OK

程式重點說明

Byte / Word 交易:交給 smbus2

read_byte_data()、read_word_data() 對應 SMBus 的 Read Byte / Read Word。read_word_data() 回傳的值已經把低 / 高位元組組合好,可以直接用 LINEAR11 解碼。設定 bus.pec = True 後,這類交易的 PEC 由核心驅動計算與檢查。

Block Read:用 i2c_rdwr() 自己處理

MFR_ID 這類 Block Read,smbus2 雖然有 read_block_data(),但很多 I2C 控制器不支援 SMBus Block Read。比較通用的做法是用 i2c_rdwr():

  1. 寫入命令碼
  2. Repeated Start 後一次讀回最大長度(Byte Count + 32 Bytes + PEC)
  3. 依第一個 Byte 的 Byte Count 取出實際資料

多讀的位元組會被忽略。因為這種方式繞過了 SMBus 層,PEC 要自己算,包含兩次位址位元組。

VOUT_MODE 只讀一次

read_vout() 第一次讀取時把 VOUT_MODE 快取起來。VOUT_MODE 在電源運作中通常不會改變;如果你的電源允許修改它,就要在修改後清除快取。

另一條路:Linux 核心 pmbus 驅動 + hwmon

如果只是要監控,不一定要自己寫程式。Linux 核心內建通用的 pmbus 驅動,會自動偵測電源支援的命令,並把量測值放到 hwmon 介面:

# 載入驅動,並告訴核心 bus 1 的 0x58 是 PMBus 裝置
sudo modprobe pmbus
echo pmbus 0x58 | sudo tee /sys/bus/i2c/devices/i2c-1/new_device

# 用 lm-sensors 查看
sudo apt install lm-sensors
sensors

sensors 會列出電壓、電流、功率、溫度與風扇轉速。也可以直接讀 sysfs:

cat /sys/class/hwmon/hwmon*/name          # 找到 pmbus 對應的 hwmonN
cat /sys/class/hwmon/hwmonN/in2_input      # 電壓,單位 mV
cat /sys/class/hwmon/hwmonN/curr2_input    # 電流,單位 mA
cat /sys/class/hwmon/hwmonN/power2_input   # 功率,單位 µW
cat /sys/class/hwmon/hwmonN/temp1_input    # 溫度,單位 m°C
項目自己寫 Python核心 pmbus 驅動
適合測試工具、除錯、讀寫任意命令長期監控、整合到現有監控系統
彈性任何命令都能讀寫只提供驅動支援的量測與告警
格式換算自己處理驅動自動處理

常見問題

症狀可能原因
i2cdetect 找不到電源接線、上拉電阻、電壓準位、電源待機電源(Standby)沒有供電
讀取回 Remote I/O error裝置 NACK:位址錯誤,或電源不支援這個命令
讀值偶爾錯誤或亂跳雜訊或 Timeout;啟用 PEC 找出錯誤的交易
READ_VOUT 數值離譜用 LINEAR11 解了 LINEAR16,或忘了讀 VOUT_MODE
溫度讀到 2000 多度LINEAR11 的尾數沒有做二補數轉換
PEC 錯誤電源不支援 PEC(查 CAPABILITY (19h) 的 b7),或線路雜訊
Device or resource busy核心驅動已經綁定這個位址

結論

  • 用 i2c-tools 先確認位址與通訊,再寫程式。
  • smbus2 的 read_byte_data() / read_word_data() 處理 Byte / Word 交易;Block Read 與 Process Call 用 i2c_rdwr(),PEC 自己驗證。
  • 換算規則:量測值用 LINEAR11,READ_VOUT 用 LINEAR16 + VOUT_MODE。
  • 只需要監控時,用核心 pmbus 驅動 + hwmon / sensors 最省事。

延伸閱讀: