文字与编码的奥秘(下)

在上篇文章中我们已经了解到,计算机内部是采用的二进制进行运算和存储的。通过计算机来代替我们进行日常的工作,必然会遇到如何进行运算以及数据如何进行存储的问题,本篇文章我将和大家一起来了解下文字是如何在计算机中存储的。

说到文字,我们通常联想到的是各种各样的字符:中文、英文、日文、韩文等等。除此之外,就是数字了,这里的数字通常就是指全世界通用的阿拉伯数字。

数字编码

为了简单起见,假设我们定义了一个 无符号 的整型: inti=5; 那计算机拿到这个i之后,他是怎么知道这个变量的值是多少的呢?他又是如何存储的呢?

因为计算机是采用的二进制,而十进制的整数要存储时,首先要先转换成二进制。那么自然而然的就得到了数字编码的过程是:

十进制数字--->二进制数字

例如:数字 5 ,在计算机中的形态就是: 00000101

上面说的是 无符号 的情况, 有符号 的情况更为复杂一些,二进制数字的最高位用作符号位。这时就涉及到另外一种情况了,即:原码,反码和补码。

正数的原码=反码=补码

负数的补码=反码+1

因为用补码存储时既能保证整数又能保证负数的值,所以计算机内部实际是用补码来表示一个数字的。

字符编码

数字编码比较简单,直接将十进制转换成二进制就可以了。但是字符就做不到了,但是我们可以把字符也想象成是一个虚拟的数字,然后再把这个虚拟的数字转换成二进制,不就可以让计算机去处理了吗?

所以字符编码的过程是:

字符------>虚拟数字
虚拟数字--->二进制数字

那怎么确定字符和虚拟数字之间的关系呢?其实这就是一个 编码 的过程,将每一个单独的字符映射为一个虚拟的数字。当我们把字符映射为数字之后,我们就得到了一个 字符集(Character Set)

我们可以这个字符集想象为一个包含字符与数字之间映射关系的表,这个表有一个名字,叫做 CodePage(码表) ,表中的每一个数字叫做 CodePoint(码点) ,但是这个码点并不是最小的单元,他可能是由一个或多个 CodeUnit(码元) 所组成的。

此外,字符集和字符编码是两个不同的概念,大家需要注意区分,举一个比较容易理解的例子,字符集相当于接口,字符编码相当于实现类。

ASCII字符集

因为计算机是美国人发明的,最初设计的码表叫ASCII表。ASCII是American Standard Code for Information Interchange的缩写,他是美国人制定的一套字符编码方案。因为英文中只有52个字母(区分大小写),再加上数字和一些特殊符号和控制字符,总的来说需要编码的字符很少,所以最初的ASCII表中只有128个码点。具体的码表如下图所示:

PS:ASCII表中的数字0-9是字符形式的数字,即:"1","2"..."9",和数字的1,2…9是不一样的。

ASCII表中的"1",对应十进制的数字是:49,转换成二进制是 00110001

EASCII字符集

计算机普及后,除了美国人使用之外,很多其他国家的人也开始使用起来。但是原本的ASCII码表已经太小了,所以需要重新找一张大表。最初的ASCII表中只用了一个字节中的7位,最高位是没有使用的,如果把最高位也利用起来的话,就可以多出来128个字符。后来,用人真的把这剩下的128个字符利用了起来,解决了部分西欧语言中的字符的映射。因为这个表是在ASCII表的基础上扩展出来的,所以被称为 EASCII字符集 ,我们经常看到的 ISO 8859-1 的编码方式就是 EASCII字符集 的一种实现。

GB XX字符集

再到后来,计算机传到中国之后,在ASCII码表的基础上,即便预留了128个码点可供选择,重新设计码点。但是对于汉字来说,128实在是太少了,所以我们需要重新造一张表。

GB2312字符集

最先被造出来的表是 GB2312 ,这张表中包含了7445个字符,其中汉字6763个。我们知道一个字节最大表示的范围(不考虑符号位)是0~255,共256个空间,2个字节的最大可表示的范围(不考虑符号位)是0~65535,共65536个空间,显然GB2312用一个字节是表示不全的,至少要用两个字节来表示。

