首页 > 技术文章 > 编码问题(上)

codefuturedalao 2021-01-28 16:11 原文

前言

一旦走上了编程之路,如果你不把编码问题搞清楚,那么它将像幽灵一般纠缠你整个职业生涯,各种灵异事件会接踵而来,挥之不去。只有充分发挥程序员死磕到底的精神你才有可能彻底摆脱编码问题带来的烦恼

——知乎 刘志军

你是否也曾遇到如下问题?

  • 控制窗口显示“烫烫烫烫烫烫烫”
  • 中文软件显示中文乱码
  • 打开文件里面都是???

如果你遇到了,那么恭喜你,遇到了计算机领域里难缠的编码问题,当你遇到这种问题时,总会上网查教程,教程中要么让你在文件前面加 \# -*- coding: utf-8 -*-;要么让你设置编辑器的编码方式,或者使用notepad++转一下编码方式;要么就让你更改windows操作系统的系统时区和语言设置,但你是否真的了解过这些操作到底代表着什么?如果你有摆脱编码困扰的想法,那本文有你想要的答案。

编码的历史

知其然,知其所以然

当计算机要与人进行交互时,显示器这个外设便登入了历史舞台,人们便遇到这样一个问题,确切的说,美国计算机学家遇到了这样的问题——“如何将字符存储在计算机中”,倘若你对计算机有一定了解的话,也一定知道计算机存储的数据以二进制来表示,如100表示数字4,每一个二进制位称为一个比特(bit),100便有3个比特,那么我们只需要设定一个映射,从比特序列映射到特定的字符,便可以实现计算机的数据到显示器字符的转换。比如我设定一个映射条目:100->'A',当计算机取出100的时候,知道应该在显示器上显示A这个字符,这种映射就称之为编码。

ASCII码

美国科学家也是这么想的,于是制定了一套字符编码,称之为ASCII码,用7位比特来映射128(2[^7])个字符,这128个字符中有33个控制字符如换行、回车和删除等,剩下的95个为打印字符。比如用0100000表示空格,1000001表示‘A’。由于计算机内存的最小寻址单元是一个字节,也就是8比特位,这就表明你存放数据最少得存放8比特位,假如说你要求显示器这里应当显示A,于是要将1000001放在特定的位置上,但是计算机最少写入8比特位,少了1比特没办法存怎么办?于是ASCII码规定,剩下的那个高位默认为0,也就是我存A的时候存01000001,完美地解决了该问题。

ASCII码的出现完美地解决了美国人民的需求,可计算机在世界范围得到应用时,各国人民,或者说使用者不同语言的人民都有将自己的文化符号显示在显示器的需求,那可如何是好呢?

编码百花齐放

上一章说到ASCII码中多出来一位,很多国家便打起了这一位的注意,他们于是将10000000-11111111范围内的比特进行编码,映射到自己国家的语言上,这些编码和ASCII码相互兼容,但彼此之间互不兼容。如美国机器上的A,在计算机中为01000001,到了法国,法国用自己的编码解释01000001还是A,于是可以看懂美国人发的文件,但如果法国发送一个编码在10000000-11111111范围内的符号给其他国家,便会造成乱码问题,比如,130在法语编码中代表了é,在希伯来语编码中却代表了字母Gimel (ג),在俄语编码中又会代表另一个符号。这便解开了乱码问题的序幕。

值得一提的是,我国在编码百花齐放时代也有自己的编码,下面进行详细的阐述。

中文编码三部曲

