一次性搞懂字符集和字符编码(ASCII、GBK、Unicode、UTF-32、UTF-16、UTF-8)

2,878 阅读20分钟

本文详细总结了常见的字符集和字符编码。一步步剖析 UTF-32、UTF-16、UTF-8 的字符编解码过程,以及背后的原因。阅读本文最好有一定的 JavaScript 和 Node.js 基础,也需要对 ArrayBufferTypedArrayDataView 有一定的了解。不过讲的内容都是计算机通用知识。代码演示部分看不太懂也没关系,主要是用来验证讲的东西都是对的😂,大部分代码看英语单词也可以猜到在干嘛。

我们知道计算机的内存或磁盘里存储的数据都是二进制的,那各种字符对应的二进制应该是什么样子呢?对于计算机诞生地的美国人来说,它们一开始要存的字符无非就是英文字母、数字、和一些特殊符号(+-&/?!.等)。所以只需要把这些字符编个号,然后存这些编号就行了。如果用 1 字节的空间来存编号,那么就可以存 2 的 8 次方 (256) 个不同的编号了,即可以用一个字节表示 256 种不同的字符,这足够给上面提到的每一个字符都找一个自己的二进制表示了。

概念阐述

上面说的“编号”准确的叫法应该是 代码点 (code point),而记录了所有我们需要编码的字符和它的代码点的对照表就是所谓的字符集 (Character set),不同的字符集收录的字符可能不同,即使是同一个字符,在不同的字符集里也可能有不同的代码点。但不管怎么说每个字符集都至少有一种把代码点和二进制进行互相转换的规则,这种规则就叫做字符编码 (Character encoding)。一个字符集可能存在多种字符编码方式,所以即使是同一个字符集里的同一字符(同一个代码点),按照不同的字符编码转换成二进制,结果也可能不一样,比如一个“Unicode 字符集”里的字符,如果采用 “UTF-32” 编码转化成二进制,得到的一定是一段 4 字节大小的二进制,但用 “UTF-16” 编码得到的二进制大小可能是 2 字节 也可能是 4 字节,这是因为 UTF-16 编码的 代码单元 code unit 是 2 字节,如果遇到代码点大于 65535 的字符,一个代码单元显然不足以表示,只能采用两个代码单元表示,就会占用 4 字节的空间。介绍完这四个基础概念之后,我们接着看常见的字符集和他们的编码方式。

ASCII 字符集 (American Standard Code for Information Interchange)

它是一个很小的字符集,但也是最常见的字符集。它收录了 128 个字符,并用 0-127 的代码点表示它们,他只有一种编码方式,也很简单,就是直接写出代码点的二进制就行了,所以一个 ASCII 字符只占用一个字节,字节的第一位永远是 0。比如数字 "0" 的代码点是 48(如下图所示),那么它编码后的的二进制表示就是 0b00110000 (0b 表示二进制)、解码时如果读取到的字节是 00110000,我们也就知道了这个字节表示的是代码点为 48 的字符,即字符 "0"。不过我们在书写 1 个字节时,通常为了方便阅读,会写成 2 个 十六进制数,比如刚才那个字节的十六进制表示就是 0x30(0x 表示十六进制)。同样的,像大写字母 A ,它的代码点是 65 ,编码后的字节就是 0x41、小写字母 a ,它的代码点是 97,字节就是 0x61了。

image

ASCII 字符集中除了大小写英文字母和数字外,其中 0 - 31 和 127 的代码点是用来表示一些控制字符的,比如 "ACK"。除去字母、数字、控制字符,剩余的代码点就给了一些特殊符号,比如 +-&/?!. 等。ASCII 编码是一种非常节省空间的编码方式,任何一个 ASCII 字符都只需要一个字节就足以表示了。

GBK 字符集

对于中国人来说,只包含 128 个字符的 ASCII 字符集显然不够,汉字千千万,于是我们就搞了自己的字符集还有对应的字符编码方式。GBK 字符集收录了 2 万多个汉字,以及所有的 ASCII 字符,最重要的是所有 ASCII 字符的代码点在 GBK 字符集和 ASCII 字符集里都是一样的。由于收录的字符很多,所以 1 字节的空间不足以表示 1 个字符,于是 GBK 编码采用 2 个字节来表示一个汉字,但为了避免浪费空间,ASCII 字符只会占 1 个字节