为了与ASCII表兼容,码点在0~127(对应的十六进制是0x00~0x7F)之间的字符与ASCII中保持一致。然后用两个连在一起的字节来表示一个汉字,但是规定第一个字节的范围是0xA1~0xF7,第二个字节的范围是0xA1~0xFE。

说到这里我们就需要了解另外一个概念了: 码元

首先记住一点:码元是组成码点的最小单位。一个码点可能由一个码元组成,也可能由多个码元组成。这取决于不同的编码方式中对码点值的处理方式,稍后我们将在Unicode字符集的编码实现中具体说明这个问题。

GB2312字符集对应的实现方式就是GB2312编码。

GBK字符集

由于GB2312字符集,只收录了6763个汉字,还有好多汉字并未收录,于是微软基于GB2312扩展出了GBK字符集。GBK字符集也是采用的两个字节,第一个字节在0x81~0xFE之间,第二个字节在0x40~0xFE之间,一共收录了两万多个码点,其中汉字有21003个,GBK与GB2312完全兼容。

GBK字符集对应的实现方式就是GBK编码。

GB18030字符集

GB18030字符集与GB2312和GBK基本兼容,但是不同的是GB18030采用变长字节的编码方式,这一点与UTF-8相同。

  • 单字节,从0到0x7F,与ASCII字符集兼容
  • 双字节,第一个字节范围是0x81~0xFE,第二个字节范围是0x40~0xFE,与GBK字符集兼容
  • 四字节,第一个字节范围是0x81~0xFE,第二个字节范围是0x30~0x39,第三个字节范围是0x81~0xFE,第四个字节范围是0x30~0x39

GB18030共收录了70244个汉字。

Unicode字符集

从ASCII字符集开始,后面由不同国家陆续推出了很多不同的字符集,也有各种各样的编码方案。但是,这带来另外一个问题,张三用A字符集编码的结果,李四用B字符集可能解码出来就会出现乱码了,甚至根本解码不出来。因为他们两个所用的码表是不一样的,码点也可能不一样,即便运气好,找到了相同的码点,也有可能解码出来是不同的字符。

那为了解决这种问题,我们就需要一个全世界都认同的大而全的码表,于是Unicode字符集就应运而生了。

由于Unicode字符集太大了,一下子管理不过来,所以在目前Unicode标准中,将字符按照一定的类别划分到0~16这17个平面(Plane层面)中,每个平面中拥有2^16 = 65536个码点。所以,目前Unicode字符集所拥有的码点总数为17*65536=1114112。

Unicode的平面划分,如下图所示:

Unicode的码点非常多,但是每个码点最少也需要4个字节,那和传统的ASCII码表就存在不兼容的问题了, 除此以外,如果每个码点都用4个字节来表示的话,就会造成空间的浪费。

UTF-XX编码

为了解决这些问题,就出现了 UTF-XX 这些编码方式,即Unicode码点转换方式(Unicode Transformation Format),一共有三种UTF编码方式,分别是:

  • UTF-8(8-bit Unicode/UCS Transformation Format)
  • UTF-16(16-bit Unicode/UCS Transformation Format)
  • UTF-32(32-bit Unicode/UCS Transformation Format)

其中最简单粗暴的就是UTF-32编码方式,他直接用4个字节来编码每个码点。而UTF-16是用2个字节或4个字节来表示码点的,这将取决于码点在Unicode中哪个Plane中,如果码点在最基本的BMP平面中,那么UTF-16将使用2个字节来编码,否则将使用4个字节来编码。最复杂,最灵活,用的最多的就是UTF-8编码方式了。他可以根据码点的范围使用1到4个字节来编码。

码元和码点

前面我们已经知道了,码点是由一个或多个码元组成的,我们用一个简单的例子来了解下。

上图中每一个方框内的都是一个字符,字符下方的是该字符对应的 码点 ,用竖线分隔出来的每个独立的部分是该码点所对应的 码元

  • UTF-32

最简单的就是UTF-32编码方式,他是定长字节的,每个字符都是4个字节,这种方式下的码元是4字节的,每个码点由1个码元组成,并且码点是定长字节的。那么4个字节的码元就可能存在字节序的问题,例如 000003A9 变换字节序之后可能就变成了: 03A90000 ,这时解码就会出现问题。

  • UTF-16