注:这里对中文编码只是简单概述,你可能之前听过或见过中文编码中的区位码,国标码、内码等概念,但本文并不涉及,详细信息可见参考资料[2]

  • GB2312

    对于有些国家用ASCII码不用的比特来编码自己的符号是够用的,显然我们国家是不够用的,中华文化博大精深,汉字的个数肯定不是一个字节所能编码的了,于是一个字节不行就加一个字节,我国推出了GB2312标准用两个字节对汉字进行编码,这种编码也称之为双字节字符集编码(Double Byte Character Set,DBCS),第一个字节称为“高位字节”(也称区字节),第二个字节称为低位字节(也称位字节),为了和ASCII码不冲突且保持兼容,GB2312规定两个字节的均不能低于10000001(0x81),于是高位字节范围0xA1-0xF7,低位字节范围0xA1-0xFE,可以试想,这样读取字节时,如果字节范围小于0x81,则是ASCII码,如果大于0x81,处于0xA1-0xF7之间,则说明是GB2312码,那么读取下一个字节,两个字节共同编码字符。

    有意思的是,GB2312编码中将原来ASCII里面本来就有的数字、标点、字母等符号又进行了重新编码,比如用0xa3e8编码字符h,而ASCII码已经采用0x68对h进行了编码,这不禁让人疑惑“为什么要浪费空间,对一个编码过的字符重新编码?”,这就要引入对全角、半角问题的讨论了。

    上图为输入法的界面,第四个框框表示当前输入字符是半角字符还是全角字符,半月形为半角、圆形为全角。

    早期的点阵显示器由于显示像素有限,当显示中文字符时,用显示英文字符的显示宽度(比如8像素的显示宽度)来显示中文字符显示不下,于是采用了更宽的空间(两倍的宽度)来显示中文字符,处于显示美观的考虑,为了令英文字符和中文字符对齐,于是设计了令英文字符、数字和标点等特殊字符在外观视觉上也占用一个汉字的宽度,这种字符采用两个字节进行编码,这些字符也称为全角字符。

    总结一下:

    • 半角字符就是ASCII码表进行编码的字母、标点和数字,他们在计算机中占一个字节,显示屏幕占据一个显示宽度
    • 全角字符就是指ASCII编码表以外的字符、标点和数字,在计算机占据多个字节,显示屏幕占据两个显示宽度(和汉字有相同的显示宽度)[3]

    例:

    中文半角:哈哈哈哈hhhh,,,。。。111

    中文全角:哈哈哈哈hhhh,,,。。。111

    英文半角:hhhh,,,...111

    英文全角:hhhh,,,...111

    可以很明显的看到半角字符和全角字符在屏幕上占据的空间不一样,全角的英文字符和中文字符占据相同的空间即对齐。

  • GBK

    GB2312编码收录的汉字覆盖中国大陆99.75%的使用频率,但对人名、古汉语等方面出现的罕用字却无法处理,比如对我国前总理***中的*就没有进行收录,于是GBK应运而生,规定仍采用两个字节进行编码,但只需要高位字节不小于10000001(0x81)即可,低位字节不做要求,这大大扩展了GBK标准可以编码的范围。于是高位字节的范围扩展到了0x81-0xFE,低位字节范围为0x40-0xFE(不包括0x7F),可见其范围扩大了许多,同时也兼容ASCII编码和GB2312编码。

  • GB18030

    GB18030是目前(公元2021年)我国最新的编码标准,与前面两位最大的不同是GB18030是变长编码,每个字可以由1个、2个或者4个字节编码,编码空间庞大,最多可定义161万个字符,且和GBK、GB2312、ASCII码都兼容。

    由下表可知,对于在ASCII码表的字符,采用一个字节进行编码,与ASCII码保持兼容,对于在GBK码表的字符,采用两个字节进行编码,与GBK保持兼容,对于GBK低位字节没用到的空间0x30-0x39,GB18030加以利用,编码了更多的字符。

    GB 18030 code points Unicode
    byte 1 (MSB) byte 2 byte 3 byte 4
    007F 128 0000007F
    80 invalid
    81FE 40FE except7F 23940 0080FFFF except D800DFFF
    8184 3039 81FE 3039 39420 0080FFFF except D800DFFF
    85 — (12600) reserved for future character extension
    868F — (126000) reserved for future ideographic extension
    unassigned D800DFFF
    90E3 3039 81FE 3039 1048576 1000010FFFF
    E4FC — (315000) reserved for future standard extension
    FDFE — (25200) user-defined
    FF invalid
    Total 1112064

好,到此为止中文编码我们就粗略的讲完了,希望读者不要过多纠缠细节,而是将这三种编码视为黑盒,涉及到的时候需要知道GB2312、GBK为二字节编码,与ASCII码兼容,GB18030为变长编码,且与GBK、GB2312保持兼容即可。因为我们还有更重要的编码需要讨论,即Unicode。

Unicode编码字符集

希望读者注意到,本节标题为Unicode编码字符集而不是Unicode编码,其中的不同,后文会详细叙述。

USC项目和Unicode项目

编码百花齐放对各国是好事,每个国家都可以在计算机上显示自己的语言,但对于全球来说,出现的各种字符集往往互不兼容,导致了乱码问题,为了避免混乱,ISO(国际标准化组织)发起了ISO 10646项目,名为“Universal Multiple Octet Coded Character Set”,简称UCS,希望将世界所有字符进行编码,形成一个统一的字符集,同时期,各大企业和组织成立了Unicode联盟,同样是为了寻求一个统一的字符集。一开始两个字符集并不兼容,但很快双方都意识到这个世界不需要两个互不兼容的字符集,于是友好地决定合并工作,项目各自独立存在,但两者保持兼容,不过由于Unicode这一名字好记,因而它使用更为广泛,成为了事实上的统一编码标准。

Unicode介绍