那么解码时如何知道某个字节是在代表一个 ASCII 字符,还是需要再组合一个字节来代表一个汉字呢?GBK 编码采用了一种很巧妙的方式,即 2 字节的字符对应的二进制第一位永远是 1,这样解码的时候,如果遇到 1 开头的字节,就连读 2 个字节按一个字符进行解码,如果遇到 0 开头的字节,这知道这个是 ASCII 字符了,不需要再继续读一个字节放在一起解了。由于这种机制,GBK 实际上无法完全利用 2 的 16 次方 (65536) 大小的空间,因为 2 字节字符的第一位的 1 是固定的,可修改的 bit 只有 15 个,但 3 万多的空间,也足够放下大部分汉字了。

Unicode 字符集

各个公司,各个国家地区都发挥了自己的聪明才智,搞出了各种花里胡哨的字符集和字符编码方式,但互联网的发展要求大家必须得有一个统一的字符集和编码方式,不然怎么在一个网页里同时展示中文、英文、日文、韩文、阿拉伯文。。。总不能来来回回切换字符集和字符编码方式吧?于是就有组织跳出直接搞了一个 Unicode 字符集,这个字符集的代码点足够多,有一百多万个(从 0x0000000x10FFFF),这么一来即使全世界所有语言的字符都放进来也还绰绰有余,甚至放了很多 emoji 😂 (点击此处查看全部 emoji)。既然可以表示这么多字符,那岂不是每个字符也需要更大的空间,不然岂不是会重复?确实如此,需要 4 个字节来表示。但 Unicode 提供了多种字符编码方式,有些编码方式可以帮我们节省空间,比如大家最常听到的 UTF-8,接下来我们就一起了解一下 Unicode 最常见的三种编码方式:

UTF-32 编码

采用 UTF-32 编码的一个 Unicode 字符固定就是用 4 个字节大小。4 个字节的内容就是代码点的二进制。比如字符 "😀" 的代码点是 128512 (通过 JS 的 "😀".codePointAt(0) 可以查看或者通过这个网站直接输入你想查的字符也可以搜到它的代码点),所以字符 "😀" 按照 UTF-32 编码时,它对应的 4 个字节就是 0x00_01_f6_00 (128512 转 16 进制)。UTF-32 的 code unit 是四个字节,所以也涉及到大小端字节序的问题,就是说一个 code unit 里的四个字节是应该从左到右,还是该从右到左读写。刚才那种从左到右顺序的写法就是大端字节序,如果用小端字节序编码,就反过来 0x00_f6_01_00。所以编解码时需要指定是 "utf-32be" (big endian) 还是 "utf-32le" (little endian),不然大小端搞错了也会乱码的。这种编码方式比较简单,就不多啰嗦了,它的缺点就是浪费空间。下面是一段 Node.js 采用 UTF-32 编码方式编解码字符 "😀" 的例子:

const iv = require("iconv-lite");

// utf-32 大端字节序编码
console.log(iv.encode("😀", "utf-32be", { addBOM: false })); // 编码结果 <Buffer 00 01 f6 00>
// utf-32 大端字节序解码
console.log(iv.decode(new Uint8Array([0x00, 0x01, 0xf6, 0x00]), "utf-32be")); // 解码结果 😀

// utf-32 小端字节序编码
console.log(iv.encode("😀", "utf-32le", { addBOM: false })); // 编码结果 <Buffer 00 f6 01 00>
// utf-32 小端字节序解码
console.log(iv.decode(new Uint8Array([0x00, 0xf6, 0x01, 0x00]), "utf-32le")); // 解码结果 😀

UTF-16 编码

采用 UTF-16 编码的一个 Unicode 字符可能是 2 字节大小,也可能是 4 字节大小。这是因为它的 code unit 是 2 字节,上面也提到过 Unicode 字符的代码点都位于 0x0000000x10FFFF,即总的有 65535 * 17 个代码点,这些代码点被分成了 17 个区(Plane,有些直译叫平面,我习惯叫区),如下图所示:

image

其中 0x0000000x00FFFF 的这个区叫基本区,也就是 0 到 65535 的代码点。对于基本区的代码点,最大也就是 0xFFFF, 所以直接 2 个字节就可以表示了,字节内容就是代码点的二进制,注意它也分大小端字节序就行了,和 UTF-32 一次编 4 个字节没太大区别,就不啰嗦了。

