字符的编码与解码

概述

参考:

我们知道计算机只认识二进制数据,其他格式的数据都需要转换成二进制才能被计算机处理,也就是说我们在计算机上看到的文本、视频、可执行程序等格式的文件,最终都会转换成二进制数据交给计算机处理

计算机中最小的数据单位是 bit,也叫二进制位(简称:位),每一个 bit 都有 0 和 1 两种状态,最早的计算机在设计时采用了 8 个 bit 作为一个 Byte(字节),所以一个字节能表示的最大整数就是二进制的 11111111 等于十进制的 255,一共 256 个数字(即.0~255),想要表示更大的整数就必须要用多个字节,例如两个字节可以表示最大的整数就是二进制的 1111111111111111,共 16 位,等于十进制的 65535。

更多的字节,就可以表示更大的数值范围,比如 32 位,最大可以表示为 4,294,967,295,我们平时说的 32 位电脑、64 位电脑,也是同一个意思,所以就说 32 位电脑,没法传输 4 G 以上的文件,就是因为其最大可以表示的数字就是 4,294,967,295,更大的文件,已经无法识别了。整数可以这么表示,那么字符怎么办呢?一堆二进制的 0 和 1,任何计算都无法算出字母 A 吧?~o(╯□╰)o

聪明的人类啊。。。如果无法通过计算得到,那么就中转一下,人为规定就好了~比如:

字符十进制编号二进制编号
A650100 0001
B66
……
a97
……

要存储字符时,就存储这个数值;要读取字符时,按照映射关系,找到这个字符;就像这样,收录许多字符,然后给它们一一编号,得到一个字符与编号的对照表,这就是 Character sets(字符集),经过这么多年的发展,大家对这个术语有很多种叫法:Character encoding(字符编码)Character map(字符映射)Code page(代码页) 都可以表示同一个概念。

Character encoding(字符编码) 是将数字分配给图形字符(尤其是人类语言的书面字符)的过程,就像上文描述的一样,使得这些字符可以使用数字计算机进行存储、传输、转换。组成字符编码的数值称为 Code points(代码点)

术语

Character(字符) 具有语义价值的最小文本单位。

Character set(字符集) 用于表示文本的元素的集合。例如,拉丁字母和希腊字母都是字符集。

Coded character set(编码字符集) 是映射到唯一数字集的字符集。由于历史原因,这通常也称为 Code page(代码页)

Character repertoire(字符库) 是可以由特定编码字符集表示的字符集。指令集可能是封闭的,这意味着在没有创建新标准的情况下不允许添加任何内容(就像 ASCII 和大多数 ISO-8859 系列的情况一样);或者它可能是开放的,允许添加(就像 Unicode 和有限范围的 Windows 代码页的情况一样)。

Code point(代码点) 是编码字符集中字符的值或位置。

Code space(代码空间) 是编码字符集跨越的数值范围。

Code unit(代码单元) 是字符编码中能够表示字符的最小位组合(用计算机科学术语来说,就是字符编码的字长)。例如,常见的代码单元包括 8位、16位、32位。在某些编码中,某些字符是使用多个代码单元进行编码的;这种编码被称为可变宽度编码。

ASCII

由于计算机是由美国人发明的,在 1967 年美国人制订了一套字符集,规定了包含大小写字母、数字和一些符号共计 128 个字符与二进制数字的对应关系,这一套字符编码被称为 American Standard Code for Information Interchange(美国标准信息交换码,简称 ASCII),ASCII 一直沿用至今。

英文比较简单,用 128 个符号编码就够了,就算需要其他的对应关系,也只需要扩展一下 ASCII 即可,一个 Byte,一共可以表示 256 个字符。但是用来表示中文就不够了,单单汉字就有超过 8 万个,所以就有了针对中文的编码标准出现,例如我们经常见到的 GB2312 字符集,使用两个字节表示一个汉字,理论上最多可以表示 65535 个;没有繁体字也不行啊,所以出现了 BIG 5 字符集;但是依然有许多字符没被收录。

世界上有上百种语言,每种语言都有自己的编码标准,例如韩文编码 EUC_KR,日文编码 Shift_JIS,俄文编码 KOI8-R,为了促进互联网的发展,本着全球化统一标准的目的,制作一个通用字符集,Unicode 应运而生。

Unicode

