浅谈字符编码问题

发布于 2025-09-06
更新于 2025-09-06

缘起

最近工作中遇到一个问题,一个C++读文件函数不支持UTF-8 BOM格式文件,而工作需要兼容UTF-8 BOM格式的文件。简单查了一下,实际上两种文件的区别仅仅是在utf-8 BOM的文件开头多了三个字节前缀:0xEF 0xBB 0xBF。要实现兼容UTF-8 BOM文件,仅需在读取文件时以二进制格式读取,检查文件开头三个字节是否为0xEF 0xBB 0xBF即可,如果文件开头带这个前缀,则舍去开头的三个字节,其余内容与UTF-8格式文件完全相同。

在工作中,我不可能满足于简单地干点小活,打算借此机会顺便学习一下字符编码的问题——计算机领域一个基础问题,于是有了这篇博客。

在计算机系统中,所有信息最终都以二进制形式存储。字符编码是一套将字符映射到数字值的规则体系,它解决了人类语言文字与计算机二进制系统之间的转换问题。没有字符编码,我们就无法在计算机上显示和处理文本信息。总体来说,现代字符编码的历史可以追溯到电报时代,1837年,摩尔斯电码(Morse code,摩斯密码)作为最早的编码系统之一,用短信号(·)和长信号(—)的组合表示字母和数字。这种编码虽然简单,但已经体现了用有限符号表示无限信息的核心思想。

摩尔斯电码(Morse Code)

电报机只能传输两种状态:有电流(信号)和无电流(静默)。1837 年,美国发明家塞缪尔・摩尔斯(Samuel Morse)与助手阿尔弗雷德・韦尔(Alfred Vail)合作,提出了一套基于 “电流通断” 的编码方案 —— 这就是后来的摩尔斯电码。1844 年,摩尔斯通过电报线路发送了人类历史上第一封电报:“What hath God wrought”(上帝创造了何等奇迹),标志着人类正式进入 “电报通信时代”,而摩尔斯电码则成为了这个时代的 “通用语言”。

plaintext
A ·−      B −···    C −·−·    D −··
E ·       F ··−·    G −−·     H ····

摩尔斯电码是一种用点和划(Dot and Dash)的组合来表示字母、数字和标点符号的编码系统。通过不同的点划组合来对应不同的字符,然后这些点划可以通过声音、光线或者电信号等方式进行传输。
一点作为一个基本的信号单位,一划的长度就相当于是3点的时间长度;在一个字母或是数字之内,每个点、划之间的间隔就应该是两点的时间长度;字母(数字)与字母(数字)之间的间隔就是7点的时间长度。最早版本的摩尔斯电码只考虑英语,后来逐渐扩展到国际摩尔斯电码,加入了适配其他语言的符号。 摩尔斯电码一个极其重要的特征是可变长度编码。并非所有字符的编码长度都相同。常用字母的编码很短:比如 E -> ·T -> - ,不常用字母的编码较长,比如 Q -> --·-J -> ·---。这种 “频率优先” 的优化思路,使摩尔斯电码的通信效率远超同时代的其他编码方案,也成为后来所有高效字符编码的核心设计准则。
摩尔斯电码的信号是连续传输的,若没有明确的 “间隔规则”,不同字符的信号会混在一起,导致接收方无法区分(例如 “・・・” 可能是 S,也可能是 EEE)。摩尔斯电码的所有间隔均基于一个基准时间单位(通常定义为“点的持续时间”,记为 $T$)。整个系统通过三种不同长度的间隔,清晰划分字符的边界:

间隔类型 定义 时长(以 $T$ 为单位)
点划内间隔 同一字符内,点(·)与划(−)之间的间隔 $1T$(1倍点长)
字符间间隔 两个相邻字符之间的间隔 $3T$(3倍点长)
单词间间隔 两个相邻单词之间的间隔 $7T$(7倍点长)

第一,字符内间隔,同一字符的点与划之间的间隔,时长为 “1 个时间单位”;​例如 A(・-)的点和划之间,需停顿 $1T$;​
第二,字符间间隔:两个相邻字符之间的间隔,时长为 “3 个时间单位”;​例如 “AB”(・-・-・)中,A 的划结束后,需停顿$3T$再传输 B 的点;​
第三,单词间间隔,两个相邻单词之间的间隔,时长为 “7 个时间单位”;​例如 “What hath” 中,“What” 结束后需停顿$7T$,再传输 “hath”。​

字母 S 的摩尔斯码是 ...(三个点)。三个点之间是点划内间隔(因为它们属于同一字符的组成部分),因此每个点之间的间隔是 $1T$。总时长计算:$1T$(第一个点) + $1T$(间隔) + $1T$(第二个点) + $1T$(间隔) + $1T$(第三个点) = $5T$。
字母 E 的摩尔斯码是 .(单个点)。三个 E 是独立的字符,因此每个 E 之间是字符间间隔($3T$)。总时长计算:$1T$(第一个 E) + $3T$(间隔) + $1T$(第二个 E) + $3T$(间隔) + $1T$(第三个 E) = $11T$。