UTF-16编码方式是变长字节的,可以看到有的码点只需要2个字节,有的码点需要4个字节。这种方式下码元是2字节的,每个码点可能由1个码元组成,也可能由2个码元组成,但是不管由几个码元组成,也都会出现字节序的问题。

  • UTF-8

UTF-8编码方式也是变长字节的,从1个字节到4个字节都有,但是他的码元是1个字节。也就意味着UTF-8编码方式不需要考虑字节序的问题。

PS:好多人说Unicode编码,这种说法是不准确的,Unicode只是一个字符集,UTF-XX才是他具体的编码方式的实现,不过目前说Unicode编码的说法比较多,通常都把他默认为是UTF-16编码。

字节序

由于UTF-16是2字节码元,一个码点是由两个字节组成的,所以就存在字节序的问题。为了解决这个问题,Unicode规范中引入了一个叫BOM(Byte Order Mark)的东西,即指定这种编码使用哪种字节序来编码,一共有两种BOM:BE和LE,即我们所熟悉的大端序和小端序。

  • 大端序:高位字节在前,低位字节在后
  • 小端序:低位字节在前,高位字节在后

举个例子,汉字“语”用UTF-16编码,大端序的结果是: 8A9E ,小端序的结果是: 9E8A

为什么会有字节序这种奇怪的问题存在呢?这跟计算机的实现有关,我们人类阅读的习惯是大端序的,但是计算机先处理低位字节再处理高位字节时效率比较高,所以计算机更喜欢小端序。

java中的编码

java中用来存储字符的类型有char和String,java规范中指出,char是由UTF-16编码格式的二字节码元来存储字符的。一个char占2个字节,即一个码元的大小,那么对于那些需要2个以上的字节存储的字符,是不能用char来保存的。String也是使用的UTF-16编码方式进行存储数据的,String可以用char[]数组进行存储,也可以用byte[]数组进行存储,这取决于字符串内字符的编码范围。

在Sun JDK6中有一个“压缩字符串”(-XX:+UseCompressedString)的功能。启用后,String内部存储字符串内容可能用byte[],也可能用char[]。当整个字符串所有字符都在ASCII编码范围内时,就使用byte[]来存储,此时字符串就处于“压缩”状态;反之,只要有任何一个字符超出了ASCII的编码范围,就退回到用char[]来存储。

下面我们来用一个简单的例子来看java中的字符编码,具体的代码如下:

private static String getHex(String str, Charset charset){
    byte[] bytes = str.getBytes(charset);
    StringBuilder sb = new StringBuilder();
    for(int i=0,s=bytes.length;i<s;i++){
        byte b = bytes[i];
        sb.append(byte2Hex(b));
        if(i<s-1){
            sb.append(" ");
        }
    }
    return sb.toString();
}

private static String byte2Hex(byte b){
    // byte(8位)转int(32位)时,高24位会被自动补齐1,而byte原本高24位是0,
    // 补齐之后二进制的补码值就变了,为了保持byte的值不变,与上0xff,
    // 这样高24位变为0,低8位保持不变
    String hexStr = Integer.toHexString(b & 0xff);
    if(hexStr.length()==1){
        hexStr = "0"+hexStr;
    }
    return hexStr;
}

private static void encode(){
    // 编码的过程
    String cn = "语";
    String en = "A";
    System.out.println(cn+"--encode with ASCII=======>"+getHex(cn,US_ASCII)); 
    System.out.println(en+"--encode with ASCII=======>"+getHex(en,US_ASCII));
    System.out.println(cn+"--encode with UTF-8=======>"+getHex(cn,UTF_8));
    System.out.println(cn+"--encode with UTF-16======>"+getHex(cn,UTF_16)); 
    System.out.println(cn+"--encode with UTF-32======>"+getHex(cn,UTF_32));
}

上面的 encode 方法先执行 String.getBytes() 来获取字符串的字节数组,然后转成十六进制的结果输出。执行完将打印出下面的信息:

语--encode with ASCII=======>3f
A--encode with ASCII=======>41
语--encode with UTF-8=======>e8 af ad
语--encode with UTF-16======>fe ff 8b ed
语--encode with UTF-32======>00 00 8b ed