Universal Coded Character Set(通用编码的字符集,简称 Unicode) 在汉语中又称为 万国码、国际码、统一码,它于 1990 年开始研发,并于 1994 年正式公布。Unicode 对世界上大部分的文字系统进行了整理,使每一个文字符号都用独一无二的编码表示,当前 Unicode 最新的版本为 2019 年 5 月公布的 12.1.0,已经收录超过 13 万个字符,很明显 2 个字节已经无法保证所有字符都独一无二了,实际上最新的 Unicode 规定可以占用 4 字节来表示一个字符,理论上最多能表示 $2^{31}$ 共计 2147483648 个字符。

Unicode 使用 16 进制格式来表示一个字符,比如:

断念梦 这三个字符以 Unicode 编码表示为:\u65ad\u5ff5\u68a6

\u 是一个用来表示 Unicode 的标记。当计算机识别到这个标记时,就会使用 Unicode 进行解码。65ad 表示 5ff5 表示 68a6 表示

所以随着技术的发展,Unicode 慢慢成为了一种 Unicode Standard(Unicode 标准)

Unicode 的问题

需要注意的是,Unicode 只是一个字符集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。

比如,汉字 的 Unicode 号为 26029,十六进制数 4E25,转换成二进制数足足有 15 个 bit 位(110010110101101),也就是说,这个符号的表示至少需要 2 个字节。表示其他更大的符号,可能需要 3 个字节或者 4 个字节,甚至更多。

这里就有两个严重的问题,第一个问题是,如何才能区别 Unicode 和 ASCII ?计算机怎么知道三个字节表示一个符号,而不是分别表示三个符号呢?第二个问题是,我们已经知道,英文字母只用一个字节表示就够了,如果 Unicode 统一规定,每个符号用三个或四个字节表示,那么每个英文字母前都必然有二到三个字节是 0,这对于存储来说是极大的浪费,文本文件的大小会因此大出二三倍,这是无法接受的。

它们造成的结果是:

  • 出现了 Unicode 的多种存储方式,也就是说有许多种不同的二进制格式,可以用来表示 Unicode。
  • Unicode 在很长一段时间内无法推广,直到互联网的出现。

其中一种 Unicode 的存储方式如下:

image.png

上图左侧的几个字符,通过字符集找到对应的二进制编号之后,你怎么知道这一大串二进制内容,就是 eggo世界 这几个字符呢?

正常来说是这样的:

image.png

但是,也可以这样啊:

image.png

这里面的主要问题,就是 划分字符边界。所以,我们需要一种编码规则,通过字符集中的对照关系,对二进制进行编码。

那么可以这样,不管编号多大多小,统一按照最长的来,位数不够,高位补零,效果如下:

image.png

这种方式,Fixed-length codes(固定长度编码)。虽然字符边界的问题解决了,但是。。。。。这么存数据。。。非常浪费内存啊。。。。

互联网的普及,强烈要求出现一种统一的编码方式。既然固定长度编码不行,咱就来个 Variable length code(可变长度编码)。

UTF-8 就是在互联网上使用最广的一种 Unicode 的编码规则。其他实现方式还包括 UTF-16(字符用两个字节或四个字节表示)和 UTF-32(字符用四个字节表示),不过在互联网上基本不用。

UTF

Unicode Transformation Formats(Unicode 转换格式,简称 UTF) 是根据 Unicode 实现的一种编码规则。主要用于规定如何划分字符边界的问题。

Unicode 标准定义了 Unicode Transformation Formats(Unicode 转换格式,简称 UTF)。UTF-8、UTF-16、UTF-32 以及其他几种编码。最常用的编码是 UTF-8

UTF-8

UTF-8 最大的一个特点,它是一种变长的编码方式。它可以使用 1~4 个 Bytes 表示一个字符(最多 32 个 bit),根据不同的字符而变化字节长度。

UTF-8 顾名思义,是一套以 8 bit 为一个编码单位的可变长编码。会将一个 “Unicom 编号” 重新编码为 1 到 4 个 Bytes。既然是可变长度编码,那么小编号的字符就要少占字节,大编号的多占字节,但是,怎么划分字符的边界呢?有这么一种方案:

UTF -8 定义了一组模板(二进制),规范如下:

注意:标识为是模板用来定位字符长度应该使用哪种规则的,模板中的标识位并不具备实际意义。

