# 字符编码的世界：从 ASCII 到 Unicode 到 UTF-8

> 读者画像：被乱码坑过很多次，`decode`、`encode`、`charset=utf-8` 这些词天天见，但从没真正想明白「字符集」和「编码」到底是不是一回事的中文程序员。
>
> 读完本文，你会：理解为什么乱码会发生、UTF-8 的每一个字节是怎么算出来的、为什么 `"👨‍👩‍👧".length` 在 JS 里是 8 而不是 1、以及遇到乱码时该怎么一步步定位问题。

---

## 目录

1. [为什么需要编码：从电报到计算机](#1-为什么需要编码从电报到计算机)
2. [混乱年代：一堆互不相认的编码](#2-混乱年代一堆互不相认的编码)
3. [Unicode：先解决"这个字符对应哪个数字"](#3-unicode先解决这个字符对应哪个数字)
4. [UTF-8 的精妙设计](#4-utf-8-的精妙设计)
5. [UTF-16、UTF-32 与 BOM](#5-utf-16utf-32-与-bom)
6. [中文与 emoji 实战：字节、代理对、grapheme](#6-中文与-emoji-实战字节代理对grapheme)
7. [实战排坑：乱码是怎么在系统各层产生的](#7-实战排坑乱码是怎么在系统各层产生的)
8. [速查表](#8-速查表)

---

## 1. 为什么需要编码：从电报到计算机

计算机的底层只认识一件事：电压的高低，也就是二进制的 0 和 1。它不认识"A"，不认识"中"，甚至不认识"1"这个数字本身的符号——它只能存储和传输一串 0/1。

所以，如果你想让计算机处理文字，你必须先解决一个问题：**规定好每一个字符对应哪一串二进制数字**。这个"规定"，就是编码（encoding）的本质。它其实是一种翻译协议：发送方把字符翻译成数字存起来或传出去，接收方按同一份协议把数字翻译回字符。只要双方翻译协议一致，就能正确还原；只要协议不一致，就是乱码。

### ASCII：第一份被广泛接受的协议

1963 年，美国制定了 ASCII（American Standard Code for Information Interchange，美国信息交换标准代码）。它的设计非常朴素：用 **7 个二进制位**（bit）表示一个字符，一共能表示 2^7 = 128 个字符，编号从 0 到 127。

- 0–31：控制字符（如换行符 `\n` 是 10，回车 `\r` 是 13，制表符 `\t` 是 9）
- 32–126：可打印字符，包括空格、数字 `0-9`、大小写英文字母 `A-Z a-z`、常见标点
- 127：DEL（删除）

举例：字符 `'A'` 在 ASCII 里的编号是 65，二进制是 `1000001`。

```
字符 'A'  →  码值 65  →  二进制 1000001  →  存储为一个字节 01000001
```

注意 ASCII 本身只需要 7 位，但计算机存储的最小单位是 1 字节（8 位），所以实际存储时前面补一个 0，变成 `01000001`（十六进制 `0x41`）。这多出来的 1 位，为后来的"混乱年代"埋下了伏笔——不同厂商开始用这多出来的 1 位（即 128–255 范围）塞进自己的字符，谁也不认谁。

### 局限：128 个字符，装不下全世界

ASCII 是为英语设计的。26 个字母大小写、10 个数字、常见符号，128 个位置绰绰有余。但世界上还有：

- 带重音符号的欧洲语言字符：é, ü, ñ, ç
- 西里尔字母（俄语）、希腊字母
- 中文、日文、韩文里成千上万的表意文字
- 阿拉伯语、希伯来语等从右向左书写的文字
- 后来的表情符号 emoji

128 个位置连欧洲语言的重音字符都装不下，更不用说几万个汉字。于是，各个国家和地区在 ASCII 的基础上，各自发明了自己的扩展编码——这就是下一节要讲的"混乱年代"。

---

## 2. 混乱年代：一堆互不相认的编码

### 2.1 单字节扩展：用满 8 位的 128–255

ASCII 只用了 7 位（0–127）。既然一个字节有 8 位，能表示 0–255，那剩下的 128–255 怎么办？西欧国家的方案是 **Latin-1（ISO 8859-1）**：在 128–255 范围内塞入带重音符号的拉丁字母，比如 é 是 233（`0xE9`）。

这解决了西欧语言的问题，但每个国家都这么"复用"128–255 这同一段空间，塞进不同的字符。同样是字节 `0xE9`，在 Latin-1 里是 `é`，换一种编码可能就是别的字符。**这是乱码的根源之一：同一个字节值，在不同编码规则下被解读成不同的字符。**

### 2.2 中文：GB2312 → GBK → GB18030

汉字有几万个，一个字节（256 种可能）根本不够，必须用两个字节（双字节编码）。中国大陆的方案演进如下：

- **GB2312**（1980）：收录约 6763 个常用汉字，用两个字节表示一个汉字。第一个字节（高字节）范围 `0xA1-0xF7`，第二个字节（低字节）范围 `0xA1-0xFE`。规则上，只要第一个字节 ≥ `0x80`，就说明这是一个双字节字符的开头。
- **GBK**（1995）：GB2312 不够用（生僻字、繁体字缺失），GBK 向下兼容 GB2312，并扩充到收录约 21000+ 个汉字，包括繁体字。
- **GB18030**（2000，强制性国家标准）：兼容 GBK，进一步扩展为变长编码（1/2/4 字节），目标是能表示 Unicode 的所有字符。国内销售的软件、政府系统必须支持 GB18030。

例如"中"字在 GBK 编码下是两个字节：`D6 D0`（十六进制），而在 UTF-8 下是三个字节 `E4 B8 AD`（下一节会展示具体算法）——**同一个汉字，在不同编码方案下占用的字节数、字节值完全不同**，这一点非常关键。

### 2.3 其他地区：Big5、Shift-JIS、EUC-KR……

- **Big5**：台湾、香港常用的繁体中文编码，双字节，编码空间和 GBK 不同，二者互不兼容。
- **Shift-JIS**：日文常用编码之一。
- **EUC-KR**：韩文常用编码之一。

这些编码各自为政，谁都不知道另一方的规则。这就是所谓的"混乱年代"：全世界有几十上百种编码方案，同一份字节流，用不同编码打开会显示完全不同的内容。

### 2.4 乱码是怎么发生的：一个具体案例

假设你用 UTF-8 编码保存了一个文件，内容是"中文"两个字。UTF-8 编码后的字节序列是：

```
E4 B8 AD E6 96 87
```

如果打开这个文件的程序错误地认为这是 GBK 编码，会按"两字节一组，且首字节 ≥ 0x80 就是双字节字符"的规则去解析：

- 第一组 `E4 B8` → 查 GBK 编码表 → 对应某个字符（不是"中"）
- 第二组 `AD E6` → 查 GBK 编码表 → 对应某个字符
- 第三组 `96 87` → 查 GBK 编码表 → 对应某个字符

结果就是三个跟"中文"毫无关系的汉字或符号，这就是乱码。**乱码的本质，不是数据丢失或损坏，而是编码方和解码方对同一串字节使用了不同的"翻译规则"。** 字节本身是对的，翻译错了。

一个更经典、几乎每个中国程序员都见过的现象是"锟斤拷"。原理是这样的：当某个系统内部用 UTF-8 处理文本，遇到无法识别或损坏的字节时，会用 Unicode 的"替换字符" `U+FFFD`（�）代替。`U+FFFD` 编码成 UTF-8 是三个字节 `EF BF BD`。如果这段"错误占位符"字节又被连续写入、且后续被错误地按 **GBK** 解码，两个连续的 `EF BF BD EF BF BD`（6 个字节）会被 GBK 按两字节一组重新切分并查表，正好查出"锟斤拷"三个字——这个结论笔者已用代码实际验证过，不是坊间传说。这也是为什么老程序员一看到网页或数据库里蹦出"锟斤拷"，就知道是编码链路上出现了 UTF-8/GBK 误判。

这个"双方规则不一致导致乱码"的教训，直接推动了下一个时代的到来：与其让每个国家维护互不相容的编码，不如做一份**全世界通用的字符集**。这就是 Unicode 要解决的问题。

---

## 3. Unicode：先解决"这个字符对应哪个数字"

这是全文最关键的一节。如果你只记住一句话，记住这句：

> **Unicode 是字符集（character set），规定"字符"和"数字"之间的对应关系；它不规定这些数字在计算机里怎么存成字节。"怎么存成字节"是编码（encoding）的工作，UTF-8/UTF-16/UTF-32 都是 Unicode 的具体编码实现。**

### 3.1 字符集 vs 编码：一个类比

把这个问题类比成"电话簿"和"拨号方式"：

- **字符集**相当于电话簿——它规定了"张三"这个人对应哪个电话号码（比如 10086）。这是一种**映射关系**，跟你用什么手机、什么网络拨号完全无关。
- **编码**相当于具体的拨号协议——把"10086"这个号码翻译成手机基站能识别的无线电信号。同一个号码，2G、3G、4G、5G 网络的信号编码方式完全不同，但电话簿里"张三对应 10086"这个映射关系是不变的。

Unicode 就是那本电话簿，它给世界上（几乎）所有的字符都分配了一个唯一的编号，这个编号叫**码点（code point）**。UTF-8、UTF-16、UTF-32 则是"把这个编号变成实际信号（字节序列）"的三种不同拨号协议。

在 Unicode 出现之前，GB2312、Big5、Shift-JIS 这些编码，其实是把"电话簿"和"拨号协议"揉在了一起——每家都自己定义了一套字符到字节的映射，谁也不认谁。Unicode 的贡献，正是把这两层拆开：先统一"电话簿"（字符集），"拨号协议"（编码方式）可以有多种，只要双方约定好用哪种协议即可。

### 3.2 码点（code point）

Unicode 给每个字符分配一个编号，写作 `U+XXXX` 的形式（十六进制）。比如：

| 字符 | 码点 | 含义 |
|---|---|---|
| `A` | U+0041 | 拉丁字母 A |
| `中` | U+4E2D | 汉字"中" |
| `😀` | U+1F600 | 笑脸 emoji |
| `𠀀` | U+20000 | 一个生僻扩展汉字 |

这里的"码点"就是一个纯粹的数字，跟这个字符最终要存成几个字节、什么样的字节完全无关。U+4E2D 就是数字 20013（十进制），仅此而已。

### 3.3 平面（plane）：Unicode 的编号空间有多大

Unicode 的码点空间理论上可以容纳 **0x000000 到 0x10FFFF**，一共 1,114,112 个码位，划分为 17 个"平面"（plane），每个平面 65536（0x10000）个码位：

- **平面 0（BMP，Basic Multilingual Plane，基本多文种平面）**：`U+0000` – `U+FFFF`。绝大多数常用字符都在这里，包括 ASCII、拉丁扩展、常用汉字、假名、韩文音节等。
- **平面 1（辅助多文种平面）**：`U+10000` – `U+1FFFF`。历史文字、音乐符号、以及**绝大部分 emoji**都在这里。比如 😀 是 U+1F600。
- **平面 2（辅助表意文字平面）**：`U+20000` – `U+2FFFF`。生僻汉字、CJK 扩展 B 区等，比如上表中的 𠀀（U+20000）。
- 平面 3–13：大多保留未用或用于特殊符号。
- 平面 14–16：特殊用途和私有用途区。

这个分层设计的现实意义是：**你日常打交道的绝大多数字符（拉丁字母、常用汉字）落在 BMP 内，一个码点用 2 字节就能装下；但 emoji、生僻字这些"平面 1 以上"的字符，码点本身就超过了 0xFFFF，这直接决定了它们在 UTF-16 里必须用"代理对"来表示（第 6 节详细展开）。**

### 3.4 一句话总结这一节

Unicode 只回答一个问题："这个字符对应哪个数字？" 它完全不关心这个数字最终以什么字节形式存在磁盘上、传输在网络里。**"字符集"和"编码"是两个不同层次的概念**——字符集是逻辑映射表，编码是物理存储/传输格式。UTF-8、UTF-16、UTF-32 是三种不同的"如何把 Unicode 码点变成字节序列"的具体实现，它们对应的字符集都是同一个 Unicode，但字节层面的结果完全不同。

---

## 4. UTF-8 的精妙设计

理解了"Unicode 只是编号表"之后，UTF-8 要解决的问题就很清楚了：**给定一个 Unicode 码点，怎么把它变成一串字节存起来？**

### 4.1 变长编码规则

UTF-8 是一种**变长编码**：根据码点数值的大小，用 1 到 4 个字节表示一个字符。规则如下：

| 码点范围 | 二进制模板 | 字节数 |
|---|---|---|
| U+0000 – U+007F | `0xxxxxxx` | 1 字节 |
| U+0080 – U+07FF | `110xxxxx 10xxxxxx` | 2 字节 |
| U+0800 – U+FFFF | `1110xxxx 10xxxxxx 10xxxxxx` | 3 字节 |
| U+10000 – U+10FFFF | `11110xxx 10xxxxxx 10xxxxxx 10xxxxxx` | 4 字节 |

规则的核心思路：

- **第一个字节的前缀位**（`0`、`110`、`1110`、`11110`）告诉你"这个字符总共占几个字节"。
- **后续字节**统一以 `10` 开头，标记"我是某个多字节字符的延续部分，不是新字符的开头"。
- `x` 位置填入码点的二进制数值，从高位到低位依次填入。

### 4.2 手算一遍："中"字的 UTF-8 编码过程

"中"的 Unicode 码点是 `U+4E2D`，十进制 20013。

**第一步：转成二进制。**

```
0x4E2D = 0100 1110 0010 1101   （共 15 位有效数字）
```

**第二步：判断落在哪个范围。** `U+4E2D` 在 `U+0800 – U+FFFF` 之间，对应 3 字节模板 `1110xxxx 10xxxxxx 10xxxxxx`，一共能装 4+6+6=16 位数据，够用。

**第三步：把二进制位从低到高，依次填进模板的 `x` 位置（从最后一个字节往前填）。**

15 位数据（补齐到 16 位：`0100 1110 0010 1101` → 补一位前导 0 变成 `0 0100 1110 0010 1101`，按 4/6/6 切分）：

```
高 4 位 → 0100
中 6 位 → 111000
低 6 位 → 101101
```

**第四步：套入模板：**

```
字节1: 1110 + 0100        = 1110 0100 = 0xE4
字节2: 10   + 111000      = 1011 1000 = 0xB8
字节3: 10   + 101101      = 1010 1101 = 0xAD
```

**结果：`E4 B8 AD`**，与本文开头用 Python 实测验证的结果完全一致。

```python
>>> '中'.encode('utf-8').hex()
'e4b8ad'
```

再验证一个 ASCII 字符 `'A'`（U+0041，十进制 65）：落在 `U+0000–U+007F`，用 1 字节模板 `0xxxxxxx`，直接填入 `0100 0001 = 0x41`。这正是 ASCII 里 `A` 的原始编码值——**UTF-8 对 0–127 范围的字符，编码结果和 ASCII 完全一样**，这不是巧合，而是设计者刻意为之。

### 4.3 为什么要兼容 ASCII

UTF-8 是 1992 年由 Ken Thompson 和 Rob Pike（Unix 与 Go 语言的作者们）设计的。它的首要设计目标之一，就是**向下兼容 ASCII**：任何合法的 ASCII 文本，天然就是合法的 UTF-8 文本，字节完全一样。

这个设计的现实价值极大：1990 年代互联网上已经有海量用 ASCII 写的代码、协议、配置文件（HTTP 头、邮件协议、C 语言源代码里大量用到的符号）。如果 UTF-8 破坏了 ASCII 兼容性，几乎所有旧系统都要重写。因为 0–127 范围内 UTF-8 和 ASCII 字节完全相同，旧的处理 ASCII 文本的程序，很多时候不需要任何修改就能"顺便"正确处理纯英文的 UTF-8 文本。这也是 UTF-8 能够平滑推广、最终统治互联网的关键原因之一。

### 4.4 自同步（self-synchronizing）：为什么从任意位置都能找到字符边界

UTF-8 编码有一个非常巧妙的性质：**只看一个字节本身，就能判断它在字符中的角色**，不需要额外的上下文：

- `0xxxxxxx`（首位是 0）：这是一个单字节字符（ASCII），本身就是完整字符。
- `110xxxxx` ~ `11110xxx`（以多个 1 开头，后跟一个 0）：这是某个多字节字符的**首字节**，且开头 1 的个数就是总字节数。
- `10xxxxxx`（以 `10` 开头）：这是多字节字符的**延续字节**，不是字符开头。

这个性质叫"自同步"。它的实际意义是：如果程序从文件或网络流的**任意位置**开始读取（比如断点续传、文件损坏后从中间恢复、多线程并发处理一个大文件的不同分段），只要往前或往后扫描几个字节，就能立刻定位到最近的字符边界——不需要从文件开头重新解析。相比之下，如果编码规则里"延续字节"和"首字节"长得一样（没有这种前缀区分），一旦丢失同步就可能永久错位、后面全部解析错误。

这也是为什么"UTF-8 字符串不会在字节流中间意外出现一个合法 ASCII 字节"——`10xxxxxx` 和 `110xxxxx` 等前缀的最高位必为 1，天然与 ASCII 的 `0xxxxxxx` 区分开，不会互相混淆，这也保证了程序按字节查找 `\0`、`/`、换行符等 ASCII 控制字符时不会被多字节字符的延续部分误伤。

### 4.5 为什么 UTF-8 成了事实标准

综合起来，UTF-8 胜出的原因：

1. **兼容 ASCII**，迁移成本低，旧系统基本无痛升级。
2. **变长且节省空间**：英文文本（互联网早期以及至今大量场景的主体内容）用 1 字节，不会像 UTF-32 那样对每个字符固定用 4 字节造成大量浪费。
3. **自同步**，容错性和可恢复性好。
4. **字节序无关**：UTF-8 是按字节流处理的，不存在"大端小端"的问题（这一点在下一节和 UTF-16 对比会更清楚）。
5. **没有 `\0` 字节混入 ASCII 范围外的字符编码里**（除非字符本身就是 U+0000），兼容大量以 `\0` 作为字符串结尾标记的 C 语言遗留代码。

今天，互联网上超过 98% 的网页使用 UTF-8（W3Techs 等机构长期统计数据），它已经是事实上的标准编码。

---

## 5. UTF-16、UTF-32 与 BOM

### 5.1 UTF-32：最简单粗暴，但浪费空间

UTF-32 用固定的 **4 字节**表示任意一个 Unicode 码点，不做任何变长处理。

- 优点：编解码逻辑极其简单，从字节数直接算出字符数（字节数 ÷ 4），随机访问第 N 个字符是 O(1)。
- 缺点：即便是最常见的 ASCII 字符，也要占满 4 字节，空间浪费严重（是 UTF-8 编码同一段英文文本的 4 倍体积）。因此 UTF-32 在存储和网络传输中几乎不被使用，多见于程序内部处理 Unicode 码点数组这类特定场景。

### 5.2 UTF-16：BMP 内定长，BMP 外变长

UTF-16 是另一种历史悠久、目前仍被 Windows API、Java、JavaScript 字符串内部表示广泛使用的编码：

- 对于 **BMP 内（U+0000 – U+FFFF）**的字符，用固定 **2 字节**（一个"码元"，code unit）表示。
- 对于 **BMP 之外（U+10000 – U+10FFFF）**的字符（比如绝大部分 emoji），需要用**两个** 2 字节码元组合表示，这就是**代理对（surrogate pair）**，下一节详细展开。

这个"取舍"是 UTF-16 的历史包袱：Unicode 早期（1990 年代初）只设计了 BMP 这一个平面（16 位足够装下），所以最早的 UTF-16 前身 UCS-2 就是纯定长 2 字节编码。后来发现 16 位不够用（emoji、生僻字爆发式增长），Unicode 扩展到 17 个平面，UTF-16 只好打补丁，从"平面 1 到 16"中额外划出两段专用码位（`0xD800–0xDBFF` 高代理、`0xDC00–0xDFFF` 低代理）来"指代"BMP 外的字符——这也是 JavaScript 里字符串 `.length` 计算的是"UTF-16 码元数"而非"字符数"的历史原因（第 6 节会详细演示这个坑）。

### 5.3 大端 小端与 BOM

UTF-16 和 UTF-32 都是以"多字节码元"为单位的编码，这就带来了**字节序（endianness）**问题：一个 2 字节的码元，比如 `0x4E2D`，在内存或文件里到底是先存高位字节 `4E` 还是先存低位字节 `2D`？

- **大端（Big-Endian, BE）**：高位字节在前，`4E 2D`。
- **小端（Little-Endian, LE）**：低位字节在前，`2D 4E`。

不同 CPU 架构、不同系统的默认字节序不同，因此 UTF-16 和 UTF-32 文件通常需要显式标注自己是大端还是小端。这就是 **BOM（Byte Order Mark，字节序标记）** 的作用：在文件最开头写入一个特殊的不可见字符 `U+FEFF`，读取方通过它编码后的字节形式判断字节序：

| 编码 | BOM 字节序列 |
|---|---|
| UTF-8 | `EF BB BF`（可选，且不涉及字节序，纯粹作为"这是 UTF-8 文件"的标记） |
| UTF-16 LE | `FF FE` |
| UTF-16 BE | `FE FF` |
| UTF-32 LE | `FF FE 00 00` |
| UTF-32 BE | `00 00 FE FF` |

**关于 UTF-8 BOM 的重要提醒**：UTF-8 是按字节流处理的，天然不存在字节序问题，理论上完全不需要 BOM。但 Windows 上一些工具（尤其是记事本）习惯在 UTF-8 文件开头加上 `EF BB BF` 作为"这是 UTF-8"的标记。这会带来实际的坑：如果程序按纯 ASCII 或忽略 BOM 的方式读取文件，文件开头会多出三个"看不见"的字节，常见后果包括：

- PHP/Python 脚本文件开头有 BOM，导致 `<?php` 前面多了不可见字符，页面输出前出现空白或报错 `headers already sent`。
- JSON 解析器严格遵循 RFC 8259（不允许 BOM）时，解析 UTF-8 BOM 开头的 JSON 直接报错。
- CSV 文件用 Excel 打开中文乱码，很多解决方案就是"保存为带 BOM 的 UTF-8"，因为 Excel 靠 BOM 判断编码。

所以实践中的建议很明确：**除非明确知道消费方需要 BOM（比如给 Excel 用的 CSV），否则统一使用不带 BOM 的 UTF-8**，这也是几乎所有现代工具链（Git、Node.js、大多数 Linux 工具）的默认约定。

---

## 6. 中文与 emoji 实战：字节、代理对、grapheme

### 6.1 一个汉字，不同编码下占几个字节

以"中"字（U+4E2D）为例，实测结果：

| 编码 | 字节序列（十六进制） | 字节数 |
|---|---|---|
| UTF-8 | `E4 B8 AD` | 3 字节 |
| UTF-16（LE） | `2D 4E` | 2 字节 |
| GBK | `D6 D0` | 2 字节 |
| Big5 | `A4 A4` | 2 字节 |
| GB2312 | `D6 D0` | 2 字节（与 GBK 相同，因为"中"是 GB2312 已收录的常用字） |

这里的实践含义非常重要：**"一个汉字在 UTF-8 下占 3 个字节"这个说法只对 BMP 内的常用汉字成立**。如果按字节截断字符串（比如数据库字段限制了字节长度，或者朴素地用 `substring` 按字节数截断），很容易把一个多字节字符从中间切断，产生半个字符的乱码尾巴（比如只保留了"中"的前两个字节 `E4 B8`，第三个字节 `AD` 被截断，剩下的两个字节既不是合法字符开头也不是完整字符）。

### 6.2 生僻字与扩展平面：4 字节的汉字

不是所有汉字都在 BMP 内。比如 𠀀（U+20000，CJK 扩展 B 区的一个生僻字）落在平面 2，UTF-8 编码是 4 字节：`F0 A0 80 80`。这类字符在旧版 MySQL 的 `utf8`（注意，不是 `utf8mb4`）字符集下会插入失败或被截断——这是本文第 7 节要讲的经典数据库乱码陷阱之一。

### 6.3 emoji 与代理对（surrogate pair）

😀（U+1F600）落在平面 1，超出了 BMP 的 `U+0000–U+FFFF` 范围。这意味着：

- **UTF-8** 下用 4 字节表示：`F0 9F 98 80`。
- **UTF-16** 下无法用单个 2 字节码元表示，必须拆成两个码元，即"代理对"。

代理对的计算方法（以 😀 为例）：

**第一步**：把码点减去 `0x10000`，得到一个 20 位的差值。

```
0x1F600 - 0x10000 = 0xF600  （二进制：1111 0110 0000 0000）
```

**第二步**：把这 20 位差值拆成高 10 位和低 10 位。

```
高 10 位: 00 1111 01   →  0x3D
低 10 位: 10 0000 0000 →  0x200
```

**第三步**：分别加上高代理区起始值 `0xD800` 和低代理区起始值 `0xDC00`。

```
高代理 = 0xD800 + 0x3D = 0xD83D
低代理 = 0xDC00 + 0x200 = 0xDE00
```

**结果**：😀 在 UTF-16 中编码为两个码元 `D83D DE00`，与 JS 引擎实测的 `codePointAt(0)` 反推结果完全吻合。

这就是为什么在 JavaScript 里：

```javascript
const emoji = '😀';
emoji.length          // 2 —— 因为 JS 字符串的 .length 数的是 UTF-16 码元数，不是"字符数"
emoji.codePointAt(0)  // 128512 (0x1F600) —— 但语义上它确实是"一个字符"
[...emoji].length     // 1 —— 用 for...of 或展开运算符按"码点"遍历才是符合直觉的 1
```

**关键结论：JS（以及 Java、C#等使用 UTF-16 作为字符串内部表示的语言）里的 `.length` 本质上是"UTF-16 码元数"，而不是人类直觉里的"字符数"。** 只要涉及 emoji、生僻字等 BMP 外字符，这两者就会对不上。

### 6.4 组合字符与 grapheme：为什么 `"👨‍👩‍👧".length` 是 8

这是比代理对更进一层的坑。先看实测结果：

```javascript
const family = '👨‍👩‍👧';
family.length             // 8
Array.from(family).length // 5（按 Unicode 码点数遍历）
```

为什么一个看起来是"一个符号"的家庭 emoji，长度既不是 1，也不是常见认知里"应该是 3"（爸爸+妈妈+女儿）？

因为 `👨‍👩‍👧` 根本不是一个独立的字符，它是由 **5 个独立的 Unicode 码点，通过"零宽连接符"（ZWJ，Zero Width Joiner，U+200D）拼接成的一个"视觉上的单一符号"**：

```
👨 (U+1F468, 男人)
+ ZWJ (U+200D, 零宽连接符，不可见)
+ 👩 (U+1F469, 女人)
+ ZWJ (U+200D)
+ 👧 (U+1F467, 女孩)
```

这 5 个码点里，👨👩👧 三个 emoji 各自落在 BMP 外，每个都需要一对代理对（2 个 UTF-16 码元），ZWJ（U+200D）落在 BMP 内，只需要 1 个码元。所以总的 UTF-16 码元数是：`2 + 1 + 2 + 1 + 2 = 8`——与实测的 `family.length === 8` 完全一致。而按码点数（`Array.from` 或 `for...of` 遍历）统计则是 5 个码点（3 个 emoji + 2 个 ZWJ）。

这里牵出了字符处理里的第三个层次概念——**grapheme（字位簇，更准确说是 grapheme cluster）**：人类视觉和认知上感受到的"一个字符"。`👨‍👩‍👧` 在人眼里是一个不可分割的"家庭"符号，这就是一个 grapheme cluster，但它底层由 5 个码点、8 个 UTF-16 码元组成。

三层概念不要混淆：

| 层次 | 关注点 | `👨‍👩‍👧` 的度量结果 |
|---|---|---|
| 字节（byte） | 存储/传输的物理单位 | UTF-8 下 18 字节 |
| 码元（code unit） | 某种编码格式下的最小单位 | UTF-16 下 8 个码元 |
| 码点（code point） | Unicode 字符集里的编号 | 5 个码点 |
| grapheme cluster | 人类视觉认知的"一个字符" | 1 个 |

如果你需要按用户直觉"数字符个数"（比如限制用户昵称最多 10 个字符、正确实现文本光标的左右移动、正确做字符串反转），既不能用字节数，也不能简单用码点数，而要用支持 grapheme cluster 切分的库（JS 里是 `Intl.Segmenter`，Python 里常用第三方库如 `grapheme`，Swift 的 `String` 原生就是按 grapheme cluster 计数的）。

---

## 7. 实战排坑：乱码是怎么在系统各层产生的

乱码几乎总是同一个模式："写入时用编码 A，读取时用编码 B（A ≠ B）"，或者"某一层不知道该用什么编码，用了错误的默认值"。下面按系统分层逐一排查。

### 7.1 数据库层

**典型场景 1：MySQL `utf8` 不是真正的 UTF-8。**

MySQL 历史遗留的 `utf8` 字符集实际上是 `utf8mb3`，最多只支持 **3 字节**的 UTF-8 字符，也就是只覆盖 BMP。前面提到的生僻字（4 字节，平面 2 以上）以及**几乎所有 emoji（U+1F300 起，平面 1，也是 4 字节）**都存不进去，插入时会报错或被静默截断成 `?`。

**排查/解决**：建表和连接时统一使用 `utf8mb4`（真正完整的 UTF-8），并确认排序规则用 `utf8mb4_unicode_ci` 或 `utf8mb4_0900_ai_ci`（MySQL 8）。这是目前几乎所有"用户资料存 emoji 报错"问题的根因。

**典型场景 2：连接字符集与库表字符集不一致。**

即使表定义是 `utf8mb4`，如果数据库连接（客户端连接字符集，如 `SET NAMES` 或驱动的 `characterEncoding` 参数）用的是别的编码（比如 `latin1`），写入时应用层的 UTF-8 字节会先被连接层按 `latin1` 解读成字符，再按表的 `utf8mb4` 重新编码写入，写入的已经是错误数据。这种"写入时叠加了一次错误转码"的乱码,即使后续读取时编码完全对齐，数据也已经无法复原（除非明确知道错误转码的规则，逆向转换回来）。

**排查方法**：在连接字符串里显式指定 `charset=utf8mb4`（如 JDBC URL 加 `?useUnicode=true&characterEncoding=utf8`，或 MySQL 连接后先执行 `SET NAMES utf8mb4`），并保证"应用代码编码 = 连接编码 = 表/列编码"三者一致。

### 7.2 文件层

**典型场景：编辑器/终端默认编码不一致。**

Windows 记事本历史上默认用系统 ANSI 代码页（简体中文系统上通常是 GBK）保存文件；Linux/macOS 生态几乎清一色默认 UTF-8。如果一份 GBK 编码的文件被 Git 提交，另一个人在 UTF-8 环境下打开，中文全部乱码。

**排查方法**：
- Linux/macOS 下用 `file -i filename` 或 `file --mime-encoding filename` 猜测文件编码。
- 用 `iconv -f GBK -t UTF-8 input.txt -o output.txt` 做编码转换。
- 编辑器（VS Code 等）状态栏会显示当前文件识别到的编码，可手动"以指定编码重新打开"来试探。

### 7.3 HTTP 层

**典型场景 1：响应头 `Content-Type` 声明的编码和实际内容编码不一致。**

服务端返回 `Content-Type: text/html; charset=GBK`，但响应体实际是用 UTF-8 生成的，浏览器会按头部声明的 GBK 去解析 UTF-8 字节，产生乱码。

**排查方法**：用浏览器开发者工具 Network 面板查看响应头的 `charset` 声明，同时对比 HTML `<meta charset="...">` 标签（两者都存在时，HTTP 头优先级更高，但如果两者矛盾本身就是 bug）。用 `curl -sI url` 查看实际返回的 `Content-Type`。

**典型场景 2：POST 表单数据编码与服务端解析编码不一致。**

尤其是老式 `application/x-www-form-urlencoded` 表单，中文会先被 URL 编码（见下一节），如果前端按 UTF-8 编码、后端按 GBK 解码 request body，中文参数会乱码。

### 7.4 URL 编码层

URL 本身的合法字符集只有 ASCII 的一个子集（字母、数字和少量符号）。中文这类非 ASCII 字符出现在 URL 里，必须先做 **percent-encoding（百分号编码）**：先把字符按某种编码（现代实践统一用 UTF-8）转成字节，再把每个字节写成 `%XX` 的十六进制形式。

以"中"字为例：UTF-8 字节是 `E4 B8 AD`，URL 编码后是：

```
%E4%B8%AD
```

**典型乱码场景**：如果编码时用 UTF-8（`encodeURIComponent` 在 JS 里固定用 UTF-8），但服务端按 GBK 去解 `%XX` 还原出的字节，同样会乱码。这也是老式 Web 应用（尤其是 ASP、早期 PHP 项目）里经常出现的"GET 参数传中文乱码，POST 正常"或反过来的典型 bug——GET 参数走 URL 编码层，容易在"用什么编码百分号解码"这一步出错。

**排查方法**：`curl -v "http://example.com/search?q=%E4%B8%AD%E6%96%87"` 直接测试百分号编码是否被正确解析；或在浏览器地址栏直接看编码后的 URL，用在线工具/`decodeURIComponent` 手动验证还原结果是否符合预期。

### 7.5 `len()` / 字符串长度函数：字节视角 vs 字符视角

不同语言的字符串长度函数，语义完全不同，混用是新手常见的坑：

```python
# Python 3：str 内部按码点存储，len() 天然按码点计数
len('中文')           # 2（码点数）
len('中文'.encode('utf-8'))  # 6（字节数，因为每个汉字 UTF-8 占 3 字节）
```

```javascript
// JavaScript：字符串内部是 UTF-16，.length 是码元数
'中文'.length          // 2（"中""文"都在 BMP 内，各占 1 个码元，凑巧和码点数相同）
'😀'.length            // 2（不是 1！因为 😀 需要代理对）
```

```go
// Go：string 本质是字节数组，len() 直接是字节数
len("中文")            // 6（字节数）
utf8.RuneCountInString("中文") // 2（码点数，需要显式调用）
```

**教训**：拿到一门新语言时，第一件事应该确认它的字符串"长度"函数返回的到底是字节数、UTF-16 码元数、还是 Unicode 码点数——这三者对纯 ASCII 文本结果相同，一旦涉及中文/emoji 就会分道扬镳，这也是"字符串截断长度限制"类 bug 的高发地带。

---

## 8. 速查表

### 8.1 三层概念速记

| 概念 | 回答的问题 | 例子 |
|---|---|---|
| 字符集（character set） | 字符对应哪个数字（码点）？ | Unicode：`中` → U+4E2D |
| 编码（encoding） | 这个数字怎么变成字节？ | UTF-8：U+4E2D → `E4 B8 AD` |
| 字节序（endianness，仅多字节码元编码需要） | 多字节数值先存高位还是低位？ | UTF-16 LE：`2D 4E`；BE：`4E 2D` |

### 8.2 UTF-8 编码字节数规则

| 码点范围 | 首字节前缀 | 字节数 | 覆盖内容 |
|---|---|---|---|
| U+0000–U+007F | `0xxxxxxx` | 1 | ASCII |
| U+0080–U+07FF | `110xxxxx` | 2 | 拉丁扩展、希腊、西里尔等 |
| U+0800–U+FFFF | `1110xxxx` | 3 | 常用汉字、假名、韩文音节等（BMP 内） |
| U+10000–U+10FFFF | `11110xxx` | 4 | 绝大部分 emoji、生僻汉字（BMP 外） |

### 8.3 常见字符的字节表

| 字符 | 码点 | UTF-8 | UTF-16(LE) | GBK |
|---|---|---|---|---|
| `A` | U+0041 | `41` (1B) | `41 00` | `41` |
| `中` | U+4E2D | `E4 B8 AD` (3B) | `2D 4E` | `D6 D0` |
| `😀` | U+1F600 | `F0 9F 98 80` (4B) | `3D D8 00 DE`（代理对） | 不支持 |
| `𠀀` | U+20000 | `F0 A0 80 80` (4B) | 代理对 | 不支持 |

### 8.4 乱码定位排查清单

遇到乱码，按下面顺序快速定位是哪一层出的问题：

1. **确认原始字节**：用 `hexdump`/`xxd`/浏览器 DevTools 看实际字节流是否符合预期编码规则（比如中文应该是 3 字节一组的 UTF-8 模式 `E?/F?` 开头）。
2. **确认声明编码**：文件的 BOM、HTTP 响应头 `Content-Type; charset=`、HTML `<meta charset>`、数据库连接字符集，是否与实际字节编码一致。
3. **确认解析编码**：接收方（浏览器、数据库驱动、文本编辑器）实际使用的解码方式是否与声明一致（声明和实际不符也会乱码）。
4. **确认写入编码**：数据从被创建的那一刻起（表单提交、文件另存为、数据库 INSERT）用的编码是否全链路统一。
5. **警惕二次转码**：数据是否被错误解码后又重新编码写入了下一层存储——这种"叠加转码"往往不可逆，需要找到具体的错误映射规则才能修复，非常麻烦，务必在源头预防。

### 8.5 一图记住核心结论

```
Unicode = 字符 ↔ 数字 的映射表（字符集，逻辑层）
UTF-8/16/32 = 数字 → 字节序列 的具体转换规则（编码，物理层）

同一个字符，Unicode 码点唯一不变；
但存成什么字节，完全取决于用哪种编码。

乱码 = 编码时的规则 ≠ 解码时的规则
```

---

## 参考资料

- Unicode Consortium, [The Unicode Standard](https://www.unicode.org/versions/latest/)
- RFC 3629, *UTF-8, a transformation format of ISO 10646*
- Ken Thompson & Rob Pike, UTF-8 原始设计（1992，Plan 9 操作系统）
- MySQL 官方文档：*The utf8mb4 Character Set*
- 本文所有字节级编码示例均使用 Python 3（`str.encode()`）与 Node.js（`Buffer`、字符串 `.length`/`.codePointAt()`）实际运行验证，未凭记忆推测。

---

*本文收录于 AI 作品集 `03-learning` 目录，与《数据库存储引擎》《HTTP 请求的一生》等文章同属基础原理系列。*
