[PHP][位转换积累]之pack和unpack

一、前面的话

PHP的pack和unpack提供了为一系列数据打包(pack)和解包(unpack)成2进制流的功能,这个功能在面向字节的字符串处理和套接字的编程环境中尤为适用。

在了解这两个函数之前,我们必须掌握一些关于面向字节流编程的概念,否则很难真正上理解它们。

1.什么是字节序

字节序,顾名思义就是字节存放的顺序

计算机在传输或存储多字节的时候,会对每个字节进行双方排序的约定,例如,单字节高位在前还是在后?是需要用1000 0000 0001 0000还是0001 0000 1000 0000 去表示32784?0x00ff需要两个字节的空间才能存储,那是把00还是ff放在前面呢?

字节序就是为了解决这个问题而生。

字节序分为两种:

BIG-ENDIAN----大字节序

LITTLE-ENDIAN----小字节序

 

BIG-ENDIAN、LITTLE-ENDIAN与多字节类型的数据有关的比如int,short,long型,而对单字节数据byte却没有影响。

BIG-ENDIAN就是最低地址存放最高有效字节。

LITTLE-ENDIAN是最低地址存放最低有效字节。即常说的低位在先,高位在后。 

 

Java中int类型占4个字节,一定要是“多字节类型的数据”才有字节序问题,汉字编码也有这个问题。请看下面4字节的例子:

 

  比如 int a = 0x05060708 

 

  在BIG-ENDIAN的情况下存放为: 

  低地址------->高地址 

  字节号: 第0字节,第1字节,第2字节,第3字节 

  数  据: 05    , 06   , 07   , 08 

 

  在LITTLE-ENDIAN的情况下存放为: 

  低地址------->高地址 

  字节号: 第0字节,第1字节,第2字节,第3字节 

  数  据: 08    , 07   , 06   , 05 

 

 

JAVA字节序:

指的是在JAVA虚拟机中多字节类型数据的存放顺序,JAVA字节序也是BIG-ENDIAN。 

 

主机字节序:

Intel的x86系列CPU是Little-Endian,而PowerPC 、SPARC和Motorola处理器是BIG-ENDIAN。

ARM同时支持 big和little,实际应用中通常使用little endian。是BIG-ENDIAN还是LITTLE-ENDIAN的跟CPU有关的,每一种CPU不是BIG-ENDIAN就是LITTLE-ENDIAN。

 

网络字节序:

4个字节的32 bit值以下面的次序传输:首先是7~0bit,其次15~8bit,然后23~16bit,最后是31~24bit。这种传输次序称作大端字节序(BIG-ENDIAN)。 TCP/IP首部中所有的二进制整数在网络中传输时都要求以这种次序。

 

不同的CPU上运行不同的操作系统,字节序也是不同的,参见下表。

    处理器     操作系统   字节排序

    Alpha     全部        Little endian

    HP-PA     NT          Little endian

    HP-PA     UNIX        Big endian

    Intelx86  全部        Little endian

 

所以在用C/C++写通信程序时,在发送数据前务必用htonl和htons去 把整型和短整型的数据进行从主机字节序到网络字节序的转换,而接收数据后对于整型和短整型数据则必须调用ntohl和ntohs实现从网络字节序到主机字 节序的转换。如果通信的一方是JAVA程序、一方是C/C++程序时,则需要在C/C++一侧使用以上几个方法进行字节序的转换,而JAVA一侧,则不需 要做任何处理,因为JAVA字节序与网络字节序都是BIG-ENDIAN,只要C/C++一侧能正确进行转换即可(发送前从主机序到网络序,接收时反变 换)。如果通信的双方都是JAVA,则根本不用考虑字节序的问题了。

总结一下,字节序

2.字符编码

字符编码的基础在这里就不敷衍了,只大概说说各字符集之间的关系。

几乎所有字符集都兼容ASCII码,也可以说几乎所有字符集都包含了ASCII,而且码位是一样的

例如一段全英文的GBK字符集是可以在UTF-8环境下正确显示的

3.带符号整型2进制表示法

带符号的整型和无符号的整型计算机的解析方法和可供取值范围是不一样的,例如

8位无符号整型:0 -> 255
11111111     255
...
10000000     128
01111111     127
...
00000000       0

8位有符号整型:-128 -> 127
01111111    127
...
00000000      0
11111111     -1        取反加一
...
10000000   -128        取反加一

