PHP: 深刻pack/unpack

摘要 PHP做爲一門爲web而生的服務器端開發語言,被愈來愈多的公司所採用。其中不乏大公司,如騰迅、盛大、淘米、新浪等。在對性能要求比較高的項目中,PHP也逐漸演變成一門前端語言,用於訪問後端接口。或者不一樣項目之間須要共享數據的時候,一般能夠抽取出數據層,經過PHP來訪問。

PHP做爲一門爲web而生的服務器端開發語言,被愈來愈多的公司所採用。其中不乏大公司,如騰迅、盛大、淘米、新浪等。在對性能要求比較高的項目中,PHP也逐漸演變成一門前端語言,用於訪問後端接口。或者不一樣項目之間須要共享數據的時候,一般能夠抽取出數據層,經過PHP來訪問。 前端

寫在前面的話

本文介紹的是經過二進制數據包的方式通訊,演示語言爲PHP和Golang。PHP提供了pack/unpack函數來進行二進制打包和二進制解包。在具體講解以前,咱們先來了解一些基礎知識。 java

什麼是字節序

在不一樣的計算機體系結構中,對於數據(比特、字節、字)等的存儲和傳輸機制有所不一樣,於是引起了計算機領域中一個潛在可是又很重要的問題,即通訊雙方交流的信息單元應該以什麼樣的順序進行傳送。若是達不成一致的規則,計算機的通訊與存儲將會沒法進行。目前在各類體系的計算機中一般採用的字節存儲機制主要有兩種:大端(Big-endian)和小端(Little-endian)。這裏所說的大端和小端便是字節序。 web

MSB和LSB

  • MSB是Most Significant Bit/Byte的首字母縮寫,一般譯爲最重要的位或最重要的字節。它一般用來表示在一個bit序列(如一個byte是8個bit組成的一個序列)或一個byte序列(如word是兩個byte組成的一個序列)中對整個序列取值影響最大的那個bit/byte。 後端

  • LSB是Least Significant Bit/Byte的首字母縮寫,一般譯爲最不重要的位或最不重要的字節。它一般用來代表在一個bit序列(如一個byte是8個bit組成的一個序列)或一個byte序列(如word是兩個byte組成的一個序列)中對整個序列取值影響最小的那個bit/byte。 數組

  • 對於一個十六進制int類型整數0x12345678來講,0x12就是MSB,0x78就是LSB。而對於0x78這個字節而言,它的二進制是01111000,那麼最左邊的那個0就是MSB,最右邊的那個0就是LSB。 服務器

大端序

  • 大端序又叫網絡字節序。大端序規定高位字節在存儲時放在低地址上,在傳輸時高位字節放在流的開始;低位字節在存儲時放在高地址上,在傳輸時低位字節放在流的末尾。 網絡

小端序

  • 小端序規定高位字節在存儲時放在高地址上,在傳輸時高位字節放在流的末尾;低位字節在存儲時放在低地址上,在傳輸時低位字節放在流的開始。 ssh

網絡字節序

  • 網絡字節序是指大端序。TCP/IP都是採用網絡字節序的方式,java也是使用大端序方式存儲。 socket

主機字節序

  • 主機字節序表明本機的字節序。通常是小端序,但也有一些是大端序。

  • 主機字節序用在協議描述中則是指小端序。

總結

  • 字節序只針對於多字節類型的數據。好比對於int類型整數0x12345678,它佔有4個字節的存儲空間,存儲方式有大端(0x12, 0x34, 0x56, 0x78)和小端(0x78, 0x56, 0x34, 0x12)兩種。能夠看到,在大端或小端的存儲方式中,是以字節爲單位的。因此對於單字節類型的數據,不存在字節序這個說法。

pack/unpack詳解

PHP pack函數用於將其它進制的數字壓縮到位字符串之中。也就是把其它進制數字轉化爲ASCII碼字符串。

格式字符翻譯

  • a -- 將字符串空白以 NULL 字符填滿

  • A -- 將字符串空白以 SPACE 字符 (空格) 填滿

  • h -- 16進制字符串,低位在前以半字節爲單位

  • H -- 16進制字符串,高位在前以半字節爲單位

  • c -- 有符號字符

  • C -- 無符號字符

  • s -- 有符號短整數 (16位,主機字節序)

  • S -- 無符號短整數 (16位,主機字節序)

  • n -- 無符號短整數 (16位, 大端字節序)

  • v -- 無符號短整數 (16位, 小端字節序)

  • i -- 有符號整數 (依賴機器大小及字節序)

  • I -- 無符號整數 (依賴機器大小及字節序)

  • l -- 有符號長整數 (32位,主機字節序)

  • L -- 無符號長整數 (32位,主機字節序)

  • N -- 無符號長整數 (32位, 大端字節序)

  • V -- 無符號長整數 (32位, 小端字節序)

  • f -- 單精度浮點數 (依計算機的範圍)

  • d -- 雙精度浮點數 (依計算機的範圍)

  • x -- 空字節

  • X -- 倒回一位

  • @ -- 填入 NULL 字符到絕對位置