Unicom 编号字节数最高位标识位编码模板(二进制)编码模板(十六进制)模板可以填充的 bit 数
0~1271 Bytes00???????0000 0000-0000 007F7
128~20472 Bytes110 和 10110????? 10??????0000 0080-0000 07FF5 + 6
2048~655353 Bytes1110 和 10 和 101110???? 10?????? 10??????0000 0800-0000 FFFF4 + 6 + 6
65536~42949672954 Bytes11110 和 10 和 10 和 1011110??? 10?????? 10?????? 10??????0001 0000-0010 FFFF3 + 6 + 6 + 6

在使用 UTF-8 规则存储字符时,首先获取到该字符的 Unicom 编号,然后根据该字符编号,决定对字符编码时所使用的模板,编码规则如下:

  • 如果字符需要存储 1 Bytes,则,模板的第一位设为 0 作为标识位。
    • 模板中只有一个 Bytes,Bytes 的标识位后面 7 bit 作为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。
  • 如果字符需要存储 n Bytes(n > 1),则模板的一个字节的前 n 个 bit 位都设为 1 ,第 n + 1 bit 位 设为 0,后面字节的前 两 bit 位 一律设为 10。这些被提前设置好的 bit 位作为标识符。
    • 要字符需要存储 2 Bytes ,则,模板的第一个 Bytes 的前 2 个 bit 为 1,第 3 个 bit 为 0;后面 Bytes 的前 两 bit 位 一律设为 10
    • 要字符需要存储 3 Bytes ,则,模板的第一个 Bytes 的前 3 个 bit 为 1,第 4 个 bit 为 0;后面 Bytes 的前 两 bit 位 一律设为 10
    • 要字符需要存储 4 Bytes ,则,模板的第一个 Bytes 的前 5 个 bit 为 1,第 5 个 bit 为 0;后面 Bytes 的前 两 bit 位 一律设为 10
    • 模板中每个 Bytes 的标识位后面剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。

每个字符编码后存储时,所使用的 “Bytes 数” 不同。跟据上表,解读 UTF-8 编码非常简单。如果一个 Bytes 的第一个 bit 是 0,则这个字节单独就是一个字符;如果第一位是 1,则连续有多少个 1,就表示当前字符占用多少个字节。

应用示例:

  • 01100101 最高位为 0,则表示这是一个只占 1 Bytes 的字符,并且是在 0~127 编号范围内的字符,使用的是 0??????? 模板
    • 除去标识 bit,剩下的 7 bit 就是该字符的二进制编号,即 1100101,十进制是 101,十六进制是 0065,也就是字符 e
  • 11100100 10111000 10010110 最高位是 1110,则表示这是一个占用 3 bytes 的字符,并且要和后面两个以 10 开头的字节共同表示一个字符,并且是在 2048~65535 编号范围内的字符。
    • 除去标识 bit,剩下的 bit 组合起来,即 01001110 00010110,十进制是 19990,十六进制是 4E16 ,也就是字符
  • 字符 在 Unicode 中的编号为 26029,十六进制是 65AD,二进制是 01100101 10101101,也就是说,如果想要使用 UTF-8 对字符编码后储存,则应该占用 3 Bytes,所以应该使用 1110???? 10?????? 10?????? 模板。
    • 的二进制 bit 填入到模板中
    • 模板第一部分需要填充 4 个 bit,从二进制中拿出来 4 个,得到 11100110,二进制剩余 ****0101 10101101
    • 模板第二部分需要填充 6 个 bit,从二进制中拿出来 6 个,得到 10010110,二进制剩余 ******** **101101
    • 模板第三部分需要填充 6 个 bit,从二进制中拿出来 6 个,得到 10101101,二进制无剩余
    • 得到二进制: 11100110 10010110 10101101
    • 对应十六进制: E6 96 AD
    • 这也正好对应了上面表格的规则,这是一个需要占用 3 Bytes 存储空间的字符。

总结

虽然使用 UTF-8 对字符进行编码后,依然会浪费一部分 bit,但是对比定长编码,已经极大得节省了存储空间。

Unicode 与 UTF 的区别

简单来说:

  • Unicode 是 字符集,即字符与编号的对应关系
  • UTF 是 编码规则

其中:

  • 字符集:为每一个「字符」分配一个唯一的 ID(学名为码位 / 码点 / Code Point)
  • 编码规则:将「码位」转换为字节序列的规则

广义的 Unicode 是一个标准,定义了一个字符集以及一系列的编码规则,即 Unicode 字符集和 UTF-8、UTF-16、UTF-32 等等编码规则……

Unicode 字符集为每一个字符分配一个编号,例如「知」的 Unicom 编号是 30693,记作 U+77E5(30693 的十六进制为 0x77E5)。

