硬核基础编码篇(二)ascii、unicode 和 utf-8

3,578 阅读6分钟

在开发过程中偶尔会遇到关于编码、Unicode,Emoji 的问题,发现自己对这方面的基础知识并没有充分掌握。所以在经过一番查找学习之后,整理几篇通俗易懂的文章分享出来。

【硬核基础】系列的几篇文章,感兴趣的朋友可以了解一下:

Intro

我们都知道计算机只能存储比特,要么是 0 要么是 1,而在没有计算机之前,人类所积累的大部分信息都是文本的形式存在。文字,书籍📚这些知识应该如何存储到计算机中呢?

当然也离不开 01,设想一下我们来设计一个系统,用来存储大小写字母(52个字符)和数字(10个字符),总共 62 个字符,可以使用 6 个 bit 来编码,将编码和字符一一映射。

例如可以像下面这样设计:

000000 a
000001 b
...
111100 8
111101 9

注意📢:这个编码示例只是演示,无实际意义!!!

有了一对一的映射之后,文本就可以存储在计算机中了。例如 hello 在我们“系统”中可以存储为 000111 000100 001011 001011 001110

在设计编码系统时,不同的字符展示风格并不会被当成不同的字符处理。同一段文本,它是否加粗/斜体/下划线/字间距,对于阅读者而言,不会将它当成两段不同的文本,对计算机也是。

image.png

一个字符最终呈现还和字体有关,如果字符在当前字体中没有对应的图形展示也会有问题。

这样的“系统“我们称为字符编码集,在刚接触编程的时候最早接触的字符编码集就是 ASCII。

ASCII

我们自己设计的 “系统” 存在很大的缺陷,例如不支持无法存储 hello world,因为我们的编码系统不支持存[空格] 👻。要满足日常最基本的需求,需要更全面的系统,在计算机中最基础的编码系统就是 ASCII。

ASCII 码的全称是 🇺🇸 美国信息交换标准码American Standard Code for Information Interchange。完整的 ASCII 码如下表,前 32 个字符为控制字符(最初设计的时候主要用在电传打字机上,这里面有很多可已经过时了),从第 32 位开始为可显示字符,包含常用的大小写字母,数字和标点符号等等。

为什么 ASCII 编码只有 128 个 ?

从上面的图中可以看到 ASCII 编码只用了 128 位(2 的 7 次方),可能读者会产生疑惑,一个字节都有 8 位,为什么 ASCII 编码不使用 8 位呢?可以收纳更多字符。

原因是最初设计 ACSII (1967 年) 的主要目的是在电传打字机上使用,那时候还没有字节的概念,同时当时寄存器也比较昂贵,编码设计需要尽可能精简,用 7 位编码就完全能够满足美国🇺🇸编码的需求。

后来 IBM 也将 ASCII 扩展到了 8 位,新增了一些字符,EASCII

总所周知,在现代计算机中一个字节是 8 个 bit,即使 ASCII 编码只用到 7 位,在存储时也至少是 8 位。

回车(CR)、换行(NL)和 EOF

在前 32 个字符中,有两个开发者容易产生疑惑的字符,就是回车和换行。

回车换行
charCode1310
hex0D0A
转义字符/正则\n\r
语义Carriage Return (CR)Line Feed (LF)

在 2018 年以前,除 Windows 之外的大多数操作系统都是以 \n 作为换行符。 而 Windows 自带的文本编辑器 Notepad 只支持 EOL 作为换行符,它由 CRLF 这两个字符组成。

所以以前经常遇到别人使用 Linux/macOS 创建的文件,一打开所有代码都在一行,庆幸现在已经修复了。

image.png

VScode 的右下角,可以选择将当前文件以哪种形式作为行尾。

// 使用 LF 保存一个换行
hexdump index.txt
0000000 0a                                             

// 使用 CRLF 保存一个换行
hexdump index.txt
0000000 0d 0a

大小写字母编码 👻

单独观察 ASCII 编码中的所有字母序列,可能还会产生这样的疑惑,编码的顺序是怎么定的,为什么 a 不紧跟在 Z 后面?(好无聊的问题😒😒😒)如果以二进制的形式查看似乎可以发现一些端倪。

A 恰好排在第 64 + 1 位,a 排在了 64 + 32 + 1 的位置,这样看起来字母的编码是有意而为之,好处是当需要切换大小写是只需要做执行一次 ^= 32,机器码执行效率更高!!!

A 1000001    a 1100001
B 1000010    b 1100010
...
Z 1011010    z 1111010
   1100001 a
^  0100000
__________
   1000001 A

局限性

ASCII 编码最大的局限性就是只使用一个字节,最多表示 256 种字符,仅能满足美国人自己使用,即使放到英国也不能完全适用(例如第 36 个字符 $ 是美元符号,没有表示英镑的符号),更别说表示数以万计象形字的亚洲文字。

为了让自己的语言能在互联网上通行,早期各个国家都除了自己语言的编码,中文的 GB2312,日文的 Shift_JIS,韩文的 Euc-kr 等等。各个编码之间存在很大差异,想在一篇文章中展示多种国家的语言是个难题。

image.png

同一个码点在不同编码下对应不同的字符

然而互联网天生就具有普遍性,如果通信过程中使用的编码不一样,在不同机器上表达不同的语义,那就毫无扩展性可言。为了解决这个混乱的局面,在 1988 年开始,几大著名计算机公司合作推出了新的编码系统,Unicode

Unicode