格式字符詳解

  • pack/unpack容許使用修飾符*和數字,緊跟在格式字符以後,用於指定該格式的個數;

  • a和A都是用來打包字符串的,它們的惟一區別就是當小於定長時的填充方式。a以NULL填充,NULL事實上是'\0'的表示,表明空字節,8個位上全是0。A以空格填充,空格也即ASCII碼爲32的字符。這裏有一個關於填充的使用場景的例子:請求登陸的數據包規定用戶名不超過20個字節,密碼通過md5加密後是固定的32個字節。用戶名就是變長的,爲了便於服務器端讀取和處理,一般會填充成定長。固然,這只是使用的方式之一,事實上還能夠用變長的方式傳遞數據包,但這不在本文的探討範圍內。字符串有一點麻煩的是編碼問題,尤爲是在跟不一樣的平臺通訊時更爲突出。好比在用pack進行打包字符串時,事實上是將字符內部的編碼打包進去。單字節字符就沒有問題,由於單字節在全部平臺上都是一致的。來看個例子(pack.php):

?
1
2
3
4
<?php
$bin = pack("a", "d");
echo "output: " . $bin . "\n";
echo "output: 0x" . bin2hex($bin) . "\n";
?
1
2
3
$ php -f pack.php
output: d
output: 0x64

$bin是返回的二進制字符,您能夠直接輸出它,PHP知道如何處理。經過bin2hex方法將$bin轉換成十六進制能夠知道,十六進制0x64表示的是字符d。對於中文字符(多字節字符)來講,一般有GBK編碼、BIG5編碼以及UTF8編碼等。好比在GBK編碼中,一箇中文字符采用2個字節來表示;在UTF8編碼中,一箇中文字符采用3個字節來表示。這一般須要協商採用統一的編碼,不然會因爲內部的表示不一致致使沒法處理。在PHP中只要將文件保存爲特定的編碼格便可,其它語言可能跟操做系統相關,所以或許須要編碼轉換。本文的例子一律基於UTF8編碼。繼續來看個例子:

?
1
2
3
4
5
<?php
$bin = pack("a3", "中");
echo "output: 0x" . bin2hex($bin) . "\n";
echo "output: " . chr(0xe4) . chr(0xb8) . chr(0xad) . "\n";
echo "output: " . $bin{0} . $bin{1} . $bin{2} . "\n";
?
1
2
3
4
$ php -f pack.php
output: 0xe4b8ad
output: 中
output: 中

您可能會以爲很奇怪,後面2個輸出是同樣的。ASCII碼錶示單字節字符(其中包括英文字母、數字、英文標點符號、不可見字符以及控制字符等等),它老是小於0x80,即小於十進制的128。當在處理字符時,若是字節小於0x80,則把它看成單字節來處理,不然會繼續讀取下一個字節,這一般跟編碼有關,GBK會將2個字節當成一個字符來處理,UTF8則須要3個字節。有時候在PHP中須要作相似的處理,好比計算字符串中字符的個數(字符串可能包含單字節和多字節),strlen方法只能計算字節數,而mb_strlen須要開啓擴展。相似這樣的需求,其實很容易處理:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
function mbstrlen($str)
{
    $len = strlen($str);
     
    if ($len <= 0)
    {
        return 0;
    }
     
    $count  = 0;
     
    for ($i = 0; $i < $len; $i++)
    {
        $count++;
        if (ord($str{$i}) >= 0x80)
        {
            $i += 2;
        }
    }
     
    return $count;
}
 
echo "output: " . mbstrlen("中國so強大!") . "\n";
?
1
2
$ php -f pack.php
output: 7

以上代碼的實現就是利用單字節字符的ASCII碼小於0x80。至於要跳過幾個字節,這要看具體是什麼編碼。接下來經過例子來看看a和A的區別:

$GOPATH/src

----pack_test

--------main.go

main.go的源碼(只是用於測試,沒有考慮細節):

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main
 
import (
    "fmt"
    "net"
)
 
const BUF_SIZE = 20
 
func handleConnection(conn net.Conn) {
    defer conn.Close()
    buf := make([]byte, BUF_SIZE)
    n, err := conn.Read(buf)
     
    if err != nil {
        fmt.Printf("err: %v\n", err)
        return
    }
 
    fmt.Printf("\n已接收:%d個字節,數據是:'%s'\n", n, string(buf))
}
 
