字符编码
编解码
人类世界常见的语言文字多种多样,有英文字母例如a,有阿拉伯数字例如6,有中文例如好 等等。但是计算机的世界里面只有二进制即0和1,所以我们要存储和计算的时候就需要将人类世界的语言文字转换为计算机能识别的二进制,而人类的语言文字与计算机二进制相互转换的过程就是编解码。
ASCII
上个世纪60年代,美国制定了一套字符编码,对英语字符与二进制之间的关系,做了统一规定被称为 ASCII 码。ASCII 码一共规定了128个字符的编码,例如大写的字母A是十进制65(二进制01000001
),而计算机中一个字节(byte)有8位(bit),一位能表示一个二进制0或者1,所以一个字节能表示最多256个符号。但是ASCII只有128个符号,所以ASCII码只占用了一个字节的后面7位,最前面的一位统一规定为0。
GB2312
既然有了美国针对英语字符制定的ASCII码,那么为了能让计算机能处理中文,于是中国也制定了一套中文与二进制之间的关系编码,那就是中华人民共和国国家标准简体中文字符集。
其中流行比较广泛的就是GB2312。GB2312使用两个字节存储字符,采用区位码方法来表示字符所在的区和位。其中第一个字节称为“高位字节”,对应分区的编号,第二个字节称为“低位字节”,对应区段内的个别码位,GB2312标准共收录6763个汉字,同时收录了包括拉丁字母、希腊字母,日文平假名及片假名字母、俄语西里尔字母在内的682个字符。
GB2312的出现基本满足了汉字的计算机处理需要,它所收录的汉字已经覆盖中国大陆99.75%的使用频率,但对于人名、古汉语等方面出现的罕用字和繁體字GB2312不能处理。因此后来又出现了GBK及GB18030汉字字符集以解决这些问题。
Unicode
美国有ASCII码,中国有GB2312,那韩国、日本等世界上各个国家都有自己的编码,同一个二进制数字可以被解释成不同的符号。因此要想正确读取一个字符,就必须知道它的编码方式,否则用错误的编码方式解码,就会出现乱码。
正因为世界各国都有自己的编码,导致程序很难适配所有编码。所以需要一种全世界通用的编码,将世界上所有的符号都纳入其中,为每一个字符都赋予一个独一无二的编码,采用统一的编解码就不会出现乱码,这就是 Unicode。
Unicode使用最多4个字节来表示,通常使用十六进制表示,即范围为00000000
-FFFFFFFF
。Unicode 是一个很大的集合,每个符号的编码都不一样。比如,U+0041表示英语的大写字母A、U+4E25表示汉字严。
Unicode规范:https://datatracker.ietf.org/doc/html/rfc3629#ref-UNICODE
UTF-8
有了Unicode统一全世界字符的编码,但Unicode 只是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。比如,汉字严的 Unicode是十六进制数4E25
,对应二进制数100111000100101
。这个二进制的表示至少需要2个字节,而目前Unicode最大4个字节,如果全部使用4个字节来进行存储,无疑会大大的浪费存储空间。
UTF-8 是 Unicode 的实现方式之一,采用一种变长的编码方式它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。
UTF-8规范:https://datatracker.ietf.org/doc/html/rfc3629#section-3
UTF-8编码规则
对于单字节的符号,字节的第一位设为0,后面7位为这个符号的 Unicode 码 对于n字节的符号(n > 1),第一个字节的前n位都设为1,第n + 1位设为0 其余字节的前两位设为10,所有字节其他二进制位为这个符号的 Unicode 码 填充二进制位时从低位往高位填充即从右往左,不足位使用0进行填充
Unicode符号范围(十六进制) | UTF-8编码方式(二进制) |
---|---|
0000 0000-0000 007F | 0xxxxxxx |
0000 0080-0000 07FF | 110xxxxx 10xxxxxx |
0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
UTF-8规则解读
「为什么不直接存储Unicode对应的二进制,而是需要定义一套规则」
假如UTF-8像ASCII码一样直接存储对应的二进制,比如汉字“好”对应的Unicode十六进制为
597d
,二进制为10110010 1111101
。那么怎么区分这个二进制10110010 1111101
,是一个Unicode字符而不是两个(10110010
和1111101
)呢?正因为Unicode字符转换成二进制最多可能占用4个字节,当超过一个字节的时候无法区分是多个单字节的字符还是单个多字节的字符,所以UTF-8不能直接像ASCII码一样直接存储对应的二进制。「为什么只有一个字节的时候需要特殊处理用0开头」
目前ASCII一共128个字符,从00000000到01111111,为了兼容ASCII所以只有一个字节的时候就用0开始。
「为什么第一字节要设计为n位填充1,n+1位填充0」
为了区分一个多字节的编码,是「多个」单字节的字符?还是「单个」多字节的字符?节省空间的做法就是用编码的第一个字节的前几位来表示这个编码占用几个字节 至于为什么是用1而不是0? 假设用0来表示,汉字"好"的字节编码就是
00000000
01011001
01111101
。这样有一个问题就是无法正确识别出编码所占的字节数,所以还需要在表示字节数位和实际存储位中间设置一个分隔位,分割位取值简单做法就是与表示字节位数值取反即可。 比如某一个Unicode字符的字节编码是两字节的二进制00111111
11110000
。这样又有一个问题就是该二进制的第一字节与单字节的规则冲突,所以设计多字节的的第一字节n位填充1,n+1作为分隔符与n位的填充符取反即为0。「为什么n-1字节以10开头」
上面的设计其实已经满足UTF-8的正常编解码了,但是还有一个问题。假设有一个二进制编码
11100010 11000011 111001111
11011100 10001111
表示有两个字符。 第一个字符三个字节对应的二进制编码为11100010 11000011 111001111
,第二个字符占两个字节对应的二进制编码位11011100 10001111
。如果因为某些原因导致写入的时候出错了写成了11000010 11000011 111001111
11011100 10001111
。这时候读取程序就会识别为第一个字符两个字节(11000010 11000011
),第二个字符三个字节(111001111 11011100 10001111
)这样读取所有字符都是错的。 为了解决读取错误的问题,因为第一个字节包含字符字节数信息,所以只需要区分开编码的第一个字节和其他字节。读取包含字符字节信息的第一个字节错误时就能知道编码错误从而采取对应措施。 而已知第一个字节使用n位填充1,n+1位填充0的规则。所以非第一字节使用最少两个位10
即可与第一字节的0
(单字节)、110
(二字节)、1110
(三字节)、11110
(四字节)区分开。 现在我们再看看,如果错误的写成了11000010 10000011 101001111
11011100 10001111
,程序读取时错误的将该二进制识别为第一个字符两个字节(11000010 11000011
)。当读取第二个字符时(111001111 11011100 10001111
),即第三个字节(111001111
)时,根据第一字节规则,字符的第一字节永远不可能为10
,这时程序就知道这个编码错误了。就能进行对应的处理方式,提示错误或者跳过该字节继续往下读,如果继续往下读最多也就当前字符出错至少其他字符能正常读取,将错误率降至最低。「为什么多余位填充0而不是1」
试想一下,如果多余位填充1。汉字“祽” 对应的Unicode二进制为
11110010 1111101
,则对应的UTF-8二进制编码为11101111 10100101 10111101
。而汉字“㥽”对应的Unicode二进制为11100101 111101
,对应的UTF-8二进制编码为11101111 10100101 10111101
。那么在解码的时候UTF-8二进制11101111 10100101 10111101
。应该解码为汉字“祽”还是汉字“㥽”呢? 相反如果多余位填充0,那么汉字“祽”对应的UTF-8二进制编码为11100111 10100101 10111101
,而汉字“㥽”对应的UTF-8二进制编码为11100011 10100101 10111101
,就不会出现编码冲突的问题。