编码又见编码

前几天,Google给我Hotmail邮箱发了封确认信。我看不懂,不是因为我英文不行,而是"???? ????? ??? ????"的内容让我不知所措。有好多程序员处理不好编码问题。不是因为他们学不会,而是因为他们太保守或太不以为然了!我想说,初级程序员需要积累更多的计算机高级知识;高级程序员需要了解更多的底层知识。
  那么Content-Type标记到底有什么作用?UTF-8与Unicode到底有何关系?…………现在我们就一起来揭开编码那神奇的面纱!

从ASCII编码谈起:
  我们需要了解的最早编码是ASCII码。它用7个二进制位来表示,由于那个时期生产的大多数计算机使用8位大小的字节,因此用户不仅可以存放所有可能的ASCII字符,而且有整整一位空余下来。如果你技艺高超,可以将该位用做自己离奇的目的:WordStar中那个发暗的灯泡实际上设置这个高位,以指示一个单词中的最后一个字母,同时这也宣示了WordStar只能用于英语文本。
  由于字节有多达8位的空间,因此许多人在想:“呀!我们可以把128~255之间的编码用做个人的应用目的。”问题在于,同时产生这种想法的人相当多,而且在128~255之间的各个位置上应该存放什么这一问题上,真是仁者见仁智者见智。事实上,只要人们开始在美国以外的地方购买计算机,那么各种各样的不同OEM字符集都会进入规划设计行列,并且各人都会根据自己的需要使用高位的128个字符。如此一来,甚至在同语种的文档之间就不容易实现互换。ASCII可被扩展,最优秀的扩展方案是ISO 8859-1,通常称之为Latin-1。Latin-1包括了足够的附加字符集来写基本的西欧语言。
    最后,这个人人参与的OEM终于以ANSI标准的形式形成文件。在ANSI标准中,每个人都认同如何使用低端的128个编码,这与ASCII相当一致。不过,根据所在国籍的不同,处理编码128以上的字符有许多不同的方式。这些不同的系统称为代码页。
  同时,甚至更为令人头疼的事情正在逐步上演,亚洲国家的字符表有成千上万个字符,这样的字符表是用8位二进制无法表示的。该问题的解决通常有赖于称为DBCS(double byte character set,双字节字符集)的繁杂字符系统。
  不过,仍然需要指出一点,多数人还是姑且认为一个字节就是一个字符,以及一个字符就是8个二进制位,并且只要确保不将字符串从一台计算机移植到另一台计算机,或者说一种以上的语言,那么这几乎总是可以凑合。当然,只要一进入Internet,从一台计算机向另一台计算机移植字符串就成为家常便饭了,而各种复杂状况也随之呈现出来。令人欣慰的是,Unicode随即问世了。

Unicode编码:
  Unicode勇往直前地创建一种单一字符集,试图囊括地球上所有合理文字体系。每个字符在Unicode中一律以2个Byte编码。ISO颁布10646号标准叫UCS(Universal Character Set)。在UCS通用集中,每个字符用4个Byte编码。庆幸的是,Unicode策进会自欺欺人1991年以来便和UCS小组密切配合,从而使Unicode和ISO 10646保持一致辞。
  仔细算来,康熙字典里的中文字就有4万多个,如果再加上里面没有的简体字,那么Unicode的6万字容量仅汉字就已用得大部分。对此,Unicode和UCS采用中、日、韩整合(CJK Unification)的解决方案,把中日韩文中笔划相近的字,尽量以一个单码来代表。经过中日韩整合的汉字,在Unicode中称Unihan(统汉字)。Unicode标准中,共有2万多个统汉字,所以它不包括日常罕见的汉字。Unihan统汉字在Unicode中主要分布在3400到9fff之间,此外f900和faff之间了有一些。其中BIG5和GB2312中的汉字和符号都在4000和9fff之间。
  UCS通用字符集中,有UCS-2和UCS-4等编码方式,其中的'2'和'4'指的是字节数。就目前而言,在UCS-2码之前补上两个零字节,便可得到相对应的UCS-4码。

