谈谈字符集与字符编码

转载

1、起源 - ASCII

我们都知道在计算机内部,所有的数据在存储和运算的时候都应该使用二进制进行表示。例如字母,数字等等。通过二进制进行表示,我们可以指定很多规则来表示这些字符,为了避免不一致性,美国国家标准学会(American National Standard Institute , ANSI )制定了 ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)编码。   

ASCII 码使用 7 位二进制数来表示 128 个字符,也就是用一个字节来表示,最前的一位默认为 0。

题外话:起初人们把其中的编号从0开始的32种状态分别规定了特殊的用途,一但终端设备或者打印机遇上这些约定好的字节时,就要做一些约定的动作。遇上 00x10, 终端就换行,遇上0x07, 终端就向人们嘟嘟叫,如果遇上0x1b, 打印机就打印反白的字,对于终端就用彩色显示字母。他们看到这样很好,于是就把这些0x20(十进制32)以下的字节状态称为"控制码"。

我们都知道计算机是美国人发明的,接着美国人又制定了一套规则 ASCII 码用来在计算机中表示自己的语言和符号,由于英语的特殊性(26个字母搞定一切),所以一个字节的二进制就可以解决问题。   

后来又有些符号和一些外来字母也需要表示,怎么办呢?我们前面讲到 ASCII 码刚开始制定时虽然用了一个字节的长度,但是空着最前的一位,所以只用了7位,还有一位没用起来,那么如果把这一位也用起来的话,也就是8位二进制,那么就可以表示256个字符了,于是扩展 ASCII 码诞生,保留原始的7位的基础上,使用了最前的一位。但是,这里又出现了新的问题。不同的国家有不同的字母,因此,哪怕它们都使用256个符号的编码方式,代表的字母却不一样。比如,130在法语编码中代表了é,在希伯来语编码中却代表了字母Gimel (ג),在俄语编码中又会代表另一个符号。但是不管怎样,所有这些编码方式中,0--127表示的符号是一样的,不一样的只是128--255的这一段。

题外话:从此之后,贪婪的人类再没有新的状态可以用了,美帝国主义可能没有想到还有第三世界国家的人们也希望可以用到计算机吧!╭(╯^╰)╮

   

2、发展 - GB2312

随着计算机的发展,越来越多的国家也使用上了计算机,那么也就需要计算机来展示自己国家的字符了,以中国为例,汉字的数量之多是英文字母无法想象的,区区一个字节是根本不可能装下庞大的汉字体系。所以我们中国自己制定了一套编码体系,称为 GB2312(信息交换用汉字编码字符集),使用两个字节,也就是16位二进制,那么算下来最多就可以存下 65536(256*256) 个字符了。最后收录了 6763 个汉字(汉字一共 10w 多,常用 3k 多),覆盖了常用词的百分之90,所以基本是满足了计算机处理汉字的需求。为了兼容 ASCII,大部分使用 GB2312 的程序都采用 EUC 存储方法。   
这里虽然只介绍了 GB2312,但是也隐含着一个问题,世界上这么多国家,每个国家都有不同的文化和语言,如果都制定属于自己的字符编码,那么就乱套了。   

对计算机而言,当你通过设备输入字符时,它会根据你预置的编码规则来识别,如果两台电脑内的编码规则不一致,那么就无法识别彼此的符号信息了,对于以信息交流为目的的计算机体系而言,这无疑是个严重的问题。

题外话:一个小于127的字符的意义与原来相同,但两个大于127的字符连在一起时,就表示一个汉字,前面的一个字节(他称之为高字节)从0xA1用到 0xF7,后面一个字节(低字节)从0xA1到0xFE,这样我们就可以组合出大约7000多个简体汉字了。在这些编码里,我们还把数学符号、罗马希腊的字母、日文的假名们都编进去了,连在 ASCII 里本来就有的数字、标点、字母都统统重新编了两个字节长的编码,这就是常说的"全角"字符,而原来在127号以下的那些就叫"半角"字符了。
中国人民看到这样很不错,于是就把这种汉字方案叫做"GB2312"。GB2312 是对 ASCII 的中文扩展。但是中国的汉字太多了,后来还是不够用,于是干脆不再要求低字节一定是127号之后的内码,只要第一个字节是大于127就固定表示这是一个汉字的开始,不管后面跟的是不是扩展字符集里的内容。结果扩展之后的编码方案被称为 GBK 标准,GBK 包括了 GB2312 的所有内容,同时又增加了近20000个新的汉字(包括繁体字)和符号。
后来少数民族也要用电脑了,于是我们再扩展,又加了几千个新的少数民族的字,GBK 扩成了 GB18030。从此之后,中华民族的文化就可以在计算机时代中传承了。
中国的程序员们看到这一系列汉字编码的标准是好的,于是通称他们叫做 "DBCS"(Double Byte Charecter Set 双字节字符集)。在DBCS系列标准里,最大的特点是两字节长的汉字字符和一字节长的英文字符并存于同一套编码方案里。