将人类可读的字符(明文)映射为一种更适合在当时信道(电报线)上传输的格式(点划序列),这与现代编码的根本目的完全一致。摩尔斯电码在连续的电波中依靠时间间隔来区分字符,而计算机编码则是在离散的数字系统中依靠比特位的排列组合来定义字符。摩尔斯电码的精髓在于可变长度编码,它通过给最常用的字母(如E)分配最短的序列来优化传输效率。这种思想至今仍存在于我们的压缩算法中。然而,它作为一种模拟时代的编码,也面临着字符集有限、依赖计时同步等问题。

摩尔斯电码表可参阅 https://morsecode.bmcx.com/

ASCII码

1963年,美国国家标准协会(ANSI)制定了ASCII(American Standard Code for Information Interchange)编码,这标志着计算机字符编码标准化的开始。

ASCII码使用 1 个字节(8 位)表示一个字符,但实际只使用低 7 位(最高位为 0),因此总共能表示 2^7 = 128 个字符。 ASCII使用7个二进制位表示128个字符(0-127)。0-31:控制字符(如换行、回车)。32-126:可打印字符(字母、数字、标点)127:删除字符(DEL)

cpp
// ASCII码表示示例
char letter_A = 65;    // 'A'的ASCII码
char newline = 10;     // 换行符
char space = 32;       // 空格

ASCII 编码仅能覆盖英文场景,无法表示中文、日文、法文等其他语言的字符。随着计算机在全球范围内的普及,ASCII 的局限性日益凸显,亟需一种能支持多语言的编码方案。而且7位设计在8位字节成为标准后造成了空间浪费。
为解决 ASCII 编码的局限,不同国家和地区先后制定了区域性编码标准,例如ISO-8859 系列(在 ASCII 基础上扩展,覆盖不同语言ISO-8859-1 覆盖西欧语言,ISO-8859-2:中欧语言,ISO-8859-5:西里尔字母,ISO-8859-7:希腊语)。 微软创建的Windows Code Page (代码页)系统,是微软提出的区域性编码系统框架,由微软定义并集成在 Windows 系统中。每个代码页都有一个唯一的数字编号,例如CP437(原始IBM PC字符集)、 GB2312、Shift-JIS、Windows-1252等,Windows 系统通过切换代码页,来识别和显示对应语言的文本文件。
1980年中国国家标准总局发布了GB2312,这是中国的第一个汉字编码标准,全称 《信息交换用汉字编码字符集·基本集》, 共收录了 6763 个常用的汉字和字符,此标准于次年5月实施,它满足了日常 99% 汉字的使用需求。它兼容 ASCII 字符,但仅覆盖常用汉字,无法表示生僻字、古汉字等,且不支持其他东亚语言(如日文、韩文)。1995 年,中国在GB2312的基础上发布了GBK ,全称 《汉字内码扩展规范》,扩展了生僻字范围,共支持 21003 个汉字,同时增加了对日文假名、韩文符号的支持。它完全兼容 GB2312,能覆盖绝大多数中文场景,但它仍属于区域性编码,与其他编码(如 Shift_JIS)不兼容,在跨语言文件交互时易出现乱码。而且GBK只是 "技术规范指导性文件",并不属于国家标准。

Windows Code Page 是微软为 Windows 系统定义的 “本地化编码集合”,每个代码页对应一个地区的编码规则,是 Unicode 普及前的 “过渡方案”,解决了早期 Windows 多语言显示问题,但因碎片化缺陷,最终被 Unicode(UTF-8、UTF-16)取代。

Unicode

UTF-8编码方式

为解决编码混乱问题,Unicode项目于1991年启动,目标是创建一个统一的字符集,包含世界上所有书写系统的字符。Unicode设计原则有三条:

  • universal 通用性:包含所有语言的字符
  • uniform 唯一性:每个字符有唯一的码点(Code Point)
  • unambiguous:编码点与字符一一对应

Unicode 为每个字符分配一个唯一的码点,通常表示为"U+"后跟十六进制数字,例如,U+0041 = 'A'U+4E2D = '中'U+1F600=😀
然而Unicode 本身只是 “字符与码点的映射表”,并不规定码点如何存储为二进制(即 “编码方式”),编码要在计算机中实际存储和传输就需要具体的编码方案,主要的Unicode编码方式包括UTF-32、UTF-16、UTF-8等。
其中UTF-32是定长 4 字节,简单但浪费存储。UTF-16大部分常用字符 2 字节,超出范围用代理对,存在大小端问题(需要 BOM)。UTF-8是由肯·汤普逊(Ken Thompson)和罗布·派克(Rob Pike)在1992年为Plan 9操作系统设计的,是变长编码,兼容 ASCII(1~4 字节),节省空间,现已成为Web和Unix-like系统的事实标准,是目前应用最广泛的一种Unicode编码方式。

