2.29. Struct 與二進位資料

struct 模組會將 Python 值打包成固定的二進位佈局,並將位元組解包還原為 Python 值。當你處理二進位檔案格式、網路協定,或是交換固定大小記錄的裝置時,就可以使用它。

兩個函式涵蓋了大多數情況:

  • struct.pack() -- 接收 Python 值與一個 格式字串,回傳具有確切佈局的 bytes 物件。

  • struct.unpack() -- 接收一個格式字串與一個 bytes 物件,回傳一個由 Python 值組成的元組。

2.29.1. 格式字串

格式字串會為記錄中的每個欄位列出一個 代碼。這些代碼同時描述了每個欄位的大小與解讀方式。

Python 的 int 沒有固定大小 -- 它會自動增長以容納你所賦予的任何值。但二進位格式 確實 有固定大小:每個整數欄位都使用約定好的位元組數。struct 會在無界限的 Python 整數與這些固定大小的表示法之間進行轉換。

整數的 寬度 是指它所使用的位元數。一個位元組為八個位元。小寫代碼是有號版本;大寫代碼是無號版本(僅限非負值):

  • b / B -- 8 位元(一個位元組)。有號為 -128..127,無號為 0..255

  • h / H -- 16 位元(兩個位元組)。有號為 -32768..32767,無號為 0..65535

  • i / I -- 32 位元(四個位元組)。有號約為 ±二十億,無號約為四十億。

  • q / Q -- 64 位元(八個位元組)。對日常使用而言實際上是無界限的。

選擇一個能輕鬆涵蓋你預期範圍的寬度。打包超出宣告範圍的值時,依據建置版本不同,可能會無聲地環繞回捲,或是引發 struct.error

其餘常見的代碼用於浮點數與位元組字串:

  • f -- 32 位元浮點數(單精度;約七位十進位數字)。MicroPython 上一般的 float 本來就是這個大小,因此將其打包成 f 不會有任何損失。

  • d -- 64 位元浮點數(雙精度;約十五位十進位數字)。將 32 位元的 MicroPython float 打包成 d 會將其拓寬為八個位元組,但不會增加精度。

  • s -- 固定長度的位元組字串,前面需加上計數(8s 表示八位元組的欄位)。

2.29.2. 位元組順序

多位元組整數可以用兩種方式儲存在記憶體中。32 位元欄位中的數字 0x12345678 會以如下方式排列:

  • 小端序(Little-endian) -- 最低有效位元組在前:78 56 34 12

  • 大端序(Big-endian) -- 最高有效位元組在前:12 34 56 78

兩者編碼的是同一個值;它們只是在欄位的哪一端放置低位位元組這一點上有所不同。若位元組順序不相符,由某一系統寫入的檔案被另一系統讀取時就會出現亂碼。

格式字串的開頭字元決定了順序:

  • < -- 小端序。常見於 x86 與 ARM。

  • > -- 大端序。常見於網路協定。

  • ! -- 網路順序,等同於 >

若沒有開頭字元,則會使用原生位元組順序與原生對齊;明確設定 <> 可消除這種歧義,而這通常正是你在讀取檔案或與另一台機器通訊時所需要的。

備註

OpenMV Cam 是 小端序 -- 與其主機 PC 相同。對於相機本機的檔案,以及往返於桌面電腦的二進位資料,請在格式字串中使用 <。對於網路協定以及任何規格要求大端序的格式,請使用 >(或 !)。

六個位元組排成一列,前兩個位元組 組成一個 "H" 欄位(16 位元無號),接下來的 四個組成一個 "I" 欄位(32 位元無號),每個欄位 都標示了它們的小端序位元組順序。

"<HI" 會將一個 16 位元值後接一個 32 位元值打包成六個小端序位元組。

2.29.3. 打包

import struct

blob = struct.pack("<HI", 320, 1000000)
print(blob, len(blob))

輸出::

b'@\x01@B\x0f\x00' 6

<HI 格式會產生六個位元組:兩個給 H 欄位,四個給 I 欄位,全部都是小端序。請傳入格式所預期的確切值數量 -- 數量不符會引發 struct.error

2.29.4. 解包

width, count = struct.unpack("<HI", blob)
print(width, count)

輸出::

320 1000000

struct.unpack() 總是回傳一個元組,即使格式只描述單一欄位。為了可讀性,請在同一行將其解包。

2.29.5. 固定長度的位元組字串

s 代碼會原樣讀取或寫入一段位元組。計數放在 s 之前 -- 4s 表示「四個位元組視為單一位元組字串」。這是在記錄中嵌入魔術值、固定大小標籤或填補過的名稱欄位的常用做法:

header = struct.pack("<4sHI", b"OMV0", 320, 1000000)
print(header)

輸出::

b'OMV0@\x01@B\x0f\x00'

前四個位元組是字面魔術值 b"OMV0";接下來兩個是 H 欄位(320);最後四個是 I 欄位(1000000)。解包會將位元組以 bytes 物件的形式還原回來:

magic, width, count = struct.unpack("<4sHI", header)
print(magic, width, count)

輸出::

b'OMV0' 320 1000000

若來源值比宣告的計數短,結果會在右側以 \x00 填補;若較長,多出的位元組會被無聲地捨棄:

struct.pack("4s", b"hi")        # b'hi\x00\x00'
struct.pack("4s", b"toolong")   # b'tool'

計數是位元組長度,而非字元數 -- s 處理的是原始位元組,因此含有多位元組字元的 UTF-8 字串需要先 .encode() 並以位元組為單位計數。

2.29.6. 計算大小與部分讀取

struct.calcsize() 會回傳一個格式字串所佔用的位元組數:

struct.calcsize("<HI")     # 6

從檔案讀取一連串記錄時,每筆記錄都恰好讀取那麼多位元組:

record_size = struct.calcsize("<HI")
with open("data.bin", "rb") as f:
    while True:
        chunk = f.read(record_size)
        if len(chunk) < record_size:
            break
        width, count = struct.unpack("<HI", chunk)
        print(width, count)

檔案結尾處的短讀會產生一個小於 record_size 的區塊 -- 應將其視為串流結束的條件,而非試圖解包一筆不完整的記錄。