3、统一 - Unicode

Unicode 又称为统一码或万国码,根据它的译名就能知道它的作用。它的出现就是为了解决字符编码的不一致性,虽然这期间也出现了不少很广泛使用的字符编码,但直到 Unicode 的出现才可以说真正出现了一套能容纳全球语言符号的编码规则。这里我们要注意,Unicode 仍然是支持、兼容 ASCII的。   
Unicode  虽然能容纳上百万数量的字符,   但是它只是一个巨大的字符集,仅仅规定了每个符号的二进制代码,却没有制定细化的存储规则,例如当用三个字节存储一个字符时,它同时也可以被理解为存储了三个 ASCII 码,另外我们之前知道 ASCII 码只需要一个字节,但是如果 Unicode 规定每个字符使用三个字节来存储的话,那岂不是额外浪费两个字节的空间?所有这些未细化的问题都将导致 Unicode 的不一致性。   

4、细化 - UTF-8

首先我们要明确 UTF-8(8-bit Unicode Transformation Format)是在统一码(Unicode)基础上细化并优化后的一种长度可变的字符编码方式,它是实现 Unicode 的方式之一,正如 jysperm 所说,除了 UTF-8,还有UTF-16,UTF-32 等都可以实现 Unicode,但是 UTF-8 相对而言是用的最为广泛的。UTF-8 可以使用1到4个字节来表示字符,它通过自身的规则能够灵活地变化长度来存储 Unicode 字符。

题外话:受到过网络编程加持的计算机僧侣们都知道,在网络里传递信息时有一个很重要的问题,就是对于数据高低位的解读方式,一些计算机是采用低位先发送的方法,例如我们PC机采用的 INTEL 架构;而另一些是采用高位先发送的方式。在网络中交换数据时,为了核对双方对于高低位的认识是否是一致的,采用了一种很简便的方法,就是在文本流的开始时向对方发送一个标志符——如果之后的文本是高位在位,那就发送"FEFF",反之,则发送"FFFE"。这里的高位低位说的也是"大头方式"(Big endian)和"小头方式"(Little endian),以汉字"严"为例,Unicode码是4E25,需要用两个字节存储,一个字节是4E,另一个字节是25。存储的时候,4E在前,25在后,就是Big endian方式;25在前,4E在后,就是Little endian方式。第一个字节在前,就是"大头方式"(Big endian),第二个字节在前就是"小头方式"(Little endian)。那么很自然的,就会出现一个问题:计算机怎么知道某一个文件到底采用哪一种方式编码?

Unicode规范中定义,每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做"零宽度非换行空格"(ZERO WIDTH NO-BREAK SPACE),用FEFF表示。这正好是两个字节,而且FF比FE大1。
如果一个文本文件的头两个字节是FE FF,就表示该文件采用大头方式;如果头两个字节是FF FE,就表示该文件采用小头方式。

   

5、关于字符编码的一个小玩笑:联通为何干不过移动?

当你在 windows 的记事本里新建一个文件,输入"联通"两个字之后,保存,关闭,然后再次打开,你会发现这两个字已经消失了,代之的是几个乱码!呵呵,有人说这就是联通之所以拼不过移动的原因。
其实这是因为GB2312编码与UTF8编码产生了编码冲撞的原因。
当你新建一个文本文件时,记事本的编码默认是ANSI(代表系统默认编码,在中文系统中一般是GB系列编码), 如果你在ANSI的编码输入汉字,那么他实际就是GB系列的编码方式,在这种编码下,"联通"的内码是:

c1 1100 0001
aa 1010 1010
cd 1100 1101
a8 1010 1000

注意到了吗?第一二个字节、第三四个字节的起始部分的都是"110"和"10",正好与UTF8规则里的两字节模板是一致的,
于是当我们再次打开记事本时,记事本就误认为这是一个UTF8编码的文件,让我们把第一个字节的110和第二个字节的10去掉,我们就得到了"00001 101010",再把各位对齐,补上前导的0,就得到了"0000 0000 0110 1010",不好意思,这是UNICODE的006A,也就是小写的字母"j",而之后的两字节用UTF8解码之后是0368,这个字符什么也不是。这就是只有"联通"两个字的文件没有办法在记事本里正常显示的原因。
而如果你在"联通"之后多输入几个字,其他的字的编码不见得又恰好是110和10开始的字节,这样再次打开时,记事本就不会坚持这是一个utf8编码的文件,而会用ANSI的方式解读之,这时乱码又不出现了。

6、一些概念与术语解释:

6.1 字符集(Character Set)

字面上的理解就是字符的集合,例如ASCII字符集,定义了128个字符;GB2312定义了7445个字符。而计算机系统中提到的字符集准确来说,指的是已编号的字符的有序集合(不一定是连续)。