UTF-8编码与UTF-16编码:
  UCS只是规定如何编码,并没有规定如何传输、保存这个编码。例如“汉”字的UCS编码是6C49,我可以用4个ascii数字来传输、保存这个编码;也可以用utf-8编码:3个连续的字节E6 B1 89来表示它。关键在于通信双方都要认可。UTF-8、UTF-7、UTF-16都是被广泛接受的方案。UTF-8的一个特别的好处是它与ISO-8859-1完全兼容。UTF是"UCS Transformation Format"的缩写。
  用Unicode字符集写的英语文本是ASCII或Latin-1写的文本大小的两倍。UTF-8是Unicode压缩版本,对于大多数常用字符集(ASCII中0~127字符)它只使用单字节,而对其它常用字符(特别是朝鲜和汉语会意文字),它使用3字节。如果写的主要是英语,那么UTF-8可减少文件大小一半左右。反之,如果主要写汉、日、朝鲜语,那么UTF-8会把文件大小增大50%。UTF-8就是以8位为单元对UCS进行编码。
  除非特别指定,XML处理器假设文本数据是UTF-8格式。
UTF-8的格式为:

UCS-2编码(16进制) UTF-8 字节流(二进制)
0000 - 007F 0xxxxxxx
0080 - 07FF 110xxxxx 10xxxxxx
0800 - FFFF 1110xxxx 10xxxxxx 10xxxxxx

例如“汉”字的Unicode编码是6C49。6C49在0800-FFFF之间,所以肯定要用3字节模板了:1110xxxx 10xxxxxx 10xxxxxx。将6C49写成二进制是:0110 110001 001001, 用这个比特流依次代替模板中的x,得到:11100110 10110001 10001001,即E6 B1 89。


在当今大多数计算机系统中,仍是以字节单元来做存取。如果以UTF-16(双字节)来存取资料,会产生许多问题。例如,在C语言中,0x00这个字节有特殊的意义和作用。而UTF-16的头256个字码的第一个字节,正是0x00(第二个字节则是相对的ISO 8850-1字码),因此用UTF-16来表示文档名、网址等,会出现许多意想不到的问题。不只0x00,还有许多其他字符,也都可能和许多OS相冲突。
  此外,UTF-16及许多亚洲地区现行的双字节区域性编码方式,在应用上都有先天性缺陷。例,字符与字符之间的边界不好找,程序在处理时必须从头扫描,才能正确可靠地找出某个字符的边界,这样效率极低。此处,若中文文件中某个地方错了一个字节,所有在这个字节之后的中文字都会坏掉,一直到下个英文字符或空白字符出现为止。
  以上问题,在UTF-8中都不存在。Unicode标准中明文规定,当UTF-8格式的文件中有不合法的组合出现时,该怎么处理。例,碰到第一个字节是以1110开头,但是下一个字节却是以0开头。
  从另一个角度看,若改用UTF-16来编码,虽然不会增加亚洲文字所占的空间(都是两个字节),但对西文来说,等于让所有的文件大小一律加倍。这也难怪西方世界的软件设计师对Unicode缺乏兴趣。有了UTF-8,为数众多的英文文件不需任何转换,就自然符合UTF-8,这对向英文国家促销Unicode有很在帮助。