UTF-8 顾名思义,是一套以 8 位为一个编码单位的可变长编码。会将一个编号重新编码为 1 到 4 个字节:

U+ 0000 ~ U+  007F: 0XXXXXXX
U+ 0080 ~ U+  07FF: 110XXXXX 10XXXXXX
U+ 0800 ~ U+  FFFF: 1110XXXX 10XXXXXX 10XXXXXX
U+10000 ~ U+10FFFF: 11110XXX 10XXXXXX 10XXXXXX 10XXXXXX

根据上表中的编码规则,之前的「知」字的码位 U+77E5 属于第三行的范围:

    7    7    E    5
    0111 0111 1110 0101    二进制的 77E5
--------------------------
    0111   011111   100101 二进制的 77E5
1110XXXX 10XXXXXX 10XXXXXX 模版(上表第三行)
11100111 10011111 10100101 代入模版
   E   7    9   F    A   5

这就是将 U+77E5 按照 UTF-8 编码为字节序列 E79FA5 的过程。反之亦然。

乱码的出现

从上边的编码介绍中我们已经知道了不同编码的存在,那么想要查看一个文件,就必须知道他的编码方式,用错误的编码方式打开文件就会出现乱码。 linux 下可以通过 file 命令查看文件的编码方式

~]# touch encoding_file
~]# file encoding_file
encoding_file: empty
~]# echo "hello world!" > encoding_file
~]# file encoding_file
encoding_file: ASCII text
~]# echo "你好,世界!" >> encoding_file
~]# file encoding_file
encoding_file: UTF-8 Unicode text

工作中我们在 XSHELL 之类的终端中查看文件时出现的乱码就是系统或文件保存的中文编码与终端设置的编码不一致,从而导致解码错误。这里涉及到三方编码:

  1. 文件内容或文件名
  2. SHELL 环境的语言编码
  3. XSHELL 之类的终端编码

需要保持三方编码统一,才不会有乱码的出现,其中 SHELL 环境的语言编码指的是登陆服务器的 SHELL 环境时指定的语言编码,例如 LANG、LC_*这些变量设置的编码,XSHELL 之类终端编码就是这类终端软件设置的编码

所有遇到的乱码问题都仔细检查以上三方编码是否一致,就可以顺利解决了,同时也建议在工作中制定相应的规范,减少乱码的发生

此时,我现在将 Xshell 中的编码规则变为其他的

image.png

此时再看这个文件,就发现,已经无法正确解码了:

~]# cat encoding_file
hello world!
浣??ソ锛???????

乱码处理技巧

  1. 临时切换命令输出语言

    正常情况下命令的输出结果都遵循系统设置的语言编码,例如

~]# echo $LANG
zh_CN.UTF-8
~]# date
2020年 03月 04日 星期三 19:00:55 HKT
~]# export LANG=en_US.UTF-8
~]# echo $LANG
en_US.UTF-8
~]# date
Wed Mar  4 19:01:21 HKT 2020

运维脚本中,我们希望所有系统执行相同命令的时候输出的结果一致,不要因为字符集不同而产生不同的结果,那么如可处理呢?在命令前添加 LC_ALL=C

~]# date
2020年 03月 04日 星期三 19:05:58 HKT
~]# LC_ALL=C date
Wed Mar  4 19:06:05 HKT 2020

这里之所以用 LC_ALL 是因为在 LOCALE 标准中,LC_ALL 优先级最高:LC_ALL>LC_*>LANG

  1. 批量转换文件名编码

有时候我们会遇到文件名或者目录名乱码的问题,尤其是在不同类型系统之间传输时,可以借助 rsync 实现批量转换文件名或目录名的编码

rsync -av –iconv=GBK,UTF8 /www/ /nav/

iconv 模块在 rsync 的 3.0 以后版本中才支持,用法为–iconv=,,需要注意的是,本地两个目录之间同步时 LOCAL 表示的是源目录的文件名编码,通过网络同步时 LOCAL 表示本地编码

其他编码规则

断念梦

  • Unicode 编码: \u65ad\u5ff5\u68a6。以 \u 开头的 16 进制表示
  • URL 与 URI 编码: %E6%96%AD%E5%BF%B5%E6%A2%A6。使用 UTF-8 编码后,每个汉字占用的 1 Bytes 的 16 进制表示符之间都有一个 % 符号。

最后修改 July 3, 2024: character code and decode (0ad331ca)