这是一篇写于2010年的旧帖,略作修改。

下文以Python 3列举一些示例。

Python 3对Unicode的支持非常好,清晰地划分字节流和字符串两种不同类型也极大地简化了对字符编码问题的理解,可以交互式执行这一点更是让简单测试一些语句方便到家了。(这正是选用Python列举示例的理由)

字节流(byte stream) VS 字符串(string)

要理解字符编码,首先要区分字节(流)与字符(串)这两类不同的事物。计算机内部只存储和处理字节,字符只是人类理解的概念。

在Python中,字节流和字符串分别对应bytes和str类型:

# 示例-1
>>> b = b'Unicode\xe5\xad\x97\xe7\xac\xa6\xe4\xb8\xb2'
>>> type(b)
<class 'bytes'>
>>> s = 'Unicode字符串'
>>> type(s)
<class 'str'>

为了便于人类与计算机的沟通,人们需要在字节与字符之间建立对应关系,这即是字符编码(Character encoding)。一种字符编码可以支持(即定义了对应关系)的所有字符称为一个字符集(Charset)。如ASCII编码支持的所有字符被称为ASCII字符集。这两个概念关系如此紧密,以至于可以视之为等价,区别仅在于前者是从对应关系角度命名,后者则是从所有字符集合的角度命名。事实上,在维基百科上这两个关键词指向的是同一个页面。

所谓乱码,或者是字符转换为字节(编码)与字节转换为字符(解码)的过程使用了不同的字符编码,或者是尝试将不是文本的字节流(可能是数据错误,也可能就是二进制数据)解码为字符。理解了这两点,乱码这个概念就没有存在的必要了。

ASCII与扩展ASCII的编码方案

最早形成标准并且至今仍有广泛影响力的字符编码莫过于ASCII。该字符集只使用单字节的低7位,即0x00到0x7f的范围。该字符集中包含了控制字符(现在可能只有其中几个是常用的)、数字、大小写英文字母和英文标点符号。ASCII是一个设计良好的字符编码规则,如充分利用码元、数字和字母的排布是连续的并且有着直观的对应、大小写字母可以通过一个bit的改变而相互转化,等等。其主要局限在于无法支持非英语的字符。ASCII只定义了128个字符,这对于拉丁语系来说尚且太少,更不必说包含成千上万字符的CJK语言和希腊、阿拉伯等世界各国语言。

第一种解决方案是扩展ASCII,把最高位为1的字节(即0x80到0xff的范围)使用上,从而还可以与ASCII保持兼容。Windows和IBM引入的代码页(Code page)正是这种解决方案,例如,cp1252支持西欧国家的各种字符,cp936和cp950支持中文字符(分别用于简体中文和繁体中文系统)。中文系统中常见的字符编码如国标码(包括gb2312、gbk、gb18030,应用于中国大陆和新加坡)和大五码(big5,应用于台港澳)也是类似的原理。

事实上,许多不同的编码名称之间可能是等价或近似等价的,只是编码的制订方不同而已。例如,西欧语言的cp1252和latin1非常相近,中文的cp936和cp950分别近似等价于gbk和big5。

维基百科的字符编码条目列举了各种常见的字符编码。Python官方文档中还用表格列举了各种编码规则不同名称的对应关系

对于大陆地区常用的中文编码,简单来说:gb2312支持常用的简体汉字,gbk/cp936支持常用的简体和繁体汉字,gb18030支持更多的汉字和部分少数民族字符,几者之间的关系为:ASCII < gb2312 < gbk/cp936 < gb18030(其中<表示包含于的关系)。虽然gbk/cp936也支持繁体汉字,但不要与big5/cp950混为一谈,二者是不同的编码方案,只是支持的字符有交集而已:

# 示例-2
>>> s = '中華'
>>> s.encode('gbk')
b'\xd6\xd0\xc8A'
>>> s.encode('big5')
b'\xa4\xa4\xb5\xd8'

想要了解更多相关细节,可以分别查看各种编码的维基条目,或者google相关的文章。

Unicode

前一种扩展ASCII的方案的主要缺点是各种编码各自为政,这便导致不同系统之间字符集可能无法兼容,一个常见的问题便是在一台电脑上保存的文本文件复制到另一台不同代码页设置的电脑上会显示乱码。例如字节流0xa1a2,在latin1、gbk、big5不同的编码方案中对应的就是不同字符:

