字符编码完全指南:从 ASCII 到 Unicode,一次讲透乱码的来龙去脉
2026-08-06
你一定见过这样的场景:同事发来的 CSV 用 Excel 打开全是”涓枃”(注:典型的 UTF-8 被当作 GBK 解读的乱码),网页上本该是 é 的地方出现 é,日志里的中文变成一串问号。这些问题的根源都一样——字符编码。它是每个程序员天天在用却很少系统学过的基础知识。这篇文章从 ASCII 讲起,把 Unicode、UTF-8、UTF-16、BOM、规范化这些概念串成一条线,读完你就有了一套排查任何乱码问题的完整思路。
从 ASCII 到代码页:编码的史前时代
1960 年代定义的 ASCII(美国信息交换标准代码)用 7 个比特表示 128 个字符:英文字母、数字、标点和一些控制符。比如大写 A 是 0x41,a 是 0x61,空格是 0x20。对于只说英语的世界,这够用了。
但世界不只说英语。一个字节有 8 个比特,比 ASCII 多出 128 个空位,于是各地区纷纷在这”高 128 位”里塞自己的字符:西欧用 Latin-1(ISO-8859-1)放 é、ü,俄罗斯用 KOI8-R 放西里尔字母,中国大陆用 GB2312 及其扩展 GBK 放汉字(汉字太多,一个字节放不下,所以 GBK 用两个字节)。这些方案统称代码页(code page)。问题在于:同一个字节 0xE4 在 Latin-1 里是 ä,在 GBK 里可能是某个汉字的前半个字节。字节本身不带任何”我用的是什么编码”的信息,一旦解码方猜错代码页,乱码就产生了。这个混乱局面就是 Unicode 要解决的。
Unicode:给每个字符一个唯一编号
Unicode 的思路非常干脆:不再纠结字节,先给全世界每个字符分配一个唯一的编号,称为码点(code point),写作 U+ 加十六进制。比如 A 是 U+0041,汉字”中”是 U+4E2D,笑脸 😀 是 U+1F600。目前 Unicode 定义的码位空间从 U+0000 到 U+10FFFF,容纳了几乎所有文字系统、符号和 emoji。
这里要建立一个关键的心智模型:码点是抽象编号,字节是存储形式,两者之间的映射规则才叫”编码”。说”一个文件是 Unicode 编码”其实是不准确的说法——文件里存的永远是字节,真正决定字节长什么样的是 UTF-8、UTF-16 这些编码方案(encoding form)。想查某个字符的码点,可以直接用 Unicode 字符查询 输入字符就能看到它的编号、名称和 UTF-8 字节序列。
UTF-8:变长编码的胜利
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 字节)
以”中”(U+4E2D)为例:它落在三字节区间。把 4E2D 展开成二进制 0100 111000 101101,按模板填入得到 11100100 10111000 10101101,即字节序列 E4 B8 AD。任何 U+0800 到 U+FFFF 的字符(包括所有常用汉字)在 UTF-8 里都占 3 字节;emoji 因为码点超过 U+FFFF,一律占 4 字节。
UTF-8 最妙的一点是完全兼容 ASCII:所有 ASCII 字符的 UTF-8 编码就是它们原本的那个字节。这意味着纯英文的 UTF-8 文件和 ASCII 文件逐字节相同,老程序不认识 UTF-8 也能正确读出英文部分。加上 UTF-8 有自同步特性(从任意字节都能很快找到下一个字符的边界),它击败一众对手成为事实标准毫不意外。想直观感受字符和字节的关系,可以用 Text ↔ Binary Converter 输入一段文字,看它在各种进制下的字节表示。
UTF-16、UCS-2 与代理对
UTF-16 是另一种主流方案:码点在 U+0000–U+FFFF 区间(即基本多文种平面 BMP)的字符用 2 字节表示,超出 BMP 的用 4 字节。Java、JavaScript 字符串和 Windows 内部用的都是 UTF-16。
超出 BMP 的部分靠代理对(surrogate pair)实现:Unicode 把 U+D800–U+DFFF 这段保留为代理区,不分配给任何字符。一个超出 BMP 的码点会被拆成两个代理码元——高位代理(U+D800–U+DBFF)加低位代理(U+DC00–U+DFFF)。以 😀(U+1F600)为例,它在 UTF-16 中表示为 D83D DE00 两个 16 位码元。
这带来一个著名的坑:JavaScript 的 "😀".length 返回 2 而不是 1,因为 length 数的是 UTF-16 码元而不是字符。更早的 UCS-2 编码干脆只支持 BMP,把超出部分直接处理不了——很多老系统的”无法显示某些 emoji”就源于此。遍历字符串时用 for...of 或展开运算符可以按码点而不是码元迭代,这是现代 JS 的正确姿势。
乱码是怎么产生的
乱码(mojibake)本质上只有两大类成因:
第一类:用错误的编码解码。 字节序列 E4 B8 AD 用 UTF-8 解读是”中”,但如果被当作 GBK 解读,E4 B8 是一个汉字、AD 又和后面的字节拼成半个字——“中文”被 GBK 误读后就成了经典的”涓枃”。反过来 GBK 字节被当 UTF-8 解读时,解码器遇到非法字节序列,通常会替换成 �(U+FFFD 替换字符)。网页上常见的 é 也是同类事故:é 的 UTF-8 字节 C3 A9 被当成 Latin-1 逐字节解释的结果。
第二类:双重编码。 一段文本先被正确编成 UTF-8,然后这串字节又被误当成 Latin-1 字符串再编码了一次 UTF-8。é 会先变成 é,再编码就成了四个字节,层层嵌套越错越离谱。修复方法是逆向操作:按 Latin-1 解码回字节,再按 UTF-8 解码,有时要重复两轮。
排查乱码时有个实用技巧:把可疑字符粘贴到 Unicode 字符查询 里看看它们各自的码点,往往能推断出原始的编码链。另外,如果文本里混着 HTML 实体(é、中 之类),那是另一个层面的转义问题,可以用 HTML 编解码器 先解开实体再看内容。
BOM:好用但有副作用的签名
UTF-16 文件开头的两个字节有歧义:4E 2D 到底是大端序(高位在前)的”中”,还是小端序的 U+2D4E?BOM(Byte Order Mark,字节顺序标记)就是为解决这个问题而生的:文件开头放一个码点 U+FEFF 的编码。UTF-16 大端是 FE FF,小端是 FF FE,读开头两字节就知道字节序。
UTF-8 没有字节序问题,但 UTF-8 BOM(EF BB BF 三个字节)还是被发明出来当作”这是 UTF-8”的签名——主要是 Windows 记事本带起来的习惯。麻烦在于很多工具和协议不期待文件开头有这三个字节:
- PHP 文件带 BOM 会导致
header()之前就有输出,报 “headers already sent”; - Shell 脚本的
#!shebang 前面多了 BOM 会导致内核找不到解释器; - CSV 带 BOM 时,某些解析器会把 BOM 当字段内容的一部分;
- 拼接多个文件时 BOM 会跑到文本中间,显示为零宽不可见字符。
经验法则:写 UTF-8 文件默认不要 BOM,除非明确知道消费方(比如某些版本的 Excel 打开 UTF-8 CSV)需要它。
规范化:é 到底是一个字符还是两个
Unicode 里很多字符有两种等价写法。é 既可以是单个码点 U+00E9(预组合形式),也可以是 e(U+0065)后面跟一个组合用尖音符 U+0301——两个码点渲染出来和单码点一模一样,但按字节或码点比较时不相等。文件系统、数据库唯一索引、密码比对都可能因此被坑:用户看着两个完全相同的字符串,程序却认为它们不同。
Unicode 定义了规范化形式来解决这个问题:
- NFC(Canonical Composition):把能合并的都合并,
e + U+0301合成U+00E9; - NFD(Canonical Decomposition):把能拆开的都拆开,
U+00E9拆成e + U+0301; - 还有 NFKC/NFKD 做兼容性分解,会把
fi连字拆成fi、全角字符转成半角等,信息有损失,慎用于存储。
实践建议:用户输入入库前做一次 NFC 规范化;做字符串比较、去重、哈希前确保两边用同一种规范化形式。macOS 的文件系统倾向于 NFD、Windows 和大多数系统用 NFC,跨平台传文件名时这个差异偶尔会咬人。
实战调试清单
遇到编码问题,按下面的顺序排查基本不会失手:
- 先看文件实际编码。Linux/macOS 下
file input.txt会给出猜测;更精确地用hexdump -C file | head看开头字节——EF BB BF是 UTF-8 BOM,FF FE是 UTF-16 小端。 - 网页乱码先看声明。HTTP 响应头
Content-Type: text/html; charset=utf-8优先级最高,其次是 HTML 里的<meta charset="utf-8">。两者不一致时以响应头为准,这也是”本地打开正常、上线就乱码”的常见原因。 - 数数字节猜编码。UTF-8 里汉字 3 字节、ASCII 1 字节、emoji 4 字节;GBK 里汉字 2 字节。看到大量
E4-E9开头的三字节序列基本可以断定是 UTF-8 中文。 - 转换而不是猜测。确定源编码后用
iconv -f GBK -t UTF-8 input.txt显式转换,别指望编辑器”自动识别”永远正确。 - 遇到奇怪的实体或转义,先用 HTML 编解码器 解开 HTML 实体,用 Unicode 字符查询 确认每个字符的真实码点,再对照 Text ↔ Binary Converter 检查底层字节——三层各看一遍,任何编码问题都无处遁形。
编码问题的可怕之处在于它从不报错,只是安静地给你错误的文本。但只要你建立起”字符是码点、文件是字节、编码是两者之间的映射”这个心智模型,再加上 BOM 和规范化的两个补丁知识,世界上绝大多数乱码对你来说都是十分钟以内能定位的小问题。