字符编码:从 ASCII 到 UTF-8

总篇 00-信息的表示与处理char 只是类型表里不起眼的一行。这篇负责把"字"补齐:数讲透了(补码管整数、IEEE 754 管小数),可文字怎么变成 01?从 ASCII 一路打到 Unicode,顺手破掉"烫烫烫""锟斤拷"这些经典灵异事件。

一、问题的起点:计算机只认识数字

内存里只有 0 和 1,想存"你好"两个字,必须先约定一件事:每个字对应哪个数字。这套约定其实是两层:

回答的问题 例子
字符集 这个字是几号? "中" → 编号 U+4E2D
字符编码 这个号怎么存成字节? U+4E2D → E4 B8 AD 三个字节

打个比方:字符集是给全世界每个字发身份证号,字符编码是规定身份证号用几位数字、怎么写在卡片上。两件事独立,混着叫就容易出乱码。

二、ASCII:英语世界的一亩三分地

1967 年定稿。英语就 26 个字母加些符号,7 位(128 个字符)足够:

字符 十进制 十六进制 二进制
'0' 48 0x30 0011 0000
':' 58 0x3A 0011 1010
'A' 65 0x41 0100 0001
'a' 97 0x61 0110 0001
空格 32 0x20 0010 0000

🤔 注意 '0' ≠ 数字 0 —— 字符 '0' 存的是 48!所以 位运算 那篇里 c - '0'(减 48)和 c & 0x0F(取低 4 位)能把字符转成真数字。

彩蛋:大小写只差一位。 对比 'A' 和 'a' 的二进制——只有第 5 位(0x20)不同:

'A' = 0100 0001     'a' = 0110 0001
                          
      0                    1    0x20 就是"大小写开关"

c | 0x20 转小写、c & 0xDF 转大写(仅对字母成立)。位运算表里那条"或=置位"的实战应用,就藏在这。

字节是 8 位的,ASCII 只用 7 位——最高位闲置。后来各家拿这第 8 位扩展出自家符号(Latin-1 等),同一套 128~255 各家解释各的,混乱的种子此时已埋下。

三、天下大乱:各国自己造表

中文常用字好几千,128 个码位塞不下,于是各汉字文化圈自造双字节字符集:

字符集 年代 收录 特点
GB2312 1980 6763 汉字 + 682 符号 国标第一代;两字节,最高位都为 1
GBK 1995 21003 汉字 向下兼容 GB2312,Windows 中文版实推
GB18030 2000 起 覆盖全部 Unicode 码位 国家强制标准,1/2/4 字节变长
Big5(大五码) 1984 繁体 13000+ 字 中国台湾地区常用

设计上有巧思:首字节 ≥ 0x81,不碰 ASCII 区间——纯英文文件在任何中文编码下一个字节都不用改。看个例子:

'中'  GBK 编码 = D6 D0
1101 0110 1101 0000
   首字节最高位是 1  这不是 ASCII,是双字节字符的开头

但要命的问题来了:不同国家的表里,同一个码位对应不同的字。同一串字节,用 A 表编码、用 B 表解码,出来就是另一副面孔——这就是乱码的根源。1990 年代,中日韩网页互访基本靠猜编码。

四、Unicode:给全世界每个字发身份证

1991 年起,Unicode 联盟干的是"书同文"的大事:给每个字符一个全球唯一的编号(码点),写作 U+xxxx

字符 码点
'A' U+0041
'中' U+4E2D
'🀄' U+1F004
'😀' U+1F600

码点空间 U+0000 ~ U+10FFFF,共 17 个平面 × 65536 ≈ 111 万个位置。常用字都住在第一个平面(BMP,U+0000~U+FFFF);emoji 之类新面孔在辅助平面。

⚠️ 关键认知:Unicode 只发编号,不管编号怎么存。 U+4E2D 存成几个字节?UTF-8、UTF-16、UTF-32 给出三种不同答案。所以说"Unicode 编码"不严谨——Unicode 是字符集,UTF-8 才是编码方式。就像身份证号统一了,但卡片上怎么写还没规定。