剩余的 0x0100000x10FFFF 统称拓展区。对于拓展区的代码点,至少需要 21 个 bit 才能全部表示出来(比如最大值 0x10FFFF),但拓展区的代码点其实只有 65535 * 16 个,所以理论上 (16+4) bit 就够了。于是对于拓展区代码点的编码规则就变成了:先把拓展区的代码点直接减去 65536,空间占用就可以压缩到 20 bit 了((0x10FFFE-65536).toString(2).length = 20)。然后 UTF-16 就规定了用 2 个 code unit,即 4 字节来表示拓展区的代码点,但这 20 bit 该如何放到 32 bit 的空间里呢?我们可以发现其中有 12 bit 是用不上的,于是这 12 bit 就被设置了固定的值,是 110110110111,其中 110110 被分配给了第 1 个字节的前 6 bit,被称为高位代理(High Surrogate),110111 分配给了第 3 个字节的前 6 bit,被称为低位代理(Low Surrogate),它们合称为代理对(Surrogate Pair)。甭管他名字叫啥,意思就是总的 32 bit,我兄弟俩先占你 12 bit,剩下的 20 bit 才是留给你用的。如下图所示,蓝色部分是固定的,绿色部分才是给我们随便用的。

image

还是以 "😀" 为例,如果我们需要对它进行 UTF-16 编码,因为它的代码点是 128512 ,大于 65535,不在基本区,所以就按拓展区编码的规则来,先减去 65536,等于 62976,二进制就是 0b00_00111101_10_00000000,不足 20 位的话前面补 0,然后从左到右依次放到上图的绿色部分。效果就是这样(十六进制的写法我也在第二行标出来了):

image

因为 UTF-16 的 code unit 是 2 个字节,所以也是有大小端字节序之分的,只不过要注意,小端字节序不是 4 个字节整体从右到左。而是以 2 个字节为一个单元进行大小端变换。下面是一段 Node.js 采用 UTF-16 编码方式编解码字符 "😀" 的例子:

const iv = require("iconv-lite");

// utf-16 大端字节序编码
console.log(iv.encode("😀", "utf-16be", { addBOM: false })); // 编码结果 <Buffer d8 3d de 00>
// utf-16 大端字节序解码
console.log(iv.decode(new Uint8Array([0xd8, 0x3d, 0xde, 0x00]), "utf-16be")); // 解码结果 😀

// utf-16 小端字节序编码
console.log(iv.encode("😀", "utf-16le", { addBOM: false })); // 编码结果 <Buffer 3d d8 00 de>
// utf-16 小端字节序解码
console.log(iv.decode(new Uint8Array([0x3d, 0xd8, 0x00, 0xde]), "utf-16le")); // 解码结果 😀

但解码时似乎遇到了一个问题,我们怎么知道向下图这样的 4 个字节是表示一个拓展区的字符还是说他是 2 个基本区的字符呢?

image

答案也很简单,基本区的字符不会以 “110110” 或者 “110111” 开头 (也即不会以 “11011” 开头),也就是说基本区的 0b11011_000_000000000b11011_111_11111111 段被我们禁用了(0xd800 - 0xdfff),这么一来基本区的代码点就少了 2 ** 11 (2048) 个了,但也无伤大雅,63488 个又不是不够用,不够用你就直接去拓展区呗。这和 GBK 中非 ASCII 字符都是以 1 开头类似,UTF-16 编码的 4 字节字符也都有自己的特殊开头。当解析时遇到 “110110” 开头的字节时,就知道接下需要再组合 3 个字节才能解析出一个字符了。

image

我们也可以尝试解码一下 Unicode 字符集中 0xd8000xdfff 的代码点,下面是测试代码,执行后你会得到一堆 �,(你也可以把 shift 设置为 20000 试试,换个区你会解锁一堆汉字):

const iv = require("iconv-lite");

const shift = 0;

for (let i = 0xd800 - shift; i <= 0xdfff - shift; i++) {
  const dv = new DataView(new ArrayBuffer(2));
  dv.setUint16(0, i, false /* false 表示按大端字节序设置 */);
  console.log(i.toString(2), iv.decode(new Uint8Array(dv.buffer), "utf-16be"));
}

有些人可能会想:既然是用了 4 个字节,总的就有 32 bit 的空间了,表示拓展区代码点最多也就是需要 21 bit,那又何必多次一举,非要压缩到 20 bit 呢?我只固定 11 bit 难道不行吗?

