字符编码:从 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 为什么能一统天下?三个金性质:
- ASCII 完全兼容——历史上所有英文文本一字节不用改,天然合法。存量数据零迁移成本,这是它赢的根本原因(和浮点数 exp 用无符号存是同一种智慧:让位模式保持某种"有序/兼容"的好性质)。
- 自同步——任取一个字节,看开头就知道它是首字节还是中间字节。传输丢包、文件从中间截断,都能重新对齐;且
0x00只出现在 U+0000 的编码里,C 字符串的\0结尾永远不会被误伤。 - 字节序无关——字节顺序固定,大端小端机器存出来一模一样(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" |
九、盲点自测
- 字符
'0'和数字 0 的区别? ——'0'存的是 48;c - '0'才是真值。 - ASCII 用几位?为什么 char 是 8 位? —— 7 位 128 字符;装进 8 位字节,最高位闲置,后来被各家扩展占用,埋下乱码伏笔。
- 大小写转换的位运算? —— 'A' 与 'a' 只差 0x20 这一位:
c|0x20转小写、c&0xDF转大写(仅对字母成立)。 - Unicode 和 UTF-8 是什么关系? —— 字符集 vs 编码方式:Unicode 发码点编号,UTF-8 规定编号怎么落成字节。一个码点可以有多种编码存储。
- UTF-8 为什么赢了? —— ASCII 完全兼容(存量零成本)+ 自同步(可重对齐、
\0安全)+ 字节序无关。 - '中' 的 UTF-8 几个字节? —— 3 字节
E4 B8 AD;汉字绝大多数在 3 字节区。 - 烫/屯/锟斤拷分别是什么事故? —— 烫=Debug 栈 0xCC(int3)按 GBK 解;屯=Debug 堆 0xCD;锟斤拷=U+FFFD 的 UTF-8 字节(EF BF BD)被按 GBK 切分。
- MySQL 的 utf8 够用吗? —— 不够,它是 utf8mb3;emoji 要 utf8mb4。
- 为什么 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-信息的表示与处理 🏁 完结,回总篇收网
💬 评论