字符编码的故事:ASCII,GB2312,Unicode,UTF-8,UTF-16
由于好奇,我非常想搞清楚关于字符编码的疑惑。比如Unicode,UTF-8,UTF-16,以及有BOM,无BOM之前的区别和联系。参考了很多资料后,我终于初步理解了。在这里总结成博文,希望对读者有所帮助。如果有什么错误,还请您不吝赐教。
字符
字符是各种文字和符号的总称,包括各国家文字、标点符号、图形符号、数字等,甚至还可以包括无法显示的字符(比如ASCII标准定义了128个字符,其中33个字符无法显示)。
字符表与字符集
为了使计算机能够处理字符信息,首先要决定选取哪些字符。这样就形成了一个集合,或者说一个表,称为字符表(character repertoire)。当然,也可以认为这就是一个字符集(character set)。
例如,将所有的英文字母放在一起可以组成一个字符集,将所有的汉字放在一起可以组成一个字符集,等等。
代码点(code point or code position)
对一个字符集中的所有字符进行编号(赋予一个非负整数的序号),每个字符的编号在这个字符集里都是独一无二的。例如,由所有英文字母组成的字符集中有52个字符(小写字母和大写字母各26个),可以将这些字符依次编号为0、1、2、…51。一个字符的编号称为该字符的代码点。
值得注意的是,对字符进行编号有很多种方法,只要保证编号的唯一性即可。
编码字符集(coded character set)
对一个字符集中的所有字符进行编号,这种编号后的字符集叫做编码字符集(这里的编码仅仅指编号,不同于下文中的编码)。常见的编码字符集有ASCII、Unicode、GBK等。
代码点与字符编码的关系
代码点仅仅是一个字符的编号,通常与字符在字符表中的位置有关,但是与它在计算机内的数字表示形式、存储方法无关。
我们在计算机屏幕上看到的是形象的字符,而在计算机内部存放的是二进制的比特序列。所谓字符编码,就是建立一种转换规则或者说映射关系,将一个字符集中的代码点按照某种规则转换成可以在计算机内存储的二进制比特序列。
用图来表示就是:
对于一个字符集来说,要正确编码解码一个字符需要三个关键元素:
- 字符集(character set)本身
- 编码字符集(coded character set)
- 字符编码(character encoding form)
其中字符集规定了计算机能处理哪些字符;编码字符集,即用一个代码点来表示一个字符在字符表中的位置;字符编码,建立一种映射关系:代码点和实际存储数值之间的关系。
需要注意的是:一个字符集可能对应着多种不同的编码方式。
编码单元
对代码点进行编码后的二进制的比特序列,是由一个或者多个编码单元按照一定的序列组成的。一个编码单元由若干个比特构成,例如7比特、8比特等(最常用的是8/16/32比特,便于存储和传输)。
比如说,UTF-8编码,采用1~4个8比特的编码单元;
UTF-16编码,采用1~2个16比特的编码单元;
UTF-32编码,采用1个32比特的编码单元。
前两个属于变长编码,后一个属于等长编码。
从ASCII编码说起
ASCII(英语发音:/ˈæski/,American Standard Code for Information Interchange,美国信息交换标准代码),是基于拉丁字母的一套电脑编码系统。
ASCII至今为止共定义了128个字符,其中33个字符无法显示,且这33个字符多数都已是陈废的控制字符。在33个字符之外的是95个可显示的字符,用键盘敲下空白键所产生的空白字符也算1个可显示字符(显示为空白)。
读了前几节的内容,估计很多读者都会想到一种简单的编码方式:既然字符集中的字符都有自己唯一的代码点,那么直接把代码点作为存储值就可以了。
Yes!ASCII字符集就是这样编码的。例如在ASCII字符集中,字符A的代码点是十进制的65,如果用一个7比特的编码单元来表示的话,就是100 0001,这正是字符A的编码。
在存储的时候,由于字节(8比特)是计算机中的常用单位,故仍以一个字节来存放字符的编码,每个字节中多余出来的最高位,统一规定为0.
非ASCII编码
用ASCII字符集来表示英语是没有问题的,但是若用它来表示其他语言,肯定是不行的。比如,在法语中,字母上方有注音符号,它就无法用ASCII字符表示。
于是,一些欧洲国家就决定,利用字节中闲置的最高位编入新的字符。比如,法语中的é的编码为130(二进制为1000 0010)。这样一来,这些欧洲国家使用的编码体系,可以表示最多256个字符。
但是,这里又出现了新的问题。不同的国家有不同的字母,因此,哪怕都使用256个字符的编码方式,代表的字母却不一样。比如,130在法语编码中代表了é,在希伯来语编码中却代表了字母Gimel(ג)。
至于亚洲国家的文字,使用的字符就更多了,汉字就多达10万左右。假如把这10万个汉字从1开始编号,那就是10万个代码点,如果直接把代码点作为存储值,一个字节只能表示256种字符,肯定是不够的,所以很容易想到用8个以上的比特(连续的多个字节)来表示一个字符。
GB2312
GB2312是中国国家标准简体中文字符集,共收录6763个汉字,其中一级汉字3755个,二级汉字3008个;此外,GB2312还收录数学符号、拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母等682个字符。这些非汉字字符有些来自ASCII字符集,但被重新编码为双字节,并称为”全角”字符;ASCII原字符则称为”半角”字符。例如,全角
a
编码为0xA3E1,半角a
则编码为0x61。
对ASCII字符集的兼容
对于ASCII字符集,编码不变,从0x00
~0x7F
.
区位码
GB2312中对所收汉字进行了“分区”处理,编号为01区至94区;每区含有94个字符,编号为01位至94位。这种表示方式也称为区位码。每一个字符都由与其唯一对应的区号和位号所确定。
例如:“啊”的编号为16区01位,它的区位码就是1601(可以认为这就是一个代码点)。
01–09区为特殊符号。
16–55区为一级汉字,按拼音排序。
56–87区为二级汉字,按部首/笔画排序。
举例来说,“啊”字是第16区中的第1个字符,它的区位码就是1601;“琛”字是第72区的第41个字符,它的区位码就是7241。
对非ASCII字符如何编码
GB2312的编码单元是8比特,刚好是一个字节。对于非ASCII字符,每个字符由2个编码单元组成。第一个编码单元称为“高位字节”,第二个编码单元称为“低位字节”。
高位字节 = 区号 + 0xA0
低位字节 = 位号 + 0xA0
举例:“啊”的区位码是1601,
高位字节 = 0x10(=16) + 0xA0 = 0xB0
低位字节 = 0x01(=1) + 0xA0 = 0xA1
所以,“啊”的编码就是0xB0(第一个编码单元)0xA1(第二个编码单元)。
再比如,“琛”的区位码是7241,
高位字节 = 0x48(=72) + 0xA0 = 0xE8
低位字节 = 0x29(=41) + 0xA0 = 0xC9
所以,“琛”的编码就是0xE8(第一个编码单元)0xC9(第二个编码单元)。
说明:“高位字节”使用了0xA1–0xF7(把01–87区的区号加上0xA0),“低位字节”使用了0xA1–0xFE(把01–94加上0xA0)。不管是高位字节还是低位字节,它们的最高位都是1,这样就可以和ASCII字符的编码区分开。
字节序
在计算机系统中,存储对象一般是以字节为单位的,每个地址单元都对应着一个字节。
对于单字节对象,存放方法和传输方式一般相同。
对于多字节对象,如整数(32位机中一般占4字节),情况就不同了。我们必须建立两个规则:
- 这个对象的地址是什么
- 在存储器中如何排列这些字节
在几乎所有机器上,多字节对象都被存储为连续的字节序列,对象的地址为所使用字节中最小的地址。
关于字节的排列,一般有两种规则:小端法(little endian)与大端法(big endian)。
首先介绍两个概念。
- 最高有效字节(the Most Significant Byte):指多字节序列中具有最大权重的字节。
- 最低有效字节(the Least Significant Byte):指多字节序列中具有最小权重的字节。
比如0x778532
,最高有效字节是0x77
,最低有效字节是0x32
;
小端(最符合人类思维的字节序 )
规则:顺着地址增加的方向,按照从最低有效字节到最高有效字节的顺序存储。
记忆口诀:高址高字节
为什么说是最符合人类思维的字节序?因为从逻辑的角度来说,高有效位在高地址处,低有效位在低地址处,想起来很自然。
大端(最直观的字节序)
规则:顺着地址增加的方向,按照从最高有效字节到最低有效字节的顺序存储。
记忆口诀:高址低字节
为什么说是最直观字节序?假设存储地址从左到右依次增加,请把这个对象读出来,将你读的内容,从左到右填充内到存即可。
假设要把对象0x87654321存储到地址0x1000处,大小端的区别如下图所示:
字节序与字符编码
对于任何字符编码,编码单元之间的顺序是由编码方式指定的,与endian无关。例如GB2312的编码单元是字节,用两个字节表示一个字符。这两个字节的顺序是固定的,不受CPU字节序的影响。
举例来说,汉字“啊”的编码是0xB0(第一个编码单元)0xA1(第二个编码单元),不管在小端系统还是大端系统,存储格式都是0xB0(较小的地址)0xA1(较大的地址)。
UTF-16的编码单元是16比特(一个字),字之间的顺序是编码方式指定的,字内部的字节排列才会受到endian的影响(后文会介绍)。
Unicode
世界上存在着多种编码方式,同一个二进制序列可以被解释成不同的符号。因此,要想正确解码一个文本文件,就必须知道它的编码方式,否则很可能出现乱码。
如果有一种编码,将世界上所有的字符都纳入其中。对于每个字符都给予一个独一无二的编码,那么乱码问题就会解决。于是,Unicode诞生了。
Unicode字符集由多语言软件制造商组成的统一码联盟(Unicode Consortium)与国际标准化组织的ISO-10646工作组制订,为各种语言中的每个字符指定统一且唯一的代码点,以满足跨语言、跨平台转换和处理文本的要求。
Unicode代码点范围为0x0~0x10FFFF,共计1114112个代码点,划分为编号0~16的17个字符平面,每个平面包含65536个代码点。其中编号为0的平面最为常用,称为基本多语种平面(Basic Multilingual Plane, BMP);其他平面则称为辅助语言平面。
为了描述一个代码点,可以采用U
加十六进制整数的方法。比如,U+0041
表示英文大写字母A,U+4E25
表示汉字”严”。
常见的Unicode编码方式
Unicode只是一个编码字符集,并没有制定具体的编码方式。
关于编码方式,当然可以采用类似ASCII字符集的编码方式——代码点等值转换法(这是我自己起的名字)。既然Unicode代码点的值的范围是0~0x10FFF,那么可以用一个21比特的编码单元来编码,直接把代码点等值转换成21比特的二进制序列。
但是这存在一个空间使用的问题,例如对于使用英语的人而言,ASCII基本可以满足使用。如果使用ASCII码,只需要1个字节来存储字符,但是若使用刚才的思路,需要将近3个字节来存储,这显然是浪费空间的。
有没有其他的编码方式呢?
Unicode的实现方式称为Unicode转换格式(Unicode Transformation Format,简称为UTF)。UTF-8,UTF-16,UTF-32是三种不同的Unicode编码方式,这三种编码方式都用于编码U+0000
~U+D7FF
以及U+E000
~U+10FFFF
的Unicode代码点。
注意:Unicode标准规定U+D800
~U+DFFF
的代码点不对应于任何字符。
UTF-8
UTF-8(8-bit Unicode Transformation Format)是一种针对Unicode的可变长度字符编码,也是一种前缀码。它可以用来表示Unicode标准中的任何字符,且其编码中的第一个字节与ASCII兼容,这使得原来处理ASCII字符的软件无须或只须做少部分修改,即可继续使用。因此,它逐渐成为电子邮件、网页及其他存储或发送文字的应用中,优先采用的编码。
编码及举例
UTF-8的编码单元是8比特,对于每个代码点,采用1~4个编码单元编码。下图摘自维基百科,我只截取了原图的一部分。
举例来说,已知”严”的代码点是U+4E25
,(0x4E25=100111000100101b),根据上表,可以发现4E25处在第三行的范围内(U+0800
~U+FFFF
),因此”严”的UTF-8编码需要三个字节,格式是1110xxxx 10xxxxxx 10xxxxxx
。然后,从”严”的最后一个二进制位开始,依次从后向前填入格式中的x,多出的x补0。这样就得到”严”的UTF-8编码是11100100 10111000 10100101
,转换成十六进制就是0xE4B8A5
。
在存储的时候,这三个编码单元的顺序是固定的。不管是大端序还是小端序,都是按照地址增加的方向依次存储0xE4
,0xB8
,0xA5
.
UTF-16
UTF-16的编码单元是16比特,对于每个代码点,采用1个或者2个编码单元来表示,因此这是一个变长表示。
编码及举例
从U+0000
~U+D7FF
以及从U+E000
~U+FFFF
的代码点
将代码点等值转换为16比特的二进制序列即可。
比如字符$
的代码点是U+0024
,对应的16比特的二进制序列为0000 0000 0010 0100
,即0x0024
.
从U+10000
~U+10FFFF
的代码点
辅助平面(Supplementary Planes)中的代码点,在UTF-16中被编码为一对16比特长的码元,称作代理对(surrogate pair),具体方法是:
用代码点减去0x10000,得到的值的范围为20比特长的0x00000
~0xFFFFF
(不足20比特就在高位填充0);然后把这20比特拆分为高10比特和低10比特,
第一个编码单元 = 高10比特 + 0xD800;
第二个编码单元 = 低10比特 + 0xDC00;
或者说,用高10比特取代下面模板中的x,用低10比特取代下面模板中的y。
110110xxxxxxxxxx 110111yyyyyyyyyy
例如要对代码点U+10437
编码:
0x10437减去0x10000,结果为0x00437,二进制为0000 0000 0100 0011 0111。
拆成高10位和低10位:0000000001(=0x0001) 和 0000110111(=0x0037)。
第一个编码单元 = 0x0001 + 0xD800 = 0xD801;
第二个编码单元 = 0x0037 + 0xDC00 = 0xDC37;
UTF-32
UTF-32采用代码点等值转换法,将每个代码点编码为1个32比特的编码单元(四字节),因此空间效率较低,不如其它Unicode编码应用广泛。
字符编码方案(Encoding Scheme)与BOM(Byte Order Mark)
上面的字符$
的编码如何存储呢?注意,我们要存储的是16比特的0x0024
,这是一个多字节对象。这时候,前面说的大小端就派上用场了。对于大端的系统,低地址存储0x00
,高地址存储0x24
;对于小端的系统,刚好相反。
假设你的计算机是小端序,计算机里有一个文本文件采用UTF-16编码。把你的文本文件传输到我的计算机上,可是我的计算机是大端序,显然我无法正确解码。这可怎么办?
因为上面的原因,就产生了所谓的UTF-16、UTF-16LE、UTF-16BE、UTF-32、UTF-32LE、UTF-32BE编码方案。编码方案是编码方式的细化,它决定了编码的传输和存储格式。字符编码方案主要关注跨平台处理编码单元宽度超过一个字节的数据。
在UTF-16文件的开始,一般会放置一个代码点为U+FEFF
的字符作为BOM
(字节顺序标记),这个字符在Unicode字符集中代表的意义是ZERO WIDTH NO-BREAK SPACE,顾名思义,它是个没有宽度也没有断字的空白。
UTF-16BE
表示以大端序存储。我们会发现最开始的两个字节是0xFE
和0xFF
.
UTF-16LE
表示以小端序存储。我们会发现最开始的两个字节是0xFF
和0xFE
.
下面的表格是UTF-16编码方式下不同编码方案的示例,来自http://www.unicode.org/versions/Unicode4.0.0/ch03.pdfUnicode
注意:BOM不是必需的。当没有BOM的时候,UTF-16和UTF-32编码默认为大端序。
UTF-8需要BOM吗
UTF-8以字节为编码单元,没有字节序问题,BOM仅仅用于表明其编码格式(signature),但不建议如此。因为UTF-8编码特征明显,无需BOM即可检测出是否为UTF-8格式(文件较短时可能不准确)。
Unicode标准并未要求或建议UTF-8编码使用BOM,但确实允许BOM出现在文件开头。带有BOM的Unicode文件有时会带来一些问题:
- Linux/Unix系统未使用BOM,因为它会破坏现有ASCII文件的语法约定。
- 某些编辑器不会添加BOM,或者可以选择是否添加BOM(比如NotePad++)。
- 某些语法分析器可以处理字符串常量或注释中的UTF-8,但无法分析文件开头的BOM。
- 某些程序在文件开头插入前导字符来声明文件类型等信息,这与BOM的用途冲突。
顺便说一下,当我把汇编源文件变成带BOM的UTF-8格式时,在Linux环境下,NASM编译器根本不认识它,会报错。
【end】
参考资料
[0]《标准C语言指南》(电子工业出版社,李忠)
[1]http://www.herongyang.com/gb2312_gb/GB2312-to-Unicode-Row-72-E8A1-E8FE.html
[2]http://www.letiantian.me/2015-03-02-character-encoding/
[3]http://cenalulu.github.io/linux/character-encoding/
[4]https://zh.wikipedia.org/wiki/GB_2312
[5]http://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html
[6]http://www.cnblogs.com/jacktu/archive/2008/11/24/1339789.html
[7]https://zh.wikipedia.org/wiki/UTF-8
[8]http://www.cnblogs.com/clover-toeic/p/5291787.html