这是因为固定下来的 bit 越多,需要禁用的基础区代码点就越少。基础区字符需要的字节是 2 个,也就是 16 位,如果固定前 6 bit,剩下的 10 bit 总的也就是 2 的 10 次方(1024)种可能,我们只需要把这 1024 个代码点禁掉就好了。由于高、低代理各固定了 6字节,所以总的禁了 2048 个基础区的代码点。12 个固定名额之所以对半分也是这个道理,假设是 7 + 5 的组合,需要禁的就是 2**(16-7) + 2** (16-5) = 2560 > 2048,所以对半分 12 个固定 bit 是最优选择。

又有人会问:干嘛不直接用 3 个字节算了,如果用三个字节,需要固定的位就是 3*8-20 = 4 位,需要禁用的基础区代码点就是 2**(16-4) = 4096。这也是不如用 4 个字节分高低位对基础区的干扰来的小。编码一段文本时,越多的字符落在基础区,意味着所需的字节就越少,所以基础区的空间很可贵。

其实 JS 的字符串底层采用的就是 UTF-16 大端字节序编码的(程序本身也是一种特殊的数据)。

这也是为啥 "😀".length = 2 的原因,因为他包含了两个 UTF-16 的 code unit。接下来做一波“字符串体操”。

"😀".charCodeAt(0).toString(16) —— D83D

"😀".charCodeAt(1).toString(16) —— DE00

反过来 console.log('\uD83D\uDE00') 就会得到一个 😀。

你用 "😀".split('') 可以快速的得到 ['\uD83D', '\uDE00']

你用 Array.from("a😀😂中").length 才能拿到真实的字符数量 —— 4。for( char of "a😀😂中"){} 也可以准确的按字符本身迭代。

UTF-8 编码

即使是 UTF-16,每个字符最少也依然需要 2 个字节表示,这依然存在空间浪费。于是终极版的 UTF-8 编码它来了!UTF-8 编码的 code unit 是 1 字节,也就是说一个字符最少只需要一个字节表示就够了,当然代码点大的还是需要 4 个字节。UTF-8 编码规则是把所有的代码点(0x0000000x10FFFF)分成了 4 个区域:

区域 (HEX)区域 (DEC)二进制编码(0 或 1 的 bit 已固定,x 表示可用的 bit)字节数可用 bit 数区域内最大代码点所需 bit 数
0x0000000x00007F0 到 1270xxxxxxx177
0x0000800x0007FF128 到 2047110xxxxx 10xxxxxx21111
0x0008000x00FFFF2048 到 655351110xxxx 10xxxxxx 10xxxxxx31616
0x0100000x10FFFF65536 到 111411111110xxx 10xxxxxx 10xxxxxx 10xxxxxx42121

有了上面的分区表,UTF-8 的编码过程表述起来也就很简单了,我概括就是 3 步:

  • 第一步:找区域。还是以 "😀" 为例,代码点是 128512,属于第四个区域。
  • 第二步:算 bit。把代码点转成 2 进制,如果二进制的结果不足 21 位,前面就补 0。(128512).toString(2).padStart(21,0) 的结果就是 000011111011000000000
  • 第三步:塞进去。把得到的 21 个 bit 从左到右依次替换掉 x 的部分,结果就是 0b11110000_10011111_10011000_10000000,即 0xf0_9f_98_80
const iv = require("iconv-lite");

// utf-8 编码
console.log(iv.encode("😀", "utf-8", { addBOM: false })); // 编码结果 <Buffer f0 9f 98 80>
// utf-8 解码
console.log(iv.decode(new Uint8Array([0xf0, 0x9f, 0x98, 0x80]), "utf-8")); // 解码结果 😀

因为 UTF-8 的 code unit 是 1 字节,所以不存在大小端字节序的区别。

那解码的过程呢?会不会出现不知道读几个字节才算一个代码点的问题?显然是不存在这个问题的。解码时按照 code unit 的大小,1 字节 1 字节的解读,如果遇到 0 开头的字节就知道当前字节一定是属于第一个区域的,因为其他区压根就没 0 开头的字节(看上表),所以这个字节直接单独解码成一个代码点就行了。如果遇到 1 开头的字节呢?那就一位一位的往后找 0,如果继续走 2 位遇到了 0(110),那就说明代码点是第二个区域的,连读 2 字节作为 1 个代码点就行了(第二个字节也要判断是不是 10 开头,如果不是,就返回 � 并丢弃第一个字节)。

const iv = require("iconv-lite");

