一文理解字符串编码

更新时间:May 11, 2018

作者:Windson Yang

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处(www.enginego.org)。

在打开网页或者文件的时候,你一定会遇过像这样的字符串乱码问题:

É��OÇ��,系统包括了...

或者报错

UnicodeDecodeError: 'ascii' codec can't decode byte 0xc4

like_unicode

同时或多或少也遇到Unicode, UTF-8, ASCII, Latin-1这些编码术语。要真正地理解字符串编码,首先要了解计算机的一些基础知识,包括字符串如何存储与转换。如果只是希望靠运气来解决或者避开它,反而会在一次次盲目的尝试中浪费更多的时间。

  1. 基础术语
  2. 计算机如何存储数据
  3. ASCII编码
  4. GBK编码
  5. Unicode
  6. UTF-8编码
  7. HTML实体编码
  8. URL编码
  9. 常见问题
  10. 总结

基础术语

  1. 字符
  2. 字符串
  3. 键值表
  4. 字符串编码与解码

字符

A B C 天 气 エ ン コ 😁

上面的用空格分割的都是单个字符(Character),它代表对人类能看懂的有意义的语言文字。

字符串

Hello 天气 Hola

字符串(Strings)就是多个字符组成的集合

键值表

一一对应的表,函数

y = x * 2

中每个x都对应着唯一的一个y值,x与y组成的集合就是键值表(Hash Table),例如:

1 -> 2
2 -> 4
3 -> 6

这里的每一个x(1, 2, 3)都有对应的y(2, 4, 6)

字符串编码与解码

编码指将字符串按照一定的模式(按照键值表的转换)转换成二进制数字,然后显示或者存储。如果按照上面的键值表,要把字符串”123”存储起来的话,先要转换成对应的”246”。因为计算机实际存储的是二进制数据,所以计算机实际存储的值是

0010 0100 0110
   2    4    6

与编码相反,字符串解码(decode)就是把二进制数据按照一定的模式转换成字符串显示。

计算机如何存储数据

因为早期的计算机存储资源非常宝贵。所以计算机科学家们希望用最少的空间来存储字符。同时,计算机是使用二进制存储数据的,无论是文字,图片,数字还是其他数据,都是以数字”0”或者”1”存储起来的。举个例子,如果计算机要存储”BEE”这个字符串,它先根据一个字母与数字的转换表把字母转换成二进制然后存储。这里我们使用一个简单的对应表叫做EngineGo表,EngineGo表用3位的二进制数字就能表示8种不同的字符。

EngineGo表:

二进制 字符 二进制 字符
000 A 100 E
001 B 101 F
010 C 110 G
011 D 111 H

当我们打开文件编辑器,添加“BEE”这3个字母并保存的时候,计算机会根据EngineGo表存储数据

001 100 100
  B   E   E 

当读取文件的时候,计算机会猜测应该使用哪个表来把二进制数据还原为字符串,如果它猜对了,使用EngineGo表还原的话,就会重新得到”BEE”这个字符串。不过如果计算机猜错了,使用其他键值表来打开这个文件的话,最终可能会报错,也可能是乱码,大部分的乱码都是因为这样而出现。理解了这个之后,其实就很容易理解字符串编码和解码了!

ASCII编码

计算机最初由西方国家设计以及发展,理所当然他们使用了英文作为常用的字符集,字符集包括大小写字母,数字加上一些标点符号和运算符号大概120个。3位二进制数字只能表达8个不同的字符,明显不够,最简单的解决方案就是使用更多的位来保存,7位能表示128个字符,加多1位用作错误检查。最后选择使用8位来存储字符,称为一个字节。8位数字对应的表就更长了(为了方便阅读把二进制转换成了十进制):

