深入理解字符编码


作者:littlewhite

大概每个人在使用软件时都遇到过乱码的问题,这是由于字符的编码和解码方式不一致导致,我们知道计算机只认识二进制数据,因此程序在处理、存储、传输文本时,需要将文本转化成二进制,通俗的来讲,编码就是将字符转化为二进制序列,解码就是将二进制序列转化为字符

编码模型

先抛开ascii,gbk,unicode,utf-8这些概念,我们先来想想如果自己要在计算机中表示所有字符该怎么办

字符集

首先我们要确认需要表示的字符集合,字符集是一个抽象的概念,它与编码无关,比如英文字符,数字标点,中文字符,这些都可以称之为字符集

编码集

为了表示这些字符集,我们需要将字符集中的每个字符进行惟一标识,最简单的方式就是将字符映射到一个非负整数,这个整数集合就是编码集合,每个字符集都对应自己的一个编码集

编码方案

由于计算机处理的是二进制数居,为了方便存储和传输,我们要将编码集中的整数表示成二进制序列,有些整数用一个子节就可以表示,有些可能需要多个子节,这个转化方式就是编码方案

有了以上的概念,我们再来理解ascii,gbk,utf-8

ASCII

计算机最早由老外发明,因此最早的ASCII编码只考虑到了英文字母的表示,

  • 字符集:英文字母、数字、标点、控制字符(回车,制表符等)
  • 编码集:由于这些字符数量有限,它的编码集也很小,只需要0-127的整数就可以表示,
  • 编码方案:存储这些编码也很简单,只需要一个子节,即将字符的编码直接转化为一个子节的二进制数据即可

比如字符A的编码值为65,二进制存储为01000001

gbk

中文博大精深,中文字符也远远多于英文字符,这样长度只有一个字节的ascii编码就无法表示数量庞大的中文字符,于是就有了gb系列的编码,其中gbk是gb系列编码的扩展

  • 字符集:ascii字符+中文
  • 编码集:每个字符用两个子集表示,编码集为0-65535(没有完全覆盖),理论上最多可以表示65536个字符,这可以表示绝大多数汉字
  • 编码方案:ascii字符保持不变,用一个字节表示,中文字符用两个字节表示,第一字节的范围是81–FE,第二字节的一部分领域在40–7E,其他领域在80–FE

比如“中”编码值为54992,十六进制为0xD6D0。gbk兼容ascii编码,事实上所有编码都兼容ascii编码。另外微软的CP936编码被视为等同于gbk

unicode

中文的编码是解决了,但是其它语言的编码怎么办呢,总不能每个国家都搞一套编码方案吧,而且在互联网时代,很多信息都是共享的,于是需要一种能表示所有字符的编码方案,unicode就是这样的

  • 字符集:所有语言的所有字符
  • 编码集:unicode是一个很大的集合,可以表示100多万个符号,最长可用4个子节表示一个符号
  • 编码方案:unicode只是规定了每个符号的二进制表示,并没有规定如何存储

比如“中”的unicode编码为十六进制0x4E2D,需要用两个字节来表示,有些字符可能需要3个甚至4个字节来表示,如果都采用定长编码,就会造成存储空间的极大浪费,因为我们知道英文字符只需要一个字节就能表示,于是便有了对unicode的不同实现方案,目前最广泛使用的就是utf-8

utf-8

utf-8是对unicode的一种实现方案,是一种可变长字符编码,也就是说它先基于unicode编码将字符表示成一个二进制,然后采用一种方式去存储这串二进制,它的规则也很简单

  1. 对于单字节的符号,字节的第一位设为0,后面7位为这个符号的unicode码。因此对于英语字母,UTF-8编码和ASCII码是相同的
  2. 对于n字节的符号(n>1),第一个字节的前n位都设为1,第n+1位设为0,后面字节的前两位一律设为10,剩下的没有提及的二进制位,全部为这个符号的unicode码

具体编码规则如下

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

举个例子,字符A的unicode值为01000001,其utf-8编码仍旧为01000001,汉字“中”的unicode编码为0x4E2D(01001110 00101101),在第三行的范围内,需要用三个字节表示,其utf-8编码值格式为1110xxxx 10xxxxxx 10xxxxxx,其中x为从最右边开始填入的unicode编码二进制,不够的补0,得到11100100 10111000 10101101

utf-8有两个主要优点,第一是变长编码,在能表示足够大的字符集的前提下减少了存储空间,第二是采用前缀编码,即任何一个字符的编码都不是另一个字符编码的前缀,这样的好处是在网络传输过程中如果丢失了一个字节,可以判断出这个字节所在字符的编码边界,只会影响这个字符的显示,而不会影响其后面的字符,这不同于gbk这种非前缀编码,gbk每个汉字用两个字节编码,如果丢失一个字节,剩下的一个字节和后面字符的第一个字节可以组成一个新的字符,这样后续的所有字符都错乱了

字符编码的应用

有了以上编码的概念,我们可以分析一些工作中遇到的和编码有关的问题来进一步加深理解

浏览器设置显示编码

最常用的应该就是浏览器里设置编码格式,当浏览器加载网页后,网页文件在内存中的格式其实是对网页文件进行编码后的字节流,这个编码格式是由web服务器决定的,为了显示字符,需要对内存中的字节流进行解码,即浏览器要做的事是

decode(二进制字节流) -> 字符

假设该段内存是以utf-8进行编码,那么以utf-8的方式来解码就可以正常显示,而如果以gbk的方式来解码,就会显示乱码

python的unicode字符

在python中有两种类型的字符串,一种是str,一种是unicode

str是面向字节的,存储的是已经编码后的二进制,unicode是面向字符的,它是一个抽象层的概念。最典型的差别就是,当我们计算长度时,str是计算字节的长度,而unicode是计算字符的长度