4.带符号char2进制表示法

    在C中,默认的基础数据类型均为signed,现在我们以char为例,说明(signed) char与unsigned char之间的区别。

      首先在内存中,char与unsigned char没有什么不同,都是一个字节,唯一的区别是,char的最高位为符号位,因此char能表示-127~127,unsigned char没有符号位,因此能表示0~255,这个好理解,8个bit,最多256种情况,因此无论如何都能表示256个数字。

      在实际使用过程种有什么区别呢?主要是符号位,但是在普通的赋值,读写文件和网络字节流都没什么区别,反正就是一个字节,不管最高位是什么,最终的读取结果都一样,只是你怎么理解最高位而已,在屏幕上面的显示可能不一样。

       二者的最大区别是:但是我们却发现在表示byte时,都用unsigned char,这是为什么呢?首先我们通常意义上理解,byte没有什么符号位之说,更重要的是如果将byte的值赋给int,long等数据类型时,系统会 做一些额外的工作。如果是char,那么系统认为最高位是符号位,而int可能是16或者32位,那么会对最高位进行扩展(注意,赋给unsigned int也会扩展)而如果是unsigned char,那么不会扩展。最高位若为0时,二者没有区别,若为1时,则有区别了。同理可以推导到其它的类型,比如short, unsigned short,等等。

       具体可以通过下面的小例子看看其区别

  include <stdio.h>

  void f(unsigned char v)
  {
    char c = v;
    unsigned char uc = v;
    unsigned int a = c, b = uc;
    int i = c, j = uc;
    printf("----------------\n");
    printf("%%c: %c, %c\n", c, uc);
    printf("%%X: %X, %X\n", c, uc);
    printf("%%u: %u, %u\n", a, b);
    printf("%%d: %d, %d\n", i, j);
  }
  

  int main(int argc, char *argv[])
  {
    f(0x80);
    f(0x7F);
    return 0;
  }


    结果输出如下:

    

    结果分析:

  对于(signed)char来说,0x80用二进制表示为1000 0000,当它作为char赋值给unsigned int或 int 时,系统认为最高位是符号位,会对最高位进行扩展。而0x7F用二进制表示为0111 1111,最高位为0,不会扩展。

  对于unsigned char来说,不管最高位是0,还是1,都不会做扩展。

二、输出格式