前面说过,Unicode只是一个字符集,并不是一个编码,这句话是什么意思?

我们回顾一下之前的编码,ASCII、GB2312、GBK等,在Unicode出现之前,所有的字符集都是和具体编码绑定在一起的,即符号对应的二进制数最终就是计算机上存储的数据,比如GBK中用0xced2来编码“我”,那么在计算机存储设备里面对应存储的就是数据0xced2,这样的编码系统通常通过简单的查表,也就是代码页就可以直接将字符映射为存储设备上的字符流了[4],例如下图

但这样做的缺点是,字符和字符流之间的耦合太紧密了,不适于进行字符集扩展。因此Unicode在设计上考虑了这一点,将字符集和字符编码方案分开。

于是,每个字符都在Unicode编码字符集中都能找到唯一的编码(称之为码点),但最终在存储设备上的却是具体的编码,如对Unicode字符“A”进行编码,Unicode中A的值为U+0041,UTF-8编码为0x41,而UTF-16(大端模式)编码的是0x0041。为此我们引入两个概念:编码字符集(Coded Character Set,CCS)和字符编码表(Character Encoding Form,CEF)[5]

  • Coded Character Set(CCS): 即编码字符集,给字符表里的抽象字符编上一个数字,也就是字符集合到一个整数集合的映射。这种映射称为编码字符集,Unicode 字符集就是属于这一层的概念;
  • Character Encoding Form (CEF) :即字符编码表,根据一定的算法,将编码字符集(CCS) 中字符对应的码点转换成一定长度的二进制序列,以便于计算机处理,这个对应关系被称为字符编码表,UTF-8、 UTF-16 属于这层概念;

因此可见之前的编码既属于CCS也属于CEF,而Unicode只属于CCS的范畴,下文我们不再称呼Unicode编码字符集,而是直接简写为Unicode,但希望读者不要混淆概念,牢记Unicode只是一个字符集,并不对应于存储设备上的编码,这一工作交给了UTF三兄弟去做。

Unicode为每一个字符分配一个唯一的字符编号即码点(Code Point),用U+紧跟着16进制数表示,如U+597D表示中文“好”,所有字符按照使用上的频繁度划分为17个平面(编号为0-16),每一个平面有2[^16] = 65536个字符,第一个平面称为*本平面(Basic Multilingual Plane,BMP),收集了使用最广泛的字符,码点范围从U+0000到U+FFFF,剩余的16个平面成为辅助平面(SMP),码点范围从U+010000到U+10FFFF。

Unicode编码三兄弟

前面说了Unicode完成了字符集合到整数(码点)集合的映射,下面需要实现字符对应的码点到二进制序列的映射,这种编码方式有很多如UTF-8、UTF-16、UTF-32等,这里的UTF是Unicode Transformation format的简写,下面来对这三个编码方式进行详细介绍。

UTF-32

虽然UTF-16是最早的Unicode字符集编码方式,但笔者认为从UTF-32介绍更符合我们的直觉。

既然Unicode有17个平面,码点范围从U+000000到U+10FFFF,那我们直接采用4字节来表示岂不美哉,多余的一字节直接补0,例如用0x0000 0000表示U+0000,0x0010 FFFF表示U+10FFFF,这样转换简单直观,但有个比较大的问题,就是太浪费空间了,大家经常用的都是英文字符,一个字节就足以表示,即使使用中文,在Unicode编码两个字节也足够了,UTF-32采用一刀切策略,使得原本很小的文件直接扩大了2倍-4倍,这是无法容忍的,因此我们需要一个更好的、更节省空间的编码方式。

UTF-16

UTF-16避开了UTF-32的缺点,采用”分治“的策略,对*本平面的码点用2字节表示,对辅助平面的码点用4字节表示。

对于*本平面的码点,UTF-16编码的结果和*本平面的码点一致,如0x0041表示U+0041,但对于辅助平面的我们不能直接补零,因为这样会造成一个问题:对于UTF-16编码的文件,当你读取两个字节的时候,你怎么知道这两个字节属于*本平面编码还是辅助平面编码?如果是*本平面的话,那太好了,这两个字节直接查Unicode表就可以知道对应字符了,如果是辅助平面那么就得再读取两个字节才能查Unicode表。所以直接编码的话会给我们判断*本平面编码还是辅助平面编码带来问题。因此我们采用下面方式编码:

