使用 struct 模塊打包 - 解包二進制數據

Python 有一個內置模塊 struct,從名字上看這和 C 的結構體有着千絲萬縷的聯繫,C 的結構體是由多個數據組合而成的一種新的數據類型。

typedef struct {
    char *name;
    int age;
    char * gender;
    long salary;
} People;

struct 模塊也是負責將多個不同類型的數據組合在一起,因爲網絡傳輸的數據都是二進制字節流。而 Python 只有字符串可以直接轉成字節流,對於整數、浮點數則無能爲力了。所以 Python 提供了 struct 模塊來幫我們解決這一點,下面來看看它的用法。

打包和解包

struct 模塊內部有兩個函數用於打包和解包,分別是 pack 和 unpack。

import binascii
import struct

# values 包含一個 12 字節的字節串、一個整數、以及一個浮點數。
values = ("古明地覺".encode("utf-8"), 17, 156.7)

# 第一個參數 "12s i f" 表示格式化字符串(format),裏面的符號則代表數據的類型
# 12s:12 個字節的字節串、i: 整數、f: 浮點數
# 因此 12s i f 表示打包的數據有三個,分別是:12 個字節的字節串、一個整數、以及一個浮點數
# 中間使用的空格只是用來對錶示類型的符號進行分隔,在編譯時會被忽略
packed_data = struct.pack("12s i f", *values)  # 這裏需要使用 * 將元組打開

# 查看打印的結果
print(packed_data)
"""
b'\xe5\x8f\xa4\xe6\x98\x8e\xe5\x9c\xb0\xe8\xa7\x89\x11\x00\x00\x003\xb3\x1cC'
"""

# 還可以將打包後的結果轉成十六進制, 這樣傳輸起來更加方便
print(binascii.hexlify(packed_data))
"""
b'e58fa4e6988ee59cb0e8a7891100000033b31c43'
"""

代碼中的 packed_data 就是打包之後的結果,而我們又將其轉成了 16 進製表示。那麼問題來了,既然能打包,那麼肯定也能解包。

import struct
import binascii

# 之前對打包之後的數據轉成 16 進制所得到的結果
data = b'e58fa4e6988ee59cb0e8a7891100000033b31c43'

# 所以可以使用 binascii.unhexlify 將其轉回來,得到 struct 打包之後的數據
packed_data = binascii.unhexlify(data)

# 然後調用 struct.unpack 進行解包,打包用的什麼格式,解包也用什麼格式
# 會得到一個元組,哪怕解包之後只有一個元素,得到的也是元組
values = struct.unpack("12s i f", packed_data)
print(str(values[0]encoding="utf-8"))  # 古明地覺
print(values[1])  # 17
print(values[2])  # 156.6999969482422

發送端將數據按照某種格式轉成二進制字節流,接收端在接收到數據之後再按照相同的格式轉成相應的數據就行。只不過 Python 中,只有字符串可以直接轉換成二進制字節流,整數、浮點數則需要藉助於 struct 模塊。

但是注意:在使用 struct 打包的時候,不能直接對字符串打包,而是需要先將字符串編碼成 bytes 對象。因爲中文字符采用不同的編碼所佔的字節數不同,所以需要先手動編碼成 bytes 對象。

整體還是比較簡單的,就是將數據按照指定格式進行打包,然後再按照指定格式進行解包。而像 12s、i、f 這些都屬於我們定義的格式中的類型指示符,而除了這些指示符之外,還有哪些類型指示符呢?

然後需要注意,我們在進行打包的時候,類型以及個數一定要匹配。

import struct

try:
    struct.pack("iii", 1, 2, 3.14)
except Exception as e:
    print(e)  # required argument is not an integer
# 告訴我們需要的是整數, 但我們傳遞了浮點數

try:
    # iii 表示接收 3 個整數, 但我們只傳遞了兩個
    struct.pack("iii", 1, 2)
except Exception as e:
    print(e)  # pack expected 3 items for packing (got 2)

try:
    # iii 表示接收 3 個整數, 但我們卻傳遞了四個
    struct.pack("iii", 1, 2, 3, 4)
except Exception as e:
    print(e)  # pack expected 3 items for packing (got 4)