首先我们需要知道 String.getBytes() 方法是获取指定字符的 外码 的过程,说到 外码 ,就需要知道与他对应的内码内码 是char或String在内存中存储时采用的编码方式,而 外码 则是字符在文件中存储,网络中传输时采用的编码方式。

第一行打印出来的 3f ,表示字符 ”语“ 在ASCII码表中没有找到对应的码点,所以编码的结果是返回了一个 ?

第二行打印出来的 41 ,就是字符 ”A“ 在ASCII码表中的码点,转换成十六进制后的结果。

第三行打印了三个字节,这与汉字 ”语“ 在UTF-8下的编码方式相符。

第四行就比较奇怪了,按照UTF-16编码方式,”语“ 的编码结果应该是 8b4d ,开头多出来的两个字节是什么情况呢?

其实上面,我们已经了解到UTF-16编码方式会有字节序的问题,如果不指定字节序的话,UTF-16编码会在结果的字节流开头加上两个字节表示字节序: fe ff 表示大端序, ff fe 表示小端序。

第五行打印的也和预期相符。

如果我们指定UTF-16编码的字节序,那么输出的结果就不会再多出两个用来表示字节序的字节了,如下代码所示:

private static void encode(){
    // 编码的过程
    String cn = "语";
    String en = "A";
    System.out.println(cn+"--encode with UTF-16BE====>"+getHex(cn,UTF_16BE));
    System.out.println(cn+"--encode with UTF-16LE====>"+getHex(cn,UTF_16LE));
}

执行完之后,打印出如下的结果:

语--encode with UTF-16BE====>8b ed
语--encode with UTF-16LE====>ed 8b

乱码

private static void decode(){
    // 解码的过程
    String cn = "语";
    String en = "A";
    byte[] cnUtf8Bytes = cn.getBytes(UTF_8);
    byte[] cnUtf16Bytes = cn.getBytes(UTF_16);
    System.out.println(cn+"--encode with UTF-8,decode with UTF-8========>"+new String(cnUtf8Bytes,UTF_8));
    System.out.println(cn+"--encode with UTF-8,decode with UTF-16=======>"+new String(cnUtf8Bytes,UTF_16));
    System.out.println(cn+"--encode with UTF-8,decode with UTF-16BE=====>"+new String(cnUtf8Bytes,UTF_16BE));
    System.out.println(cn+"--encode with UTF-8,decode with UTF-16LE=====>"+new String(cnUtf8Bytes,UTF_16LE));
    System.out.println(cn+"--encode with UTF-16,decode with UTF-8=======>"+new String(cnUtf16Bytes,UTF_8));
    System.out.println(cn+"--encode with UTF-16,decode with UTF-16======>"+new String(cnUtf16Bytes,UTF_16));
    System.out.println(cn+"--encode with UTF-16,decode with UTF-16BE====>"+new String(cnUtf16Bytes,UTF_16BE));
    System.out.println(cn+"--encode with UTF-16,decode with UTF-16LE====>"+new String(cnUtf16Bytes,UTF_16LE));

    byte[] enUtf8Bytes = en.getBytes(UTF_8);
    byte[] enUtf16Bytes = en.getBytes(UTF_16);
    System.out.println(en+"--encode with UTF-8,decode with UTF-8========>"+new String(enUtf8Bytes,UTF_8));
    System.out.println(en+"--encode with UTF-8,decode with UTF-16=======>"+new String(enUtf8Bytes,UTF_16));
    System.out.println(en+"--encode with UTF-8,decode with UTF-16BE=====>"+new String(enUtf8Bytes,UTF_16BE));
    System.out.println(en+"--encode with UTF-8,decode with UTF-16LE=====>"+new String(enUtf8Bytes,UTF_16LE));
    System.out.println(en+"--encode with UTF-16,decode with UTF-8=======>"+new String(enUtf16Bytes,UTF_8));
    System.out.println(en+"--encode with UTF-16,decode with UTF-16======>"+new String(enUtf16Bytes,UTF_16));
    System.out.println(en+"--encode with UTF-16,decode with UTF-16BE====>"+new String(enUtf16Bytes,UTF_16BE));
    System.out.println(en+"--encode with UTF-16,decode with UTF-16LE====>"+new String(enUtf16Bytes,UTF_16LE));
}

