最根本的问题:计算机怎么表示文字?
计算机只能存 0 和 1。要表示文字,必须解决两个问题:
1. 字符集(Character Set)——给每个字符分配一个唯一的数字编号。
2. 编码(Encoding)——把这个数字编号用具体的二进制字节表示出来。
乱码的根源:编码不统一时代
在 Unicode 诞生之前,不同国家、不同系统用各自独立的编码方案:
问题是:同一个二进制数,在不同编码下代表不同字符。 当你用 GBK 打开日文编码的文件时——乱码就出现了。
Unicode:一个编号体系统一全世界
Unicode 的目标:给所有文字系统的每个字符分配一个唯一的编号,不再依赖任何特定编码。
码点(Code Point)
Unicode 中每个字符的编号叫做码点,写作 U+XXXX:
目前 Unicode 已定义超过 15 万个字符,涵盖 160+ 种文字系统,仍在持续扩充。
Unicode 的平面(Plane)结构
Unicode 的编码空间从 U+0000 到 U+10FFFF,共有 1,114,112 个码点位置。
这些位置被分成 17 个平面(Plane),每个平面 65,536 个码点:
BMP(Basic Multilingual Plane) 是最重要的平面:
范围:U+0000 ~ U+FFFF(0~65535)
包含:ASCII、CJK 统一汉字(约 20,000+ 个)、标点符号、数学符号等
日常使用的几乎所有字符都在 BMP 内
BMP 之外的情况:
Emoji 大部分在 Plane 1(U+1F600 ~ U+1F9FF)
极罕用汉字(如 𪚥)在 Plane 2
编码方案:把码点变成字节
有了码点,下一步是决定如何存储它。Unicode 本身不是一种编码方案,它只定义了编号。把码点变成字节有三种主流方案:UTF-8、UTF-16、UTF-32。
UTF-32:最简单、最浪费
每个码点固定用 4 字节:
U+0041 (A) -> 00 00 00 41 (4 字节)
U+4E2D (中) -> 00 00 4E 2D (4 字节)
U+1F60A (😊) -> 00 01 F6 0A (4 字节)
优点:解码最快,每个码点固定 4 字节,直接读出
缺点:极浪费——纯英文文件膨胀 4 倍
应用:几乎不用,只在少数需要随机访问字符的场合
UTF-16:系统内部的主流
BMP 内的字符用 2 字节,BMP 外的字符用 4 字节(通过代理对):
U+0041 (A) -> 00 41 (2 字节)
U+4E2D (中) -> 4E 2D (2 字节)
U+1F60A (😊) -> D8 3D DE 0A (4 字节,两个码元)
代理对(Surrogate Pair):保留 U+D800~U+DFFF 这段空间(不分配给任何实际字符),专门用于拼接出 BMP 外的码点。
大小端问题:4E 2D 还是 2D 4E?UTF-16 文件头部常有 BOM(Byte Order Mark,U+FEFF)来标记字节序。
实际应用:Windows NT 内核、Java String、JavaScript String 内部都用 UTF-16。
UTF-8:互联网的事实标准
变长编码——不同码点范围用不同字节数:
编码逻辑可以总结为三条规则:
首字节前缀决定总字节数:前面有几个 1 就占几字节,最后以 0 结尾
续字节都以 10 开头:解码器看到 10xxxxxx 就知道是上一个字符的延续
剩余位填入码点二进制:从低位开始填充
实战拆解 1:A(U+0041)-> 1 字节
码点二进制: 01000001 (7 位)
模板: 0xxxxxxx
填入: 01000001
-> [0x41] = 1 字节
U+0000 ~ U+007F 就是 ASCII 的范围。
UTF-8 完全兼容 ASCII——纯英文的 ASCII 文件用 UTF-8 解读完全不变。
实战拆解 2:英镑符号(U+00A3)-> 2 字节
码点: U+00A3 = 10100011 (8 位)
模板: 110xxxxx 10xxxxxx
填入: 110 00010 10 100011
|__c2__| |__a3__|
-> [c2 a3] = 2 字节
U+0080 ~ U+07FF 覆盖了西欧带音符的字母、中东文字、部分标点符号等。
实战拆解 3:中(U+4E2D)-> 3 字节 (中文汉字的典型)
码点: U+4E2D = 0100 111000 101101 (16 位)
模板: 1110xxxx 10xxxxxx 10xxxxxx
填入: 1110 0100 10 111000 10 101101
|_e4__| |__b8__| |__ad__|
-> [e4 b8 ad] = 3 字节
这是中文在 UTF-8 下最常见的编码长度。每一个常用汉字被编码为 3 个连续的 UTF-8 字节。
实战拆解 4:笑脸(U+1F600)-> 4 字节
码点: U+1F600 = 000 011111 011000 000000 (17 位)
模板: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
填入: 11110 000 10 011111 10 011000 10 000000
|_f0__| |_9f__| |_98__| |_80__|
-> [f0 9f 98 80] = 4 字节
所有 Emoji 和 BMP 外的罕用汉字都需要 4 字节。
UTF-8 的设计巧思
自同步(Self-Synchronizing):从任意位置读一个字节就能判断自己是字符开头还是续字节
0xxxxxxx -> 单字节字符开头
10xxxxxx -> 续字节(属于上一个字符)
110xxxxx -> 2 字节字符开头
1110xxxx -> 3 字节字符开头
11110xxx -> 4 字节字符开头 即使数据从中间截断,解码器也能在下一个 0 或 11 开头处重新同步。
无字节序问题:UTF-8 以字节为单位,没有大端小端的歧义,不需要 BOM
完全向后兼容 ASCII:0x00~0x7F 编码与 ASCII 完全一致
三种编码方案对比
实际选择:
出门在外用 UTF-8(文件、网络、API、数据库)
窝里用 UTF-16(操作系统和编程语言内部的字符串表示)
极少用 UTF-32(只在需要 O(1) 随机访问特定码点的特殊场景)
如何实际查看编码
在python中:
ord() 函数将单个 Unicode 字符转换为其整数表示形式。chr() 函数将整数 Unicode 代码点转换为包含对应字符的字符串。encode() 函数将 Unicode 字符串编码为 UTF-8的字节字符串decode() 函数将 UTF-8 字节字符串解码为 Unicode 字符串
>>> ord('中')
20013
>>> chr(20013)
'中'
>>> hex(20013)
'0x4e2d'
>>> "中".encode()
b'\xe4\xb8\xad'
中 的码点为 U+4E2D,UTF-8编码后的字节为 e4 b8 ad
>>> "中".encode('utf-8')
b'\xe4\xb8\xad'
>>> "中".encode('utf-16')
b'\xff\xfe-N'
>>> "中".encode('utf-32')
b'\xff\xfe\x00\x00-N\x00\x00'
>>> chr(0x4e)
'N'
>>> chr(0x2d)
'-'
\xff\xfe 表示小端序
评论