此外,我們之前說一個長度爲 12 的字節串,可以使用 12s 來表示,那麼 3s 就表示長度爲 3 的字節串。問題來了,i 表示整數,那麼 3i 表示什麼呢?

import struct

try:
    struct.pack("3i", 1, 2)
except Exception as e:
    print(e)  # pack expected 3 items for packing (got 2)

# 告訴我們接收 3 個值, 但是隻傳遞了兩個
packed_data = struct.pack("3i", 1, 2, 3)
print(struct.unpack("3i", packed_data))  # (1, 2, 3)

我們看到 3i 在結果上等同於 iii,但對於 s 而言,3s 可不等同於 sss。3s 仍然表示接收一個元素,只不過這個元素是字節串,並且長度爲 3。這些細節要注意。

當然對於字符串而言,即使長度不一樣也是無所謂的,我們舉個例子。

import struct

# 第一個值是整數, 第二個值是字節串(長度應該爲3, 但不是3也可以)
packed_data = struct.pack("i3s", 6, b"abcdefg")
print(packed_data)  # b'\x06\x00\x00\x00abc'
# 我們看到被截斷了, 只剩下了 abc

packed_data = struct.pack("i6s", 6, b"abc")
print(packed_data)  # b'\x06\x00\x00\x00abc\x00\x00\x00'
# 6s 需要字節長度爲 6, 但是我們只有三個, 所以在結尾補上了 3 個 \0

總之在使用 struct 進行打包的時候,需要記住兩點:

而對於解包而言,我們也需要關注,但只需要關注一點,那就是大小。怎麼理解呢?來舉個例子:

import struct

packed_data = struct.pack("ii", 1, 2)
print(packed_data)  # b'\x01\x00\x00\x00\x02\x00\x00\x00'
# 因爲 i 表示 C 的 int, 而 C 的一個 int 佔 4 字節, 所以結果是 8 字節。
# 只不過 1 和 2 只需一字節即可存儲, 因此其它的部分都是 0
# 打包之後的 packed_data 的大小, 不取決於打包的元素, 而是取決於格式化字符串中的類型符號
# 比如 struct.pack("4s", b"abc") , 儘管傳遞的字節串只有 3 字節
# 但指定的是 4s, 所以打包之後的 packed_data 佔 4 字節

# 而我們在解包的時候, 指定的符號的字節大小 和 packed_data 要匹配
# 比如這裏的 packed_data 是 8 字節, 在打包結束之後它的大小就已經固定了
try:
    print(struct.unpack("i", packed_data))
except Exception as e:
    print(e)  # unpack requires a buffer of 4 bytes
# 告訴我們需要一個 4 字節的buffer, 這是因爲我們的 packed_data 是 8 字節
# 同理:
try:
    print(struct.unpack("iii", packed_data))
except Exception as e:
    print(e)  # unpack requires a buffer of 12 bytes
# 這樣也是不可以的, 告訴我們需要 12 字節

# 只有字節數匹配, 纔可以正常解析
print(struct.unpack("ii", packed_data))  # (1, 2)

那麼問題來了,我們說一個 long long 是佔 8 字節,正好對應兩個 int,那麼將兩個 int 按照一個 long long 來解析可不可以呢?再有 8s 也是 8 字節,又可不可以進行解析呢?我們來試一下。

import struct

packed_data = struct.pack("ii", 1, 2)
print(packed_data)
"""
b'\x01\x00\x00\x00\x02\x00\x00\x00'
"""

print(struct.unpack("8s", packed_data))
"""
(b'\x01\x00\x00\x00\x02\x00\x00\x00',)
"""
print(struct.unpack("q", packed_data))
"""
(8589934593,)
"""

# 答案顯然是可以的, 因爲字節數是相匹配的
# 對於 8s 而言, 我們看到解析出來的結果就是原始的字節流
# 對於 q, 也可以正確解析, 只不過結果不是我們想要的

# 但是我們觀察一下按照 q 解析出來的結果, 結果是 8589934593, 那麼它是怎麼得到的呢?
# 如果將 packet_data 按照 8 字節整數解析,相當於將兩個 4 字節整數合併成一個 8 字節整數
# 其中整數 2 佔據前 32 個位,整數 1 佔據後 32 個位
print((<< 32) + 1)
"""
8589934593
"""
# 怎麼樣, 結果是不是一樣呢? 至於這裏爲什麼不是 (1 << 31) + 2, 我們後面會說