执行完,将打印出如下结果:

语--encode with UTF-8,decode with UTF-8========>语
语--encode with UTF-8,decode with UTF-16=======>�
语--encode with UTF-8,decode with UTF-16BE=====>�
语--encode with UTF-8,decode with UTF-16LE=====>꿨�
语--encode with UTF-16,decode with UTF-8=======>����
语--encode with UTF-16,decode with UTF-16======>语
语--encode with UTF-16,decode with UTF-16BE====>语
语--encode with UTF-16,decode with UTF-16LE====>�
A--encode with UTF-8,decode with UTF-8========>A
A--encode with UTF-8,decode with UTF-16=======>�
A--encode with UTF-8,decode with UTF-16BE=====>�
A--encode with UTF-8,decode with UTF-16LE=====>�
A--encode with UTF-16,decode with UTF-8=======>��A
A--encode with UTF-16,decode with UTF-16======>A
A--encode with UTF-16,decode with UTF-16BE====>A
A--encode with UTF-16,decode with UTF-16LE====>�䄀

可以看到,用一种编码方式编码出来的结果,用另一种编码方式去解码,就会出现乱码的情况。甚至用相同的编码方式,解码时指定的字节序不同也会出现乱码的情况。

实用工具介绍

我们在处理自定义协议,或者抓包到一段报文时,常常需要进行协议的解析,而这时通常需要进行字符的解码。但是码流是用什么格式编码的我们是不知道的,为此笔者自己写了一个实用的工具,可以将一段字符编码成不同格式,也可以将一段码流用不同的编码方式进行解码。话不多说,直接看图:

Text2Hex

将字符用不同编码方式进行编码,并转成十六进制:

Hex2Text

将十六进制的码流用不同的编码方式进行解码:

Socket client

一个tcp客户端,连接上服务端后,可以发送数据,并将接收到的结果,转换成十六进制码流,然后自动用不同的编码方式进行解码,一眼就可以看出对方采用的何种编码方式:

时间戳转换和md5计算

另外两个常用的工具是时间戳转换和md5计算

本文分享自微信公众号 - 码洞(codehole)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-01-09

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏大史住在大前端

Stanford公开课《编译原理》学习笔记(2)递归下降法

课程里涉及到的内容讲的还是很清楚的,但个别地方有点脱节,建议课下自己配合经典著作《Compilers-priciples, Techniques and Too...

6310
来自专栏码匠的流水账

聊聊nacos的ServerListManager

nacos-1.1.3/naming/src/main/java/com/alibaba/nacos/naming/cluster/ServerListMana...

1900
来自专栏飞总聊IT

系列 | 漫谈数仓第三篇NO.3 『数据魔法』ETL

☞ ETL同步之道 [ Sqoop、DataX、Kettle、Canal、StreamSets ]

30020
来自专栏cnblogs

cordova环境搭建

说明:gradle下载后,解压到硬盘某个目录即可;安装步骤:java->node->adb-studio

5710
来自专栏算法与编程之美

Java|怎样快速搭建一个spring boot项目

当我在网站搭建学习到一定阶段的时候,我们就会学习到springboot框架,我们怎么利用IDEA快速搭建一个spring boot项目呢?

12420
来自专栏Python数据科学

即学即用的30段Python实用代码

Python是目前最流行的语言之一,它在数据科学、机器学习、web开发、脚本编写、自动化方面被许多人广泛使用。它的简单和易用性造就了它如此流行的原因。

8610
来自专栏IT那个小笔记

Spring基本使用

他解决的是业务逻辑层和其他各层的松耦合问题,因此它将面向接口的编程思想贯穿整个系统应用。

10820
来自专栏IT大咖说

你以为反射真的不能为所欲为?至少JDK8以后很强

这里就不在赘述如何通过Method对象调用方法了。文章末尾会给出上一章节的地址。今天我们要研究的是Method如何获取方法参数这一块。看似简单却又是那么的传奇。...

8120
来自专栏ChaMd5安全团队

“送给最好的TA.apk”简单逆向分析

20190927收到一个apk,名字叫“送给最好的TA.apk”。文件哈希值如下:

29840
来自专栏攻城狮的那点事

JVM基本结构及内存模型及优化

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模...

10730

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励