关于字符编码,我提了三个问题,你闭眼能回答吗?
本文遵循 CC协议2.5 授权转载
当我们打开软件,看到乱码的时候,我们知道肯定是编码错误,换一个编码方式就可以解决。但是更多的细节呢?很多人都忽略了。
今天找了一篇很通俗易懂的文章,来讲解编码的历史和设计原理,让你了解更多字符编码相关的细节。虽然此文发布时间很早了,但是就像我说的,这个世界,底层的一些技术和知识,是不会轻易变动的,字符编码就是其中之一。
本文算是比较长的,带着问题去读,是一个让你读下去的学习方式,这里简单提三个和字符编码相关的问题。
为什么不同的 OEM 字符集解析字符,可能不一致,但是对 ASCII 字符集中包含的字符,解析却是一致的?
如何理解 Unicode、UTF-8、UTF-16 之间的关系?
我们知道,造成乱码出现的原因,是因为使用了错误的字符编码去解码字节流,那么如果碰到 “�????????” 这样的乱码,依据此数据,再提供当前的编码以及正确的编码,还有可能还原其最初的数据吗?为什么?
答案都在文章中,先思考以上问题,再读下去。
字符编码的问题看似很小,经常被技术人员忽视,但是很容易导致一些莫名其妙的问题。这里总结了一下字符编码的一些普及性的知识,希望对大家有所帮助。
还是得从 ASCII 码说起
说到字符编码,不得不说 ASCII 码的简史。
计算机一开始发明的时候是用来解决数字计算的问题,后来人们发现,计算机还可以做更多的事,例如:文本处理。
但由于计算机只能识“数”,因此人们必须告诉计算机哪个数字来代表哪个特定字符,例如 65 代表字母 ‘A’,66 代表字母 ‘B’,以此类推。
但是计算机之间字符与数字的对应关系必须要一致,否则就会造成同一组数字在不同计算机上显示出来的字符不一样。
因此美国国家标准协会 ANSI 制定了一个标准,规定了常用字符的集合,以及每个字符对应的编号,这就是 ASCII 字符集(Character Set),也称 ASCII 码。
当时的计算机普遍使用 8 比特字节,作为最小的存储和处理单元,加之当时用到的字符也很少,26 个大小写英文字母以及数字,再加上其他常用符号,也就不到 100 个。因此使用 7 个比特位,就可以高效的存储和处理 ASCII 码,剩下最高位 1 比特,被用作一些通讯系统的奇偶校验。
注意,字节代表系统能够处理的最小单位,不一定是 8 比特。只是现代计算机的事实标准就是用 8 比特来代表一个字节。
在很多技术规格文献中,为了避免产生歧义,更倾向于使用 8 位组(Octet)而不是字节(Byte)这个术语来强调 8 个比特的二进制流。下文中为了便于理解,我会延用大家熟悉的“字节”这个概念。
ASCII 字符集由 95 个可打印字符(0x20-0x7E)和 33 个控制字符(0x00-0x19,0x7F)组成。
可打印字符用于显示在输出设备上,例如屏幕和打印纸,控制字符用于向计算机发出一些特殊指令,例如 0x07 会让计算机发出哔的一声,0x00 通常用于指示字符串的结束,0x0D 和 0x0A 用于指示打印机的打印针头退到行首(回车)并移到下一行(换行)。
那时候,字符编解码系统非常简单,就是简单的查表过程。例如将字符序列编码为二进制流写入存储设备,只需要在 ASCII 字符集中依次找到字符对应的字节,然后直接将该字节写入存储设备即可。解码二进制流的过程也是类似。
OEM 字符集的衍生
当计算机开始发展起来的时候,人们逐渐发现,ASCII 字符集里那可怜的 128 个字符已经不能再满足他们的需求了。
人们就在想,一个字节能够表示的数字(编号)有 256 个,而 ASCII 字符只用到了 0x00~0x7F,也就是占用了前 128 个,后面 128 个数字并没有被使用,因此很多人打起了后面这 128 个数字的主意。
可是问题在于,很多人同时有这样的想法,但是大家对于 0x80-0xFF 这后面的 128 个数字分别对应什么样的字符,却有各自的想法。这就导致了当时销往世界各地的机器上出现了大量各式各样的 OEM 字符集。
下面这张表是 IBM-PC 机推出的,其中一个 OEM 字符集,字符集的前 128 个字符和 ASCII 字符集的基本一致(为什么说基本一致呢,是因为前 32 个控制字符在某些情况下会被 IBM-PC 机当作可打印字符解释),后面 128 个字符空间,加入了一些欧洲国家用到的重音字符,以及一些用于制表符。
事实上,大部分 OEM 字符集是兼容 ASCII 字符集的,也就是说,大家对于 0x00~0x7F 这个范围的解释基本是相同的,而对于后半部分 0x80~0xFF 的解释却不一定相同。甚至有时候同样的字符在不同 OEM 字符集中对应的字节也是不同的。
不同的 OEM 字符集导致人们无法跨机器交流各种文档。
例如职员甲发了一封简历 résumés 给职员乙,结果职员乙看到的却是 rλsumλs,因为 é 字符在职员甲机器上的 OEM 字符集中对应的字节是 0x82
,而在职员乙的机器上,由于使用的 OEM 字符集不同,对 0x82
字节解码后得到的字符却是 λ。
多字节字符集(MBCS)和中文字符集
上面我们提到的字符集都是基于单字节编码,也就是说,一个字节翻译成一个字符。这对于拉丁语系国家来说可能没有什么问题,因为他们通过扩展第 8 个比特,就可以得到 256 个字符了,足够用了。
但是对于亚洲国家来说,256 个字符是远远不够用的。因此这些国家的人为了用上电脑,又要保持和 ASCII 字符集的兼容,就发明了多字节编码方式,相应的字符集就称为多字节字符集。例如中国使用的就是双字节字符集编码(DBCS,Double Byte Character Set)。
对于单字节字符集来说,代码页中只需要有一张码表即可,上面记录着 256 个数字代表的字符。程序只需要做简单的查表操作就可以完成编解码的过程。
代码页是字符集编码的具体实现,你可以把他理解为一张“字符-字节”映射表,通过查表实现“字符-字节”的翻译。下面会有更详细的描述。
而对于多字节字符集,代码页中通常会有很多码表。那么程序怎么知道该使用哪张码表去解码二进制流呢?答案是,根据第一个字节来选择不同的码表进行解析。
例如目前最常用的中文字符集 GB2312,涵盖了所有简体字符,以及一部分其他字符; GBK(K 代表扩展的意思)则在 GB2312 的基础上,加入了对繁体字符等其他非简体字符(GB18030 字符集不是双字节字符集,我们在讲 Unicode 的时候会提到)。这两个字符集的字符都是使用 1-2 个字节来表示。
Windows 系统采用 936 代码页来实现对 GBK 字符集的编解码。在解析字节流的时候,如果遇到字节的最高位是 0 的话,那么就使用 936 代码页中的第 1 张码表进行解码,这就和单字节字符集的编解码方式一致了。
当字节的高位是 1 的时候,确切的说,当第一个字节位于0x81
–0xFE
之间时,根据第一个字节不同,找到代码页中的相应的码表,例如当第一个字节是 0x81
,那么对应 936 中的下面这张码表:
(关于 936 代码页中完整的码表信息,参见MSDN:http://msdn.microsoft.com/en-us/library/cc194913%28v=MSDN.10%29.aspx.)
按照 936 代码页的码表,当程序遇到连续字节流0x81 0x40
的时候,就会解码为“丂”字符。
ANSI标准、国家标准、ISO标准
不同 ASCII 衍生字符集的出现,让文档交流变得非常困难,因此各种组织都陆续进行了标准化流程。
例如美国 ANSI 组织制定了 ANSI 标准字符编码(注意,我们现在通常说到 ANSI 编码,通常指的是平台的默认编码,例如英文操作系统中是 ISO-8859-1,中文系统是 GBK),ISO 组织制定的各种 ISO 标准字符编码,还有各国也会制定一些国家标准字符集,例如中国的 GBK,GB2312 和 GB18030。
操作系统在发布的时候,通常会往机器里预装这些标准的字符集,还有平台专用的字符集,这样只要你的文档是使用标准字符集编写的,通用性就比较高了。
例如你用 GB2312 字符集编写的文档,在中国大陆内的任何机器上都能正确显示。同时,我们也可以在一台机器上阅读多个国家不同语言的文档了,前提是本机必须安装该文档使用的字符集。
Unicode的出现
虽然通过使用不同字符集,我们可以在一台机器上查阅不同语言的文档,但是我们仍然无法解决一个问题:在一份文档中显示所有字符。为了解决这个问题,我们需要一个全人类达成共识的巨大的字符集,这就是 Unicode 字符集。
1. Unicode字符集概述
Unicode 字符集涵盖了目前人类使用的所有字符,并为每个字符进行统一编号,分配唯一的字符码(Code Point)。
Unicode 字符集将所有字符按照使用上的频繁度划分为 17 个层面(Plane),每个层面上有 216=65536 个字符码空间。
其中第 0 个层面 BMP,基本涵盖了当今世界用到的所有字符。其他的层面,要么是用来表示一些远古时期的文字,要么是留作扩展。
我们平常用到的 Unicode 字符,一般都是位于 BMP 层面上的。目前 Unicode 字符集中尚有大量字符空间未使用。
2. 编码系统的变化
在 Unicode 出现之前,所有的字符集都是和具体编码方案绑定在一起的,都是直接将字符和最终字节流绑定死了,例如 ASCII 编码系统规定使用 7 比特来编码 ASCII 字符集;GB2312 以及 GBK 字符集,限定了使用最多 2 个字节来编码所有字符,并且规定了字节序。这样的编码系统通常用简单的查表,也就是通过代码页就可以直接将字符映射为存储设备上的字节流了。例如下面这个例子:
这种方式的缺点在于,字符和字节流之间耦合得太紧密了,从而限定了字符集的扩展能力。假设以后火星人入住地球了,要往现有字符集中加入火星文就变得很难甚至不可能了,而且很容易破坏现有的编码规则。
因此 Unicode 在设计上考虑到了这一点,将字符集和字符编码方案分离。
也就是说,虽然每个字符在 Unicode 字符集中都能找到唯一确定的编号(字符码,又称 Unicode 码),但是决定最终字节流的却是具体的字符编码。例如同样是对 Unicode 字符 “A” 进行编码,UTF-8 (小端模式)字符编码得到的字节流是 0x41
,而 UTF-16 (大端模式)得到的是 0x00 0x41
。
3. 常见的Unicode编码
UCS-2/UTF-16
如果要我们来实现 Unicode 字符集中 BMP 字符的编码方案,我们会怎么实现?
由于 BMP 层面上,有 216=65536 个字符码,因此我们只需要两个字节,就可以完全表示这所有的字符了。
举个例子,“中” 的 Unicode 字符码是 0x4E2D
(01001110 00101101),那么我们可以编码为 01001110 00101101(大端)或者 00101101 01001110 (小端)。
UCS-2 和 UTF-16 对于 BMP 层面的字符均是使用 2 个字节来表示,并且编码得到的结果完全一致。不同之处在于,UCS-2 最初设计的时候只考虑到 BMP 字符,因此使用固定 2 个字节长度,也就是说,他无法表示 Unicode 其他层面上的字符,而 UTF-16 为了解除这个限制,支持 Unicode 全字符集的编解码,采用了变长编码,最少使用 2 个字节,如果要编码 BMP 以外的字符,则需要 4 个字节结对,这里就不讨论那么远,有兴趣可以参考维基百科:UTF-16/UCS-2。
Windows 从 NT 时代开始就采用了 UTF-16 编码,很多流行的编程平台,例如 .Net,Java,Qt 还有 Mac 下的 Cocoa 等都是使用 UTF-16 作为基础的字符编码。例如代码中的字符串,在内存中相应的字节流就是用 UTF-16 编码过的。
UTF-8
UTF-8 应该是目前应用最广泛的一种 Unicode 编码方案。
由于 UCS-2/UTF-16 对于 ASCII 字符使用两个字节进行编码,存储和处理效率相对低下,并且由于 ASCII 字符经过 UTF-16 编码后得到的两个字节,高字节始终是 0x00
,很多 C 语言的函数都将此字节视为字符串末尾从而导致无法正确解析文本。因此一开始推出的时候遭到很多西方国家的抵触,大大影响了 Unicode 的推行。后来聪明的人们发明了 UTF-8 编码,解决了这个问题。
UTF-8 编码方案采用 1-4 个字节来编码字符,方法其实也非常简单。
(上图中的 x 代表 Unicode 码的低 8 位,y 代表高 8 位)
对于 ASCII 字符的编码使用单字节,和 ASCII 编码一摸一样,这样所有原先使用 ASCII 编解码的文档就可以直接转到 UTF-8 编码了。
对于其他字符,则使用 2-4 个字节来表示,其中,首字节前置 1 的数目代表正确解析所需要的字节数,剩余字节的高 2 位始终是 10。例如首字节是 1110yyyy,前置有 3 个 1,说明正确解析总共需要 3 个字节,需要和后面 2 个以 10 开头的字节结合才能正确解析得到字符。
关于UTF-8的更多信息,参考维基百科:UTF-8。
GB18030
任何能够将 Unicode 字符映射为字节流的编码,都属于 Unicode 编码。
中国的 GB18030 编码,覆盖了 Unicode 所有的字符,因此也算是一种 Unicode 编码。只不过他的编码方式并不像 UTF-8 或者 UTF-16 一样,将 Unicode 字符的编号通过一定的规则进行转换,而只能通过查表的手段进行编码。
关于 GB18030 的更多信息,参考:GB18030。
4. Unicode相关的常见问题
Unicode是两个字节吗?
Unicode 只是定义了一个庞大的、全球通用的字符集,并为每个字符规定了唯一确定的编号,具体存储为什么样的字节流,取决于字符编码方案。
推荐的 Unicode 编码是 UTF-16 和 UTF-8。
带签名的 UTF-8 指的是什么意思?
带签名指的是字节流以 BOM 标记开始。很多软件会“智能”的探测当前字节流使用的字符编码,这种探测过程出于效率考虑,通常会提取字节流前面若干个字节,看看是否符合某些常见字符编码的编码规则。
由于 UTF-8 和 ASCII 编码对于纯英文的编码是一样的,无法区分开来,因此通过在字节流最前面添加 BOM 标记可以告诉软件,当前使用的是 Unicode 编码,判别成功率就十分准确了。
但是需要注意,不是所有软件或者程序都能正确处理 BOM 标记,例如 PHP 就不会检测 BOM 标记,直接把它当普通字节流解析了。因此如果你的 PHP 文件是采用带 BOM 标记的 UTF-8 进行编码的,那么有可能会出现问题。
Unicode 编码和以前的字符集编码有什么区别?
早期字符编码、字符集和代码页等概念都是表达同一个意思。例如 GB2312 字符集、 GB2312 编码,936 代码页,实际上说的是同个东西。
但是对于 Unicode 则不同, Unicode 字符集只是定义了字符的集合和唯一编号,Unicode 编码,则是对 UTF-8、UCS-2/UTF-16 等具体编码方案的统称而已,并不是具体的编码方案。所以当需要用到字符编码的时候,你可以写 gb2312,codepage936,utf-8,utf-16,但请不要写 unicode(看过别人在网页的 meta 标签里头写 charset=unicode,有感而发)。
乱码问题
乱码指的是程序显示出来的字符文本无法用任何语言去解读。一般情况下会包含大量 � 或者 ?。乱码问题是所有计算机用户或多或少会遇到的问题。
造成乱码的原因就是因为,使用了错误的字符编码去解码字节流,因此当我们在思考任何跟文本显示有关的问题时,请时刻保持清醒:当前使用的字符编码是什么。只有这样,我们才能正确分析和处理乱码问题。
例如最常见的网页乱码问题。如果你是网站技术人员,遇到这样的问题,需要检查以下原因:
服务器返回的响应头中, Content-Type 没有指明字符编码
网页内是否使用 META HTTP-EQUIV 标签指定了字符编码
网页文件本身存储时,使用的字符编码和网页声明的字符编码是否一致
注意,网页解析的过程,如果使用的字符编码不正确,还可能会导致脚本或者样式表出错。
不久前看到某技术论坛有人反馈,WinForm 程序使用 Clipboard 类的 GetData 方法去访问剪切板中的 HTML 内容时会出现乱码的问题,我估计也是由于 WinForm 在获取 HTML 文本的时候没有用对正确的字符编码导致的。
Windows 剪贴板只支持 UTF-8 编码,也就是说你传入的文本都会被 UTF-8 编解码。这样一来,只要两个程序都是调用 Windows 剪切板 API 编程的话,那么复制粘贴的过程中不会出现乱码。除非一方在获取到剪贴板数据之后使用了错误的字符编码进行解码,才会得到乱码(我做了简单的 WinForm 剪切板编程实验,发现 GetData 使用的是系统默认编码,而不是 UTF-8 编码)。
关于乱码中出现�或者?,这里需要额外提一下,当程序使用特定字符编码,解析字节流的时候,一旦遇到无法解析的字节流时,就会用�或者?来替代。
因此,一旦你最终解析得到的文本包含这样的字符,而你又无法得到原始字节流的时候,说明正确的信息已经彻底丢失了,尝试任何字符编码,都无法从这样的字符文本中还原出正确的信息来。
必要的术语解释
字符集(Character Set),字面上的理解就是字符的集合,例如 ASCII 字符集,定义了 128 个字符;GB2312 定义了 7445 个字符。而计算机系统中提到的字符集准确来说,指的是已编号的字符的有序集合(不一定是连续)。
字符码(Code Point)指的就是字符集中每个字符的数字编号。例如 ASCII 字符集用 0-127 这连续的 128 个数字分别表示 128 个字符;GBK 字符集使用区位码的方式为每个字符编号,首先定义一个 94X94 的矩阵,行称为“区”,列称为“位”,然后将所有国标汉字放入矩阵当中,这样每个汉字就可以用唯一的“区位”码来标识了。例如“中”字被放到 54 区第 48 位,因此字符码就是 5448。而 Unicode 中将字符集按照一定的类别划分到 0~16 这 17 个层面(Planes)中,每个层面中拥有 216=65536 个字符码,因此 Unicode 总共拥有的字符码,也即是 Unicode 的字符空间总共有 17*65536=1114112。
编码的过程是将字符转换成字节流。而解码的过程是将字节流解析为字符。
字符编码(Character Encoding)是将字符集中的字符码,映射为字节流的一种具体实现方案。例如 ASCII 字符编码规定使用单字节中,低位的 7 个比特去编码所有的字符。假如 ‘A’ 的编号是 65,用单字节表示就是 0x41
,因此写入存储设备的时候就是b’01000001’
。GBK 编码则是将区位码(GBK 的字符码)中的区码和位码的分别加上 0xA0
(160)的偏移(之所以要加上这样的偏移,主要是为了和 ASCII 码兼容),例如刚刚提到的“中”字,区位码是 5448,十六进制是 0x3630
,区码和位码分别加上 0xA0
的偏移之后就得到 0xD6D0
,这就是“中”字的 GBK 编码结果。
代码页(Code Page)一种字符编码具体形式。早期字符相对少,因此通常会使用类似表格的形式,将字符直接映射为字节流,然后通过查表的方式,来实现字符的编解码。现代操作系统沿用了这种方式。例如 Windows 使用 936 代码页、Mac 系统使用 EUC-CN 代码页实现 GBK 字符集的编码,名字虽然不一样,但对于同一汉字的编码肯定是一样的。
大小端的说法源自《格列佛游记》。我们知道,鸡蛋通常一端大一端小,小人国的人们,对于剥蛋壳时应从哪一端开始剥起有着不一样的看法。同样,计算机界对于传输多字节字(由多个字节来共同表示一个数据类型)时,是先传高位字节(大端)还是先传低位字节(小端)也有着不一样的看法,这就是计算机里头大小端模式的由来了。无论是写文件还是网络传输,实际上都是往流设备进行写操作的过程,而且这个写操作是从流的低地址向高地址开始写(这很符合人的习惯),对于多字节字来说,如果先写入高位字节,则称作大端模式。反之则称作小端模式。也就是说,大端模式下,字节序和流设备的地址顺序是相反的,而小端模式则是相同的。一般网络协议都采用大端模式进行传输,windows 操作系统采用 Utf-16 小端模式。
再推荐一个专栏,作者是微软 Azure 资深工程师,有近十年云计算的工作经验,才能让他把 Linux 系统原理和常见的性能问题,打磨总结到可以以一个专栏的形式发布出来。
优惠期只需要 ¥68,现在扫码支付极客时间 App 还返现 6 元。一顿饭钱换十年的经验,没啥好说的,有需要扫码自取。↓↓↓
「」👈推荐我的知识星球,一年 50 个优质问题,上桌联机学习。
公众号后台回复成长『成长』,将会得到我准备的学习资料,也能回复『加群』,一起学习进步;你还能回复『提问』,向我发起提问。
推荐阅读:
所谓的“寒冬”,其实是在倒逼我们技术升级|QUIC 确定为 HTTP/3 | 分词,科普及解决方案| 图解:HTTP 范围请求 | 小程序学习资料 |HTTP 内容编码 | 辅助模式实战 | 辅助模式玩出花样 | 小程序 Flex 布局