字符编码漫谈:从乱码说起

你有没有遇到过这种情况:打开一个文本文件,满屏的”锟斤拷”、”烫烫烫”、”��”,或者一串乱七八糟的问号?

这背后,是一段长达半个多世纪的”字符编码进化史”。

一、一切的起点:计算机只认 0 和 1

我们先回到最朴素的问题:计算机怎么”看见”文字?

答案是:它根本看不见。

计算机内部只有电压的高低,对应着 0 和 1 两种状态。无论是图片、音乐、视频,还是你正在读的这段文字,在计算机眼里都是一长串的二进制数字。

1
2
你看到的:  "Hi"
计算机看到: 01001000 01101001

那么问题来了:01001000 凭什么代表 H?

答案是:因为我们约定它代表 H

这个”约定”就是字符编码——人类和计算机之间的一份”翻译合同”。

二、ASCII:美国人先定了规矩(1963)

故事的开端,是 1963 年的美国。

当时的计算机还是”贵族玩具”,主要用户是说英语的科学家和工程师。他们需要解决的问题很简单:怎么让计算机存储和显示英文?

于是 ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)诞生了。

ASCII 的设计

  • 7 位二进制(后来扩展为 8 位,即 1 字节)表示一个字符
  • 总共可以表示 128 个字符(2⁷ = 128)
  • 包括:26 个大写字母、26 个小写字母、10 个数字、各种标点符号、一些控制字符
字符 十进制 二进制
A 65 01000001
a 97 01100001
0 48 00110000
空格 32 00100000

对于只用英文的世界,ASCII 完美无缺。

但是…世界不只有英文。

三、混乱年代:每个国家都自己搞一套

当计算机走向世界,问题来了:ASCII 只有 128 个位置,根本装不下中文、日文、阿拉伯文、希伯来文……

于是各个国家和地区开始自己设计编码方案,这造就了一段”群雄割据”的混乱年代。

中国:从 GB2312 到 GBK 再到 GB18030

GB2312(1980)

中国国家标准发布,收录了 6,763 个汉字 和 682 个其他符号(标点、希腊字母等)。

它的思路很巧妙:

  • 一个英文字符仍然用 1 字节(兼容 ASCII)
  • 一个中文字符用 2 字节,且每个字节的最高位都是 1(避免和 ASCII 冲突)

GBK(1995)

GB2312 只收录了 6,763 个汉字,但实际汉字有 9 万多个。很多人名(比如朱镕基的”镕”)、古汉语用字、繁体字都打不出来。

微软为了在 Windows 95 中支持更多汉字,扩展出了 GBK,收录了 21,003 个汉字,向下兼容 GB2312。

GB18030(2000)

国家强制标准,收录了所有 CJK(中日韩)统一汉字,包括少数民族文字,共 70,000+ 字符。采用 1/2/4 字节变长编码。

其他国家也在”各自为政”

编码 地区 字符数 字节数
ASCII 美国 128 1
ISO-8859-1 西欧 256 1
GB2312/GBK 中国大陆 2 万 1-2
Big5 台湾、香港 1.3 万 1-2
Shift-JIS 日本 8 千 1-2
EUC-KR 韩国 1.1 万 1-2

混乱的代价:乱码遍地

由于每种编码对同一段字节序列的解释都不一样,问题接踵而至:

🔥 场景一:同事发来的文件打不开

你在中国大陆用 GBK 保存的文件,发给台湾同事。他用 Big5 打开,看到的全是乱码。

🔥 场景二:网页一片”锟斤拷”

浏览器以错误的编码解析网页,中文全部变成”锟斤拷烫烫烫”——这正是著名的”锟斤拷之谜”(GBK 解码 UTF-8 替换字符 时产生的现象)。

🔥 场景三:多语言无法共存

一个文档里既要写中文又要写日文?对不起,GBK 装不下日文,Shift-JIS 也装不下中文,根本无解

整个世界的文字交流,被困在了一个个孤岛上。

四、Unicode:让全世界字符住进同一栋楼(1991)

20 世纪 90 年代,几家科技公司(Xerox、Apple、Sun、IBM 等)联合发起了 Unicode 联盟,目标是:

给世界上每一个字符,分配一个独一无二的编号。

这个编号叫做码点(Code Point),用 U+ 加十六进制数字表示。

1
2
3
4
'A'   →  U+0041
'中' → U+4E2D
'你' → U+4F60
'😀' → U+1F600