所以在解析的時候,格式化字符串中的類型符號對應的字節數,要和 packed_data 的字節數相匹配,這是不報錯的前提。當然如果想得到正確的結果,最關鍵的還是解包對應的格式化字符串,要和打包對應的格式化字符串保持一致。

struct.Struct

在 struct 模塊中,我們可以直接使用 struct.pack 和 struct.unpack 這兩個模塊級的函數,但是 struct 模塊還提供了一個 Struct 類。

import struct

s = struct.Struct("ii")
# 和使用 struct.pack("ii", 1, 2) 之間是等價的
packed_data = s.pack(1, 2) 
print(packed_data)  
"""
b'\x01\x00\x00\x00\x02\x00\x00\x00'
"""

如果我們需要使用同一種格式化字符串來對大量數據進行打包的話,那麼使用 struct.Struct 是更推薦的,可以類比正則。

re.search(pattern, string) 這個過程分爲兩步,會先將 pattern 進行編譯轉換,然後再進行匹配。如果我們需要同一個 pattern 匹配 100 個字符串的話,那麼要編譯轉換 100 次。

而如果先對 pattern 進行編譯 comp = re.compile(pattern),那麼不管調用 comp.search(string) 多少次,都只會進行一次編譯轉換,效率會更高。struct 也是類似的,如果要按照相同的格式進行多次打包,那麼創建一個 Struct 實例並在這個實例上調用方法時(不使用模塊級函數)會更高效。

當然,使用 Struct 類還有一個好處,就是可以獲取一些額外信息。

import struct

s = struct.Struct("ii4sf")
print("格式化字符串:", s.format)  # 格式化字符串: ii4sf
print("字節數:", s.size)  # 字節數: 16

我們看到打包後的數據大小是由格式化字符串中的符號所決定的。

字節序

說到字節序,你應該會想到大端存儲、小端存儲,所謂大端存儲就是:數據的低位存儲在內存的高地址中,高位存儲在內存的低地址中。而小端存儲與之相反:數據的低位存儲在內存的低地址中,高位存儲在內存的高地址中。

那麼 Python 的 struct 默認使用什麼存儲呢?答案是小端存儲。

import struct

# i 表示 int32,那麼相應的整數 1 就佔 4 字節
# 其中最低位存儲的是 1,剩餘三個位存儲的是 0
packed_data = struct.pack("i", 1)
print(packed_data)  # b'\x01\x00\x00\x00'
# 打包之後的數據是一個字節串,或者理解爲 C 的字符數組
# 而數組的元素,從左往右對應的內存地址是依次增大的
# 所以結果就是低位存在了低地址中,所以這裏是小端存儲

# 而如果想要變成大端存儲的話, 可以這麼做
packed_data = struct.pack(">i", 1)
print(packed_data)  # b'\x00\x00\x00\x01'
# 我們看到此時結果就變了

當然我們在解析的時候也需要注意大小端的問題,如果是打包的時候使用的是大端存儲,那麼解包的時候也要使用大端存儲。

import struct

# 因爲 \x01 在最後面,而後面表示內存的高地址
# 此時這個數字如果想表示 1, 那麼它一定是大端存儲的 1
# 也就是將低位的 \x01 放在了高地址中
packed_data = b'\x00\x00\x00\x01'

# 這裏我們不指定大小端, 默認是小端
print(struct.unpack("i", packed_data))
"""
(16777216,)
"""
# 我們看到結果變了, 至於這個結果怎麼來的, 很簡單
# 無論打包還是解包,如果不指定字節序,那麼默認都是小端,低位存在低地址中
# 所以 b'\x00\x00\x00\x01' 等價於如下
print(0b00000001_00000000_00000000_00000000) 
"""
16777216
"""