pack和unpack第一个参数接受的是一个编码格式

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 倒回一位
Z 将字符串空白以 NULL 字符填满(php 5.5后新增)
@ 填入 NULL 字符到绝对位置
  • pack/unpack允许使用修饰符*和数字,紧跟在格式字符之后,用于指定该格式的个数;

  • a和A都是用来打包字符串的,它们的唯一区别就是当小于定长时的填充方式。a以NULL填充,NULL事实上是'\0'的表示,代表空字 节,8个位上全是0。A以空格填充,空格也即ASCII码为32的字符。这里有一个关于填充的使用场景的例子:请求登录的数据包规定用户名不超过20个字 节,密码经过md5加密后是固定的32个字节。用户名就是变长的,为了便于服务器端读取和处理,通常会填充成定长。当然,这只是使用的方式之一,事实上还 可以用变长的方式传递数据包,但这不在本文的探讨范围内。字符串有一点麻烦的是编码问题,尤其是在跟不同的平台通信时更为突出。比如在用pack进行打包 字符串时,事实上是将字符内部的编码打包进去。单字节字符就没有问题,因为单字节在所有平台上都是一致的。来看个例子:

  • $bin = pack("a", "d");
    echo "output: " . $bin . "\n";
    echo "output: 0x" . bin2hex($bin) . "\n";
    

    输出

  • output: d
    output: 0x64
    

     $bin是返回的二进制字符,您可以直接输出它,PHP知道如何处理。通过 bin2hex方法将$bin转换成十六进制可以知道,十六进制0x64表示的是字符d。对于中文字符(多字节字符)来说,通常有GBK编码、BIG5编 码以及UTF8编码等。比如在GBK编码中,一个中文字符采用2个字节来表示;在UTF8编码中,一个中文字符采用3个字节来表示。这通常需要协商采用统 一的编码,否则会由于内部的表示不一致导致无法处理。在PHP中只要将文件保存为特定的编码格即可,其它语言可能跟操作系统相关,因此或许需要编码转换。 本文的例子一概基于UTF8编码。继续来看个例子:

  • $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";
    

    输出

  • output: 0xe4b8ad
    output: 中
    output: 中
    

    您可能会觉得很奇怪,后面2个输出是一样的。ASCII码表示单字节字符(其中包括英文字母、数字、英文标点符号、不可见字符以及控制字符等等),它总是 小于0x80,即小于十进制的128。当在处理字符时,如果字节小于0x80,则把它当作单字节来处理,否则会继续读取下一个字节,这通常跟编码有 关,GBK会将2个字节当成一个字符来处理,UTF8则需要3个字节。有时候在PHP中需要做类似的处理,比如计算字符串中字符的个数(字符串可能包含单 字节和多字节),strlen方法只能计算字节数,而mb_strlen需要开启扩展。类似这样的需求,其实很容易处理:

  • 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";
    

    输出

  • output: 7
    

    以上代码的实现就是利用单字节字符的ASCII码小于0x80。至于要跳过几个字节,这要看具体是什么编码。接下来通过例子来看看a和A的区别:

    $GOPATH/src

    ----pack_test

    --------main.go

    main.go的源码(只是用于测试,没有考虑细节):

  • 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

  • $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);
    

     

  • $ cd $GOPATH/src/pack_test
    $ go build
    $ ./pack_test
    
    
    $ php -f pack.php
    

    当执行php后,可以看到服务器端在控制台输出:

  • 已接收:20个字节,数据是:'中国强大'
    

    以上的输出中,单引号不是数据的一部分,只是为了便于观察。很明显,我们打包的字符串只占12字节,a20表示20个a,您当然可以连续写20个a,但我 想您不会这么傻。如果是a*的话,则表示任意多个a。通过服务器端的输出来看,PHP发送了20个字节过去,服务器端也接收了20个字节,但因为填充 的\0是空字符,所以您不会看到有什么不一样的地方。现在我们将a20换成A20,代码如下:

  • $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);
    
    $ php -f pack.php
    

     您会发现服务器端的输出不一样了:

  • 已接收:20个字节,数据是:'中国强大        '
    

    是的,空格存在于数据中。这就是a和A的区别。

  • h和H的描述看起来有些奇怪。当以16进制表示法赋值,它们都是读取十进制,以十六进制方式读取,以半字节(4位)为单位。这听起来有些拗口,还是以实例来说明:
  • $bin = pack ( "H*", 0x37c );
    var_dump( $bin );
    var_dump( bin2hex($bin) );
    var_dump( base_convert ( bin2hex($bin) ,  16 ,  2 ) );
    var_dump( unpack( "h*", $bin ) );
    #此时0x37c被转换为十进制,也就是892
    #接下来892并不会当作十进制数处理,而是划分为89(0101 1001)和2(0010)的两个十六进制数
    #由于是高位在前,89的8被认为是高位,9被认为是低位,所以89并不作出任何改变
    #而2的2被认为是高位,低位被0填充,所以也就变成了20(0010 0000)
    #最后的结果是打包成8920的十六进制数
    
    $bin = pack ( "h*", 0x37c );
    var_dump( $bin );
    var_dump( bin2hex($bin) );
    var_dump( base_convert ( bin2hex($bin) ,  16 ,  2 ) );
    var_dump( unpack( "h*", $bin ) );
    #此时0x37c被转换为十进制,也就是892
    #接下来892并不会当作十进制数处理,而是划分为89(0101 1001)和2(0010)的两个十六进制数
    #由于是低位在前,89的8被认为是高位,9被认为是低位,所以低位的9被放置在前8在后,98(1001 0101)
    #而2的2被认为是高位,低位被0填充,所以也就变成了20(0000 0010)
    #最后的结果是打包成9802的十六进制数
    

     

  •  实际上,当函数接受的类型和变量不一致时,PHP解析十六进制表示数,如0x1234,会先将数值转换为十进制格式,然后再把这个转换成十进制的数当作十六进制去运算!!!

  • 不仅仅是pack,看看下面的例子
  • var_dump( base_convert( 'e4b8ad', 16, 2 ) );   #输出111001001011100010101101
    var_dump( base_convert( 0xe4b8ad, 16, 2 ) ); #输出10100100110001001010010000101
    

    上面的例子e4b8ad转换成2进制等于111001001011100010101101,并且它确实应该等于111001001011100010101101,而下面的0xe4b8ad的结果则是先把e4b8ad转化为10进制数14989485,再把14989485当作16进制转化为10100100110001001010010000101

  • 为了找到原因,看看PHP 手册上是怎么描述base_convert函数的,
  • 函数原型是:string base_convert     ( string $number    , int $frombase    , int $tobase    )
  • 所以第一个参数必须是string,否则php会执行强制类型转换,导致结果不是期望值
  • 如果不带0x表示法,结果并不会那么复杂
  • $bin = pack ( "H*", ‘37c’ );
    var_dump( $bin );
    var_dump( bin2hex($bin) );
    var_dump( base_convert ( bin2hex($bin) ,  16 ,  2 ) );
    var_dump( unpack( "h*", $bin ) );
    #此时37c终于被当作十六进制执行了
    #接下来37c划分为37(0011 0111)和c(1100)的两个十六进制数
    #由于是高位在前,37的3被认为是高位,7被认为是低位,所以37并不作出任何改变
    #而c的c被认为是高位,低位被0填充,所以也就变成了c0(1100 0000)
    #最后的结果是打包成37c0的十六进制数
    
    $bin = pack ( "h*", '37c' );
    var_dump( $bin );
    var_dump( bin2hex($bin) );
    var_dump( base_convert ( bin2hex($bin) ,  16 ,  2 ) );
    var_dump( unpack( "h*", $bin ) );
    #此时37c终于被当作十六进制执行了
    #接下来37c划分为37(0011 0111)和c(1100)的两个十六进制数
    #由于是低位在前,37的3被认为是高位,7被认为是低位,所以低位的7被放置在前3在后,73(0111 0011)
    #而c的c被认为是高位,低位被0填充,所以也就变成了0c(0000 1100)
    #最后的结果是打包成9802的十六进制数
    

    H和h会把字符串当成16进制转换为2进制的流,每个字节表示两位十六进制数,当数字不足两位,pack会根据高低位填充0,以补足一个字节,利用这个特性可以把一些unicode码‘解析’成字符,在浏览器下运行这个片段

  • $bin = pack ( "H*", 'e4b8ad' );#e4b8ad是unicode的字符"中"utf8编码
    $bin = unpack ( "a*", $bin );
    var_dump( $bin );#如果浏览器编码为utf8,这里将会输出"中"
    

    事实上我们并不能用'解码'一词来解析上面的例子,在例子中pack和unpack只是起到了把二进制流打包和解包的作用,当输出到浏览器,浏览器自然聪明地把二进制流转换为字符串,也就是说,把二进制流解析成字符者,是浏览器

  • 利用这个原理,可以轻松处理不同编码的字符串之间的互转,如果你明白utf-8的编码原理,解析字符对你来说并不是一件很困难的事情,以下例程用ucs-2和utf-8作为演示
  • $bin = iconv('UTF-8', 'UCS-2', '中');//默认文本格式为utf-8,获取字符“中”ucs-2编码
    var_dump( bin2hex($bin) );
    var_dump( base_convert ( bin2hex($bin) ,  16 ,  2 ) );
    $bin = iconv('UCS-2', 'UTF-8', $bin);//再次转化为utf-8
    var_dump( bin2hex($bin) );
    var_dump( base_convert ( bin2hex($bin) ,  16 ,  2 ) );
    
    $bin = pack('H*','4e2d');//得知字符的ucs-2编码后打包成二进制字节流
    $bin = iconv('UCS-2', 'UTF-8', $bin);//转化为utf-8
    var_dump( $bin );
    var_dump( bin2hex($bin) );
    var_dump( base_convert ( bin2hex($bin) ,  16 ,  2 ) );
    

     

  • 剩下的打包格式并不像H/h这样难以理解,唯一需要注意的一点是,任何规定长度的数据类型,无论输入的填充变量长度是否足够,pack都会以定长的形式填充,不足补0
  • $bin = pack('nn', 46);
    var_dump( $bin );
    var_dump( strlen($bin) ); #长度为2
    var_dump( base_convert ( bin2hex($bin) ,  16 ,  2 ) );
    
    
    $bin = pack('nn', 46, 2);
    var_dump( $bin );
    var_dump( strlen($bin) );#长度为4
    var_dump( base_convert ( bin2hex($bin) ,  16 ,  2 ) );
    

    好啦,大概就算这样

--------------------------------------------------------------------------------------------------------------------------------------------------

 

posted @ 2016-06-04 17:36  yiyide266  阅读(1800)  评论(0编辑  收藏  举报