Unicode 的容量极其庞大:从 U+0000U+10FFFF,理论上可以容纳 超过 100 万个字符。截至目前,已经收录了 14 万多个字符,涵盖了几乎所有现存语言、历史文字、emoji、数学符号、甚至麻将牌和扑克牌。

但 Unicode 只解决了一半问题

Unicode 只规定了”字符 → 编号” 的对应关系,没规定”编号 → 字节” 怎么存。

比如 的码点是 U+4E2D,需要 2 字节才能装下:4E 2D

但问题来了:

  • A(U+0041)怎么办?也用 2 字节存成 00 41?那英文文档体积要翻倍!
  • emoji 😀(U+1F600)需要 3 字节,又怎么和 2 字节的中文混存?
  • 字节里出现的 00,会让以 \0 结尾的 C 字符串提前截断。

于是,需要一种”编码方案”来解决 Unicode 的存储问题。

UTF-8、UTF-16、UTF-32 都是这种方案,其中 UTF-8 笑到了最后

五、UTF-8:伟大的妥协(1992)

UTF-8 由 Unix 之父 Ken ThompsonRob Pike 在 1992 年的一个晚餐餐巾纸上设计完成。它的天才之处在于:变长编码

UTF-8 的核心设计

不同范围的字符,用不同长度的字节来存:

Unicode 范围 UTF-8 字节数 字节模式
U+0000 ~ U+007F 1 字节 0xxxxxxx
U+0080 ~ U+07FF 2 字节 110xxxxx 10xxxxxx
U+0800 ~ U+FFFF 3 字节 1110xxxx 10xxxxxx 10xxxxxx
U+10000 ~ U+10FFFF 4 字节 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

看一个具体例子:中文”中”是怎么编码的?

1
2
3
4
5
1. 查 Unicode 码表:'中' = U+4E2D
2. 转换为二进制: 0100 1110 0010 1101 (16 位)
3. 套 3 字节模板: 1110xxxx 10xxxxxx 10xxxxxx
4. 把二进制填进去: 11100100 10111000 10101101
5. 最终十六进制: E4 B8 AD

所以 "中" 在 UTF-8 中占 3 字节,即 E4 B8 AD

UTF-8 的三大杀手锏

1. 完美兼容 ASCII

前 128 个字符(英文字母、数字、符号)的 UTF-8 编码和 ASCII 完全一致,都是 1 字节。这意味着所有过去 50 年写的英文文档、英文程序代码,不用任何改动就是合法的 UTF-8

2. 自同步性(Self-synchronizing)

通过字节首部的特征,可以瞬间判断出当前位置:

  • 0xxxxxxx → 单字节字符
  • 10xxxxxx → 多字节字符的”后续字节”
  • 110xxxxx1110xxxx11110xxx → 多字节字符的”起始字节”

即使从一个文件的中间任意位置开始读取,也能快速找到下一个完整字符的边界。单个字节损坏,不会导致整个文件报废

3. 没有字节序问题

UTF-16 和 UTF-32 因为每个字符跨越多个字节,需要规定哪个字节在前(大端序 or 小端序),所以要在文件开头加 BOM(Byte Order Mark)标记。

UTF-8 是按字节为单位读取的,根本不存在字节序问题,文件可以”裸奔”。

六、为什么中文要用 3 个字节?

这是一个常见的疑问:为什么英文 1 字节,中文却要 3 字节?GBK 不是只用 2 字节就够了吗?

数学上的限制

UTF-8 的 2 字节模板 110xxxxx 10xxxxxx 实际可用位只有 11 位,最多能表示 2¹¹ = 2,048 个字符

而中文常用汉字就有 7,000 多个,加上扩展共 9 万多。11 位的”小房间”根本住不下。

只能升级到 3 字节模板,可用 16 位,能装 65,536 个字符——这才够用。

Unicode 的”分区”逻辑

Unicode 在分配码点时,大致遵循”字符多的语言往后排“:

1
2
3
4
U+0000 ~ U+007F     ASCII 英文                   → 1 字节
U+0080 ~ U+07FF 拉丁扩展、希腊、阿拉伯 → 2 字节
U+0800 ~ U+FFFF 中日韩汉字 ★ → 3 字节
U+10000 ~ U+10FFFF emoji、生僻字、古文字 → 4 字节

中文汉字主要在 U+4E00 ~ U+9FFF,正好落在 3 字节区间

这是不是对中文的”歧视”?