func main() {
    ln, err := net.Listen("tcp", ":9872")
     
    if err != nil {
        fmt.Printf("error: %v\n", err)
        return
    }
 
    for {
        conn, err := ln.Accept()
        if err != nil {
            continue
        }
        go handleConnection(conn)
    }
}

代碼很簡單,收到數據,而後輸出。

pack.php

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
$host = "127.0.0.1";
$port = "9872";
 
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP)
  or die("Unable to create socket\n");
 
@socket_connect($socket, $host, $port) or die("Connect error.\n");
 
if ($err = socket_last_error($socket))
{
 
  socket_close($socket);
  die(socket_strerror($err) . "\n");
}
 
$binarydata = pack("a20", "中國強大");
$len = socket_write ($socket , $binarydata, strlen($binarydata));
socket_close($socket);
?
1
2
3
$ cd $GOPATH/src/pack_test
$ go build
$ ./pack_test
?
1
$ php -f pack.php

當執行php後,能夠看到服務器端在控制檯輸出:

?
1
已接收:20個字節,數據是:'中國強大'

以上的輸出中,單引號不是數據的一部分,只是爲了便於觀察。很明顯,咱們打包的字符串只佔12字節,a20表示20個a,您固然能夠連續寫20個a,但我想您不會這麼傻。若是是a*的話,則表示任意多個a。經過服務器端的輸出來看,PHP發送了20個字節過去,服務器端也接收了20個字節,但由於填充的\0是空字符,因此您不會看到有什麼不同的地方。如今咱們將a20換成A20,代碼以下:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
$host = "127.0.0.1";
$port = "9872";
 
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP)
  or die("Unable to create socket\n");
 
@socket_connect($socket, $host, $port) or die("Connect error.\n");
 
if ($err = socket_last_error($socket))
{
 
  socket_close($socket);
  die(socket_strerror($err) . "\n");
}
 
$binarydata = pack("A20", "中國強大");
$len = socket_write ($socket , $binarydata, strlen($binarydata));
socket_close($socket);
?
1
$ php -f pack.php

您會發現服務器端的輸出不同了:

?
1
已接收:20個字節,數據是:'中國強大        '

是的,空格存在於數據中。這就是a和A的區別。

  • h和H的描述看起來有些奇怪。它們都是讀取十進制,以十六進制方式讀取,以半字節(4位)爲單位。這聽起來有些拗口,仍是以實例來講明:

?
1
2
<?php
echo "output: " . pack("H", 0x5) . "\n";
?
1
2
$ php -f pack.php
output: P

首先是讀取十進制,因此0x5會轉成十進制的5,而後以半字節爲單位而且以十六進制方式讀取,爲了補足8位,因此須要在5後面補0,變成0x50。別忘了十六進制的一位至關於二進制的四位。0x50正好是字符P的ASCII碼。

?
1
2
<?php
echo "output: " . chr(0x50) . "\n";
?
1
2
$ php -f pack.php
output: P

h和H的差異在於h是低位在前,H是高位在前,拿前面的例子來看看h的行爲:

?
1
2
3
4
<?php
$bin = pack("h", 0x5);
echo "output: " . $bin . "\n";
echo "output: " . ord($bin) . "\n";
?
1
2
3
$ php -f pack.php
output: 
output: 5

讀取十進制的5,後面補0,變成十六進制的0x50,由於H是高位在前,因此沒有變化,而h就須要將0x50變成0x05。因爲0x05是不可見字符,因此上面的字符輸出是空的。

h和H是以半字節爲單位,h2和H2則表示一次讀取8位,同理h3和H3能夠推導出來,可是別忘了補足8位哦!

?
1
2
<?php
echo "output: " . pack("H", 0x47) . "\n";
?
1
2
$ php -f pack.php
output: p

以上的代碼中,0x47爲十進制的71,由於讀取半個字節,因此變成0x7,後面補0變成0x70,則恰好是字符p的ASCII碼。若是換成是h格式化,則最終的結果是0x07,由於低位在前。

對於一次讀取多個字節,也以一樣的規則:

?
1
2
<?php
echo "output: " . pack("H2h2", 0x47, 0x56) . "\n";
?
1
2
$ php -f pack.php
output: qh

0x47是十進制的71,因爲使用H2格式化,因此一次讀取8位,最後變成十六進制的0x71,即字符q的ASCII碼。0x56是十進制的86,因爲使用h2格式化,因此一次讀取8位,最後變成十六進制的0x86,可是因爲h表示低位在前,所以0x86變成0x68,即字符h的ASCII碼。

  • c和C都表示字符,前者表示有符號字符,後者表示無符號字符。