编码序的问题:
  将编码存储为2个字节的传统方法称为UCS-2(因为它有2个字节)或者UTF-16(因为它有16位),不过仍然需要弄清它是高端存放模式的UCS-2,还是低端存放模式的UCS-2。
  big endian和little endian是CPU处理多字节数的不同方式。例如“汉”字的Unicode编码是6C49。那么写到文件里时,究竟是将6C写在前面,还是将49写在前面?如果将6C写在前面,就是big endian。如果将49写在前面,就是little endian。

  "endian"这个词出自《格列佛游记》。小人国的内战就源于吃鸡蛋时是究竟从大头(Big-Endian)敲开还是从小头(Little-Endian)敲开,由此曾发生过六次叛乱,一个皇帝送了命,另一个丢了王位。

  我们一般将endian翻译成"字节序",将big endian和little endian称作"大尾"和"小尾"。
  UTF-8以字节为编码单元,没有字节序的问题。UTF-16以两个字节为编码单元,在解释一个UTF-16文本前,首先要弄清楚每个编码单元的字节序。例如"奎"的Unicode编码是594E,"乙"的Unicode编码是4E59。如果我们收到UTF-16字节流"594E",那么这是“奎”还是"乙"?
  Unicode规范中推荐的标记字节顺序的方法是BOM(即Byte Order Mark)。
  BOM是一个有点小聪明的想法:

  在UCS编码中有一个叫做"ZERO WIDTH NO-BREAK SPACE"的字符,它的编码是FEFF。而FFFE在UCS中是不存在的字符,所以不应该出现在实际传输中。UCS规范建议我们在传输字节流前,先传输字符"ZERO WIDTH NO-BREAK SPACE"。

  这样如果接收者收到FEFF,就表明这个字节流是Big-Endian的;如果收到FFFE,就表明这个字节流是Little-Endian的。因此字符"ZERO WIDTH NO-BREAK SPACE"又被称作BOM。

  UTF-8不需要BOM来表明字节顺序,但可以用BOM来表明编码方式。字符"ZERO WIDTH NO-BREAK SPACE"的UTF-8编码是EF BB BF(读者可以用我们前面介绍的编码方法验证一下)。所以如果接收者收到以EF BB BF开头的字节流,就知道这是UTF-8编码了。

  Windows就是使用BOM来标记文本文件的编码方式的。

汉字的编码:
  早期的计算机使用7位的ASCII编码,为了处理汉字,程序员设计了用于简体中文的GB2312和用于繁体中文的BIG5。

  GB2312(1980年)一共收录了7445个字符,包括6763个汉字和682个其它符号。汉字区的内码范围高字节从B0-F7,低字节从A1-FE,占用的码位是72*94=6768。其中有5个空位是D7FA-D7FE。GB2312-80中共收录了7545个字符,用两个字节编码一个字符。每个字符最高位为0。GB2312-80编码简称国标码。

  GB2312支持的汉字太少。1995年的汉字扩展规范GBK1.0收录了21886个符号,它分为汉字区和图形符号区。汉字区包括21003个字符。

  从ASCII、GB2312到GBK,这些编码方法是向下兼容的,即同一个字符在这些方案中总是有相同的编码,后面的标准支持更多的字符。在这些编码中,英文和中文可以统一地处理。区分中文编码的方法是高字节的最高位不为0。按照程序员的称呼,GB2312、GBK都属于双字节字符集 (DBCS)。
  2000年的GB18030是取代GBK1.0的正式国家标准。该标准收录了27484个汉字,同时还收录了藏文、蒙文、维吾尔文等主要的少数民族文字。从汉字字汇上说,GB18030在GB13000.1的20902个汉字的基础上增加了CJK扩展A的6582个汉字(Unicode码0x3400-0x4db5),一共收录了27484个汉字。
  GB18030的编码采用单字节、双字节和4字节方案。其中单字节、双字节和GBK是完全兼容的。4字节编码的码位就是收录了CJK扩展A的6582个汉字。 例如:UCS的0x3400在GB18030中的编码应该是8139EF30,UCS的0x3401在GB18030中的编码应该是8139EF31。

