前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >文字与编码的奥秘(下)

文字与编码的奥秘(下)

作者头像
老钱
发布2019-10-25 00:46:47
1.3K1
发布2019-10-25 00:46:47
举报
文章被收录于专栏:码洞码洞

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

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

数字编码

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

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

代码语言:javascript
复制
十进制数字--->二进制数字

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

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

正数的原码=反码=补码

负数的补码=反码+1

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

字符编码

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

所以字符编码的过程是:

代码语言:javascript
复制
字符------>虚拟数字
虚拟数字--->二进制数字

那怎么确定字符和虚拟数字之间的关系呢?其实这就是一个 编码 的过程,将每一个单独的字符映射为一个虚拟的数字。当我们把字符映射为数字之后,我们就得到了一个 字符集(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中的字符编码,具体的代码如下:

代码语言:javascript
复制
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() 来获取字符串的字节数组,然后转成十六进制的结果输出。执行完将打印出下面的信息:

代码语言:javascript
复制
语--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编码的字节序,那么输出的结果就不会再多出两个用来表示字节序的字节了,如下代码所示:

代码语言:javascript
复制
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));
}

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

代码语言:javascript
复制
语--encode with UTF-16BE====>8b ed
语--encode with UTF-16LE====>ed 8b

乱码

代码语言:javascript
复制
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));
}

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

代码语言:javascript
复制
语--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计算

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-01-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 码洞 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 数字编码
  • 字符编码
    • ASCII字符集
      • EASCII字符集
        • GB XX字符集
          • GB2312字符集
            • GBK字符集
              • GB18030字符集
                • Unicode字符集
                  • UTF-XX编码
                  • 码元和码点
                  • 字节序
                • java中的编码
                  • 乱码
                    • 实用工具介绍
                      • Text2Hex
                      • Hex2Text
                      • Socket client
                      • 时间戳转换和md5计算
                  领券
                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档