在计算机的世界里,看似简单的文本背后隐藏着一套精密而复杂的编码体系。我们每天与文字打交道,却常常对“字符是如何被表示、存储和传输的”这一根本问题缺乏清晰的认识,从而导致诸如乱码、兼容性错误乃至安全漏洞等一系列困扰。本文旨在系统性地梳理字符编码的核心概念,帮助读者建立起坚实而准确的知识框架。
一切理解始于三个基本要素:字符 (人类书写的基本单位)、字符集 (为字符分配唯一编号——即“码点”——的集合) 以及编码 (将码点转换为字节序列的具体规则)。从早期的 ASCII 到统一全球文字的 Unicode,字符集的发展解决了 “有哪些字符” 的问题;而从 UTF-8、UTF-16 到 UTF-32,不同的编码方案则致力于高效、可靠地解决 “如何存储这些字符” 的工程挑战。
本文将深入探讨这些标准的内在机制,包括 UTF-8 凭借其字节流特性和自同步能力成为互联网事实标准的原因,UTF-16 中代理对的设计精妙与潜在陷阱,以及 UTF-32 在简单性与空间效率之间的权衡。同时,我们也会澄清常见的误解,例如 BOM(字节顺序标记)在 UTF-8 中的冗余性及其可能引发的跨平台问题。
通过厘清这些概念及其相互关系,我们不仅能从根本上理解乱码的成因,更能掌握在现代软件开发中正确处理国际化文本的关键原则,从而编写出更加健壮、兼容和可靠的程序。
注:以上概述为 AI 总结生成。
字符、字符集、编码
要想把 ASCII、Unicode 的之间的事情讲清楚,首先要了解字符、字符集、编码这三个概念。
字符 (Character) 是人类书写系统中的最小单位,比如字母 'A'、汉字 '中'、数字 '3'、标点符号 ',' 等。字符本身是抽象的、离散的,不涉及如何在计算机中表示。每种语言体系都有自己的字符,每种语言体系所有字符的集合叫做字符集 (Character Set)。譬如 ASCII 字符集包含 128 个字符,其中包括 26 个字母的大小写(a-zA-Z)、阿拉伯数字(0-9)、一些常见标点符号("," “.” “?” “!"),以及一些控制字符(换行、回车)。这里需要强调的是,字符集只定义 “有哪些字符” 以及 “每个字符对应什么编号”,但不说明这个编号如何存储到计算机中。如何存储的问题由编码 (Encoding) 来解决。
由于计算机是个 “文盲” 只认识数字,如果要处理文本,就必须先把文本转换为数字才能处理。同时,为了将某个字符与该字符集中的其他字符区分开来,工程师为字符集中的每个字符分配一个唯一的编号,这个编号就被称作为 “码点” (code point),可以这样说,码点是字符集和计算机之间的桥梁。通过编码规则,码点可以转换为二进制的形式,进而能在计算机中存储、传输。
每个字符所用的编码长度叫做码长 (Code Length)。最早的计算机在设计时采用 8 个比特 (bit) 作为一个字节 (byte),所以,以 1 Byte 为码长的字符集最多能表示 255 个字符 (0b11111111 == 255),如果要表示更多的字符,就必须用大的码长。比如码长为 2 Byte 的字符集最多能表示 65535 个字符,如果进一步扩大码长,4 Byte 的字符集可以表示字符数目将达到恐怖的 4294967295。
关于如何编码,也就是字符串如何存储的问题,会在后面结合具体的编码方案讲解。
ASCII 横空出世
在计算机刚诞生的年代,它还只是个 “高级计算器”,只能处理数字,不能表示文字。那时候要是想让机器 “说话”,得靠打孔卡、电传打字机或者闪烁的灯泡来传递信息,效率低得令人发指。但美国人可不满足于此:既然计算机能算数,为啥不能写信、发电报、甚至写小说呢?于是,在 1960 年代,一群美国工程师和通信专家聚在一起,拍板决定:咱们得搞一套统一的字符编码标准,让所有机器都能用同一种 “语言” 交流! 就这样,ASCII (American Standard Code for Information Interchange,美国信息交换标准代码) 应运而生。
ASCII 的设计哲学非常朴素:够用、简单、兼容电传打字机。当时主流的通信设备是 Teletype (电传打字机),它用的是 7 位信号传输,所以 ASCII 也顺势采用 7 位二进制 来表示字符。不多不少,正好能表示 128 个不同的码点 (0~127)。这 128 个字符被精心分配:
| 编号范围 | 分类 | 示例 |
|---|---|---|
| 0 ~ 31 | 包含所有 “控制字符”。 它们不打印出来,但负责指挥机器干活 |
BEL (响铃,0x07):作为新消息提醒;CR (回车,0x0D) 和 LF (换行,0x0A):让光标回到行首或下一行;ESC (转义,0x1B):为未来扩展留个后门。 |
| 32 ~ 126 | 包含所有可打印字符 | 空格 ([space], 32); 阿拉伯数字 (0–9, 48~57); 大写字母 (A–Z, 65~90); 小写字母 (a–z, 97~122); 以及各种标点符号:! " # $ % & ’ ( ) * + , - . / : ; < = > ? @ [ \ ] ^ _ ` { |} ~ |
| 127 | DEL (删除) |
一个历史遗留的幽灵字符,源自打孔带时代。 当时在纸带上多打一个孔就能 “覆盖” 掉前一个字符,相当于物理删除 |
所以,ASCII 码中真正能让你在屏幕上看到字的,只有 32~126 这 95 个字符。但就凭这 95 个符号,人类第一次实现了 “用键盘输入,用屏幕输出” 的文字数字化!
由于计算机后来普遍采用 8 bite (1 Byte) 作为最小存储单位,而 ASCII 只用了其中 7 位,第 8 位常常被闲置或用于奇偶校验。这就埋下了一个伏笔:剩下的那一位,能不能用来装更多字符?当然能!但那是后来 EASCII 的故事了。
在 ASCII 诞生之初,全世界还没意识到 “字符编码” 会成为国际争端的导火索。毕竟,对 1960 年代的美国人来说,英语就是世界语,字母表里不需要帽子 (变音符号),也不需要汉字、西里尔字母或阿拉伯文。他们天真地以为 “有了 ASCII,全世界就能畅通无阻地交流了”。殊不知,这场 “字符大一统” 的美梦,很快就会被 café、Mädchen 和“你好”击得粉碎。而 ASCII,虽只代表英文世界的语言习惯,却因其简洁与先发优势,成为了所有后续编码的共同起点,就像拉丁文之于欧洲语言,哪怕你不认识,也得从它开始学起。从此,计算机不再只是算术机器,它开始学会 “书写”。而人类,也正式迈入了数字文本的时代。
EASCII-ASCII 扩展
不仅美国人要上网,大洋彼岸的欧洲人同样也想上网。但是这里有个问题,欧洲人不全都说英语,除开拉丁字符之外,欧洲语言还大量使用了 “变音符号”。带有变音符号 (diacritical marks) 的拉丁字母看起来就像是带了个帽子。例如:café[法]、Mädchen[德]、niño[西]、maçã[葡]、ősz[秋天]、 český[捷]、smörgås[瑞]、hyvää[芬]。这些 “带帽子” 的字母不仅是装饰,而是改变发音、区分词义甚至构成合法单词的必要部分。去掉它们可能导致意思完全错误 (如法语 ou “或者” vs où “哪里”)。这时候 ASCII 码所能表示的字符就不够用了。
由于计算机普遍使用 8 位字节 (bits),人们自然想到:既然 ASCII 只用了 7 位,那第 8 位可以用来表示更多字符!于是欧洲人规定,EASCII 码 (Extended ASCII) 的前 128 个 (0–127) 码点的字符与标准 ASCII 完全一致,后 128 个 (128–255) 码点用于表示额外字符,如带重音的字母 (如 é, ñ, ü)、货币符号 (如 £, €*)、框线字符、数学符号等。欧洲人这下终于可以上网了,并且高兴的把这个编码方案叫做 ISO-8859-1 (也叫做 Latin-1),可以正常显示北美、西欧、澳大利亚和非洲的语言(毕竟很多非洲国家说法语,殖民主义啧啧)。
然而这事还没完,中国人、日本人、韩国人、俄国人也要上网啊!由于计算机 8 bits 的限制,1 Byte 只能表示 255 个字符。并且这 255 个字符的容量已经被欧美瓜分干净了,因此剩下的各国表示不服,表示不使用西欧发布的 Latin-1,而是使用各国自己发明的 EASCII 编码,譬如俄国使用的是 KOI8-R 编码方案。这导致了 EASCII 实际上并不是一个统一标准,不同厂商/地区定义了不同的 “扩展部分”,导致出现了多个互不兼容的 EASCII 编码。特别是对于中国而言,255 个字连常用汉字都无法完全覆盖,因此中国使用的 是码长为 2 Byte 的 GBK2313 编码,用来把中文编进去。(所以你猜猜早年的国标仿宋字体为什么叫做仿宋_GB2313,这里的 2313 指的就是字符集中的汉字数量)
全世界有上百种语言,日本把日文编到 Shift_JIS 里,韩国把韩文编到 Euc-kr 里,各国有各国的标准,虽然老 ASCII 码的部分是通用的,但额外扩展的区域就会不可避免地出现冲突,结果就是,在多语言混合的文本中,显示出来会有乱码,例如访问其他国家的网站有时候根本无法正常显示(笑死,巴别塔之乱 2.0 了属于是),全世界的互联网就乱了套了。世界期盼着一个统一的字符集实现 “车同轨书同文”。
Unicode,字符集的大一统
因此,Unicode 联盟 (Unicode Consortium) 于 1991 年正式推出 Unicode 标准,目标只有一个:为世界上每一个字符分配一个唯一的编号 (码点,Code Point),从此 Unicode 把所有语言都统一到一套编码里,这样就不会再有乱码问题了。
这里再次复习一下有关字符集 (Character Set)、编码方案的区别。字符集定义了哪些字符存在 (Unicode 包括汉字、阿拉伯文、梵文、表情符号、数学符号、甚至古埃及象形文字)。并且每个存在的字符对应一个唯一的码点 (Code Point),Unicode 码点的格式为 U+XXXX(十六进制)。例如:U+0041 表示 “A”,U+4E2D 表示 “中”,U+1F600 表示 emoji 字符😄。而编码方案定义了字符集中的字符要如何存储。
这里就延伸出 Unicode 的 3 个容易混淆的概念:
◾Unicode
Unicode 是一套全球字符的逻辑编号系统 (即 “字符集” )。
目前遇到的根本问题是:1 Byte 的码长容纳不了全世界所有国家的文字。Unicode 的解决办法也很简单,和中文一样,暴力加大码长,就可以容纳得下所有的字符了。于是 Unicode 使用 0~0x10FFFF (即 0~1,114,111) 的定长整数编号来表示字符,也就是说,Unicode 的码点来到了惊人的 1,114,111 个。这下每个国家的字符就都能分配到属于自己的码点了。
Unicode 的码点范围是 U+0000 到 U+10FFFF,总共 1,114,112 个位置 (即 0x110000 == 17 × 65536)。这个巨大的空间被划分为 17 个 “平面” (Plane),每个平面包含 65,536 (2¹⁶) 个码点。其中平面 0 为基本多文种平面 (BMP) 包含绝大多数常用字符。另外的平面 1~16 为辅助平面 (Supplementary Planes)。每个平面都被划分了对应的功能:
| 平面编号 | 名称 | 码点范围 | 内容说明 |
|---|---|---|---|
| Plane 0 | 基本多文种平面 (BMP, Basic Multilingual Plane) |
U+0000–U+FFFF |
包含几乎所有现代语言常用字符: 拉丁、希腊、西里尔、阿拉伯、希伯来、中日韩常用汉字、标点、符号等 |
| Plane 1 | 辅助多文种平面 (SMP) | U+10000–U+1FFFF |
数学符号、音乐记谱、古文字 (如楔形文字)、大量 emoji |
| Plane 2 | 表意文字补充平面 (SIP) | U+20000–U+2FFFF |
中日韩越生僻汉字 (如“𪚥” U+2A6A5) |
| Plane 3–13 | 未分配或专用 | — | 预留 |
| Plane 14 | 特别用途平面 (SSP) | U+E0000–U+EFFFF |
语言标签、变体选择器等 |
| Plane 15–16 | 私用区 (PUA) | U+F0000–U+10FFFF |
用户自定义字符 (不保证跨系统兼容) |
事实上,超过 99% 的日常文本都落在 BMP (Plane 0) 中。这也是为什么早期很多人误以为 Unicode 就是 16 bite 的。
虽然理论上 Unicode 的最大码长只需要 21 bit 即可表示,但由于计算机的字长为 8 bite 的倍数,因此高码点的码点实际上需要 4 Byte (32 bite) 才能表示。
◾UCS - Universal Coded Character Set
由 ISO/IEC 制定的国际标准 (ISO/IEC 10646),与 Unicode 中 UTF-32 编码方案的码点完全对齐。在 UTF-32 中,每个字符直接用其码点的 32 位无符号整数表示,高位补零。而 UCS-4 中每个字符用 4 字节 (32bits) 直接表示其 UCS 码点,两者实际上是完全等价的。可以说,UTF-32 和 UCS 是一套码点,两个名字。
◾UTF - Unicode Transformation Format
UTF 是将 Unicode 码点转换为字节序列的具体编码规则。目前主流的 UTF 编码有三种:UTF-8、UTF-16、UTF-32:
| 编码方式 | 码长特点 | 实际字节长度 |
|---|---|---|
| UTF-32 | 定长编码 | 每个字符固定 4 Byte (32 bite), (即使只用到低 21 位,高位补 0) |
| UTF-16 | 变长编码 | 基本多文种平面 (BMP, U+0000–U+FFFF), 2 Byte辅助平面 (如 emoji, U+10000 以上), 4 Byte (使用代理对) |
| UTF-8 | 变长编码 | ASCII 字符 (U+0000–U+007F), 1 Byte拉丁 / 希腊 / 西里尔等 ( U+0080–U+07FF), 2 Byte中日韩常用字 ( U+0800–U+FFFF), 3 Byte罕见字 / emoji ( U+10000–U+10FFFF), 4 Byte |
其中 UTF-8 在传输和存储领域中有着近乎统治的地位,而内存中的二进制表示几乎与 UTF-32 编码方案的码点一一对应。
UTF-32,最接近 Unicode 本身的方案
UTF-32 是一种定长编码方案。每个码点固定用 4 Byte (32 bite) 进行表示。这意味着,不管是低码点的英文字符,还是高码点的 emoji,都是固定 4 Byte 长度。(A UTF-32: 0x00000041,😄 UTF-32: 0x0001F600)。因为以上特点,UTF-32 与 UCS-4 在实际效果上完全等价。可以直接将 UTF-32 编码方式视为 Unicode 码点的 4 Byte (32 bite) 存储。这种实现简单粗暴,不够 4 Byte 的位数直接补零即可。
Code Point BIN Hex Character ---------- ----------------------------------- -------- --------- U+0041 00000000 00000000 00000000 01000001 00000041 A U+000A 00000000 00000000 00000000 00001010 0000000A LF (NL line feed, new line) U+007F 00000000 00000000 00000000 01111111 0000007F DEL (delete) U+0080 00000000 00000000 00000000 10000000 00000080 PAD (Padding Character U+4E2D 00000000 00000000 01001110 00101101 00004E2D 中 U+1F60A 00000000 00000001 11110110 00001010 0001F60A 😊 U+10FFFF 00000001 00001111 11111111 11111111 0010FFFF Unicode 最大合法码点(未分配字符)
注:UTF-32 编码方式空间效率极低。
上面的例子可以看出,4 Byte 定长的 UTF-32 存在极大的资源浪费。
然而,UTF-32 最突出的缺点是空间效率极低。对于以 ASCII 字符为主的文本 (如英文代码、配置文件、网络协议头),每个字符本可用 1 字节表示,UTF-32 却强制使用 4 字节,造成 高达 75% 的存储和带宽浪费。即使是中文、阿拉伯文等 BMP 内的字符,UTF-8 通常用 3 字节,UTF-16 用 2 字节,而 UTF-32 仍用 4 字节,依然明显冗余。在内存受限或需要高效传输的场景 (如 Web、移动应用、大数据处理) 中,这种开销是难以接受的。
其次,存在字节序 (Endianness)。虽然 UTF-32 没有变长或代理对问题,但它依赖 4 字节整数的存储顺序。不同架构的 CPU(如 x86 小端序 vs. 某些 ARM 或网络协议大端序)对同一码点的字节排列不同,因此在跨平台交换 UTF-32 数据时,必须通过 BOM(Byte Order Mark,00 00 FE FF 或 FF FE 00 00)或显式约定字节序,否则会解析出完全错误的字符。这增加了互操作的复杂性,而 UTF-8 则完全规避了此问题。
再者,与现有生态兼容性差。互联网标准 (HTTP、HTML、XML、JSON) 普遍推荐或默认使用 UTF-8;主流操作系统 (Linux、macOS) 和文件系统也以 UTF-8 为主;绝大多数编程语言的 I/O 接口优先支持 UTF-8。使用 UTF-32 通常意味着额外的转换开销,且容易在与其他系统交互时引发编码错误。
最后,实际收益有限。尽管随机访问性能优越,但现代软件中真正需要高频随机字符访问的场景并不多。多数文本处理是顺序读取 (如解析、搜索、显示),此时 UTF-8 的紧凑性和缓存友好性反而更具优势。而且,许多语言 (如 Python、Rust) 在内部采用更智能的字符串表示 (如 Latin-1/UTF-16/UTF-8 自适应),在保持高效的同时兼顾空间与功能,进一步削弱了 UTF-32 的必要性。
UTF-32 的本质是 “以空间换 simplicity”。它用高昂的存储代价换取了实现上的极致简洁和操作上的确定性。在需要绝对可靠字符语义且性能关键的内部计算中,它仍有价值;但在现实世界的文本存储、传输和通用编程中,其低效和兼容性问题使其远不如 UTF-8 实用。因此,理解 UTF-32 有助于深入掌握 Unicode 体系,但日常开发中应谨慎使用。
UTF-16,最“中庸”的方案
UTF-16 是一种对 BMP 友好的变长编码方式,其核心设计在于以 16 bits (2 Bytes) 为基本单位来表示字符,但 16 位的码长最大只能表示 65535 (U+FFFF) 个码点,这个长度刚好和 Unicode 编码平面的宽度一致,也就是说 U+0000 - U+FFFF 刚好足够表示“基本多文种平面” (Basic Multilingual Plane, BMP),因此 UFT-16 无法覆盖整个 Unicode 码点空间 (U+10FFFF)。为了解决容量的限制,UTF-16 引入了 “代理对” (Surrogate Pair) 机制。Unicode 在 BMP 内部专门划出一段保留区域 (U+D800 - U+DFFF),这段区域不分配任何实际字符,而是用作编码辅助平面字符的 “中介”,其中 U+D800 - U+DBFF 被定义为 “高位代理” (High Surrogate),U+DC00 - U+DFFF 被定义为 “低位代理” (Low Surrogate)。
+------+------+- - - +------+------+------+------+- - - +------+------+ | D800 | D801 | .... | DBFE | DBFF | DC00 | DC01 | .... | DFFE | DFFF | +------+------+- - - +------+------+------+------+- - - +------+------+ │ │ │ ╰───────── High Surrogate ─────────╯────────── Low Surrogate ─────────╯
注:高位代理和低位代理必须成对出现且顺序固定。单独出现的代理码点在语义上是非法的。
这种模式更像是手柄游戏按键方案:
常用操作绑定在单按键上,不那么常用的操作绑定在按键组合上。
当需要表示一个辅助平面字符时,UTF-16 会将其码点转换为一对这样的代理值:00111101
- 首先将原始码点减去
0x10000,得到一个介于0 - 0xFFFFF(20 bits) 的偏移量; - 然后将该偏移量的高 10 位加上
0xD800得到高位代理,低 10 位加上0xDC00得到低位代理, - 最终形成一个由两个 16 位单元组成的序列。
例如,笑脸表情 😊 的 Unicode 码点是 U+1F60A,减去 0x10000 后得到 0xF60A,其高 10 位为 0x3D,低 10 位为 0x20A,因此高位代理为 0xD800 + 0x3D = 0xD83D,低位代理为 0xDC00 + 0x20A = 0xDE0A,于是该字符在 UTF-16 中被编码为连续的两个 16 位值:0xD83D 和 0xDE0A。这种机制使得 UTF-16 能够用 4 字节(两个 16 位单元)表示原本超出 16 位范围的字符,从而完整支持整个 Unicode 字符集。
BIN HEX
High -------------- ----
0x1F60A ╭─────> B0000 0011 1101 003D
- 0x10000 │ .
--------- │ 0xD800 + 0x3D == 0xD83D ─────╮
0xF60A │ │
│ │
bin 1111 0110 0000 1010 ─────╯ │
│ │ HEX
│ ╰─────> D83D DE0A
│ BIN HEX │
│ Low -------------- ---- │
╰─────> 0010 0000 1010 020A │
. │
0xDC00 + 0x20A = 0xDE0A ─────╯
通过代理对机制,将一个高码点(0x1F60A)转换为 两个 2 Byte 代理对(0xD83D, 0xDE0A)
然而,代理对的引入也带来了一些实践上的复杂性,被称为 “代理对陷阱” (Surrogate Pair Pitfall)。
- 首先,高位代理和低位代理必须成对出现且顺序固定,单独出现的代理码点(如仅
0xD83D而无后续0xDE0A)在语义上是非法的,多数系统会将其视为无效序列并替换为替换字符 (U+FFFD) 或抛出错误。 - 其次,在使用 UTF-16 作为内部字符串表示的编程语言或操作系统中 (如 Java、JavaScript、Windows API),字符串的 “长度” 通常指的是 16 位代码单元 (code unit) 的数量,而非实际用户感知的 “字符 ”数量。这意味着一个像 “🙂” 这样的 emoji 在 Java 中调用
.length()会返回 2,尽管它在屏幕上只显示为一个符号。这种差异容易导致字符串截断、反转或索引操作时出现意外行为,例如若在代理对中间截断,就会产生孤立的代理,进而破坏文本完整性。(正是因为这个原因,JS 如今已转向更灵活的内部表示)
相较于 UTF-32 和 UTF-8,UTF-16 的优点是对 BMP 字符节省空间 (比 UTF-32 省一半)。缺点是不是真正 “定长”,字符串长度计算复杂,代理对若被错误截断,会导致严重解析错误,相比之下,UTF-8 虽然也是变长编码,但其字节流结构天然避免了此类代理概念,而 UTF-32 则通过固定 4 字节直接存储码点,完全不需要代理机制。因此,现代编程实践中,处理包含非 BMP 字符的文本时,应优先使用基于 “码点” 的操作接口,而非基于 “代码单元” 的底层访问。另一方面,以 16 bits 为最小单位导致完全不兼容 ASCII 编码。UFT-16 在特性上处于是 UTF-8 和 UTF-32 的中间态,高不成低不就了属于是。
尽管如此,UTF-16 凭借其在 BMP 字符上的紧凑性和历史惯性,仍在许多平台中占据重要地位。
UTF-8,最泛用的方案
UTF-8 是一种前缀码变长编码,最大特点是 UTF-8 完全兼容 ASCII,所有 ASCII 字符 (U+0000–U+007F) 在 UTF-8 中编码完全不变。其编码规则如下:
| 码点范围 | 字节数 | 编码模板(二进制) | 示例 |
|---|---|---|---|
U+0000 – U+007F |
1 | 0xxxxxxx |
A (U+0041, 1 Byte) → 01000001 |
U+0080 – U+07FF |
2 | 110xxxxx 10xxxxxx |
é (U+00E9, 2 Byte) → 11000011 10101001 |
U+0800 – U+FFFF |
3 | 1110xxxx 10xxxxxx 10xxxxxx |
中 (U+4E2D, 3 Byte) → 11100100 10111000 10101101 |
U+10000 – U+10FFFF |
4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
😄 (U+1F600, 4 Byte) → 11110000 10011111 10011000 10000000 |
UTF-8 最多只用到 4 字节。早期标准曾允许 5–6 字节,但因 Unicode 码点上限设为 U+10FFFF,2003 年后相关标准已被废弃。
UTF-8 之所以没有字节序(Endianness)问题,根本原因在于它的编码单位是单个字节(8 位),而不是多字节的“字”(word)。字节序问题只在多字节数据单元(如 16 位、32 位整数)被拆分为多个字节存储时才会出现,而 UTF-8 的设计天然规避了这一点。
具体来说,字节序问题源于不同硬件架构对 “多字节值” 的字节排列顺序存在差异。例如,一个 16 位整数 0x1234,在大端序 (Big-Endian) 系统中存储为 12 34,而在小端序 (Little-Endian)系统中则存储为 34 12。如果两个系统直接交换这种二进制数据而不约定字节序,就会解析出完全错误的数值。类似地,UTF-16 和 UTF-32 使用 2 字节或 4 字节作为基本编码单元来表示字符码点,因此必须明确字节序,通常通过 BOM (Byte Order Mark,如 FE FF 或 FF FE)来标识。
而 UTF-8 完全不同:它是一种面向字节流 (byte-oriented) 的变长编码。这意味着 UTF-8 的最大处理单位 为 1 Byte (同时也是最小处理单位)。无论一个字符最终占用 1、2、3 还是 4 个字节,每个字节都是独立解析的,且字节之间的顺序是逻辑固定的,不依赖于底层硬件的字节序。UTF-8 的编码规则明确规定了多字节序列的结构:首字节以特定前缀 (如 110xxxxx 表示 2 字节序列) 标识长度,后续字节均以 10xxxxxx 开头。解码器只需按字节流顺序逐个读取,根据首字节判断整个字符占几个字节,再依次读取后续字节拼合出原始码点。这个过程不涉及将多个字节 “组合成一个整数后再解释其高低位”,因此无论系统是大端还是小端,只要按顺序读取字节流,就能得到一致的结果。
这样的设计也使得 UTF-8 编码的错误恢复能力极强,即使发现某字节损坏,也可以快速将损坏部分直接替换为错误替换字符 (Replacement Character, U+FFFD),例如:如果遇到 1110xxxx 开头的字节损坏,则直接向后丢弃包含该字节在内的 3 个字节,从而快速同步到下一个有效字符。发现问题即抛弃,无需任何额外的处理逻辑。
0xxxxxxx >>> 单字节字符,调用 BMP 平面 11110xxx >>> 四字节字符,向后读取 3 个格式为 10xxxxxx 的字节,最后拼合出原始码点 1110xxxx >>> 三字节字符,向后读取 2 个格式为 10xxxxxx 的字节 >>> 中途发现错误字符,解析失败,直接丢弃该单元中未处理的字节,并插入一个错误替换字符 U+FFFDUTF-8 多字节字符与错误处理机制
并且,由于 UTF-8 的基本编码单元为 1 Byte,因此 UTF-8 编码完全兼容 ASCII 码。老系统可以用 UTF-8 无缝处理纯英文 UTF-8 文本。也正因为这个特点,UTF-8 相较 UTF-16、UTF-32 更能节省带宽,体现为使用 UTF-8 编码的英文网页/代码 (相较使用 ASCII 编码) 体积几乎不变。
正因为如此,UTF-8 在网络传输、文件存储和跨平台交换中具有极强的兼容性。它在编码上与 UTF-32、UTF-16 完全不同,本质上就是一个普通的字节序列,就像 ASCII 一样,无需额外协商字节序。这也是为什么互联网标准 (如 HTTP、HTML、XML) 普遍推荐使用 UTF-8:它简洁、高效、自同步,且彻底摆脱了字节序这一历史包袱。虽然 UTF-8 也允许使用 BOM (EF BB BF),但这只是为了标识 “此文本是 UTF-8 编码”,而非用于解决字节序问题 (因为根本不需要),许多 Unix/Linux 系统甚至建议避免使用 UTF-8 BOM,以免干扰脚本解析。
BOM,那个多余的“零宽无断空格”
你可能在查看某些文本文件的二进制内容时,注意到文件开头存在三个神秘的字节 EF BB BF。这正是 UTF-8 编码中所谓的 BOM (Byte Order Mark,字节顺序标记),其对应的 Unicode 码点是 U+FEFF,原本的语义是“零宽无断空格” (Zero Width No-Break Space),一个不可见、不占宽度、且阻止前后文字在排版时被断行的控制字符。
然而,在编码上下文中,U+FEFF 被赋予了全新的角色:作为标识文本编码格式的签名。对于 UTF-16 和 UTF-32 这类以多字节为单位的编码来说,BOM 的存在具有实际意义,因为它们必须明确字节序 (即高位字节在前还是低位字节在前)。例如,UTF-16 文件若以 FE FF 开头,表示采用大端序 (Big-Endian);若以 FF FE 开头,则表示小端序 (Little-Endian)。这种区分对正确解析字符至关重要,因此在这两种编码中,BOM 虽非强制,但常被推荐使用。
Hex View 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F ASCII -------- ----------------------- ----------------------- ---------------- 00000000 48 65 6C 6C 6F 2C 20 77 6F 72 6C 64 21 Hello, world!
Hex View 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F ASCII
-------- ----------------------- ----------------------- ----------------
00000000 EF BB BF 48 65 6C 6C 6F 2C 20 77 6F 72 6C 64 21 ...Hello, world!
然而,UTF-8 的情况截然不同。由于 UTF-8 是一种面向字节流的编码,每个字符由 1 到 4 个独立字节组成,且字节内部的位模式已足以确定字符边界和含义,完全不涉及多字节整数的字节排列问题,因此从技术上讲,UTF-8 根本不需要 BOM 来指示字节序。EF BB BF 在 UTF-8 中纯粹是一个历史遗留的兼容性措施,最初由微软在 Windows 系统中引入,目的是让记事本等应用程序能够自动识别一个看似纯 ASCII 的文件实际上是 UTF-8 编码的。于是,当你在 Windows 记事本中选择“UTF-8”保存文件时,它会默认在文件开头插入这三个字节。乍看之下这似乎无害,但在跨平台环境中却埋下了隐患。例如,Linux 或 macOS 上的 shell 脚本通常以 #!/bin/bash 开头,如果该文件带有 UTF-8 BOM,系统在执行时会把 EF BB BF 当作脚本内容的一部分,导致解释器无法识别第一行,从而报错 “/bin/bash^M: bad interpreter” 或类似错误。同样,许多 JSON 解析器(如 Python 的 json 模块或 JavaScript 的 JSON.parse)严格遵循 RFC 标准,而 RFC 7159 明确指出 JSON 文本应以普通 Unicode 字符开始,不应包含 BOM;一旦遇到 EF BB BF,它们可能直接抛出语法错误,即使后续内容完全合法。此外,Web 开发中若 HTML 文件意外包含 UTF-8 BOM,可能导致页面在某些浏览器中出现空白、CSS 失效,或在 AJAX 请求中引发解析异常。
正因如此,主流开发社区和标准组织普遍建议:除非面对特定旧系统(如某些 Windows 应用)的强制要求,否则 UTF-8 文件应避免使用 BOM。现代编辑器(如 VS Code、Sublime Text、Vim)通常提供 “UTF-8 without BOM” 的选项,而 Unix-like 系统生态更是默认期望无 BOM 的 UTF-8。实际上,Unicode 官方也指出,UTF-8 的 BOM “仅应在无法通过其他方式确定编码时才考虑使用”,而在绝大多数网络协议、配置文件、源代码和数据交换格式中,编码信息更应通过元数据 (如 HTTP 头中的 Content-Type: text/html; charset=utf-8) 或约定俗成 (如所有源代码默认 UTF-8) 来传达,而非依赖文件开头的魔法字节。因此,理解 BOM 的来龙去脉并主动规避其在 UTF-8 中的滥用,是保障软件可移植性与健壮性的重要实践。
计算机工作时的编解码
搞清楚了ASCII、Unicode 和 UTF-8 的关系,我们就可以总结一下现在计算机系统通用的字符编码工作方式:
在计算机内存中,为了规整单位字符长度 (方便计算),统一使用定长的 Unicode 编码,当需要保存到硬盘或者需要传输的时候,考虑到节省空间的问题,就转换为可变长的 UTF-8 编码,最大化的节省存储空间。当对记事本 (软件) 进行编辑的时候,字符从文件中以 UTF-8 编码的格式被读取出来,而后被转换为 Unicode 字符送到内存里,编辑完成后,保存的时候再把字符串从 Unicode 编码转换为 UTF-8 编码,保存到文件。
Notepad(software) File(hardware) +--------------+ +--------------+ | Memory | | Disk | | | Save [abc.txt] > UTF-8 | | | Unicode | ------------------------------------------------------------>>> | UTF-8 | | | | | | | Unicode < Load [abc.txt] | | | Unicode | <<<------------------------------------------------------------ | UTF-8 | | | | | | Memory | | Disk | +--------------+ +--------------+
浏览网页的时候,服务器会把动态生成的 Unicode 内容转换为 UTF-8 再传输到浏览器:
+--------------+ +--------------+ | Server | | Client | | | | | | Unicode | ------------------ Sent HTTP Webpage [UFT-8] --------------->>> | UTF-8 | | | | | | Server | | Client | +--------------+ +--------------+
所以很多网页的源码上会有类似 <meta charset="UTF-8" /> 的信息,表示该网页使用的正是 UTF-8 编码。
总结
ASCII 与 Unicode 的区别
现在来捋一捋 ASCII 编码和 Unicode 编码的区别。ASCII 编码是 1 Byte,而 Unicode 编码通常是 UCS-16 编码方案,也就是用 2 Byte 表示一个字符 (如果要用到非常偏僻的字符,就需要 4 Byte)。现代操作系统和大多数编程语言都直接支持 Unicode。
- 字母 “A” 用 ASCII 编码是十进制的 65(0b01000001);
- 字符 “0” 用 ASCII 编码是十进制的 48(0b00110000);
- 汉字 “中” 已经超出了 ASCII 编码的范围,用 Unicode 编码是十进制的 20013(0b01001110 00101101)。
可以猜测,如果把 ASCII 编码的 “A” 用 Unicode 编码,只需要在前面补 0 就可以,因此,“A” 的 Unicode 编码是 (0b00000000 01000001),也就是 U+0041。
新的问题又出现了:如果统一成 Unicode 编码,乱码问题从此消失了。但是,如果你写的文本基本上全部是英文的话,用 Unicode 编码比 ASCII 编码需要多一倍的存储空间,在存储和传输上就十分不划算。所以,本着节约的精神,又出现了把 Unicode 字符集转化为 “可变长编码” 的 UTF-8 编码。UTF-8 编码把一个 Unicode 字符根据不同的数字大小编码成 1~6 个字节,常用的英文字母被编码成 1 个字节,汉字通常是 3 个字节,只有很生僻的字符才会被编码成 4~6 个字节。如果你要传输的文本包含大量英文字符,用 UTF-8 编码就能节省空间:
| 字符 | ASCII | Unicode | UTF-8 |
|---|---|---|---|
| A | 01000001 | 00000000 01000001 | 01000001 |
| 中 | 无 | 01001110 00101101 | 11100100 10111000 10101101 |
从上面的表格还可以发现,UTF-8 编码有一个额外的好处,就是 ASCII 编码实际上可以被看成是 UTF-8 编码的一部分,所以,大量只支持 ASCII 编码的历史遗留软件可以在 UTF-8 编码下继续工作。
再论“编码”
ASCII、Unicode 这意义上的编码,和 URL、Base64 这种意义上的编码,是同一个概念吗?
先说结论:它们并不是同一个概念。
虽然都叫 “编码 (encoding)”,但 ASCII/Unicode 和 URL/Base64 所指的 “编码” 在本质上属于不同层次、不同目的的概念。前者属于字符编码 (Character Encoding) 的范畴,目的是将人类可读的字符 (如字母、汉字、符号) 映射为计算机可以存储和处理的二进制数字;后者属于数据表示/传输编码 (Data Representation or Transfer Encoding) 的范畴,目的是将任意二进制数据 (或某些受限字符) 转换成特定字符集内安全、可传输的文本格式,以便在某些协议或媒介中安全传递。
字符编码工作在语义层面,解决的是 “如何用数字表示文字” 的问题。其代表有 ASCII、Unicode (如 UTF-8、UTF-16)。这些编码的重点都在于如何用二进制来表示字符串。主要涉及的概念有字符集、码点、编码方案 (规则)。例如 “A” 在 ASCII 中编码为 0x41 (十进制 65),汉字 “中” 在 UTF-8 中编码为三个字节 0xE4 0xB8 0xAD。
数据表示/传输编码工作在传输/表示层面,解决的是 “如何在只支持某些字符的系统中安全传递任意数据” 的问题。其代表有 Base64、URL 编码 (Percent-encoding)。与字符编码相反,它们的工作过程是将存储、传输过程中的字节 (Bytes) 序列,转换为可打印 ASCII 字符串。例如,Base64 将二进制 0x48 0x65 0x6C 0x6C 0x6F(即 “Hello”)编码为 "SGVsbG8=";URL 编码将空格 ' ' 转为 "%20",中文 '中'(先 UTF-8 编码再转)变为 "%E4%B8%AD"。
我们可以发现,数据表示/传输编码的工作更倾向于使用有限的通用字符串 (基本上可以认为是 ASCII 码范围) 来表示任意的数据。更重要的是,数据表示/传输编码可以用转义的方式来表示不可打印字符串。例如,对于换行符 (\n, newline, ASCII=0x0A),Base64 编码为 Cg==,URL 编码为 %0A。
总的来说,尽管术语都叫 “编码”,但 ASCII、Unicode 与 URL、Base64 并不是同一种意义上的 “编码”。它们更像是 “编码” 这个词在不同上下文中的多义用法,就像 “苹果” 既可以是水果也可以是公司一样。
附件 A:ASCII码表(0-127)
| Bin | Dec | Hex | 缩写/字符 | 解释 |
|---|---|---|---|---|
| 00000000 | 0 | 00 | NUL(null) | 空字符 |
| 00000001 | 1 | 01 | SOH(start of headling) | 标题开始 |
| 00000010 | 2 | 02 | STX (start of text) | 正文开始 |
| 00000011 | 3 | 03 | ETX (end of text) | 正文结束 |
| 00000100 | 4 | 04 | EOT (end of transmission) | 传输结束 |
| 00000101 | 5 | 05 | ENQ (enquiry) | 请求 |
| 00000110 | 6 | 06 | ACK (acknowledge) | 收到通知 |
| 00000111 | 7 | 07 | BEL (bell) | 响铃 |
| 00001000 | 8 | 08 | BS (backspace) | 退格 |
| 00001001 | 9 | 09 | HT (horizontal tab) | 水平制表符 |
| 00001010 | 10 | 0A | LF (NL line feed, new line) | 换行键 |
| 00001011 | 11 | 0B | VT (vertical tab) | 垂直制表符 |
| 00001100 | 12 | 0C | FF (NP form feed, new page) | 换页键 |
| 00001101 | 13 | 0D | CR (carriage return) | 回车键 |
| 00001110 | 14 | 0E | SO (shift out) | 不用切换 |
| 00001111 | 15 | 0F | SI (shift in) | 启用切换 |
| 00010000 | 16 | 10 | DLE (data link escape) | 数据链路转义 |
| 00010001 | 17 | 11 | DC1 (device control 1) | 设备控制1 |
| 00010010 | 18 | 12 | DC2 (device control 2) | 设备控制2 |
| 00010011 | 19 | 13 | DC3 (device control 3) | 设备控制3 |
| 00010100 | 20 | 14 | DC4 (device control 4) | 设备控制4 |
| 00010101 | 21 | 15 | NAK (negative acknowledge) | 拒绝接收 |
| 00010110 | 22 | 16 | SYN (synchronous idle) | 同步空闲 |
| 00010111 | 23 | 17 | ETB (end of trans. block) | 传输块结束 |
| 00011000 | 24 | 18 | CAN (cancel) | 取消 |
| 00011001 | 25 | 19 | EM (end of medium) | 介质中断 |
| 00011010 | 26 | 1A | SUB (substitute) | 替补 |
| 00011011 | 27 | 1B | ESC (escape) | 溢出 |
| 00011100 | 28 | 1C | FS (file separator) | 文件分割符 |
| 00011101 | 29 | 1D | GS (group separator) | 分组符 |
| 00011110 | 30 | 1E | RS (record separator) | 记录分离符 |
| 00011111 | 31 | 1F | US (unit separator) | 单元分隔符 |
| 00100000 | 32 | 20 | (space) | 空格 |
| 00100001 | 33 | 21 | ! | |
| 00100010 | 34 | 22 | " | |
| 00100011 | 35 | 23 | # | |
| 00100100 | 36 | 24 | $ | |
| 00100101 | 37 | 25 | % | |
| 00100110 | 38 | 26 | & | |
| 00100111 | 39 | 27 | ' | |
| 00101000 | 40 | 28 | ( | |
| 00101001 | 41 | 29 | ) | |
| 00101010 | 42 | 2A | * | |
| 00101011 | 43 | 2B | + | |
| 00101100 | 44 | 2C | , | |
| 00101101 | 45 | 2D | - | |
| 00101110 | 46 | 2E | . | |
| 00101111 | 47 | 2F | / | |
| 00110000 | 48 | 30 | 0 | |
| 00110001 | 49 | 31 | 1 | |
| 00110010 | 50 | 32 | 2 | |
| 00110011 | 51 | 33 | 3 | |
| 00110100 | 52 | 34 | 4 | |
| 00110101 | 53 | 35 | 5 | |
| 00110110 | 54 | 36 | 6 | |
| 00110111 | 55 | 37 | 7 | |
| 00111000 | 56 | 38 | 8 | |
| 00111001 | 57 | 39 | 9 | |
| 00111010 | 58 | 3A | : | |
| 00111011 | 59 | 3B | ; | |
| 00111100 | 60 | 3C | < | |
| 00111101 | 61 | 3D | = | |
| 00111110 | 62 | 3E | > | |
| 00111111 | 63 | 3F | ? | |
| 01000000 | 64 | 40 | @ | |
| 01000001 | 65 | 41 | A | |
| 01000010 | 66 | 42 | B | |
| 01000011 | 67 | 43 | C | |
| 01000100 | 68 | 44 | D | |
| 01000101 | 69 | 45 | E | |
| 01000110 | 70 | 46 | F | |
| 01000111 | 71 | 47 | G | |
| 01001000 | 72 | 48 | H | |
| 01001001 | 73 | 49 | I | |
| 01001010 | 74 | 4A | J | |
| 01001011 | 75 | 4B | K | |
| 01001100 | 76 | 4C | L | |
| 01001101 | 77 | 4D | M | |
| 01001110 | 78 | 4E | N | |
| 01001111 | 79 | 4F | O | |
| 01010000 | 80 | 50 | P | |
| 01010001 | 81 | 51 | Q | |
| 01010010 | 82 | 52 | R | |
| 01010011 | 83 | 53 | S | |
| 01010100 | 84 | 54 | T | |
| 01010101 | 85 | 55 | U | |
| 01010110 | 86 | 56 | V | |
| 01010111 | 87 | 57 | W | |
| 01011000 | 88 | 58 | X | |
| 01011001 | 89 | 59 | Y | |
| 01011010 | 90 | 5A | Z | |
| 01011011 | 91 | 5B | [ | |
| 01011100 | 92 | 5C | \ | |
| 01011101 | 93 | 5D | ] | |
| 01011110 | 94 | 5E | ^ | |
| 01011111 | 95 | 5F | _ | |
| 01100000 | 96 | 60 | ` | |
| 01100001 | 97 | 61 | a | |
| 01100010 | 98 | 62 | b | |
| 01100011 | 99 | 63 | c | |
| 01100100 | 100 | 64 | d | |
| 01100101 | 101 | 65 | e | |
| 01100110 | 102 | 66 | f | |
| 01100111 | 103 | 67 | g | |
| 01101000 | 104 | 68 | h | |
| 01101001 | 105 | 69 | i | |
| 01101010 | 106 | 6A | j | |
| 01101011 | 107 | 6B | k | |
| 01101100 | 108 | 6C | l | |
| 01101101 | 109 | 6D | m | |
| 01101110 | 110 | 6E | n | |
| 01101111 | 111 | 6F | o | |
| 01110000 | 112 | 70 | p | |
| 01110001 | 113 | 71 | q | |
| 01110010 | 114 | 72 | r | |
| 01110011 | 115 | 73 | s | |
| 01110100 | 116 | 74 | t | |
| 01110101 | 117 | 75 | u | |
| 01110110 | 118 | 76 | v | |
| 01110111 | 119 | 77 | w | |
| 01111000 | 120 | 78 | x | |
| 01111001 | 121 | 79 | y | |
| 01111010 | 122 | 7A | z | |
| 01111011 | 123 | 7B | { | |
| 01111100 | 124 | 7C | | | |
| 01111101 | 125 | 7D | } | |
| 01111110 | 126 | 7E | ~ | |
| 01111111 | 127 | 7F | DEL (delete) | 删除 |