# 示例-3
>>> b = b'\xa1\xa2'
>>> b.decode('latin1')
'¡¢'
>>> b.decode('gbk')
'、'
>>> b.decode('big5')
'﹜'

于是人们想要统一,由此产生的第二种方案便是Unicode,一个类似于巴别塔的计划(不同的是Unicode成功了)。关于Unicode组织与国际标准化组织的ISO-10646工作组分别不约而同地制订统一字符编码方案、后来发现彼此的存在从而开始协作和共享、最终却依然各自发布标准的迭事可以参看Unicode维基条目。二者的编码方案是兼容的,差异主要是实现方式。我们接下来只谈如何理解Unicode。

Unicode可以分为编码方案实现方式两个层面来看。

编码方案定义了字符与码位(Code point,可理解为一个整数值)的对应关系。Unicode定义了1114112个码位(当然并非所有码位都对应有字符,有些是保留为特殊用途,有些是暂时尚未定义,有些是预留为私有空间),划分为17个字符平面,编号0-16,每个平面包含65536个码位,其中编号为0的平面最为常用,称为基本多文种平面(Basic Multilingual Plane, BMP)。Unicode码位的表示方式是“U+”加上十六进制的码位编号,BMP的码位编号为4位的十六进制数,范围是U+0000到U+FFFF,BMP之外的其它平面的码位,编号为5到6位的十六进制数。

平时我们提到Unicode字符,绝大多数情况指的都是BMP的字符。其中,U+0000到U+007F的范围与ASCII字符完全对应,U+4E00到U+9FA5的范围定义了常用的中文字符(这些字符也都在GBK字符集中)。如在SQL Server上借助自然数辅助表可轻松查看常用Unicode字符的码位:

# 示例-4
SELECT
    [码位(dec)] = n,
    [码位(hex)] = CAST(n AS binary(2)),
    [Unicode字符] = NCHAR(n),
    [UCS-2LE编码] = CAST(NCHAR(n) AS binary(2)),
    [GBK/CP936编码] = CAST(CAST(NCHAR(n) AS varchar(2)) AS varbinary(2)) --只在数据库排序规则为Chinese_PRC_XXX时有效
FROM dbo.Nums
WHERE n BETWEEN 32 AND 126 --ASCII,略去控制字符
    OR n BETWEEN 19968 AND 40869 --中文字符
    OR n BETWEEN 65281 AND 65374 --全角标点字母数字,对应半角为n-65248的ASCII字符
    OR n = 12288 --全角空格,对应半角空格为32
--以上查询就是gbk编码的主要字符

补充:以上查询在Python中近似等价于以下语句(为避免输出过多,只显示边界值):

def print_unicode(n):
    ch = chr(n)
    print('{}\t{}\t{}\t{}\t{}'.format(n,
                                      hex(n),
                                      ch,
                                      ch.encode('utf-16le'),
                                      ch.encode('gbk')
                                      ))

for i in (32, 126,
          19968, 40869,
          65281, 65374,
          12288):
    print_unicode(i)

实现方式定义了每个码位如何以字节流的方式表示。ISO-10646的标准把Unicode称为通用字符集(Universal Character Set, UCS),相应的实现方式以“UCS-”加上编码所用的字节数命名。如UCS-2用2个字节编码,只能表示BMP中的字符,UCS-4用4个字节编码,可以表示所有平面的字符,这两种实现方式都是定长编码。另一种实现方式来自Unicode标准,名为通用编码转换格式(Unicode Translation Format, UTF),常用的实现方式以“UTF-”加上编码所用的基本位数命名。如UTF-8以8位单字节为单位,将不同码位映射到一组字节,BMP字符在UTF-8中被编码为1到3个字节(其中,U+0000到U+007F的ASCII字节在UTF-8中是1个字节(与ASCII编码相同),中文字符在UTF-8中通常是3个字节),BMP之外的字符则映射为4个字节;UTF-16以16位双字节为单位,BMP字符为2个字节,BMP之外的字符为4个字节;UTF-32则是定长的4个字节。这三种实现方式都可以表示所有平面的字符。

在这些常见的实现方式中,通常人们提到Unicode,往往指的是UCS-2,这是UTF-16的子集(即BMP部分),由于BMP之外的字符很少用到,因而UCS-2和UTF-16在多数情况下可近似视为等价。UCS-4和UTF-32是等价的,但目前使用比较少。