# 所以我們也要用大端存儲進行解析, 表示: 我是大端存儲, 存儲在高地址的是數據的低位
print(struct.unpack(">i", packed_data)) 
"""
(1,)
"""
print(0b00000000_00000000_00000000_00000001)  
"""
1
"""
# 所以通過 b'\x00\x00\x00\x01' 的值是不是 1,可以判斷當前採用的是大端還是小端
# 因爲 \x01 在高地址,如果值爲 1,說明 \x01 是低位,因此是大端,否則小端

然後再回顧一下之前的例子,我們用一個 long long 表示兩個 int。

import struct

packed_data = struct.pack("ii", 1, 2)
print(packed_data)
"""
b'\x01\x00\x00\x00\x02\x00\x00\x00'
"""

# 將兩個 int 轉成一個 long long,默認是小端,所以低位存在低地址中
# 因此 \x01\x00\x00\x00 表示 1,佔據低 32 個位
# \x02\x00\x00\x00 表示 2,佔據高 32 個位
print(struct.unpack("q", packed_data))
print((<< 32) + 1)
"""
(8589934593,)
8589934593
"""

# 如果按照大端解析的話,低位存在高地址中,那麼就是相反的
# 但此時 \x01\x00\x00\x00 表示的不再是 1
# 同理 \x02\x00\x00\x00 表示也不再是 2
print(struct.unpack(">q", packed_data))
print(
    (0b00000001_00000000_00000000_00000000 << 32) +
    0b00000010_00000000_00000000_00000000
)
"""
(72057594071482368,)
72057594071482368
"""

因此 struct 模塊給我們提供了自定義字節序的功能,可以顯式地指定是使用大端存儲、還是小端存儲。而方法也很簡單,只需要給格式化字符串的第一個字符指定爲特定的符號即可實現這一點。

  1. > : 大端字節序(Big-endian)

  2. < : 小端字節序(Little-endian)

  3. ! : 網絡字節序(實際上就是大端字節序)

  4. = : 本地字節序(當前系統的字節序)

  5. @ : 本地字節序(和 = 相同,但可能有不同的對齊方式)

緩衝區

pack 方法在打包的時候,會爲打包數據申請一塊內存空間,也就是說每一次 pack 都需要申請內存資源,顯然這是一種浪費。通過避免爲每個打包數據分配一個新緩衝區,在內存開銷上可以得到優化。而 pack_into 和 pack_from 可以支持我們從指定的緩衝區進行讀取和寫入。

import struct
import ctypes

# 創建一個 string 緩存, 大小爲 10
buf = ctypes.create_string_buffer(10)
# raw 表示原始數據,這裏都是 \0,因爲 C 中是通過 \0 來標識一個字符串的結束
print(buf.raw)  # b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
# value 就是 Python 中的字符串, 顯然爲空
print(buf.value)  # b''

# 然後我們進行打包, 第二個參數表示緩衝區
# 第三個參數表示偏移量, 0表示從頭開始寫入; 然後後面的參數就是打包的數據
struct.pack_into("ii2s", buf, 0, 123, 345, b"ab")

# 打包之後的數據會存在 buf 中,解包的話,使用 unpack_from
# 會從 buf 中讀取數據並解析,第三個參數表示從偏移量爲 0 的位置開始解析
values = struct.unpack_from("ii2s", buf, 0)
print(values)  # (123, 345, b'ab')

這裏的 pack_into 不會產生新的內存空間,都是對 buf 進行操作。另外我們還看到了偏移量,所以可以將多個打包的數據寫入到同一個 buf 中,然後也可以從同一個 buf 中進行解包,而保證數據不衝突的前提正是這裏的偏移量,舉個栗子:

import struct
import ctypes

s1 = struct.Struct("ii6si")
s2 = struct.Struct("2s")
buf = ctypes.create_string_buffer(s1.size + s2.size)
s1.pack_into(buf, 0, 1, 2, b"abcdef", 3)
# 偏移量爲 s1.size
s2.pack_into(buf, s1.size, b"gh")

# 從 si.size 開始解析
print(s2.unpack_from(buf, s1.size))  # (b'gh',)
# 從 0 開始解析,解析 s1.size 個字節
print(s1.unpack_from(buf, 0))  # (1, 2, b'abcdef', 3)

以上就是 struct 模塊,它定義了 Python 中整數、浮點數和二進制流之間的通用轉換邏輯。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/PFX5eLqk_GxErCSnGiUSyA