6.2 字符码(Code Point)

指的就是字符集中每个字符的数字编号。例如ASCII字符集用0-127这连续的128个数字分别表示128个字符;GBK字符集使用区位码的方式为每个字符编号,首先定义一个94X94的矩阵,行称为“区”,列称为“位”,然后将所有国标汉字放入矩阵当中,这样每个汉字就可以用唯一的“区位”码来标识了。例如“中”字被放到54区第48位,因此字符码就是5448。而Unicode中将字符集按照一定的类别划分到0~16这17个层面(Planes)中,每个层面中拥有216=65536个字符码,因此Unicode总共拥有的字符码,也即是Unicode的字符空间总共有17*65536=1114112。
编码的过程是将字符转换成字节流。
解码的过程是将字节流解析为字符。

6.3 字符编码(Character Encoding)

是将字符集中的字符码映射为字节流的一种具体实现方案。例如ASCII字符编码规定使用单字节中低位的7个比特去编码所有的字符。例如‘A’的编号是65,用单字节表示就是0x41,因此写入存储设备的时候就是b’01000001’。GBK编码则是将区位码(GBK的字符码)中的区码和位码的分别加上0xA0(160)的偏移(之所以要加上这样的偏移,主要是为了和ASCII码兼容),例如刚刚提到的“中”字,区位码是5448,十六进制是0x3630,区码和位码分别加上0xA0的偏移之后就得到0xD6D0,这就是“中”字的GBK编码结果。

6.4 代码页(Code Page)一种字符编码具体形式

早期字符相对少,因此通常会使用类似表格的形式将字符直接映射为字节流,然后通过查表的方式来实现字符的编解码。现代操作系统沿用了这种方式。例如Windows使用936代码页、Mac系统使用EUC-CN代码页实现GBK字符集的编码,名字虽然不一样,但对于同一汉字的编码肯定是一样的。

6.5 大小端

大小端的说法源自《格列佛游记》。我们知道,鸡蛋通常一端大一端小,小人国的人们对于剥蛋壳时应从哪一端开始剥起有着不一样的看法。同样,计算机界对于传输多字节字(由多个字节来共同表示一个数据类型)时,是先传高位字节(大端)还是先传低位字节(小端)也有着不一样的看法,这就是计算机里头大小端模式的由来了。无论是写文件还是网络传输,实际上都是往流设备进行写操作的过程,而且这个写操作是从流的低地址向高地址开始写(这很符合人的习惯),对于多字节字来说,如果先写入高位字节,则称作大端模式。反之则称作小端模式。也就是说,大端模式下,字节序和流设备的地址顺序是相反的,而小端模式则是相同的。一般网络协议都采用大端模式进行传输,windows操作系统采用Utf-16小端模式。   

6.6 Unicode是两个字节吗?

Unicode只是定义了一个庞大的、全球通用的字符集,并为每个字符规定了唯一确定的编号,具体存储为什么样的字节流,取决于字符编码方案。推荐的Unicode编码是UTF-16和UTF-8。   

6.7 带签名的UTF-8指的是什么意思?

带签名指的是字节流以BOM标记开始。很多软件会“智能”的探测当前字节流使用的字符编码,这种探测过程出于效率考虑,通常会提取字节流前面若干个字节,看看是否符合某些常见字符编码的编码规则。由于UTF-8和ASCII编码对于纯英文的编码是一样的,无法区分开来,因此通过在字节流最前面添加BOM标记可以告诉软件,当前使用的是Unicode编码,判别成功率就十分准确了。但是需要注意,不是所有软件或者程序都能正确处理BOM标记,例如PHP就不会检测BOM标记,直接把它当普通字节流解析了。因此如果你的PHP文件是采用带BOM标记的UTF-8进行编码的,那么有可能会出现问题。   

6.8 Unicode编码和以前的字符集编码有什么区别?

早期字符编码、字符集和代码页等概念都是表达同一个意思。例如GB2312字符集、GB2312编码,936代码页,实际上说的是同个东西。但是对于Unicode则不同,Unicode字符集只是定义了字符的集合和唯一编号,Unicode编码,则是对UTF-8、UCS-2/UTF-16等具体编码方案的统称而已,并不是具体的编码方案。所以当需要用到字符编码的时候,你可以写gb2312,codepage936,utf-8,utf-16,但请不要写unicode(看过别人在网页的meta标签里头写charset=unicode,有感而发)。   

7、总结

字符集定义了一组字符,编码定义了如何来表示字符集中的字符(是不是有点类似抽象类与实现类的关系呢?)。比如 Unicode 是一个「字符集」,而 UTF-8 是一种「编码方式」,其他的编码方式还有 UTF-16 和 UTF-32.   
我们可以用记事本来测试不同字符编码的差异:   
    现在,我终于明白了部分关于编码的知识,不过博文中间可能有些概念理解得不是特别正确,如有错误,欢迎指正。