?
1
2
3
<?php
echo "output: " . pack("c", 65) . "\n";
echo "output: " . pack("C", 65) . "\n";
?
1
2
3
$ php -f pack.php
output: A
output: A
  • s爲有符號短整數;S爲無符號短整數。它們都爲主機字節序,而且爲16位。一般爲主機字節序的格式化字符,通常只用於單機的操做,由於您沒法肯定主機字節序到底是大端仍是小端。固然,您必定要這麼幹的話,也是有辦法來獲取本機字節序是屬於大端或小端,但那樣是沒有必要的。稍後就會給出一個經過PHP來判斷字節序的例子。

?
1
2
3
4
5
<?php
$bin1 = pack("s", 345);
$bin2 = pack("S", 452);
print_r(unpack("sshort1", $bin1));
print_r(unpack("sshort2", $bin2));
?
1
2
3
4
5
6
7
8
9
$ php -f pack.php
Array
(
    [short1] => 345
)
Array
(
    [short2] => 452
)
  • n和v除了明確指定了字節序,其它行爲跟s和S是同樣的。

  • i和I依賴於機器大小及字節序,不多用它們。

  • l、L、N、V跟s、S、n、v相似,除了表示的大小不一樣,前者都爲32位,後者都爲16位。

  • f、d是由於float和double與CPU無關。通常來講,編譯器是按照IEEE標準解釋的,即把float/double看做4/8個字符的數組進行解釋。所以,只要編譯器是支持IEEE浮點標準的,就不須要考慮字節順序。

  • 剩下的x、X和@用得比較少,對此不做深究。

unpack的用法

  • unpack是用來解包通過pack打包的數據包,若是成功,則返回數組。其中格式化字符和執行pack時一一對應,可是須要額外的指定一個key,用做返回數組的key。多個字段用/分隔。例如:

?
1
2
3
4
5
6
7
8
<?php
$bin = @pack("a9SS", "陳一回", 20, 1);
$data = @unpack("a9name/sage/Sgender", $bin);
 
if (is_array($data))
{
    print_r($data);
}
?
1
2
3
4
5
6
7
$ php  -f pack.php
Array
(
    [name] => 陳一回
    [age] => 20
    [gender] => 1
)

一些例子

  • 判斷大小端

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
function IsBigEndian()
{
    $bin = pack("L", 0x12345678);
    $hex = bin2hex($bin);
    if (ord(pack("H2", $hex)) === 0x78)
    {
        return FALSE;
    }
 
    return TRUE;
}
 
if (IsBigEndian())
{
    echo "大端序";
}
else
{
    echo "小端序";
}
 
echo "\n";
?
1
2
$ php -f pack.php
小端序
  • 網絡通訊

    好比如今要經過PHP發送數據包到服務器來登陸。在僅須要提供用戶名(最多30個字節)和密碼(md5以後固定爲32字節)的狀況下,能夠構造以下數據包(固然這事先須要跟服務器協商好數據包的規範,本例以網絡字節序通訊):

    包結構:

字段 字節數 說明
包頭 定長 每個通訊消息必須包含的內容
包體 不定長 根據每一個通訊消息的不一樣產生變化

其中包頭詳細內容以下:

字段
字節數 類型
說明
pkg_len 2
ushort 整個包的長度,不超過4K
version 1 uchar 通信協議版本號
command_id 2 ushort 消息命令ID
result 2 short 請求時不起做用;請求返回時使用

固然實際中可能會涉及到各類校驗。本文爲了簡單,只是列舉一下一般的工做流程及處理的方式。

登陸(執行命儲1001)

字段 字節數 類型 說明
用戶名 30 uchar[30] 登陸用戶名
密碼 32 uchar[32] 登陸密碼

包頭是定長的,經過計算可知包頭佔7個字節,而且包頭在包體以前。好比用戶陳一回須要登陸,密碼是123456,則代碼以下:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
$version    = 1;
$result     = 0;
$command_id = 1001;
$username   = "陳一回";
$password   = md5("123456");
// 構造包體
$bin_body   = pack("a30a32", $username, $password);
// 包體長度
$body_len   = strlen($bin_body);
$bin_head   = pack("nCns", $body_len, $version, $command_id, $result);
$bin_data   = $bin_head . $bin_body;
// 發送數據
// socket_write($socket, $bin_data, strlen($bin_data));
// socket_close($socket);

服務器端經過讀取定長包頭,拿到包體長度,再讀取並解析包體。大體的過程就是這樣。固然服務器端也會返回響應包,客戶端作相應的讀取處理。

相關文章
相關標籤/搜索