console.log(iv.decode(new Uint8Array([0b1100_0000 /* 这个字节要被丢弃 */, 0b0100_0001]), "utf-8")); // 解码结果 �A

同样的继续走 3 位遇到 0 就连读 3 字节,继续走 4 位遇到 0 就连读 4 字节。如果走了 5 位、6 位。。。才遇到 0,甚至一直都是 1 呢?说明是瞎编的呗,直接给他一个 �,跳过这个字节就完事了。

那如果遇到 10 开头的呢?说明要不就是瞎编的,要不就是没从正确的位置开始,比如二进制前面的部分丢失了,所以同样是返回一个 � 然后跳过这个字节,继续去后面找合法的字节并解读它的含义。

const iv = require("iconv-lite");

console.log(iv.decode(new Uint8Array([0b1000_0000, 0b1011_1111, 0b0100_0001]), "utf-8")); // 解码结果 ��A

UTF-8 编码会不会像 UTF-16 编码那样存在高区挤占低区可用空间的问题呢?答案是不会!因为 UTF-8 分的每个区都有保证和其他区不重复的“特征位”(0、110、1110、11110)。加上了这些特征位之后,虽然导致了可用 bit 的减少,但只要可用 bit 形成的空间足够放下该分区的所有代码点就行了。既然低区的二进制不会与高区的二进制重叠,自然也就不需要禁用低区的部分代码点了。UTF-16 由于低区缺少这种“特征位”,所以才强行禁用一部分代码点。

其实 GBK 和 UTF-8 都是在利用了低区的特征位来实现“可变字节数”的编码。“特征位”占得越多,可用位就越少,同样的字节数,能表示的代码点就越少,UTF-8 二字节的分区里只容纳了 128-2047 这一千多个代码点。可以说非常奢侈,导致 1 个汉字用 UTF-8 编码都得需要 3 个字节才行,这无疑是低效的。所以 GBK 依然有很高的使用价值,特别是当你需要传输或保存包含大量汉字的文本时,比如你要开发一个中文新闻网站,里面通篇都是大量的汉字,使用 GBK 编码可以让你需要传输的字节数最多降低 50%,这无疑对服务器的带宽压力和传输速度来说都是一个不小的优化。GBK 也是收录在 WHATWG 中的编码方式,所以你服务响应时设置一个 content-type: text/html; charset=gbk 的响应头,浏览器也是能正常显示的。不过我不用 GBK,因为它不支持我喜欢的 emoji 😂。

const iv = require("iconv-lite");
const http = require("http");

http
  .createServer((req, res) => {
    const text = "GBK也是很棒的一种编码!";

    if (req.url.endsWith("/gbk")) {
      res.setHeader("content-type", "text/html; charset=gbk");
      const gbkBuffer = iv.encode(text, "gbk");
      res.end(gbkBuffer);
    } else {
      res.setHeader("content-type", "text/html; charset=utf-8");
      const utf8Buffer = iv.encode(text, "utf8");
      res.end(utf8Buffer);
    }
  })
  .listen(8080);
image image

BOM 字节序标记 (Byte Order Mark)

你可能也注意到了上面我在用 iconv-lite 给字符编码时,总是传一个 addBOM 的参数,并设置了 false。BOM 就是字节序标记,前面也介绍了字节序分大端和小端,某个编码方式如果它的 code unit 是多字节的,这个字节序的信息就很关键了,因为按错误的字节序解码大概率要乱码。那我怎么告诉解码者我是按哪种字节序编码的呢?为了解决这个问题,大家就约定在二进制的最前面加几个特殊的字节,也就是“标记”,来表明接下来的二进制是按什么字节序编码的。

const iv = require("iconv-lite");

console.log(iv.encode("", "utf-32be", { addBOM: true })); // <Buffer 00 00 fe ff>
console.log(iv.encode("", "utf-32le", { addBOM: true })); // <Buffer ff fe 00 00>
console.log(iv.encode("", "utf-16be", { addBOM: true })); // <Buffer fe ff>
console.log(iv.encode("", "utf-16le", { addBOM: true })); // <Buffer ff fe>
console.log(iv.encode("", "utf-8", { addBOM: true })); // <Buffer ef bb bf>

总结如下表:

BOM编码字节序
00 00 FE FFUTF-32大端
FF FE 00 00UTF-32小端
FE FFUTF-16大端
FF FEUTF-16小端
EF BB BFUTF-8无意义,一般被用来检测字节流是不是 UTF-8,但不建议添加