微软提供了GB18030的升级包,但这个升级包只是提供了一套支持CJK扩展A的6582个汉字的新字体:新宋体-18030,并不改变内码。Windows 的内码仍然是GBK。
  这里还有一些细节:
  GB2312的原文还是区位码,从区位码到内码,需要在高字节和低字节上分别加上A0。
  前面提到从ASCII、GB2312、GBK到GB18030的编码方法是向下兼容的。而Unicode只与ASCII兼容(更准确地说,是与ISO-8859-1兼容),与GB码不兼容。例如“汉”字的Unicode编码是6C49,而GB码是BABA。

  对于任何字符编码,编码单元的顺序是由编码方案指定的,与endian无关。例如GBK的编码单元是字节,用两个字节表示一个汉字。 这两个字节的顺序是固定的,不受CPU字节序的影响。UTF-16的编码单元是word(双字节),word之间的顺序是编码方案指定的,word内部的字节排列才会受到endian的影响。后面还会介绍UTF-16。

  GB2312的两个字节的最高位都是1。但符合这个条件的码位只有128*128=16384个。所以GBK和GB18030的低字节最高位都可能不是1。不过这不影响DBCS字符流的解析:在读取DBCS字符流时,只要遇到高位为1的字节,就可以将下两个字节作为一个双字节编码,而不用管低字节的高位是什么。
  目前Windows的内核已经采用Unicode编码,这样在内核上可以支持全世界所有的语言文字。但是由于现有的大量程序和文档都采用了某种特定语言的编码,例如GBK,Windows不可能不支持现有的编码,而全部改用Unicode。

  Windows使用代码页(code page)来适应各个国家和地区。code page可以被理解为下节提到的内码。GBK对应的code page是CP936。

  微软也为GB18030定义了code page:CP54936。但是由于GB18030有一部分4字节编码,而Windows的代码页只支持单字节和双字节编码,所以这个code page是无法真正使用的。


内码与外码:
  我们常说汉字的"内码"与"外码"。内码是汉字在计算机内部存储,处理和传输用的信息编码。它必须与ASCII码兼容但又不能冲突,所以把国标码两个字节的最高位置'1',以区别于西文,这就是内码。汉字的输入码称为"外码"。输入码即指我们输入汉字时使用的编码。常见的外码分为数字编码(如区位码),拼音编码和字形编码(如五笔)。
    再说区位码,"啊"的区位码是1601,写成16进制是0x10,0x01。这和计算机广泛使用的ASCII编码冲突。为了兼容00-7f的ASCII编码,我们在区位码的高、低字节上分别加上A0。这样"啊"的编码就成为B0A1。我们将加过两个A0的编码也称为GB2312编码,虽然GB2312的原文根本没提到这一点。 
  内码是指操作系统内部的字符编码。早期操作系统的内码是与语言相关的.现在的Windows在内部统一使用Unicode,然后用代码页适应各种语言,"内码"的概念就比较模糊了。我们一般将缺省代码页指定的编码说成是内码。内码这个词汇,并没有什么官方的定义。代码页也只是微软的一种习惯叫法。作为程序员,我们只要知道它们是什么东西,没有必要过多地考证这些名词。
  所谓代码页(code page)就是针对一种语言文字的字符编码。例如GBK的code page是CP936,BIG5的code page是CP950,GB2312的code page是CP20936。
  Windows中有缺省代码页的概念,即缺省用什么编码来解释字符。例如Windows的记事本打开了一个文本文件,里面的内容是字节流:BA、BA、D7、D6。Windows应该去怎么解释它呢?是按照Unicode编码解释、还是按照GBK解释、还是按照BIG5解释,还是按照ISO8859-1去解释?如果按GBK去解释,就会得到"汉字"两个字。按照其它编码解释,可能找不到对应的字符,也可能找到错误的字符。所谓"错误"是指与文本作者的本意不符,这时就产生了乱码。
  答案是Windows按照当前的缺省代码页去解释文本文件里的字节流。缺省代码页可以通过控制面板的区域选项设置。记事本的另存为中有一项ANSI,其实就是按照缺省代码页的编码方法保存。

  Windows的内码是Unicode,它在技术上可以同时支持多个代码页。只要文件能说明自己使用什么编码,用户又安装了对应的代码页,Windows就能正确显示,例如在HTML文件中就可以指定charset。

  有的HTML文件作者,特别是英文作者,认为世界上所有人都使用英文,在文件中不指定charset。如果他使用了0x80-0xff之间的字符,中文Windows又按照缺省的GBK去解释,就会出现乱码。这时只要在这个html文件中加上指定charset的语句,例如:
  <meta http-equiv="Content-Type" content="text/html; charset=ISO8859-1">
如果原作者使用的代码页和ISO8859-1兼容,就不会出现乱码了。                         

进一步的参考资料
"Short overview of ISO-IEC 10646 and Unicode" (http://www.nada.kth.se/i18n/ucs/unicode-iso10646-oview.html) 

你可能感兴趣的:(编码又见编码)