在Unicode*本平面中U+D800到U+DFFF是空段,并没有用来编码字符,因此这个空段可以用来对辅助平面进行映射。辅助平面共16个,每个平面大小为65536,因此总空间为2[^20],我们需要20位来表示这么多字符,我们将20位分为两个部分,前面两个字节提供10位,后面两个字节提供10位,因此前面两个字节用0xD800-0xDBFF(10位)的空间编码,后两个字节用0xDC00-0xDFFF(10位)的空间进行编码,这就表明,当读取到两个字节时,如果在0xD800-0xDBFF范围内,则表明为辅助平面内的字符,再读取两个字节,这两个字节的范围一定在0xDC00-0xDFFF之间,然后通过解码规则还原码点,再根据Unicode表就可以找到对应字符了;如果字节范围不在0xD800-0xDBFF之间,则表明为*本空间内的字符,直接用读取的两个字节值根据Unicode表找到对应字符即可。

具体编码规则如下:

  • *本平面字符

    直接转换

  • 辅助平面字符[6]

    1. 第一步,Unicode码点值减去0x10000,结果在0x00000-0xFFFFF之间
    2. 前16比特加上0xD800,为高两个字节的值
    3. 后16比特加上0xDC00,为低两个字节的值

资料[7]中有具体的编码例子,读者可以前往查看,帮助理解。

说到了UTF-16,不得不提一嘴UCS-2,USC项目和Unicode项目章节提到过这两个组织同时期建立,目标相同,最后决定合并字符集。但UCS的开发进度快于Unicode,1990年就公布了第一套编码方法UCS-2,使用2个字节表示已经有码点的字符。(那个时候只有一个平面,就是*本平面,所以2个字节就够用了。)UTF-16编码迟至1996年7月才公布,明确宣布是UCS-2的超集,即*本平面字符沿用UCS-2编码,辅助平面字符定义了4个字节的表示方法。两者的关系简单说,就是UTF-16取代了UCS-2,或者说UCS-2整合进了UTF-16。所以,现在只有UTF-16,没有UCS-2[7]

USC-2编码方式:

图 5. UCS-2 编码方式

UTF-8

UTF-8相比于UTF-16来说,空间利用率更高,也是互联网上使用最广的编码方式。它可以使用1-4个字节来编码一个符号。其编码规则如下图所示:

  • 对于U+0000-U+007F的内容,用一个字节编码,字节最高位置为0,其余位和Unicode相同(可以发现,这样编码和ASCII码兼容)
  • 对于U+007F以上的内容,第一个字节的前n位置为1,然后紧跟一个0,其余字节的高两位置为10,剩下的位用Unicode码点进行填充。(n为编码字节数)

同样是变长编码,UTF-8不像UTF-16一样有类似的问题:当读取字节时,如何知道当前字节是否已经可以编码一个字符了,是否需要继续读取字节?因为UTF-8的第一个字节的高位1的个数已经告知了我们几个字节编码一个字符,因此当我们读取第一个字节后,就可以知道还需要读取几个字节,比如若第一个字节最高位为0,则不用读取字节,直接查Unicode表转换为字符;若高位字节为11110xxx,则还需要读取三个字节,然后才能查Unicode表转换字符。因此我们说UTF-8是面向字节流的,每次读取utf-8文件需要一个字节一个字节地读取,这对我们等会理解UTF-8不需要BOM很有帮助。

编码的确定

介绍完了编码的历史,不知道大家会不会有这样的疑问:“对于一个文件,我怎么知道他的编码方式是什么,如果我不知道他的编码方式,那么我如何解码呢?”,这个问题确实存在,因为我们打开一个文件,确实是无法提前得知文件的编码方式的,要知道,操作系统存储的有关文件的信息中并不包含“编码方式”这一个条目。

UTF编码内部确定

为了解决该问题,Unicode规定在文件头部需要出现BOM(Byte Order Mark,字节顺序标记)来标识文件编码和大小端存储方式。

  • UTF-8 不需要 BOM 来表明字节顺序,但可以用 BOM 来表明编码方式。字符 “零宽无间断间隔” 的 UTF-8 编码是 EF BB BF,如果接收者收到以 EF BB BF 开头的字节流,就知道这是 UTF-8 编码了。
  • Big-Endian(BE)即大端序,UTF-16(BE)以 FEFF 作为开头字节,UTF-32(BE)以 00 00 FE FF 作为开头字节;
  • Little-Endian(LE)即小端序,UTF-16(LE)以 FFFE 作为开头字节,UTF-32(LE)以 FF FE 00 00 作为开头字节[5]

关于大小端,很多文章均有说明,此处不予阐述。

这里值得探讨的是为什么UTF-8不需要BOM来表示字节顺序,而UTF-16和UTF-32均需要?UTF-32就不用说了,四个字节四个字节地存储,肯定会有大端小端问题,因此需要BOM来表示字节顺序;UTF-16也是需要两个字节两个字节地存储和读取,如字符

推荐阅读