编号 字符 编号 字符 编号 字符 编号 字符
0 NUT 32 (space) 64 @ 96
1 SOH 33 ! 65 A 97 a
2 STX 34 66 B 98 b
3 ETX 35 # 67 C 99 c
4 EOT 36 $ 68 D 100 d
5 ENQ 37 % 69 E 101 e
6 ACK 38 & 70 F 102 f
7 BEL 39 , 71 G 103 g
8 BS 40 ( 72 H 104 h
9 HT 41 ) 73 I 105 i
10 LF 42 * 74 J 106 j
11 VT 43 + 75 K 107 k
12 FF 44 , 76 L 108 l
13 CR 45 - 77 M 109 m
14 SO 46 . 78 N 110 n
15 SI 47 / 79 O 111 o
16 DLE 48 0 80 P 112 p
17 DCI 49 1 81 Q 113 q
18 DC2 50 2 82 R 114 r
19 DC3 51 3 83 S 115 s
20 DC4 52 4 84 T 116 t
21 NAK 53 5 85 U 117 u
22 SYN 54 6 86 V 118 v
23 TB 55 7 87 W 119 w
24 CAN 56 8 88 X 120 x
25 EM 57 9 89 Y 121 y
26 SUB 58 : 90 Z 122 z
27 ESC 59 ; 91 [ 123 {
28 FS 60 < 92 / 124
29 GS 61 = 93 ] 125 }
30 RS 62 > 94 ^ 126 `
31 US 63 ? 95 _ 127 DE

早期计算机科学家们统一用这张表作字符串编码,称为ASCII编码。存储和读取也是像使用EngineGo表一样简单。这时候存储”BEE”就会存储为:

1000010 1000101 1000101
     66      69      69
      B       E       E

非常简单吧。

GBK编码

ASCII编码只能表示128个字符。遇到中文,常用字就几千个肯定应付不来,其他亚洲语言也遇到这样的问题。所以一开始国内使用的并不是ASCII编码,而是GBK编码。本质其实也一样,一张更大的表,用更多的位来表示字符。GBK的编码方式比较有趣,是可变长度的编码,它为了兼容ASCII编码,使用了单字节编码和双字节编码。 如果遇到一个小于127的字符,那么编码方式就与ASCII表一样,遇到大于127的字符,就表示一个汉字。也就是说当我们把”BEE”用GBK编码存储的时候,它和使用ASCII表存储的二进制数据是一样的。 这里我们以Python2为例:

1000010 1000101 1000101
      B       E       E

# encode是Python中编码的方法
>>> foo = 'BEE'.encode('ascii')
>>> bar = 'BEE'.encode('gb2312')
>>> foo == bar
True
# 虽然bar是使用gb2312编码,但是因为只包含ASCII中的字符,所以可以用ASCII来解码
>>> bar.decode('ascii')
u'BEE'
>>> u'BEE' == 'BEE'
True

从上面的输出可以看到,如果只是存储ASCII表出现的字符,那么大部分编码表保存的结果都是一样。都能被ASCII解码,因为它们都需要兼容ASCII表

不过如果我们要存储中文的时候,就不一样啦,例如存储“你好”,GBK编码会把这个字符串编码成

11000100 11100011 10111010 11000011

“你”和”好”这两个字符是用两个字节保存的。 这时候使用python2就会报错:

>>> '你好'.encode('gb2312')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe4 in position 0: ordinal not in range(128)

出错的原因是因为python2当遇到ASCII表中没有的字符的时候,默认会把它们使用utf-8编码来存储,但是gbk编码表无法对utf-8编码进行解码,这句话你可能需要读完文章才能理解。

# \x代表使用了16进制,e4 bd a0 e5 a5 bd是你好的utf-8编码 
>>> '你好'
'\xe4\xbd\xa0\xe5\xa5\xbd'
# 需要先解码再编码
>>> '你好'.decode('utf-8').encode('gb2312')
'\xc4\xe3\xba\xc3'

另外常见编码错误就是使用错误的编码保存字符串,例如使用ASCII表保存”你好”,因为ASCII表里面没有对应的字符,它不知道如何保存。

>>> '你好'.encode('ascii')
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)

同理,使用ASCII解码无法解析使用GBK编码保存包含非ASCII字符的二进制数据

# decode是Python中解码的方法
>>> foo = '你好'.decode('utf-8').encode('gb2312')
>>> foo
'\xc4\xe3\xba\xc3'
>>> foo.decode('ascii')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc4 in position 0: ordinal not in range(128)

当你使用GBK编码保存文件,而文件里面包含了中文字符,那么别人使用ASCII表就无法解码。不过,如果GBK编码的文件只存储ASCII编码出现的字符,那么解码的时候也能正确解码,其实大多数编码问题都很好解决,只需要在文档的信息上面添加这是用什么编码的,打开的时候选择对应的解码就好,不过另外一个问题就比较头痛,很多国家都开始使用自己的编码,例如日本使用Shift JIS,韩国使用KS X 1001。而且即使是中文,还有繁体中文,简体中文,编码经过发展也有几个不同版本。这样下去就越来越乱了,即使你知道文档里面存的是中文,也不知道用哪个中文编码才能正确打开,当文档中没有包含编码信息的时候,计算机就会猜测是什么编码。

>>> hello = "你好"
>>> hello_gbk = hello.decode('utf-8').encode("gb2312")
# 'ト羲テ'
>>> hello_gbk.decode("Shift-JIS")
u'\uff84\u7fb2\uff83'
>>> hello_gbk.decode('ascii')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc4 in position 0: ordinal not in range(128)

“你好”经过GBK编码变成4个字节,从这4个字节,计算机无法知道它原本是什么编码的,它尝试用用日本的Shift JIS编码来解码,这个情况下没有报错,因为Shift JIS中刚好有这4个字节对应的字符串。如果我们用ASCII表来解码,就会因为找不到对应的字符串而报错。

Unicode编码

如果你足够聪明的话,最简单解决的方法就是,大家都用统一的表达方式,并且用足够多的二进制位来存储世界上所有字符串。这并不是痴人说梦,Unicode就是为此而生,它把每个国家的每个字符都编进去,最新的版本已经有 136,755个字符串了。例如你好,对应的是 U+4F60 U+597D。不同于之前的其他编码,Unicode不是一种存储方式,只是一个标准,它规定了表现形式,至于如何编码和解码则根据不同的方式,这是什么意思呢?

Unicode中把每个字符串都定义了对应的表现形式:

4f60 597d
   你   好

Unicode特别的地方,它只指定了表示形式,而存储形式则可以根据需要而选择,如果根据我们之前的方法,每个字符都保存4个字节的二进制(把你好保存为00004f60,0000597d),那么这就称为UTF-32编码。很直观,既然能用4个字节表达所有字符串了,这没有任何技术上的问题。如果大家都使用UTF-32进行编码和解码的话其实就已经解决我们上面的问题了。

happy

UTF-8编码

新的问题其实是传输量的问题,原本传输”A”这个字符串,使用UTF-32编码的话需要用4个字节。而使用ASCII则只需要一个字节,如果日常生活只需要用英文,加上以前的存储空间非常贵,网络传输速度也慢。西方的国家存储或者传输中平白无故增加三倍的存储量当然不划算。如果把UTF-8编码和Unicode编码直接保存(UTF-32)作对比,UTF-8为了减少存储量,把常用的字符(例如英文字符)用一个字节来表示(其实就是ASCII编码),其他用两到四个字节表示。

character encoding bits
A ASCII 01000001
A Unicode 10011110 01000001(U+0041)
A UTF-32 00000000 00000000 00000000 01000001
A UTF-8 01000001
ASCII 无法表示
Unicode 1001111 01100000(U+4f60)
UTF-32 00000000 00000000 10011110 01100000
UTF-8 11100100 10111101 10100000(e4bda0)

从上表可以看到,使用UTF-8编码之后,A还是只需要一个字节存储,而汉字“你”从4个字节减少为3个字节。把Unicode编码转换成UTF-8编码非常简单,我们首先需要一张转换表:

Unicode范围 转换规则
0x00000000 - 0x0000007F 0xxxxxxx
0x00000080 - 0x000007FF 110xxxxx 10xxxxxx
0x00000800 - 0x0000FFFF 1110xxxx 10xxxxxx 10xxxxxx
0x00010000 - 0x001FFFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
  1. 找出要转换的字符的Unicode范围,我们以“你”为例子,它的Unicode编码是U+4f60,对应的是第三列(07FF<4f60<FFFF),找到右边的转换规则。
  2. 把4f60的二进制1001111 01100000从右到左填入转换规则的x中,空的填0。1001111 01100000 -> 1110xxxx 10xxxxxx 10xxxxxx -> 11100100 10111101 10100000
  3. 最后得到的11100100 10111101 10100000(e4bda0)也就是你的UTF-8编码了。

很可惜,大部分国内的在线转码工具都把Unicode和UTF-8混淆了,如果你尝试汉字转换成UTF-8编码,工具返回的是它的Unicode编码。

example example2

HTML实体编码

编码在其他地方也有非常多的应用,日常接触到的还有HTML实体编码,什么时候需要用到HTML实体编码呢?你可以尝试新建一个后缀为.html的文件,内容为

<!doctype html>
<html>
    <p></div></p>
</html>

保存,然后打开。奇怪,怎么没有把”</div>“这个字符串显示出来呢?😱😱,因为浏览器把”</div>“中的”<“和”>“当成标签的开始和结束解析。要解决这个问题呢?只需要把文件内容更改为:

<!doctype html>
<html>
    <p>&#60;/div&#62;</p>
</html>

一切正常, :D。浏览器解析HTML的时候会把特殊的字符串理解成非字面的含义,所以当需要显示这些特殊字符串的时候,需要经过下表的转换:

显示结果 描述 实体名称 实体编号
空格 &nbsp; &#160;
< 小于号 &lt; &#60;
> 大于号 &gt; &#62;
& &amp; &#38;
双引号 &quot; &#34;
单引号 &apos; &#39;
重音符 &acute; &#96;
© 版权 &copy; &#169;
® 注册商标 &reg; &#174;
商标 &trade; &#8482;
中文 &#x4F60; &#x4F60;&#20320
中文 &#x597D; &#x597D;&#22909

从上表可以看到,**特殊字符串是强制需要转换的,而且实际上,所有字符都可以经过转换表示,只需要用

  • &#x加上其16进制Unicode编码或者
  • &#加上其10进制Unicode编码

作为实体编号即可,浏览器既能解析字符串本身,也能解析其UTF-8编码**

URL编码

URL编码其实也简单易懂,因为RFC3986中规定了URI中不能出现

: / ? # [ ] @ ! $ & ' ( ) * + , ; = 

当要表示这些字符的时候,使用%加上该字符的16进制UTF-8编码来表示,出现非ASCII表的字符也一样,例如在浏览器输入

https://www.example.com/你好

浏览器会自动把不合规定的字符转换成%加UTF-8编码再进行请求(但是在地址栏还是会显示原本的字符“你好”),当你粘贴在文本编辑器的时候就可以看到原本的URL变成:

https://www.example.com/%E4%BD%A0%E5%A5%BD

这里可以看到“你好”使用了它的UTF-8编码表示

常见问题

了解编码之后,python2的编码问题其实也容易理解了。python2是把字符串直接经过utf-8编码保存的

# utf-8编码
>>> "你好"
'\xe4\xbd\xa0\xe5\xa5\xbd'

打印出来的就是”你好”的utf-8编码的16进制(e4bda0->你 e5a5bd->好)

# 获得unicode编码
>>> '你好'.decode('utf-8')
u'\u4f60\u597d'

使用ASCII解码就无法解析使用utf-8编码保存的”你好”

>>> '你好'.decode('ascii')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe4 in position 0: ordinal not in range(128)

进行字符串相加的时候要注意,非ASCII编码的字符,unicode编码和utf-8编码不能直接相加

>>> u'你好' + '费曼'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe8 in position 0: ordinal not in range(128)
>>> u'hello ' + 'world'
u'hello world'

总结

UTF-8编码是现在标准的解决方案,当遇到乱码或者编码出错的时候,先想想原本数据是用什么编码存储的,然后使用对应的方式解码就好。单纯从二进制数据是无法判断它是用什么编码存储的。