编码方式 存储单位 特点 适用场景
UTF-8 1-4 字节 可变长度,兼容 ASCII 网络传输、文件存储(节省空间)
UTF-16 2-4 字节 可变长度,部分字符用 2 字节 曾是Windows和Java的默认编码
UTF-32 4 字节 固定长度,1 个码点 = 1 个 4 字节 \

UTF-8编码方式对所有ASCII码点值(0x00~0x7F)具有透明性。所谓透明性,具体指的是在U+0000到U+007F范围内(十进制为0~127)的Unicode码点值,亦即ASCII字符的Unicode码点值,被直接转换为UTF-8单一字节码元0x00~0x7F,与ASCII码没有区别。
并且,0x00~0x7F不会出现在UTF-8编码的非ASCII字符的首字节与非首字节的任意一个字节中(非ASCII字符的UTF-8编码为由两个或两个以上的单字节码元所组成的码元序列),这样就保证了与早已应用广泛且已成为工业标准的ASCII编码的完全兼容,且避免了歧义,同时纠错能力也强。
由于UTF-8编码没有状态,从UTF-8字节流的任意位置开始可以有效地找到一个字符的起始位置,字符边界很容易界定、检测出来,所以具有很好的“自同步性”。
UTF-8是字节顺序无关的(因为采用的是单字节码元,而非像UTF-16、UTF-32采用的是多字节码元),它的字节顺序在所有系统中都是一样的,其码元序列与字节序列相同,因此它实际上并不需要字节顺序标记BOM(Byte-Orde Mark),虽然Windows系统经常“多此一举”地加上BOM。

BOM字节顺序标记

BOM(Byte Order Mark,字节顺序标记) 是位于文件开头的特殊Unicode字符(U+FEFF),用于标识文本的编码方式和字节序,原本是为 UTF-16/UTF-32 设计的,在 UTF-16 或 UTF-32 中,不同平台可能是大端序(Big-Endian)或小端序(Little-Endian),需要通过 BOM 来标记。UTF-8 并不存在字节序问题,但微软的记事本等工具仍然会在 UTF-8 文件开头加上 EF BB BF 作为标记。
其实UTF-8 的BOM对UFT-8没有作用,是为了支持UTF-16,UTF-32才加上的。BOM签名的意思就是告诉编辑器当前文件采用何种编码,方便编辑器识别,但是BOM虽然在编辑器中不显示,但是会产生输出,容易导致乱码风险,很多程序(如 Linux 系统工具)可能不识别 BOM,甚至会将其当作文件内容的一部分(导致乱码)。

BOM在不同编码中的表现:

  • UTF-8:EF BB BF (0xEF, 0xBB, 0xBF)
  • UTF-16 BE:FE FF (大端序)
  • UTF-16 LE:FF FE (小端序)
  • UTF-32 BE:00 00 FE FF
  • UTF-32 LE:FF FE 00 00

不推荐使用 UTF-8 with BOM

标准 UTF-8 文件不需要 BOM,根据 Unicode 标准,UTF-8 编码本身不需要 BOM,因为它没有字节序问题。UTF-8 BOM 是微软的 “非标准扩展”,微软在 Windows 系统中(如 Notepad)默认保存 UTF-8 文件时会添加 BOM,导致其他不支持 BOM 的程序读取时出现乱码(例如,将 BOM 的 3 个字节当作普通字符处理,显示为 “”)。

特性 UTF-8(无 BOM) UTF-8 BOM
文件开头 无特殊字节 包含 0xEF 0xBB 0xBF
标准兼容性 符合 Unicode 标准 微软非标准扩展
跨平台支持 所有平台兼容 Windows 支持良好,Linux/macOS 部分程序不兼容
乱码风险 无(若程序明确按 UTF-8 读取) 程序不支持 BOM 时,开头会出现乱码

总结与参阅

小结

utf8文件与utf8 bom文件的唯一区别就在于UTF+BOM比UTF无BOM多了三个字节前缀:0xEF 0xBB 0xBF
bom是为utf-16和utf-32准备的,用于标记字节顺序。微软在utf-8中使用bom是因为这样可以把UTF-8和ASCII等编码区分开来,但这样的文件在windows之外的操作系统里会带来问题。

编码 特点 优点 缺点 常见场景
ASCII 7 位,128 字符 简单,历史悠久 国际化差 早期文件、协议
ISO-8859 单字节扩展 覆盖部分语言 不统一 西欧、拉美旧系统
GB2312/GBK/GB18030 针对中文 存储高效 与 Unicode 不兼容 中国大陆旧系统
Shift-JIS 针对日文 覆盖平假名片假名 解析复杂 日本旧系统
UTF-16 定长/半定长 全球覆盖 大小端、BOM 问题 Windows 内部
UTF-32 固定 4 字节 简单直观 空间浪费 数据库/内部处理
UTF-8 变长,兼容 ASCII 高效、通用 解析稍复杂 互联网、跨平台标准

字符编码的发展史,其实是一段不断追求「统一」与「高效」的历史。是看似细微的 BOM,折射出的是软件兼容性与历史包袱的复杂性。

参阅