字符编码笔记
字符编码对每个程序员来说,都应该是必须了解的,因为字符编码、解码时出现问题,可能引起一些非预期的行为,比如乱码、代码执行错误等等。通过网上搜索了一些参考资料、博客,大致整理了一些关于字符编码的知识,希望对自己和有缘看到此文的朋友们有所帮助。
在正式读下文前,先对下文使用的几个术语的含义有个简单理解:
- 字符集:简单理解就是字符的集合,准确来说,指的是已编号的字符的有序集合(不一定是连续);
- 字符码:又称码位,字符集中每个字符的数字编号;
- 编码:将字符转换成字节流的过程;
- 解码:将字节流解析为字符的过程;
- 字符编码:将字符集中的字符码映射为字节流的一种具体实现方案;
ASCII 编码
计算机中最基本的描述状态的概念是位,取值只能是0和1,然后通过不同的位的组合来达到存储数据,表达数据的作用,其中,“位”又有一个称呼叫做“比特”。而在存储和处理数据时,普遍使用8个比特作为最小的存储和处理单元,而这8个比特组成的单元称为字节。
ASCII 字符集是由美国国家标准协会ANSI 指定的标准,旨在规定常用字符的集合以及每个字符对应的编码,并以此希望统一字符-字节的对应关系,避免同一段字节在不同计算机显示出来的字符不一致的问题。
ASCII 字符集使用一个字节来包含95个可打印字符(0x20-0x7E)和33个控制字符(0x00-0x19、0x7F),总共128 个字符,而这128个字符(2的7次方,对应的编码十六进制区段是:0x00-0x7F)占用了一个字节的后7位。也正因为这个原因,ASCII 规定最前面的1位统一定为0,只允许使用最后的7位来对应字符。
OEM 字符集
由于ASCII 编码中包含的128 个字符不能满足所有人群的需要,比如,这128 个字符就不包含任一汉字,于是人们就打起了ASCII 中使用的那一个字节的第一位的主意,以求能多包含128个字符(对应的编码十六进制区段是:0x80-OxFF)。问题在于,很多人都同时有这种想法,导致这新的128 个字符码对应的字符并不统一。
规定新的128 个字符码是各个原始设备制造商,所以,也称这些新的字符集为OEM 字符集。
由于OEM 字符集扩展的是字节的最高位,所以,理论上,这些字符集都是兼容ASCII 字符集的。
不同厂商使用不同的OEM 字符集,会造成A 用户使用X 厂商的OEM 字符集编码写了一份文档并传给了B,B 使用Y 厂商的OEM 字符集编码来读取的话,有可能看到的文本跟A 用户原本写的是不一样的。
ANSI 标准、国家标准、ISO 标准
- ANSI 标准指美国ANSI 组织制定的ANSI 标准字符编码;
- 国家标准,指各国对自身使用的字符制定的一些标准字符集,例如中国的GB2312、GBK、GB18030(本文对GB 字符集没有深入的研究和解读,读者有兴趣不妨先自行查阅相关资料);
- ISO 标准指ISO 组织制定的各种ISO 标准字符编码;
如果我们想查看使用不同语言的文档,就必须在本机安装该文档使用的字符集,才能将字节流正确对应上字符。
Unicode 字符集
虽然,我们能够通过使用不同字符集来查看不同语言的文档,但更好的解决思路应该是,能有一个字符集,这个字符集包含了所有字符,也就是只要我安装了这个字符集,就能显示预期的正确字符。
而解决这个问题的字符集,就是Unicode 字符集。
Unicode 字符集是由Xerox、Apple 等软件制造商组成的Unicode(统一码)联盟制定的标准。 而同期,国际标准化组织(ISO)创建了ISO 10646 这个项目,而这个项目也是为了创建一个包含所有字符的字符集。在1991 年前后,这两个项目组意识到世界上不需要两个互不兼容的字符集,于是开始合并双方的工作结果。 最终的合并结果是,这两个组织的字符集互相兼容,也就是,每个字符在两个字符集对应的字符码是一样的,同时又是独立发展成Unicode 字符集和UCS 字符集。
到2014.12,Unicode 的版本到7.0,一共收入了109449 个字符,其中中日韩文字为74500个。
Unicode 以平面为单位定义字符,每个平面可以存放65536个(两个字节长度,2的16次方)字符。目前,一共有17个平面(2的5次方),即Unicode 字符集当前包含有2的21 次方个字符。
其中第0个平面被称为基本平面,缩写BMP,也就是最前面的65536 个字符,基本上涵盖了现时使用度最广的字符。
这里需要注意三点:
- Unicode 仅仅是一个字符集,它只规定了字符的二进制字符码,并没有规定这个二进制字符码应该如何存储以及传输,而这些二进制字符码与字节流在本质上没有任何关系,可以理解为,前者是编号,后者是实际的数据;
- Unicode 向下兼容ASCII 编码,也就是说,ASCII 字符集中的字符二进制码与Unicode 中的字符码是一致的;
- Unicode 不兼容GB 码;
Unicode 仅仅是一个字符集,意味着它想做的工作只是尽量多的包含字符,然后给这些字符一个唯一标识,但并不规定这些字符与字节流的转化关系,这样,就能保证字符集本身的扩展能力,而字符与字节流的转化关系就交由编码方案来完成,也就下文提到的UCS-2、UTF16、UTF8。
常见的Unicode 编码
UCS-2/UTF-16
UTF-16 使用可变字节进行字符编码,对于BMP 内的字符使用两个字节,也就是16 个二进制位来编码,编码方法就是直接等于字符在字符集中编码的二进制,比如:“中”的Unicode字符码是0x4E2D(01001110 00101101),那么UTF-16 编码为01001110 00101101(大端)或者00101101 01001110 (小端)。
当然,如果嫌字节流的表达方式太长,也可以转成4 个十六进制字符表示,这四个字符当然也会和Unicode 中对应的字符码是一致的。
而对于辅助平面上的字符,UTF-16 则使用四个字节来编码,四个字节理论上能编码4294967296 个字符,而上文提即的目前为止,Unicode 收集包含的字符数量在十万个左右,所以,使用四个字节编码完全没压力。
那么问题又来了,从UTF-16 编码后字节流来看怎么知道是要将接下来两个字节还是四个字节当成一个字符来看待呢?解决办法是对于四字节字符使用代理码对编码。详细情况是,在BMP 内,从U+D800到U+DFFF是一个空段,即这些码点不对应任何字符。因此,这个空段可以用来映射辅助平面的字符。
具体来说,除去BMP 外,Unicode还剩16(2的四次方)个平面,所以辅助平面的字符位共有2的20次方个,也就是说,对应这些字符至少需要20个二进制位。UTF-16将这20位拆成两半,前10位映射在U+D800到U+DBFF(空间大小为2的10次方),称为高位(H),后10位映射在U+DC00到U+DFFF(空间大小为2的10次方),称为低位(L)。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示,其中第一个字符字节流处于0xD800 到0xDBFF 区段,第二个字符字节流处于0xDC00 到0xDFFF 区段。比如:U+1D306(𝌆),在UTF-16 中只能使用两个16为码元来编码:0xD834 0xDF06。
所以,当程序进行UTF-16 解码时,如果发现字节流的区段符合上面的规则,就会将连续的两个字符当作一个辅助平面字符编码来处理。这里要提醒一点就是,一个代理码对只表示一个字符。
提到UTF-16 编码,就不得不提UCS-2 编码。还记得上文提及的UCS 字符集吗?在该字符集刚推出规范时,所收纳的字符数量可以用两个字节的字符码范围来一一对应,于是就相应推出了一种编码方案:UCS-2,即使用两个字节,也就是16 个二进制位来对UCS 字符集内的字符进行编码和解码,编码方法跟UTF-16 编码BMP 字符的方法是一样的。也由于前面说的历史原因,UCS-2 对辅助平面字符的编码就无能为力了,容易导致字符解码错误等问题。
据笔者所了解的,UCS-2 编码方案的使用场景有一个就是Javascript 语言本身使用的字符编码方案。UCS 字符集、UCS-2 字符编码先于Unicode 字符集、UTF-16 编码方案推出规范,而Javascript 语言又刚好在这两个时间段之间设计并完成解析工作的,导致,Javascript 从一出生开始,就只有UCS-2 字符编码方案使用,所以,造成后来Javascript 引擎本身对辅助平面字符的解码会出现非预期行为,这个打算另起一篇文章讨论下,所以,这里先有个这种印象即可。
另外说下,由于UCS-2 编码方案较UTF-16 编码方案早推出,同时鉴于UCS 字符集以及Unicode 字符集从使用层面上是一致的关系来看,可以认为UTF-16 是UCS-2 的超集。
UTF-8
上面的UTF-16 编码方案存在一个问题,那就是浪费资源。可以想象,如果用两个字节的长度去编码一个本来使用一个字节就可以完成的编码的字符,那就会造成高位的那个字节流浪费了。
举个例子,字符‘A’的Unicode 字符码是U+0041,如果用ASCII 编码,则编码后的字节流是Ox41,如果用UTF-16 编码,则编码后的字节流是Ox0041,明显,高位的字节其实都是0,白白浪费了这一字节的内容。
UTF-16 这种存在浪费字节流的编码方案,会降低存储和处理字符的效率,个人觉得更重要的一点是,对于互联网来说,这是在赤裸裸的浪费流量,提高页面的加载成本,所以,在网络传输上面,UTF-16 编码显然不合适。
后来,聪明的人们创造出了一种新的编码方案,UTF-8 编码。
UTF-8 同样使用可变字节数对字符进行编码,它使用1~4 个字节来编码一个字符,不同的字符使用不同数量的字节。
UTF-8 编码规则有两条:
- 对于单字节符号,字节最高位设为0,后面的7 位为字符的Unicode 二进制字符码,因为这条规则,所以UTF-8 与ASCII 编码是兼容的;
- 对于n 字节的符号(n>1),最高位字节前n 位都设为1,第n+1 位设为0,后面所有字节的前两个高位设为10,剩下的没有提及的二进制位,则先将字符的Unicode 字符码转成二进制表示,从最后一个字节往前逐位填充,多余的用0 补充;
下表总结了编码规则,字符x 表示可用编码的位:(注:该表格参考自阮一峰老师的笔记,见下面的参考资料)
| 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 |
Unicode BOM 标记
BOM 全称是Byte Order Mark,也就是字节序标记。这个标记有什么用呢,同时又用在哪里呢?
由于不同的CPU 在对多字节数字符的编码解码方式上有所不同,一种是高位字节在前,低位字节在后,这种称为Big endian(大端),另一种是低位字节在前,高位字节在后,这种称为Little endian(小端)。所以,BOM 标记的作用就是显式声明当前使用大端还是小端存储,以确保解码时不会出错。
在Unicode 各种编码方式中,UTF-16、UTF-32(定长编码方案,固定使用四字节编码,编码方案与UTF-16 类似,但由于很浪费字节流,所以使用面很窄,不详细说了)由于使用双字节或四字节来编码字符,所以,就需要使用BOM 来声明编码字节序,以方便在解析UTF-16 编码的文本时,清楚知道每个编码单元的字节序。举个例子:
汉字“源”,Unicode码是6E90,UTF-16 需要用两个字节存储,一个字节是6E,另一个字节是90。存储的时候,6E在前,90在后,就是Big endian方式;90在前,6E在后,就是Little endian方式。在不通过BOM 标记字节序情况下,如果使用小端字节序去解析大端字节序的“严”字时,将得到这样的字节序:Ox906E,这是另一个汉字“遮”,看,出问题了吧!
那BOM 标记在文本中到底怎么出现的呢?
在Unicode 编码中有一个叫做"ZERO WIDTH NO-BREAK SPACE"的字符,它的编码是FEFF。而FFFE在Unicode 中是不存在的字符,所以不应该出现在实际传输中。所以,Unicode 规范建议我们在传输字节流前,先传输字符“ZERO WIDTH NO-BREAK SPACE”以声明字节序。这样如果接收者收到FEFF,就表明这个字节流是大端的;如果收到FFFE,就表明这个字节流是小端的。因此字符"ZERO WIDTH NO-BREAK SPACE"又被称作BOM。
有同学问了,UTF-8 也可能是多字节编码,那UTF-8 需不需要这个BOM 标记呢?理论上,不需要。回顾上面说的UTF-8 编码规则,虽说是多字节编码,但其实每个字节都是独立存在的,只是通过字节前几比特位来决定这是一个字符的高位字节还是中间的字节。所以,UTF-8 是不需要BOM 标记的,但可以用BOM来表明编码方式。字符"ZERO WIDTH NO-BREAK SPACE"的UTF-8编码是EF BB BF,所以如果接收者收到以EF BB BF开头的字节流,就知道这是UTF-8编码了。
Windows 笔记本就是使用BOM来标记文本文件的编码方式的。
字符编码之于程序员
在查了几篇博客资料后,我对代码的工作流理解如下,我们编写的程序从本质上可以看成都是文本,字符串。代码在保存到硬盘时,会将我们编写的字符,根据保存文件时使用的字符编码方案编码成字节流。而当我们要打开这个代码文件或者这个文件要执行时,系统就是去读取这个文件,同时,按照特定的字符编码方案,将文件中的二进制字节流解码回字符串文本,然后将这些文本保存到内存中以供调用。
关键点来了:
- 保存文件时,系统从哪里得知要使用什么字符编码方案来编码? windows 记事本的话可以在保存类型的下拉框中选择指定编码方案。其他IDE 或者文本编辑器,大多可以在菜单或配置中找到字符编码方案。
- 读取文件时,系统从哪里得知要使用什么字符编码方案来解码当前文件?
个人总结如下:
- 如果有BOM,就可以使用UTF-16 来解码;
- 文件的编写者在文件中显式声明编码方案,比如脚本文件一般会在开头使用注释声明,而在html 页面中使用meta 标签声明chatset;
- 保存文件时和读取文件时使用的字符编码方案不一样会发生什么结果?
- 乱码;(錕斤拷)
- 程序执行与预期行为不一致;(比如由于某个字符解析错误,导致从该字符开始的字节会跟着解析同步出错)
上面第三点是极其重要的,每个程序都要确保代码声明的编码方案和保存时使用的编码方案要一致,如果出现上面页面乱码问题,需要检查以下几个可能的原因:
- 服务器返回的响应头Content-Type没有指明字符编码;
- 网页内是否使用META HTTP-EQUIV标签指定了charset;
- 网页文件本身存储时使用的字符编码和网页声明的字符编码是否一致;
最后,还要说下关于BOM 的问题。上文提到,windows 笔记本在使用UTF-8 对文本编码时也会在字节流最前面加上BOM 来标识区分UTF-18 和UTF-16 编码,也就是使用带BOM 的UTF-8。然后,这种可能出于好意的区分,却给程序运行带来很多诡异的问题。主要因为,很多UNIX 软件,比如shell并不会检测BOM,把EF BB BF、FEFF、FFFE 这些字节当作BOM 标识,而是当作普通字符来处理的,这样就会导致很多脚本文件解析的时候,最开始无端端多了两三个字节出来,破坏了脚本首行的#!标识,于是,解析后的行为就脱离了预期。
所以,在使用UTF-8 编码时,一定一定不要不要加BOM 标识。