Skip to main content

了解字符编码:ASCII、UTF-8

· 约23分钟
Proca

导语

这,是一个字符串

smile 😊

由于你的计算机只能识别 01 这样的数字,为了让它读懂这个字符串,需要建立字符串中的每个字符到特定内容的映射 (Mapping).

于是,ASCII、GBK、BIG-5、UTF-32、UTF-16、UTF-8等编码应运而生。它们基于对应的字符集 (Charset),将其中字符 (Character)码点 (Code Point) 之间的映射关系,进一步实现为字符计算机储存、传输内容(如二进制数、电脉冲)的映射关系,让我们得以在计算机上使用我们的语言.

为了更好地了解现代计算机所使用的编码规则,我们先从 ASCII 开始.

ASCII

ASCII [/ˈæski/] (American Standard Code for Information Interchange) 是基于拉丁字母的一套电脑编码系统,由 ANSI (American National Standards Institute) 于1967年推出,作为计算机及其他设备的文本字符编码标准。它主要用于显示现代英语,其拓展版本 EASCII 则可以部分支持其他西欧语言,并等同于国际标准 ISO/IEC646.

ASCII 支持的字符包括:

阿拉伯数字:

0123456789

小写英文字符:

abcdefghijklmnopqrstuvwxyz

大写英文字母:

ABCDEFGHIJKLMNOPQRSTUVWXYZ

常用英文符号:

!"#$%&\'()*+,-./:;<=>?@[\]^_`{|}~

控制字符(负责对应换行、回车等特殊的控制功能):

NUL SOH STX ETX EOT ENQ ACK BEL...

之所以说ASCII支持了如上的字符,源自于其所支持字符与码点 (Code Point) 之间的映射关系,如下表:

码点字符码点字符码点字符码点字符码点字符码点字符码点字符码点字符
0NUL1SOH2STX3ETX4EOT5ENQ6ACK7BEL
8BS9HT10LF11VT12FF13CR14SO15SI
16DLE17DC118DC219DC320DC421NAK22SYN23ETB
24CAN25EM26SUB27ESC28FS29GS30RS31US
32(space)33!34"35#36$37%38&39'
40(41)42*43+44,45-46.47/
480491502513524535546557
56857958:59;60<61=62>63?
64@65A66B67C68D69E70F71G
72H73I74J75K76L77M78N79O
80P81Q82R83S84T85U86V87W
88X89Y90Z91[92\ 93]94^95_
96`97a98b99c100d101e102f103g
104h105i106j107k108l109m110m111o
112p113q114r115s116t117u118v119w
120x121y122z123{124|125}126~127DEL

如果对于控制字符的作用感兴趣,可以点击这里了解.

到目前为止,你也许觉得奇怪:建立十进制的码点与字符之间的映射关系,到底如何有助于计算机储存字符呢?

有这样的疑问很正常,因为上表中我们所讨论的只是ASCII字符集 (Charset),而非ASCII字符编码 (Character encoding)。前者只是字符及其对应码点的集合,不代表字符一定会以对应码点被储存在计算机中,后者才真正定义了字符到计算机储存内容的映射.

ASCII字符编码采用了最简单的编码规则:将ASCII字符集内各个字符所对应的码点,以二进制形式储存在计算机中.

由于ASCII只包括128个字符,使用7个二进制位足以表示其所有字符。然而,计算机通常以字节(Byte) 为基本单位进行读写操作,而单位字节中包括8个位(Bit),亦即8个二进制位。因此,为了方便计算机读写,ASCII字符编码会在码点对应二进制数开头留上一个0.

因此实际上,以我们开头所提到的字符串smile 😊 为例,ASCII字符编码长这样:

(请注意:字符串中含有一个空格)

码点字符二进制码点储存在计算机中的内容
115s111 00110111 0011
109m110 11010110 1101
105i110 10010110 1001
108l110 11000110 1100
101e110 01010110 0101
32010 00000010 0000
😊

看起来很完美,不是吗?

才怪!在上表中,我们无法使用ASCII字符编码表示emoji 😊 !并且,不仅仅是emoji,法语中的重音符(如:à, è, ù)、长音符(如:â, ê, î, ô, û)……,以及日语、中文……

总之,在美式英语使用场景之外,ASCII字符编码都让人捉襟见肘.

为了在计算机上使用本地区的语言,不同国家和地区都开始制定属于自己的编码标准.

在中国大陆,有GB2312(其拓展版本为GBK),使用区位码来记录字符,并向下兼容ASCII;在港澳台地区,有被称作大五码(Big5)的字符集业界标准;在日本,有Shift JIS编码表……

设想一下:在拥有224个国家与地区的地球上,如果每个地区都只是使用自己地区的独特字符集,地区之间的交流将会有多么麻烦!

为了让你感同身受,我们来看一个例子:

你在你的电脑上使用Visual Studio Code编辑代码时,“不小心”地将默认编码设置为GBK,在你的编辑器中,一切显示正常——无论是代码还是中文注释。但当你将该文件发送给你的同事后,不出意外地,你的同事会发现代码中的注释全是些看不懂的文字!

原因就在于Visual Studio Code的默认编码为UTF-8(我们稍后会介绍)而非GBK。储存在计算机中的二进制内容,依据不同的编码标准打开时,呈现的内容截然不同!

为了解决这类问题,人们需要一种更通用、支持更多语言的字符集。1991年,Unicode 字符集发布,每个人都能在支持Unicode的设备上阅读自己的文字。经过多个版本的迭代,Unicode字符集的囊括范围,已由初代的7161个字符(1991年),扩大到如今的144,697个字符(2021年).

现在,我们来详细了解一下Unicode字符集及其对应的各种字符编码.

Unicode

如上所述,Unicode字符集如今囊括了144,697个字符,即拥有144,697个字符与码点之间的映射关系,数量远远大于ASCII字符集所支持的128个字符。而要在计算机上正常使用数量如此庞大的Unicode字符集,仍需实现字符到计算机储存内容的映射关系.

ASCII字符集不同,Unicode字符集拥有不止一种编码格式:有些编码格式原理十分简单粗暴,有些编码格式则稍加灵活与技巧。现在,请你思考一下:如果由你制定Unicode字符集的编码格式,基于Unicode字符集庞大的字符数量,你会如何做?

你可能会想:“哎呀这不简单嘛,直接以码点的二进制形式存进去不就好了嘛!”

接下来,让我们探讨一下这种方案的可行性.

尝试制定基于Unicode字符集的字符编码

还是以我们开头提到的字符串smile 😊为例.

首先,我们需要知道该字符串中各个字符在Unicode字符集中的码点,才能将其转换为二进制数。但Unicode字符集可不像ASCII字符集那样仅仅有128个字符——只靠肉眼查表就能轻松找到目标。因此,对于普通人来说,查询Unicode字符码点最简单的方式是通过网络;但作为一名程序员,我们还有更优雅的方式:

尝试在Google Chrome开发者工具的 console 中输入以下代码:

"s".codePointAt();

期望的输出如下:

115

(在Javascript中,通过调用字符串的codePointAt()方法,可以获取字符串中首字符在Unicode中的码点)

下一步,将码点转换为二进制。同样地,作为程序员,在这一步我们也有更优雅的实现方式:

同样尝试在Google Chrome开发者工具的 console 中输入以下代码:

"s".codePointAt().toString(2);

期望的输出如下:

'1110011'

(在Javascript中,通过调用数字numtoString(base)方法,可以将num转化为base进制,并以字符串的形式输出。其中base的范围为 [2, 36]

重复以上步骤,我们便得到字符串smile 😊中各个字符的Unicode码点及其对应的二进制形式,如下所示:

Unicode码点字符二进制码点
115s111 0011
109m110 1101
105i110 1001
108l110 1100
101e110 0101
3210 0000
128522😊1 1111 0110 0000 1010

现在我们可以直观地感受到:Unicode字符集解决了先前ASCII字符集不支持emoji😊的问题!

但接下来,我们是否可以直接将二进制码点直接存储进计算机中呢?换言之,当我们直接将二进制码点存储进计算机后,计算机能否正确识别每个字符?

尝试从计算机的角度读取我们制定的字符编码

基于上表,当我们直接将二进制码点存储进计算机,我们将得到:

11 1001 1110 1101 1101 1011 1011 0011 0010 1100 0001 1111 0110 0000 1010

现在我们不知所措了——到底是该每次读取1个字节(即8比特),还是每次读取2个字节,还是……?

其实无论我们每次读取多少个字节,我们都不大可能得到我们期望读取出的二进制码点。这是因为,我们表中的二进制码点没有任何一项的位数是2整数幂!并且,即使他们的位数恰巧是2的整数幂,各项的位数也可能不相同。因此当所有字符的二进制码点连接在一起后,我们将束手无措——不知在何处截断.

优化我们制定的字符编码

还记得我们在介绍ASCII字符编码时,是如何优化二进制码点,进而让计算机顺利正确读取的吗?

没错,将二进制码点通过补零操作,达成如下目标:

1 .补零后的二进制码点,其位数为单位字节大小的整数倍.

2 .补零后的二进制码点,不同字符所对应的二进制码点位数相同.

接下来,让我们据此优化我们制定的字符编码.

Unicode字符集目前囊括144,679个字符,使用1个字节——即8比特显然是杯水车薪。那么2个字节——即16比特呢?

2^16 = 65,536

65536比128大了不少,但仍然不够。让我们看看3个字节,即24比特

2^24 = 16,777,216

比144,679大多了!

但实践中,若2个字节不够,我们通常使用4个字节,即32比特.

于是,为了优化我们制定的字符编码,我们把先前讨论的二进制码点长度补全到32比特

Unicode码点字符二进制码点32位二进制码点
115s111 00110000 0000 0000 0000 0000 0000 0111 0011
109m110 11010000 0000 0000 0000 0000 0000 0110 1101
105i110 11010000 0000 0000 0000 0000 0000 0110 1001
108l110 11000000 0000 0000 0000 0000 0000 0110 1100
101e110 01010000 0000 0000 0000 0000 0000 0110 0101
3210 00000000 0000 0000 0000 0000 0000 0010 0000
128522😊1 1111 0110 0000 10100000 0000 0000 0001 1111 0110 0000 1010

现在,我们可以把32比特的二进制码点储存进计算机。此后,在计算机要读取字符时,它只要按照顺序,每次读取32个比特,就可以正确地截断读取范围,进而解析出字符了!

反思:我们制定的字符编码还有什么明显的问题吗?

要找出问题似乎还真不容易:我们已经能够表示Unicode字符集的所有字符,也已经能够让计算机正确读取字符。ASCII字符编码存在的问题似乎都被我们解决了.

但探索真理的过程并不会这样一帆风顺。现在,让我们再仔细观察上面的表格,从空间利用的角度思考一下:我们制定的字符编码缺点在哪?

相信你已经看见了,我们在补零的过程中,补充了太多的0

就字符s来说,同样一个字符,在ASCII字符编码里面表示为0111 0011,只需要1个字节;但其在我们制定的编码里面表示为0000 0000 0000 0000 0000 0000 0111 0011,足足用了4个字节!占用的空间是使用ASCII字符编码的4倍!

对于所有的英文字母、数字也是如此,这对于英文使用者来说实在是太不友好了.

实际上,对于中文使用者来说也不友好——同样的一个汉字在GBK中只需要2个字节,而在我们制定的字符编码中需要4个字节!占用的空间是GBK2倍!

其实,我们上面所探索出来的“字符编码”,正是UTF-32字符编码(Unicode Transformation Format - 32)。正如我们看到的那样,UTF-32的空间利用效率并不高,日常使用中,UTF-32远没有此后推出的UTF-8流行——原因正在于UTF-8高效地利用了空间.

UTF-8使用了何种编码方式?又是如何解决UTF-32的空间利用效率问题的?相信接下来的内容可以解答你的疑惑.

UTF-8

在我们上述的讨论中,似乎存在着这样的“电车难题”.

train

别担心,UTF-8作为字符苍生的救星,它将帮助我们解开“电车难题”.

我们知道,UTF-32空间利用效率不高的原因主要是:任意字符都以4字节长度的编码存储,大量的无用信息占用了大量的空间!比如对于任意英文字母编码,3字节的空间都被用来记录0。于是UTF-8为了节约存储空间,对于不同的字符,设置了不同的编码长度:1字节、2字节、3字节、4字节,即可变长度编码(Variable Length Encoding).

具体上,它是这么做的:

码点范围二进制码点储存在计算机中的内容占用字节数
0-1270zzzzzzz0zzzzzzz1
128-204700000yyy yyzzzzzz110yyyyy 10zzzzzz2
2048-65535xxxxyyyy yyzzzzzz1110xxxx 10yyyyyy 10zzzzzz3
65536-1114111000wwwxx xxxxyyyy yyzzzzzz11110www 10xxxxxx 10yyyyyy 10zzzzzz4

在UTF-8中,码点越小的字符,其所占用的空间便越小。要达成这一点有多种方式,比如直接将二进制码点存储进计算机中——就像我们起初探索Unicode字符编码时想做的那样。这么做的确能减少空间占用,但我们的计算机将无从得知在何处截断一连串的字符编码.

针对此问题,UTF-8使用了一种巧妙的方法来解决:

对于0-127码点范围的字符,使用1个字节存储,内容上与码点的二进制形式无异,以0开头,表明该元组序列为单元组序列.

对于128-2047码点范围的字符,使用2个字节存储,第一个字节以110开头,第二个字节以10开头。110表明该多位元组序列为2位元组序列.

对于2048-65535码点范围的字符,使用3个字节存储,第一个字节以1110开头,后续字节以10开头。1110表明该多位元组序列为3位元组序列.

对于65536-1114111码点范围的字符,使用4个字节存储,第一个字节以11110开头,后续字节以10开头。11110表明该多位元组序列为4位元组序列.

于是,通过在每段字符编码的开头添加具有特殊含义的内容,计算机得以明确每个字符的截断范围.

终于!我们既让更靠前的字符占用的空间更小;又让计算机能够正确地截断对应字符的编码。相比一视同仁所有字符的UTF-32,UTF-8简直是外星科技般的存在!

总结

从ASCII到UTF-8,你已经大致了解了字符编码的有关内容。你了解了计算机是何以“读懂”我们的文字,也理解了何为字符集字符编码码点,还探究了一些常见的字符编码。

在字符编码方面,本文没有提及的概念、原理与细节还有很多很多。若读者想加深对这方面知识的理解,可自行查阅相关资料、整合信息,并内化为自己脑中的知识。这是作为一名程序员的基本修养

支持一下

暂无评论,来留下友好的评论吧