表面看,中文存储要花 3 倍空间,似乎”吃亏”了。但实际上:

维度 UTF-8 GBK
中文存储 3 字节 2 字节 ✓
英文存储 1 字节 ✓ 1 字节 ✓
全球字符支持 100 万+ ✓ 仅 2 万
中日混排 支持 ✓ 乱码
Emoji 支持 支持 ✓
跨平台兼容 全球标准 ✓ 中文区限定

而且现实中:

  • HTML、JSON、代码里有大量英文(标签、关键字、字段名)
  • 经过 Gzip 压缩后,UTF-8 和 GBK 的体积差距几乎可以忽略
  • 网络带宽和存储已经不再是稀缺资源

用一点点空间换全球通用性,这笔账太划算了。

七、今天的世界:UTF-8 一统江湖

进入 21 世纪后,UTF-8 几乎赢得了所有战场:

  • Web:截至 2024 年,全球 98% 以上的网页使用 UTF-8 编码
  • Linux / macOS:系统默认编码就是 UTF-8
  • JSON:RFC 8259 强制要求使用 UTF-8
  • Python 3:源码默认编码是 UTF-8,字符串内部用 Unicode
  • MySQL:推荐使用 utf8mb4(完整的 UTF-8,支持 emoji)
  • HTML5:<meta charset="UTF-8"> 是标配

只有 Windows 系统因为历史包袱,中文版默认还在使用 GBK(CP936),这就是为什么很多 Python 初学者在 Windows 上遇到”奇怪的乱码问题”——一个文件里 UTF-8 和 GBK 在打架。

八、实战:常见的”乱码”是怎么产生的?

理解了原理后,我们来看看几种典型的乱码场景。

场景一:UTF-8 文件被当成 GBK 打开

1
2
3
4
5
6
# 用 UTF-8 编码保存"你好"
b = "你好".encode('utf-8')
print(b) # b'\xe4\xbd\xa0\xe5\xa5\xbd' (6 字节)

# 用 GBK 强行解码
print(b.decode('gbk')) # 浣犲ソ ← 乱码!

原因:UTF-8 的 6 字节被 GBK 当成 3 个 2 字节的中文字符来解读。

场景二:著名的”锟斤拷”

当 GBK 程序遇到无法识别的 UTF-8 字符时,常会用 (替换字符,UTF-8 编码为 EF BF BD)替代。两个连续的 在 UTF-8 中是 6 字节 EF BF BD EF BF BD,用 GBK 解读就成了:

1
2
3
EF BF → 锟
BD EF → 斤
BF BD → 拷

“锟斤拷” 横空出世!

场景三:Windows 的”烫烫烫”

Visual Studio Debug 模式下,未初始化的栈内存被填充为 0xCC(int 3 中断指令)。两个 0xCC 在 GBK 中正好是汉字”烫”。

“烫烫烫” 由此而来。

九、给开发者的建议

  1. 新项目一律用 UTF-8,没有任何理由用 GBK
  2. 数据库选 utf8mb4 而不是 utf8(MySQL 的 utf8 是个历史 bug,只支持 3 字节)
  3. 文件读写显式指定编码:open(file, encoding='utf-8')
  4. HTTP 响应头加上 Content-Type: text/html; charset=utf-8
  5. 遇到乱码先确认两件事:文件实际编码是什么?程序用什么编码读的?
  6. 不要相信”自动检测编码”:大多数库的自动检测都不靠谱

十、总结:编码的来龙去脉

让我们最后梳理一遍整个故事:

1
2
3
4
5
6
7
8
9
1963  ASCII       美国人定的规矩,128 个字符,只够英文用

1980s 各国乱战 GB2312、Big5、Shift-JIS……一国一套,互不兼容

1991 Unicode 给全世界字符统一编号(但没说怎么存)

1992 UTF-8 变长编码,完美兼容 ASCII,赢得全球

今天 一统江湖 Web、Linux、JSON、Python 全部默认 UTF-8

字符编码的本质,是人类与机器之间的一份”翻译合同”。

从 ASCII 的”美国中心”,到各国分裂的”巴别塔”,再到 Unicode/UTF-8 的”世界大同”,这是一段技术不断追赶人类多样性的历史。

下一次当你流畅地在网页上看到中文、英文、日文、emoji 共存,请记得感谢一下 60 年前那批默默工作的工程师们——是他们的智慧,让全世界的文字可以在计算机里和平共处。


参考资料