>>> len('中国')
6
>>> len(u'中国')
2

str和unicode可以互相转化,str到unicode是解码,unicode到str是编码。字节解码为字符,字符编码为字节

>>> '中国'.decode('utf-8')
u'\u4e2d\u56fd'
>>> u'中国'.encode('utf-8')
'\xe4\xb8\xad\xe5\x9b\xbd'

编码解码都需要指定编码方式,解码时指定的编码方式必须和二进制数据的实际编码方式一致,这里因为我终端默认采用的是utf-8编码,所以用utf-8方式来解码,如果用gbk来解码就会报错

>>> '中国'.decode('gbk')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'gbk' codec can't decode bytes in position 2-3: illegal multibyte sequence

unicode字符往往在程序内部逻辑使用,而需要存储或网络传输时,则需要将unicode字符编码成二进制字节流,如果我们直接将unicode字符直接写文件是会报错的,而写str类型数据则不会

>>> file = open('xx', 'w')
>>> file.write('中国')
>>> file.write(u'中国')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)

这是因为unicode只是用来描述字符,而文件存储和传输的对象是二进制数据,所以需要将unicode字符编码才行,将unicode写文件时默认采用ASCII编码,中文的unicode编码值超出了ASCII编码的范围。解决上面的问题有两个方式,一个是在write时将unicode字符进行编码,比如用utf-8或gbk,一个是打开文件时指定编码格式,使用file = codecs.open('xx', 'w', 'utf-8')即可

但是用sys.stdout.write将unicode字符写到标准输出,或者print一个unicode字符时却不会报错,这是因为python能通过sys.stdout.encoding获取到标准输出的编码格式,实际上在向标准输出写的过程中内部已经进行了转码

在不同编码的文件之间复制文本

我们用编辑器打开两个编码不同当文本,假设文本A的编码方式是A,文本B的编码方式是B,你会发现在文本A中复制的字符粘贴到文本B中,并不会出现乱码

编辑器加载一个文本,其实内部存储的是这个文本编码后的二进制数据,复制粘贴的操作实际上是二进制数据的交互,然后以对应的编码方式解码,一段以A方式编码的内存按B方式解码,显示出来的字符应该是乱码,但其实操作系统的剪贴板在中间做了转换,我们把剪贴板也看作一段内存区域,只不过它是固定以unicode方式编码的(utf-8或utf-16,根据操作系统而不同),之所以用unicode,是因为可以表示所有语言的字符,当我们复制A编码的文本时,会向剪贴板的内存中写入这段字符的unicode编码数据,当我们往B中复制的时候,再将这段unicode编码数据转换为以B方式编码的数据,所以文本B中不会出现乱码

windows下的clipspy或Mac下的clipboard viewer软件可以监视剪贴板的二进制数据

vim的编码

vim的使用环境比较复杂,可以在操作系统里以gui的形式打开,也可以在终端打开,还可以通过ssh远程打开,vim和编码相关的设置主要有以下几个

  • fileencodings 一个编码列表,打开文件时会根据这个列表来识别文件编码
  • fileencoding 保存文件时的编码格式,如果未指定,则为fileencodings中匹配的编码
  • encoding vim内部使用的编码,如buffer,menu等
  • termencoding 在终端环境下使用Vim时,通过termencoding项来告诉vim终端所使用的编码,默认和encoding一致

这里我们主要讨论前三个

文件读写过程中的编码转化过程如下

  1. 读取文件二进制数据,根据fileencodings指定的编码顺序依次匹配,若没有匹配成功,则显示乱码
  2. 文件已经解码为字符,根据encoding指定的编码将字符编码为二进制数居,编码结果保留在内存中
  3. 保存文件时,将内存中的二进制数据按encoding的方式解码,然后按照fileencoding的方式编码,编码结果写入文件

读是从文件二进制到内存二进制的转换,写是从内存二进制到文件二进制的转换,这其中都经历了一次解码和编码。内存相当于一个中转站,不管读写都要经过内存,如果我们改变fileencoding或encoding会出现什么问题呢

  1. 修改fileencoding
    假设encoding为utf-8,文件为gbk编码,打开文件后内存数据保存的是文件二进制数据经过gbk解码,再经过utf-8编码后的结果,如果我们此时设置fileencoding为utf-8,然后保存,会将内存数据以utf-8解码,再按utf-8方式编码后写入文件(可能判断解码和编码格式一样而直接写文件,但其过程仍就可以用一次解码和编码来描述),其实这个过程就是转换了文本的编码格式,重新打开后文件字符还是一样的,只是存储的字节不一样了

  2. 修改encoding
    假设encoding为utf-8,文件编码也为utf-8(假设为N个中文字符),打开文件后内存数据是utf-8编码后的结果(字节数为3N),这时将encoding改为gbk,然后保存,此时由于fileencoding为utf-8,所以会将内存数据先按照gbk的方式解码(3N/2个字符,这一步可能解码失败),再按照utf-8的方式编码(字节数为3N/2*3)后写入文件,这时文件字节数为4.5N,既不是utf-8(3N字节)编码也不是gbk(2N字节)编码,这样就乱码了,所以encoding不要轻易修改,一般设置为和操作系统编码一致

  3. 修改fileencodings
    打开一个utf-8编码的中文文件,修改fileencodings为gbk,通过:e命令刷新缓冲区,这时发现字符全部乱码了,其实这个结果是将utf-8编码的二进制文件内容按gbk方式解码得到的字符,通俗来讲就是切换显示编码,和浏览器里切换编码格式的效果一样,只影响字符的显示,不影响存储,但是这时如果插入新文本则会以gbk方式编码,会导致原来的文本是utf-8,新增的文本是gbk,这样无论以哪种方式显示都会产生乱码