五、UTF-8:变长设计的教科书

UTF-8(1993,Ken Thompson 参与)的思路:常用的短存,生僻的长存。靠字节开头的"前缀"自报家门:

码点范围 字节数 二进制模板
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,总共就是几个字节;后续字节一律 10 开头。完整推导一遍 '中':

U+4E2D = 0100 1110 0010 1101     (十六进制逐位展开,16 位)
落在 3 字节区 → 模板 1110xxxx 10xxxxxx 10xxxxxx,共 16 个空位
从高位到低位填入:0100 | 111000 | 101101

1110 0100   10 111000   10 101101
= E4        B8          AD

对照几个实例:

字符 码点 UTF-8 字节 字节数
'A' U+0041 41 1
'中' U+4E2D E4 B8 AD 3
'😀' U+1F600 F0 9F 98 80 4

'A' 编码后还是 0x41,和 ASCII 一字不差——这就是 UTF-8 的第一杀器。

UTF-8 为什么能一统天下?三个金性质:

  1. ASCII 完全兼容——历史上所有英文文本一字节不用改,天然合法。存量数据零迁移成本,这是它赢的根本原因(和浮点数 exp 用无符号存是同一种智慧:让位模式保持某种"有序/兼容"的好性质)。
  2. 自同步——任取一个字节,看开头就知道它是首字节还是中间字节。传输丢包、文件从中间截断,都能重新对齐;且 0x00 只出现在 U+0000 的编码里,C 字符串的 \0 结尾永远不会被误伤。
  3. 字节序无关——字节顺序固定,大端小端机器存出来一模一样(UTF-16/32 就得靠 BOM 操心字节序,见 2.4 大端小端)。

六、UTF-16 与 UTF-32:一句话带过

编码 思路 代价 / 用处
UTF-32 每码点固定 4 字节 简单粗暴、随机访问 O(1),但纯英文也膨胀 4 倍,少见
UTF-16 BMP 用 2 字节,辅助平面用代理对 4 字节 Java 和 JavaScript 字符串的内部编码

emoji '😀' = U+1F600,超出 BMP(U+FFFF),UTF-16 得拆成两个"代理字符"存(D83D DE00)——这就是 Java 里 "😀".length() == 2 的真相:不是数错了,是 UTF-16 里它真的占两个 char。

七、乱码考古:三大灵异事件破案

先立总纲:乱码的本质 = 字节没变,解释错了。 下面三桩悬案,全是这句话的变体。

案一:烫烫烫烫烫……

MSVC(Visual Studio)Debug 模式会把栈上未初始化的内存填成 0xCC(顺带一提,0xCC 正是 x86 的 int3 断点指令——程序一旦跳进去,调试器立刻抓个正着,等于"越界即报警"):

0xCC 0xCC 按 GBK 解 → 烫

满屏烫烫烫 = 你读了没初始化的栈变量。

案二:屯屯屯屯屯……

同一套思路,Debug 模式给堆内存填的是 0xCD

0xCD 0xCD 按 GBK 解 → 屯

屯屯屯 = 你读了没初始化的堆内存。烫在栈上、屯在堆上,两位"质检员"分工明确。

案三:锟斤拷锟斤拷……(最连环)

① 某段字节流解码失败 → 解码器把坏字符替换成 U+FFFD( replacement character � )
② 程序把它存回 UTF-8:U+FFFD = EF BF BD,连续多个就是
   EF BF BD EF BF BD EF BF BD ...
③ 这串 UTF-8 字节又被拿去按 GBK 显示(两字节一字):
   EF BF → 锟      BD EF → 斤      BF BD → 拷
④ 三字节序列对两字节切分永远错开 → "锟斤拷"无限循环

一桩案子牵出两次解码事故:先错解出 U+FFFD,再错按 GBK 显示。附赠一个常见变体——UTF-8 的"你好"(E4 BD A0 E5 A5 BD)被按 GBK 显示,就变成"浣犲ソ"。

