Perl 的 pack 與 unpack 使用教學(處理二進位資料)

在 Perl 中如果要處理二進位(Binary)的資料結構,通常都會使用 packunpack 來處理,這裡介紹這兩個函數的使用方法。


在 C 語言中,我們可以使用 sizeof() 來得知配置給變數的記憶體大小,有了變數的記憶體位址與大小,就可以直接存取變數內部的資料,這種直接存取記憶體的方式,在處理二進位資料時,是常見的手法。

在 Perl 中就沒辦法像 C 語言這樣直接存取記憶體,不過我們可以使用 pack()unpack() 這兩個函數來將資料進行轉換,達到相同的功能。pack() 可以將變數中儲存的資料依照指定的格式樣板(template)轉換為一連串的位元組序列(byte sequence),而 unpack() 的功能則剛好相反,它是將位元組序列轉換回 Perl 的變數。

不是所有被 pack() 轉換過的資料都可以直接被 unpack() 轉換回來,有些時候需要一些技巧。

您可能會問為什麼我們在 Perl 中會需要用到記憶體中二進位的資料?最常見的狀況就是當我們需要處理一些二進位檔案、設備(device)或是網路傳輸時,這類的 I/O 資料通常都會需要以二進位的方式表示;另外一的狀況就是使用 Perl 中沒有的系統呼叫(system call)時,有時也會需要將資料以 C 語言中儲存的方式傳入;甚至也可以將這樣的二進位資料處理方式應用在文字的處理上,以簡化處理的流程。

基本使用方式

首先介紹 unpack() 的使用方式,假設我們有一串二進位的資料,想要轉換為十六進位的方式傾印(dump)出來,可以這樣寫:
#!/usr/bin/perl

# 二進位的資料
$bin = "abcd";

# 轉換為十六進位的字串
$hex = unpack('H*', $bin);
print "$hex\n";
輸出為
61626364
其中 unpack() 的第一個參數是指定資料格式的樣板(template),這個例子的 H* 則是代表任意個十六進位數字的字串,詳細的樣板說明可以參考 pack 函數的說明

這裡輸出的數字就是 abcd 四個字母的 ASCII 碼,以小寫的 a 來說,其 ASCII 碼為 0x61,所以輸出的前兩個數字就是 61,其他以此類推。

如果要將十六進位的字串轉換為二進位的資料,可以使用 pack() 函數:
#!/usr/bin/perl

# 十六進位的字串
$hex = "61626364";

# 轉換為二進位的資料
$bin = pack('H*', $hex);
print "$bin\n";
輸出為
abcd

文字處理

假設我們有一個文字檔案,其內容如下:
Date      |Description                | Income|Expenditure
01/24/2001 Zed's Camel Emporium                    1147.99
01/28/2001 Flea spray                                24.99
01/29/2001 Camel rides to tourists      235.00
如果想要使用 Perl 解析這類的資料,一般第一個會想到的就是 split() 函數,不過這裡的資料並沒有很明顯可以辨識的分隔字元,所以也沒辦法直接用 split() 來處理,大概只能用 substr()
while (<>) {
  my $date   = substr($_,  0, 11);
  my $desc   = substr($_, 12, 27);
  my $income = substr($_, 40,  7);
  my $expend = substr($_, 52,  7);
  # ...
}
雖然這樣的方式可以正常解析出這樣的資料,不過有點冗長。另一種常見的作法是改用常規表示法(regular expression)來匹配:
while (<>) {
  my($date, $desc, $income, $expend)
    = m|(\d\d/\d\d/\d{4}) (.{27}) (.{7})(.*)|;
  # ...
}
不過這樣的程式碼可能也不是很好被閱讀或維護。

這種狀況我們可以使用 unpack() 函數來處理:
while (<>) {
  my($date, $desc, $income, $expend) = unpack("A10xA27xA7A*", $_);
  # ...
}
這樣的程式碼會比較簡潔,而且容易閱讀,以下我們來解釋這裡的格式樣板 "A10xA27xA7A*" 所代表的意義。

首先我們要解析第一個欄位,也就是前 10 個字元,在 pack 的樣板表示法中,字元是以 A 來表示的,而 10 個字元則寫成 A10,所以如果我們只是要解析前 10 個字元,就可以寫成
$date = unpack("A10", $_);
接著第一欄與第二欄之間有一個沒有用的空白字元,這個字元我們使用 x 來跳過(x 代表跳過一個位元組)。

而之後的第二欄與第三欄分別是 27 與 7 個字元,加上去之後就會變成
my($date, $description, $income) = unpack("A10xA27xA7", $_);
而最後一個欄位因為是選擇性的,有些行根本沒有,所以我們就用 A* 表示任意長度的字串,這樣只要是在這個之後的任何字元,都會被納入這一欄,如此一來就得到最後的結果:
my ($date, $description, $income, $expend) = unpack("A10xA27xA7xA*", $_);
假設我們想要計算收入與支出的總和並以同樣的格式輸出,可以這樣寫:
while (<>) {
    my ($date, $desc, $income, $expend) = unpack("A10xA27xA7xA*", $_);
    $tot_income += $income;
    $tot_expend += $expend;
}
$tot_income = sprintf("%.2f", $tot_income);
$tot_expend = sprintf("%12.2f", $tot_expend);
$date = POSIX::strftime("%m/%d/%Y", localtime);
print pack("A11 A28 A8 A*", $date, "Totals", $tot_income, $tot_expend);
這樣其輸出就會跟原本的格式一致,而跟附加原本的內容之後,就會變成這樣:
01/28/2001 Flea spray                                 24.99
01/29/2001 Camel rides to tourists     1235.00
03/23/2001 Totals                      1235.00      1172.98
這裡我們在 pack()unpack() 中所使用的樣板有些差異,因為小寫的 xpack() 的樣板中代表 null 字元,而我們排版需要的是一個空白字元,所以在 pack() 中要將 x 改為空白字元,這樣才能維持正確的排版。

參考資料:perldocPerl Cookbook
本站已經搬家了,欲查看最新的文章,請至 G. T. Wang 新網站