为了解决使用不同编码导致出现乱码的问题,Unicode 给世界上每个字符都赋予一个唯一的数字,我们将这些数字称为码点 (Code Point)。Unicode 的前 128 个字符和 ASCII 一样,在它的基础上扩充到了 21-bit,理论上可以表示 1,114,112 个字符。自 1991 年发布 v1.0 到 2021-09 发布 v14.0 总共包含 144,697 个字符,还有大量的 CodePoint 没有被使用。

码点 Code Point

前面说了,Unicode 就是给世界上每个字符都赋予一个唯一数字,这样在一篇文章里面展示多种不同国家字符的问题就解决了。Code Point 的范围是 U+0000 到 U+10FFFFU+表示这是 Unicode 编码集,后面跟着一个十六进制数。

使用 HTML,可以直接通过 Unicode 展示字符实体,形式是在 &#x 后面加上对应 CodePoint 的十六进制。或者 &#后面加 CodePoint 的十进制数字。

烫 => 烫
烫 => 烫

悟 => 悟
悟 => 悟

image.png

Code Point 和语言、系统、程序无关,所以使用 HTML Unicode Entity 不会受 Content-Type 影响。

字符平面映射 Unicode planes

目前的 Unicode 字符分为 17 组编排,每组称为平面(Plane),而每平面拥有65536(即216)个代码点。然而目前只用了少数平面。

image.png

第一个平面简称为 BMP,看国外相关文章的时候这个简称出现的频率很高,绝大多数 Unicode 字符都在这里面。其他平面可以称为补充平面 Supplementary Planes,补充平面的范围是 U+10000 到 U+10FFFF。比如以下这些字符就是在补充平面:

字符编码
𝐁U+1D401
🀵U+1F035
💩U+1F4A9

从上面的章节我们了解到,Unicode 编码需要使用到 21-bit,至少需要使用三个字节来存储和传输,那么用 Unicode 每个字符都占用三个字节吗?

码元 Code Unit

当然不是,倘若每个字符都是使用三个字节来存储,对于纯英文的文章来说就吃大亏了,前两个字节全都是 0,大量浪费内存空间。

那为什么 ASCII 码不需要考虑使用更高效的编码方式呢?因为它只占用 7 个 bit,无论如何都只需要一个字节。

使用一定的编码方式将一个字符编码进字节,编码后的有限比特长度整型值我们称为码元 Code Unit。常用的编码方式有:UTF-8,UTF-16,UTF-32。

我们用得比较多的是 UTF-8,它是一种变长的编码方式,一个字符使用 UTF-8 进行编码得到的码元,可能占用一个字节,也可能占用四个字节。

UTF-8 编码细节

UTF-8的设计有以下的多字符组序列的特质:

  1. 对于单字节的字符,最高位是 0,编码结果和 ASCII 保持一致。
  2. 多字节序列第一个字节最高位有连续几个 1 决定了字节序列的长度,例如 110 表示编码结果占用两个字节,1110 是三个字节,依次类推。非首字节的起始两位固定是 10

文字表示可能不太清晰,看下面这个表格。

码点位数码点起始码点结束字节序列Byte1Byte2Byte3Byte4
7U+0000U+007F10xxxxxxx
11U+0080U+07FF2110xxxxx10xxxxxx
16U+0800U+FFFF31110xxxx10xxxxxx10xxxxxx
21U+10000U+1FFFFF411110xxx10xxxxxx10xxxxxx10xxxxxx

在本地动手做实验验证一下,创建 index.html 文件,使用 utf-8 编码,执行以下命令。

$ echo -n A > index.html
$ ls -lh index.html
> 1B
$ hexdump index.html
> 41

解析
-n 参数让 echo 命令不要输入结尾的换行符,文件里只包含 A 一个字符。
-l 参数分别让 ls 命令以详细列表形式展示,-h 则是让打印结果对阅读友好(我把不相干的信息隐藏掉了)

可以看到使用 utf-8 编码,字符 A 只占用了一个字节,并且和 ASCII 编码一样,编码结果都是 41(16)。

再试一下 echo 一个中文字符。

$ echo -n 烫 > index.html
$ ls -lh index.html
> 3B
$ hexdump index.html
> e7 83 ab

使用 utf-8 编码,【烫】字用了三个字节,编码结果和 Unicode 的码点不一样。根据上面的表格,将其Unicode 码点 70EB 转成二进制 01110000 11101011 从右往左依次填到 x 的位置,高位补 0 得到

11100111 10000011 10101011

转成十六进制就是 e7 83 ab 了!!!✿✿ヽ(°▽°)ノ✿

懒得手动计算进制之间的转换,可以使用我们在 硬核基础二进制篇(二)位运算 文章学过的,使用 parseInttoStirng 来合理偷懒。

parseInt('10000011', 2).toString(16)

对于在补充平面里的字符也是一样道理,echo 一个 💩,可以看到占用了四个字节。编码逻辑和上面的基本一样,就不赘述了,编码结果可以自行验证。

$ echo -n 💩 > index.html
$ ls -lh index.html
> 4B
$ hexdump index.html
> f0 9f 92 a9

结语

这篇文章我们回顾了 ASCII 编码,UNICODE 编码以及 UTF-8 编码的具体细节,希望对读者理解文本编码这件事情有所帮助。

下一篇文章我们将继续学习 JavaScript 中和编码相关的各种疑难杂症,争取这次一网打尽!

再次,由于时间仓促 && 水平有限,文章中必定存在大量不准确的描述、甚至错误的内容,如有发现还请善意指出。❤️

扩展阅读