💡 破案口诀:看到乱码别猜,先问两句话——这串字节原本是什么编码?现在被当成了什么编码? 用十六进制工具看字节,一切灵异事件都有标准答案。

八、实战避坑清单

场景 正确姿势
MySQL 字符集 utf8 实际是 utf8mb3(最多 3 字节),存 emoji 直接报错/截断 utf8mb4(MySQL 8.0 起已是默认)
Java char 只有 2 字节,装不下 emoji;按 charAt 数长度会数错 用码点 API(codePointAt/codePoints)处理"字符"
Python Python2 的 str/unicode 双轨制是历史天坑 用 Python3;str 就是码点序列,读写文件显式 encoding="utf-8"
网页 少了 ``,浏览器按默认编码猜 → 乱码 HTML 里显式声明 + HTTP 响应头 Content-Type
文件读写 不指定编码,跟着系统 locale 走,换台机器就翻车 永远显式 encoding="utf-8"

九、盲点自测

  1. 字符 '0' 和数字 0 的区别? —— '0' 存的是 48;c - '0' 才是真值。
  2. ASCII 用几位?为什么 char 是 8 位? —— 7 位 128 字符;装进 8 位字节,最高位闲置,后来被各家扩展占用,埋下乱码伏笔。
  3. 大小写转换的位运算? —— 'A' 与 'a' 只差 0x20 这一位:c|0x20 转小写、c&0xDF 转大写(仅对字母成立)。
  4. Unicode 和 UTF-8 是什么关系? —— 字符集 vs 编码方式:Unicode 发码点编号,UTF-8 规定编号怎么落成字节。一个码点可以有多种编码存储。
  5. UTF-8 为什么赢了? —— ASCII 完全兼容(存量零成本)+ 自同步(可重对齐、\0 安全)+ 字节序无关。
  6. '中' 的 UTF-8 几个字节? —— 3 字节 E4 B8 AD;汉字绝大多数在 3 字节区。
  7. 烫/屯/锟斤拷分别是什么事故? —— 烫=Debug 栈 0xCC(int3)按 GBK 解;屯=Debug 堆 0xCD;锟斤拷=U+FFFD 的 UTF-8 字节(EF BF BD)被按 GBK 切分。
  8. MySQL 的 utf8 够用吗? —— 不够,它是 utf8mb3;emoji 要 utf8mb4。
  9. 为什么 Java 里 "😀".length() == 2 —— String 内部是 UTF-16,emoji 在辅助平面,用两个 char 的代理对(D83D DE00)表示。

十、动手玩一把

# 看一个字符的真实字节(xxd 十六进制转储)
echo -n 中 | xxd        # e4 b8 ad —— 3 字节,和推导一致
echo -n A | xxd         # 41       —— 和 ASCII 时代一模一样
echo -n 😀 | xxd        # f0 9f 98 80

# 编码转换
iconv -f GBK -t UTF-8 in.txt > out.txt   # GBK 转 UTF-8
# Python 亲手复现三桩乱码案
"中".encode("utf-8")           # b'\xe4\xb8\xad'
"中".encode("gbk")             # b'\xd6\xd0'
"你好".encode("utf-8").decode("gbk")   # '浣犲ソ' ← 字节没变,解释错了
b"\xcc\xcc".decode("gbk")      # '烫'
"\ufffd\ufffd".encode("utf-8").decode("gbk")  # '锟斤拷'

参考资料

  • 阮一峰《字符编码笔记:ASCII,Unicode 和 UTF-8》(2007):https://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html —— 十多年前的文章,至今仍是中文圈最好的入门
  • 《深入理解计算机系统》(CSAPP) 第 2 章 —— 本分区总篇的原著

至此,"数"(补码、IEEE 754)和"字"(字符编码)都闭环了——信息的表示与处理系列真正完结。下一站进操作系统。

⬅️ 浮点数 🏠 00-信息的表示与处理 🏁 完结,回总篇收网