引子我之前有篇文详细介绍过pack和unpack:php: 深入pack/unpack,如果有不明白的地方,建议再回过头去看多几遍。现在应该能够写出以下代码:
$ php -f test.phpa
但是,为什么会输出'a'呢?虽然我们知道字符'a'的ascii码就是97,但是pack方法返回的是二进制字符串,为什么不是输出一段二进制而是'a'?为了确认pack方法返回的是一段二进制字符串,这里我对官方的pack的描述截了个图:
确实如此,pack返回包含二进制字符串的数据,接下来详细进行分析。
程序是如何显示字符的这里所说的'程序',其实是个宏观的概念。
对于在控制台中执行脚本(这里是指php作为cli脚本来执行),脚本的输出会写入标准输出(stdin)或标准错误(stderr),当然也有可能会重定向到某个文件描述符。拿标准输出来说,暂且忽略它是行缓冲、全缓冲或者是无缓冲。脚本进程执行完毕后如果有输出则会在控制台上输出字符串。那这里的控制台就是所说的'程序'。
对于web来说(这里是指php作为web的服务器端语言),程序执行完后会将结果响应给浏览器或其它useragent,为了方便描述,这里统一称为useragent。这里的useragent就是所说的'程序'。
当然还有其它情况,比如在gui窗口中的输出,编辑器打开一个文件等等,这都涉及到如何显示字符串的问题。
在控制台中执行控制台通过shell命令来执行脚本,它会fork一个子进程,之后通过exec替换子进程的地址空间,因为这个子进程不是会话首进程,所以它可以关联到终端。脚本输出执行完毕后退出,回到控制台。来看下面的例子:
$ php -f test.php回
test.php是utf-8格式的文件,我的linux系统的locales是zh_cn.utf-8。
$ localelang=zh_cn.utf-8language=lc_ctype=zh_cn.utf-8lc_numeric=zh_cn.utf-8lc_time=zh_cn.utf-8lc_collate=zh_cn.utf-8lc_monetary=zh_cn.utf-8lc_messages=zh_cn.utf-8lc_paper=zh_cn.utf-8lc_name=zh_cn.utf-8lc_address=zh_cn.utf-8lc_telephone=zh_cn.utf-8lc_measurement=zh_cn.utf-8lc_identification=zh_cn.utf-8lc_all=
回到刚才的代码,test.php是utf-8编码的文件,汉字'回'是三个字节表示的utf8字符(如果不明白,可以看我的另一篇文章: javascript: 详解base64编码和解码 ),所以test.php文件的内容保存在硬盘上的数据就是4个字节('\n'是ascii字符,用1个字节表示)。将test.php输出时,将这4个字节发送到标准输出,之后被冲洗(这里忽略掉被flush的时机),由控制台来显示。回想一下linux系统上的locale设置,很显然是采用utf8的机制来显示字符,所以前三个字节被当成一个utf8字符,它被组合在一起转成unicode码然后查表,再显示出来。
2);$c2 = (($byte2 & 0x03) 6) | ($byte3 & 0x3f);$dec = (($c1 & 0x00ff) 8) | $c2;echo unicode编码: . $dec . \n;
$ php -f test.phputf-8编码: e59b9eunicode编码: 22238
javascript测试:
script type=text/javascript>/*** utf16和utf8转换对照表* u+00000000 – u+0000007f 0xxxxxxx* u+00000080 – u+000007ff 110xxxxx 10xxxxxx* u+00000800 – u+0000ffff 1110xxxx 10xxxxxx 10xxxxxx* u+00010000 – u+001fffff 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx* u+00200000 – u+03ffffff 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx* u+04000000 – u+7fffffff 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx*/var code = ('回').charcodeat(0);// 1110xxxxvar byte1 = 0xe0 | ((code >> 12) & 0x0f);// 10xxxxxxvar byte2 = 0x80 | ((code >> 6) & 0x3f);// 10xxxxxxvar byte3 = 0x80 | (code & 0x3f);console.group('test chr: ');console.log(utf-8编码:, byte1.tostring(16).touppercase() + '' + byte2.tostring(16).touppercase() + '' + byte3.tostring(16).touppercase());console.log(unicode编码: , code);console.groupend();script>
我们看到输出是一样的。
作为web的服务器端语言执行这次无非是由刚才的控制台执行变成了useragent,其实道理还是一样的。服务器端php脚本输出会通过http的响应返回给useragent,那么useragent就要对它进行显示。当然,这里还有点例外。数据是通过网络作为字节流发送回useragent,通常useragent有几种方式来判断字节流是属于什么编码(或许还涉及到压缩,但这里将不考虑这个因素)。
服务器端可以通过响应头部来告诉useragent应该用什么编码来处理这些数据,比如:
但是万一这两种方式都没有提供,那也只能靠猜了。事实也确实如此,据我所知,firefox就是这么做的,并且将代码开源了: universalchardet 。但是这种方式并不能百分之百正确检测,所以偶尔会访问到乱码的页面。
编辑器打开一个文件在windows上用notepad新建文本文件另存为时有几种编码选项:ansi, unicode, unicode bigendian, utf-8。
在其它编辑器中选项更多,包括有bom和无bom的。bom是文件头的前几个字节,通过bom,处理它的程序就知道这个文件是采用什么编码,并且是什么字节序。然而在php中,从来都没有将bom考虑进去,所以php解释器去执行一个php文件时,不会忽略前几个bom字节,这就导致了问题。一般的问题在于发送cookie前,bom被输出了。所以现在一般推荐无bom的文件。
无bom有时候也是会有问题的,因为这需要处理它的程序去检测它是什么编码。检测的方式一般是扫描文件,然后根据不同编码的规则来判断二进制。这里举一个出现问题的例子。在windows上新建一个文本文件并保存为ansi编码,然后在文件中输入'联通',如图所示:
保存好后关闭test.txt文件,然后再双击打开,如图所示:
我们看到显示的是乱码,具体我们可以分析一下产生乱码的原因。用editplus新建一个ansi文件,输入'联通',然后切换到十六进制查看方式,如下图所示:
对应的十六进制是:c1 aa cd a8,转成二进制后如下:
11000001 10101010 11001101 10101000
接着我们来看下utf-8的转换表:
u+00000000 – u+0000007f 0xxxxxxxu+00000080 – u+000007ff 110xxxxx 10xxxxxxu+00000800 – u+0000ffff 1110xxxx 10xxxxxx 10xxxxxxu+00010000 – u+001fffff 11110xxx 10xxxxxx 10xxxxxx 10xxxxxxu+00200000 – u+03ffffff 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxxu+04000000 – u+7fffffff 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
很显然都被当作了二字节的utf-8字符,拿gbk的编码去utf-8的码表里查,您说能查到吗?
总结现在我们已经知道了不管是什么编码的数据,总是一个字节一个字的存储,并且在存储时会进行相应的编码转换。比如汉字'回'的gbk编码和utf-8编码的字节数和编码值都不一样,所以在将gbk的文件另存为utf-8时必然会存在转换,反之也是一样的。而在读取时如果有bom就按bom规定的编码来处理,否则要进行编码检测后再处理。
再说pack之前讲了这么多编码方面的问题,其实就是为了让大家更好的理解接下来要讲的。pack可以将ascii进行打包然后输出(事实上就是将一个多字节变成多个单字节,之后可以通过unpack转换回来),这个我们已经知道了。但是方式是有很多种,原理是一样的。我们来详细分析。对pack/unpack不太熟悉的还是建议去翻看我之前的一篇文章:php: 深入pack/unpack 。因为本人的机器是小端序的,所以本文只考虑小端序。大端序是一样的方式,只不过字节序不一样罢了,可以先判断本机的字节序再处理。
$ php -f test.phpaaaaaaaa回回回
我们一句句的来分析,首先是:
echo pack(c, 0x61) . \n;echo pack(s, 0x6161) . \n;echo pack(l, 0x61616161) . \n;
这三句代码很简单,c是无符号字节,s是2个无符号字节,l是4个无符号字节,所以输出也没什么疑问。无论几个字节,都是ascii码,0x61的二进制的高位为0,所以能正确显示。
echo pack(l, 0x9e9be561) . \n;
我们或许还记得汉字'回'的utf-8编码为:0xe59b9e,l是按主机字节序打包的,而我的机器是小端序,所以0x9e9be561打包后就变为:0x61e59b9e。0x61是字符'a'的ascii码,而后面的三个字节程序通过判断0xe5就能知道这是一个三字节的utf-8字符,因此这三个字节会转成unicode码去查表,然后显示。
echo chr(0xe5) . chr(0x9b) . chr(0x9e) . \n;
chr是返回ascii码所代码的字符,它其实不仅仅是转换单字节的字符,对于多字节同样适用。它会根据刚才所说的规则将三个utf-8字节转成unicode码然后去查表。
echo pack(h6, e59b9e) . \n;
对于h格式字符,它和h的区别就是前者是高四位在前,后者是低四位在前,但它们都是以半字节为单位读取的,并且以十六进制的方式。您应该看到我在用h进行打包时传的是字符串e59b9e,如果传的是0xe59b9e就不对了,这样的话先会转成十进制15047582,然后在前面加上0x变成十六进制0x15047582。
所谓按半字节读取其实是这样的,比如0x47,先转成十进制71,然后变成十六进制的0x71。按半字节读取必然会丢弃4位,然后要补0。读取了0x7,对h来说,它是高位,那么在低位补0变成0x70。对于h来说,它是低位,那么在高位补0变成0x07。
再说unpackunpack是pack的逆函数,当然unpack有自己的语法,但这不是重点,因为这些只是表象。
unpack其实只是将多个字节压缩成一个字节。比如0x12和0x34这两个字节如果要组成一个双字节,则可以使用unpack的s格式化字符来实现,代码如下:
4660)0x1234
因为是小端序,所以要写成3421。其实还可以用位运算的方式来实现。这个时候就不需要考虑字节序了,因为字节序只是存储时才需要考虑的问题,对于输出来说,是按照我们自然的方式:
$ php -f test.php0x1234
再说chrphp官方文档上所描述的chr方法的原型参数是一个int型,虽然形参名为ascii,但不要被骗了。如图所示:
chr确实是可以接收一个int类型的参数,而不仅仅是一个ascii码。还记得之前所做的测试吗?通过chr方法将三个utf-8的字节组合在一起。很显然utf-8的每个字节都大于127,因为最高位都是1。
不过说起来chr方法还是比较傻的,比如有如下代码:
$ php -f test.php回?
chr方法完全没有考虑将0xe59b9e拆成三个字节来组合,所以最终是乱码。
再说ordord接受一个string类型的参数,它用于返回参数的ascii码。如下图所示:
虽然它只返回ascii码,但它的参数却不限定。比如您可以传递单字节或多字节。举例如下:
$ php -f test.php97229229
传入汉字'回',它会自动截取第一个字节,然后返回它的十进制表示。
实现自己的pack理解了原理,其实自己去实现也就是那么回事。本文以格式化字符l为例,l是无符号32位整型,它是按主机字节序来打包的。所以我们要先判断机器的字节序。
0xff && $num 0xffff){// 补2个字节$padding = str_repeat(chr(0), 2);$byte3 = ($num >> 8) & 0xff;$byte4 = $num & 0xff;// 如果是小端序,则按小端序方式if (!isbigendian()){$bin = chr($byte4) . chr($byte3) . $padding;}else{$bin = $padding . chr($byte3) . chr($byte4);}}else if ($num > 0xffff && $num 0x7fffff){// 补1个字节$padding = chr(0);$byte2 = ($num >> 16) & 0xff;$byte3 = ($num >> 8) & 0xff;$byte4 = $num & 0xff;// 如果是小端序,则按小端序方式if (!isbigendian()){$bin = chr($byte4) . chr($byte3) . chr($byte2) . $padding;}else{$bin = $padding . chr($byte2) . chr($byte3) . chr($byte4);}}else{$byte1 = ($num >> 24) & 0xff;$byte2 = ($num >> 16) & 0xff;$byte3 = ($num >> 8) & 0xff;$byte4 = $num & 0xff;// 如果是小端序,则按小端序方式if (!isbigendian()){$bin = chr($byte4) . chr($byte3) . chr($byte2) . chr($byte1);}else{$bin = chr($byte1) . chr($byte2) . chr($byte3) . chr($byte4);}}return $bin;}$bin = my_pack(0x12);print_r(unpack(l, $bin));$bin = pack(l, 0x12);print_r(unpack(l, $bin));$bin = my_pack(0x1234);print_r(unpack(l, $bin));$bin = pack(l, 0x1234);print_r(unpack(l, $bin));$bin = my_pack(0x123456);print_r(unpack(l, $bin));$bin = pack(l, 0x123456);print_r(unpack(l, $bin));$bin = my_pack(0x12345678);print_r(unpack(l, $bin));$bin = pack(l, 0x12345678);print_r(unpack(l, $bin));
$ php -f test.phparray( [1] => 18)array( [1] => 18)array( [1] => 4660)array( [1] => 4660)array( [1] => 1193046)array( [1] => 1193046)array( [1] => 305419896)array( [1] => 305419896)
测试中调用pack和my_pack的结果是一样的。unpack的实现就是pack的逆操作,只需把pack的结果的每一个字节取到它的ascii码(可以通过ord方法来做),然后将4个字节根据高低位次序(这还要根据大小端)通过位运算变成一个4字节的整数,其它格式化字符也是类似如此实现。
关于iso 8859-1编码iso 8859-1又称 latin-1 或西欧语言。是国际标准化组织内iso/iec 8859的第一个8位字符集。它以ascii为基础,在空置的0xa0-0xff的范围内,加入96个字母及符号,藉以供使用附加符号的拉丁字母语言使用。
从定义可知,latin-1编码是单字节编码,向下兼容 ascii ,其编码范围是0x00~0xff。0x00~0x7f之间完全和ascii码一致,0x80~0x9f之间是控制字符,0xa0~0xff之间是文字符号。
iso-8859-1收录的字符除ascii收录的字符外,还包括西欧语言、希腊语、泰语、阿拉伯语、希伯来语对应的文字符号。欧元符号出现的比较晚,没有被收录在iso-8859-1当中。
因为iso-8859-1编码范围使用了单字节内的所有空间,在支持iso-8859-1的系统中传输和存储其他任何编码的字节流都不会被抛弃。换言之,把其他任何编码的字节流当作iso-8859-1编码看待都没有问题。这是个很重要的特性,mysql数据库默认编码是latin-1就是利用了这个特性。ascii编码是一个7位的容器,iso-8859-1编码是一个8位的容器。
结束语pack/unpack在实际工作中用得非常多,因为很多公司用php做前端,通过tcp调用接口,这就需要用到pack/unpack来打包和解包。希望本文能对大家有帮助。