无论是2字节的UCS-2/UTF-16还是4字节的UCS-4/UTF-32,既然需要用多个字节表示一个字符,便涉及了字节序(byte order)的问题。这是由于早期的处理器对内存地址解析方式的不同:比如对于一个2字节的内存单元(值为0x4E59),PowerPC、68000等处理器以内存的低地址作为最高有效字节,从而认为这个单元是U+4E59(乙),x86、x64等处理器以内存的高地址作为最高有效字节,从而认为这个单元是U+594E(奎)。前者被称为大端(Big-Endian),后者被称为小端(Little-Endian)(这组概念来自于《格列佛游记》一书中描述的小人国战争,战争的起因是关于吃鸡蛋应该从大的一头(Big-Endian)还是从小的一头(Little-Endian)敲开)。Unicode的处理措施是引入一个特殊字符U+FEFF,称为BOM(Byte Order Mark),相反的U+FFFE在Unicode中是不存在的。通过在一个文本的开头写一个BOM,比如0xFEFF4E59,这样程序就可以知道这是一个大端格式的文本,反之,如果是0xFFFE4E59,这便是一个小端格式的文本。尽管现代处理器很多都可以通常设置选择支持大端或小端,然而由于历史数据的大量存在,处理字节序和BOM就成为Unicode编码必须面对的问题。

(补充:在扩展ASCII的方案中的CJK编码,如gb2312、gbk/cp936、big5/cp950,汉字部分也是两个字节,但这些编码依然是以单字节为单位处理的:如果一个字节的最高位是0,则是ASCII字符,如果一个字节的最高位是1,则这个字节和其下一个字节其同组成一个汉字。而UCS-2/UTF-16是以双字节为单位进行处理的,所以才有字节序的问题。注意二者的区别。)

UTF-8是以单字节为单位进行编码,因而不存在字节序的问题。UTF-8完全兼容ASCII字符,从而可以很好地与历史数据保持兼容。而且UTF-8的编码方式使得当表示某个字符的一组字节出现错误时,不影响下一组字节的处理(而gbk常常会出现一个地方错误就很长一段乱码的情况)。这些优点,使得UTF-8成为在网络和通信中广泛使用的一种Unicode字符编码方案。

BOM造成的混乱余波未了:尽管UTF-8无关字节序,而有些程序(比如Windows平台的notepad)会在UTF-8格式的文本开头也加上BOM(U+FEFF对应的UTF-8编码是0xEFBBBF),这在一些情况下会造成文本解析的问题。

Unicode标准把编码方案和实现方式分离开,好处是允许不同实现方式的存在(如UCS和UTF),负面效果却是加剧了字符编码的复杂局面。我们列举一下常见的Unicode字符编码:UCS-2LE/UTF-16LE(小端)、UCS-2BE/UTF-16BE(大端)、UTF-8,虽然它们内部采用的是相同的码位映射标准,然而不同的实现方式使得它们需要当作不同的字符编码来对待;再考虑是否添加BOM,问题的复杂度再乘以2。

字符编码相关的问题领域

简单来说,只要是在计算机中处理文本(字符串),就必然与字符编码相关。在此粗略梳理一下与字符编码关系密切的几个问题领域。

1. 输入与输出(显示)

这是人机交互的必备元素。

输入法定义了一个或一组按键与一个字符的对应关系。输入法程序需要把这个字符以正确的字符编码转换为字节流输入到一个应用程序(编辑器、浏览器、聊天工具等)中。

字体文件定义了每个字符在屏幕或打印机下的显示方式(每个字符本质上是一个点阵或矢量图)。

当一个人对着电脑打出一个字,整个人机系统的处理是这样的:

    人按键 --(输入法)--> 字符 --(字符编码)--> 电脑中存储的字节 --(字符编码)--> 字符 --(字体库)--> 人在屏幕上看到

2. 文本文件

对于计算机来说,不存在文本文件与二进制文件的区别(都是字节流)。只不过是人类为了使用方便,把经常需要用文本编辑器来编辑操作的文件视为一种与一般二进制文件不同的特殊形式。人们把人类容易理解(human-readable)的文本以指定字符编码转换为字节流写入文件,这便是文本文件。比如在Windows的notepad中,通过“另存为”文件对话框,可以选择字符编码,其中,ANSI、Unicode、Unicode big endian、UTF-8分别对应系统代码页(在区域和语言设置中设定。简体中文系统为代码页936,即cp936/gbk)、UCS-2LE、UCS-2BE、UTF-8四种字符编码(后三种编码默认都会写BOM)。

当一个文本编辑器在打开一个文本文件时,则需要识别该文件使用的字符编码。依然以notepad为例,若一个文件没有BOM,则为ANSI(系统代码页)编码,若一个文件带有BOM,根据BOM的不同,记事本程序可以判断是哪一种Unicode编码。

然而这只是Windows一家的标准。一个来自互联网的文本文件可能创建于各种操作系统平台,并不一定都会写BOM。于是现代的文本编辑器需要用更聪明的方式识别字符编码,即根据不同字符编码的特征来识别。只是根据特征来识别并不能做到完全精准,特别是当文件内容比较少的时候(特征可能不够明确)。比如现在的notepad已经做到了这根据特征识别编码一点,一个网上盛传的趣闻是在notepad中只写“联通”两个字,保存之后再打开,会显示为黑框,原因正是“联通”二字在gbk编码下的字节符合UTF-8的特征,从而导致notepad错误地识别为UTF-8,如果再多打几个字则不会有问题,因为不可能这些字的编码都符合UTF-8的特征。

(另外,文本文件通常都会包含换行符(EOL),关于Windows、Unix/Linux、Mac系统在这一点上的不同可自行参看维基条目,这个问题与字符编码关系不大,但也是文本文件编辑中必须考虑的问题,特此一提。)

文本文件并不仅仅指TXT的纯文本,事实上各种源代码文件(c/sql/html/java等)也都是文本文件。编写源代码文件时也必须考虑字符编码(除非你只用ASCII字符),否则编译器或解释器(Web浏览器可视为HTML的解释器)可能会按照不是你所希望的方式去解析你的代码。

3. 网络通信

网络通信是在不同的计算机系统之间传递信息,因而字符编码的统一约定尤其重要。

通信问题还会涉及到编码解码(encode/decode)和加密解密(encrypt/decrypt)。前者的编码涉及多种情况,或是为了避免使用一些特殊用途的字符(如base64、URL encoding),或是为了增加信息传递的准确性(校验码和纠错码);后者是为了保证信息传递的安全性。这两种情况都是对字节流进行处理,即字符编码的下一环节。不要混淆。

4. 信息系统

信息系统的应用层,不管是C/S还是B/S,都是网络通信的特殊形式。客户端必须以正确的编码来解析,才能正常看到页面的文本,同样,客户端提交给服务器的数据,服务器端也必须以正确的编码才能正常解析。这就要求开发前端程序时使用统一的字符编码约定。(补充:如今UTF-8已成互联网的事实标准。)

信息系统的持久层,即数据存储层,也需要采用约定的字符编码来存储文本信息。

以SQL Server为例,服务器的区域设置和数据库的排序规则决定了该数据库使用的代码页(参看联机丛书:安装程序中的排序规则设置),varchar/char类型使用该代码页的字符编码,nvarchar/nchar类型使用UCS-2LE的字符编码(字符编码与代码页无关,但数据的排序与排序规则设定有关)。如示例-4所示(注意Unicode码位与UCS-2LE编码的区别)。

示例-2展示gbk/cp936和big5/cp950编码的不同,对应的SQL Server示例如下:

# 示例-5
DECLARE @ansi varchar(20), @ucs2 nvarchar(10)
SET @ansi = '中華'  --这两个字实际上是4个字节,根据该脚本的字符编码转换为数据库中的varchar类型字符串
SET @ucs2 = @ansi   --隐式转换:varchar -> nvarchar
SELECT @ansi, @ucs2, CAST(@ansi AS varbinary(20)), CAST(@ucs2 AS varbinary(20))
--以上代码在排序规则为Chinese_PRC_CI_AS的数据库上执行结果:
中華    中華    0xD6D0C841    0x2D4EEF83
--以上代码在排序规则为Chinese_Taiwan_Stroke_CI_AS的数据库上执行结果:
中華    中華    0xA4A4B5D8    0x2D4EEF83

补充:在MySQL中,统一采用UTF-8存储字符值,更简单统一。但MySQL中的utf8编码只支持BMP字符,MySQL 5.5.3之后引入的utf8mb4编码则支持BMP字符和BMP之外的补充字符。