loading...
Featured image of post 一文弄懂字符集与编码

一文弄懂字符集与编码

这是一个关于计算机和字符的故事

在计算机的世界里,我们看到的所有内容 —— 无论是文字、符号还是表情 —— 本质上都是一串串由01组成的二进制数字

那么一个我们能看懂的、有意义的符号,是如何被计算机存储,又是如何被准确无误地显示出来的呢?

这个过程就是我们今天要讲述的关于“字符”的完整故事

基础概念:字符、字形、字节与字

要理解后续的一切,我们必须先精确区分几个最基本的概念:

  • 抽象的语义单位 —— 字符(Character)
  • 具象的视觉单位 —— 字形(Glyph)
  • 计算机的物理存储单位 —— 字节(Byte)
  • 以及容易混淆的“字”——计算机的字(Word)人类语言的字

字符(Character)

字符,是一个抽象的、信息的基本语义单元,是我们用来表达意义的最小符号

  • 英文字母Abc是字符

  • 数字123是字符

  • 标点符号.?!是字符

  • 汉字是字符

  • 表情符号😂👍也是字符!

在这个层面,字符是无形的,它只关乎“是什么”,而不关乎“长什么样”或“如何存储”

字形(Glyph)

字形,是字符的具体视觉表现形式,是我们真正在屏幕上看到的那个“形状”

一个字符可以有多种不同的字形:

  • 字符“拉丁字母A”可以有 A (正常), A(粗斜体), 𝔸 (空心) 等多种字形
  • 同一个汉字“骨”,在宋体、楷体、草书中的字形也完全不同。

反过来,多个字符也可能组合成一个字形(这被称为“合字”或“连字”,Ligature):

  • 在某些英文字体中,字符 fi 相邻时,会合并成一个单独的 字形,以避免 f 的钩与 i 的点碰撞。

简单说:字符是“骨”,字形是“皮肉”。我们操作和存储的是字符,而最终渲染出来给人看的是字形。

字节(Byte)

字节,是计算机中数据存储和处理的基本单位

  • 一个字节由8个比特(bit)组成,每个比特非0即1
  • 一个字节能表示2⁸=256种不同的状态(0-255)

字(Word)

计算机的“字”与人类的“字”毫无必然联系

计算机中的“字”(Word)

在计算机科学中,字(Word)是CPU处理数据的自然单位,是计算机一次性读取、处理或传输的最小数据块

字的大小依赖于计算机架构

  • 16 位系统的一个字是 2 个字节(16 位)
  • 32 位系统的一个字是 4 个字节(32 位)
  • 64 位系统的一个字是 8 个字节(64 位)

在汇编语言和系统底层编程中,“字”是非常重要的基本数据宽度概念

人类语言中的“字”

在人类语言里,“字”指的是一个书写符号单位:

  • 在中文里,“字”通常是一个汉字,例如
  • 在英文里,没有严格对应的“字”概念,通常用letter(字母)或word(单词)描述
  • 在日语里,一个“字”可能是汉字、平假名、片假名中的任意一个符号

在大多数情况下,人类语言里的“字”可以看作是一个字符,但它们并不是严格等同的概念

我们可以认为一个英语单词是一个“字”,但它却是多个“字符”的组合


那么此时,矛盾就出现了:

世界上有成千上万个抽象的“字符”,而计算机的基本存储单元“字节”一次只能表示256种状态!

这就需要建立一套完整的映射体系,才能让所有字符都能在计算机上显示

字符集

为了让计算机能够处理字符,人们首先需要做一件事:

把世界上所有的字符收集起来,排个队,给每个字符分配一个独一无二的编号

字符集,就是这样一个“字符的集合”以及“字符与编号的对应表”。可以把它想象成一本为全世界所有字符编写的巨大“字典”,而对应的编号,我们称之为码点

字符集本身只是一套标准、一个“名册”,它只规定了“哪个字符对应哪个数字”,但还没有规定这个数字具体应该如何在计算机中用字节来存储

字符集的历史演进与兼容性问题

ASCII时代

计算机诞生于美国,最早的ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)字符集是为英语环境量身定做的

它收录了128个最常用的字符,包括:

  • 95个可打印字符:大小写英文字母(A-Z, a-z)、数字(0-9)、以及各种标点和符号(!,@,#等)

  • 33个不可打印的控制字符:如回车(CR)、换行(LF)、制表符(Tab)等,用于控制打印机等设备

  • 它的码点范围是0到127,所以用一个字节(0-255)来存储一个ASCII 字符绰绰有余

    实际上,ASCII码只用到了一个字节(8个比特)中的低7位,最高位始终为0

    例如,字符'A'的码点是 65,其二进制表示为01000001

    这个“最高位为0”的特性,为后来的扩展埋下了伏笔

ASCII码现在依然在被广泛使用,你可以在很多编程语言里面见到它,而且可以说它是现代字符编码体系的根基

ASCII 表| 菜鸟教程

地区性字符集时代

当计算机走向世界,问题出现了:

欧洲需要 é, ü,中国需要汉字,日本需要假名,单一的 ASCII 远远不够用!

于是,世界各地纷纷利用ASCII留下的“遗产”—— 那个未被使用的最高位比特0,来扩展自己的字符集

当一个字节的最高位是0时,它仍然表示一个标准的ASCII字符

当最高位是1时,它就进入了各个地区自定义的“扩展区”(码点范围 128-255)

下面是一些比较有代表性的编码方案吗,我们后面还会详细说明:

  • ISO-8859-1 (Latin-1)

    面向西欧,它利用了128-255的码点来表示带音标的字母,如éü©

  • GB2312/GBK

    面向中国大陆,由于汉字数量远超128个,它采用双字节编码方案

    当检测到一个字节的最高位是1时,就认为它和紧随其后的下一个字节共同表示一个汉字

    GBKGB2312 的扩展,收录了更多汉字

  • BIG5

    面向中国台湾地区,同样是双字节方案,用于表示繁体字

  • Shift_JIS

    面向日本,是一个更复杂的变长编码方案,用于表示日文汉字和假名

虽然这样方便了各个地区的用户,但也是之后乱码问题出现的罪魁祸首

Unicode时代

为了终结大家各用各的这种混乱,Unicode应运而生

它的目标是“天下书同文”,它不是要创造又一个与其它标准竞争的字符集,而是要统一和包容所有字符!

也就是说,它要为地球上每一种语言的每一个字符都分配一个全球唯一的码点!

这一伟大的工程应该造福了全人类·,所以我们也叫他统一码/万国码

Unicode码点的标准表示方法是用U+十六进制数字来表示:

  • 汉字的Unicode码点是U+4E2D
  • 表情😂 的Unicode码点是U+1F602

你也可能看到另一种方法:\u+十六进制数字,这是编程语言中的转义字符表示法:

  • 汉字的Unicode转义字符是\u4E2D
  • 表情😂 的Unicode转义字符是\u1F602

如何存储如此庞大的字符量呢?

Unicode标准码点范围是从U+0000U+10FFFF,共计约1,114,112个码点

这些码点被划分成17 个平面(Planes),每个平面包含65536(即 2¹⁶)个码点

  • 基本多文种平面(BMP, Plane 0)

    包含了从U+0000U+FFFF的码点,涵盖了世界上绝大多数常用字符,包括常用汉字

  • 辅助平面(Supplementary Planes, Plane 1–16)

    包含了从U+010000U+10FFFF的码点,用于表示生僻字、古代文字以及各种符号,比如Emoji表情

从此,无论在哪个国家、哪个平台,U+4E2D永远只代表这一个字符

字符集标准组织

制定这些标准的是一些国际组织,其中最重要的是:

  • Unicode联盟(Unicode Consortium)

    一个非营利组织,其成员包括苹果、谷歌、微软、Adobe等各大科技巨头。它负责开发、维护和推广Unicode标准,包括我们日常使用的Emoji表情的标准化

  • 国际标准化组织(ISO)

    它也发布了与Unicode对应的 ISO/IEC 10646 标准,基本上可以认为Unicode和 ISO/IEC 10646 是同一个字符集标准的不同名称


至此,我们通过字符集,成功地将所有抽象的字符转化为了全球统一的数字(码点)

一个极其重要的问题

Unicode本身只定义了码点,它并没有规定这个号码在计算机中应该如何用字节来存储!

编码

现在我们有了“字符”和“码点”的对应关系(字符集),但还有一个最关键、最实际的问题没有解决:

如何将这些码点(数字),特别是那些大于 255 的庞大数字,高效地用计算机唯一懂的语言来表示?

答案是编码 —— 将字符的码点(数字)翻译成字节序列(物理存储)的具体规则

如果说字符集是字典,那编码就是语法规则,它教我们如何用字节来书写字典里的每一个页码(码点)

编码格式

在编程领域,为了处理非ASCII字符,历史上形成了两种截然不同的解决思路

多字节字符集 (MBCS - Multi-byte Character Set)

这是一种“让程序变聪明”的编码层面解决方案

字符串在内存中仍然以char数组的形式存放,但程序在处理时,必须“意识到”这个字节序列采用了某种特定编码(如 GBK)

比如,对于C语言字符串 "你好",在GBK编码下,它占4个字节

  • strlen("你好")会返回4,因为它只认识字节
  • 而一个特殊的、能识别多字节的函数mbstrlen("你好")则会返回2,因为它知道两个字节才构成一个汉字

这种方式处理起来非常复杂且极易出错,因为我们不能再想当然地通过str[i]来访问第i个字符 —— str[i]可能只是一个汉字的前半部分!字符串的截取、遍历和修改都变得困难了!

宽字符 (Wide Character)

这是一种“让数据类型变强大”的数据类型层面解决方案

它不再让程序去适应复杂的字节流,而是定义了一个新的、足够宽的数据类型,这个类型通常是2或4个字节

比如我们在C++中定义一个这样的宽字符wchar_t类型:

wchar_t* wide_str = L"你好";

wide_str数组的每个元素都能完整地存放一个字符的码点,而L前缀告诉编译器,这个字符串用宽字符存储,不是普通的char字符串

wide_str[0]就是字符wide_str[1]就是字符,数组长度为2

这让字符串的索引和遍历恢复了简单

他也有不少缺点

  • 空间浪费

    即使是存储纯英文 “hello”,每个字符也要占用2或4个字节,造成空间浪费

  • 跨平台困难

    虽然很难以置信,但wchar_t的具体大小在不同主流操作系统上并不统一!

    在 Windows 上它通常是 2 字节(对应 UTF-16),而在 Linux 和 macOS 上它通常是 4 字节(对应 UTF-32),这使得依赖 wchar_t 的代码难以跨平台移植!

+++

正是因为这两种早期方案都有明显的缺陷,现代编程实践大多推荐直接使用UTF-8编码的普通char字符串,并配合专门、可靠的库(如 ICU)来处理复杂的字符串操作

至于什么是UTF-8编码,我们接着往下看

编码方式

ASCII编码

最简单的编码,码点值就是字节值

字符'A'的码点是 65,其编码就是一个值为 65 的字节

GBK编码

GBK的全称是国标扩展码拼音“Guóbiāo Kuòzhǎn”的缩写

这是一种典型的多字节编码(MBCS),解码时检查一个字节的最高位:

  • 如果为0(0-127),则它本身就是一个单字节的ASCII字符
  • 如果为1(128-255),则它必须和紧随其后的下一个字节共同组成一个双字节的汉字

UTF-8编码(现代标准)

这是现代互联网的基石,它是一种针对Unicode的、可变长度的字符编码,其设计堪称精妙绝伦

它兼容ASCII编码:对英文字母和数字用1个字节编码,与ASCII完全一样,这意味着一个纯英文的ASCII文件,本身就是一个合法的UTF-8文件,无需任何转换

它对不同字符有不同字节规定:对拉丁文、希腊文用2字节;对常用汉字用3字节;对罕见字符和表情用4字节

UTF-8通过每个字节开头的几个比特来标记这个字符的长度:

  • 0xxxxxxx: 单字节,表示U+0000到U+007F(ASCII)
  • 110xxxxx 10xxxxxx: 双字节,由一个110开头的字节和个10开头的字节组成
  • 1110xxxx 10xxxxxx 10xxxxxx: 三字节,由一个1110开头的字节和两个10开头的字节组成
  • 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx: 四字节

具体对应关系如下:

Unicode 范围 (十六进制) UTF-8 字节序列 (二进制) 字节数 常见语言/用途举例
U+0000 - U+007F 0xxxxxxx 1 英语(基本拉丁字母)、数字、ASCII 标点、控制字符
U+0080 - U+07FF 110xxxxx 10xxxxxx 2 拉丁文扩展(é ñ 等)、希腊文、俄语西里尔字母、希伯来文、阿拉伯文
U+0800 - U+FFFF 1110xxxx 10xxxxxx 10xxxxxx 3 东亚文字(中文、日文假名、韩文音节)、越南文、泰文、天城文(印地语等)、大部分表情符号
U+10000 - U+10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 4 罕见/扩展字符(CJK 扩展汉字、古代文字、乐谱符号、更多表情符号)

这些x是实际用来填充字符码点二进制位的地方,从右向左、从低位到高位填充

eg:汉字“中” (U+4E2D)

  1. Unicode 码点U+4E2D
  2. 范围4E2DU+0800 - U+FFFF之间,所以需要 3 个字节
  3. 二进制表示4E2D的二进制是0100 1110 0010 1101 (16位)
  4. 匹配模板:三字节模板是1110xxxx 10xxxxxx 10xxxxxx。这个模板共有4+6+6=16个x,正好容纳16位
  5. 填充
    • 最右边的6个x填入最低的6位:001101
    • 中间的6个x填入接下来的6位:111000
    • 最左边的4个x填入最高的4位:0100
  6. 得到字节序列
    • 1110 0100 -> E4
    • 10 111000 -> B8
    • 10 001101 -> AD
  7. 结果:汉字“中”的UTF-8编码是**E4 B8 AD**

这种设计确保了程序在任何位置开始读取字节流,都能准确判断出一个字符的起始和结束边界

UTF-16 编码

UTF-16也是一种用于编码Unicode字符的格式

它通常使用2个字节(对于BMP内的字符)或4个字节(对于辅助平面的字符)来表示一个字符

由于其大部分情况下定长的特性,便于程序内部进行索引,因此被广泛用作许多系统和语言的内部内存表示,例如Windows操作系统内核、Java语言的String类型

UTF-32 编码

类似UTF-16,它也是定长的,为每一个字符分配固定的4个字节(32位)来存储其Unicode码点值

它和UTF-16一样,在文件存储和网络传输方面不如UTF-8流行,一个主要原因就是接下来要讲的字节序问题

字节序

当一个字符需要用多个字节来表示时,就必须面对一个问题:

这些字节应该按什么顺序存储?就像写数字258,是从左到右写 2, 5, 8,还是从右到左写8, 5, 2

这也就是所谓的字节序 —— 多字节数据在内存或文件中按字节排列的顺序

UTF-8的编码规则中,字节内部的位顺序是固定的,没有所谓“高字节”和“低字节”的问题;而UTF-16和UTF-32是定长的,而且字节内部可以交换顺序,所以会有字节序区别

常见的两种字节序:

  • 大端序 (Big-Endian, BE)高位的有效字节存放在内存的低地址(大头在前)

    这符合人类的阅读习惯

  • 小端序 (Little-Endian, LE)低位的有效字节存放在内存的低地址(小头在前)

    这在 x86 架构的计算机(我们日常使用的大多数 PC)中更为常见

比如,字符 的UTF-16编码 (U+4E2D) 是两个字节4E2D

  • 在大端序系统上,内存中存储为:... [地址1000]: 4E, [地址1001]: 2D ...
  • 在小端序系统上,内存中存储为:... [地址1000]: 2D, [地址1001]: 4E ...

如果搞错了字节序,解码就会出错!

为了解决这个问题,人们发明了BOM(Byte Order Mark),它是一个放在文件开头的、不可见的特殊暗号字符(码点为 U+FEFF

通过读取文件开头的几个字节,以BOM为参考,程序就可以判断文件的字节序和编码:

  • 如果文件开头是FE FF,说明是UTF-16 大端序
  • 如果文件开头是FF FE,说明是UTF-16 小端序
  • 如果文件开头是EF BB BF,说明是UTF-8

正如前面所说,UTF-8本身没有字节序问题,它的BOM只是用来表明这是一个UTF-8文件

然而,在Web开发和类Unix系统中,普遍推荐使用不带 BOM 的 UTF-8(UTF-8 without BOM),因为某些旧的脚本和工具可能不认识 BOM,并将其作为垃圾内容输出,导致页面顶部出现空行或其他问题


理解了前面的字符集和编码,乱码的产生原因就非常清晰了

乱码

乱码的唯一根源在于:用 A 编码方式存储的字节序列,却被当作 B 编码方式去解码和显示。

这就像你拿到一份用中文密码本加密的信息,却错误地拿了一本俄文密码本去解密,结果自然是一堆谁也看不懂的天书

字节本身是无辜的,它们只是一串0和1,真正的罪魁祸首是解码时错误的假设

错误的翻译 —— GBK打开UTF-8文件

这是最常见的场景:我们用国际标准的UTF-8创建了文件,但它被一个只认识本地编码的旧程序打开了

我们用 Python 写入一句话“你好,世界”,并明确使用utf-8编码保存,查看它UTF-8编码字节

text = "你好,世界"
utf8_bytes = text.encode('utf-8')
with open("utf8.txt", "wb") as f:
    f.write(utf8_bytes)
print(utf8_bytes.hex().upper())

输出:

E4BDA0E5A5BDEFBC8CE4B896E7958C

我们再模拟一个旧程序用gbk编码去读取这个hello_utf8.txt文件:

with open("utf8.txt", "rb") as f:
    byte_data = f.read()
#errors='replace' 表示遇到无法解码的字节时,用 '' 替换
garbled_text = byte_data.decode('gbk', errors='replace')
print(garbled_text)

输出:

浣犲ソ锛屼笘鐣

这就是典型的乱码,因为utf-8下的E4BDA0...这串字节在GBK的字典里恰好对应了另一组完全不同的汉字

“锟斤拷”

这个著名的乱码组合,就是源于将UTF-8编码的汉字,错误地用 GBK 或其他本地编码来显示

它的成因非常特殊,它源于对“错误”本身的再次错误解读

替换字符

这个故事的主角,是一个特殊的Unicode字符 —— 替换字符

当一个程序在解码字节流时,如果遇到了一个不符合当前编码规则的、无法识别的无效字节序列,该怎么办?

一个设计良好的程序不会崩溃,而是会遵循Unicode标准,在那个出错的位置插入一个官方的替换字符,也就是**U+FFFD**,它在屏幕上通常显示为一个黑色的菱形中间带一个问号

错误的诞生

现在,让我们用代码来模拟它的产生过程:

假设一个程序(比如数据库、文本编辑器等)在处理数据时,遇到了它无法理解的字节,作为安全措施,程序在内存中生成了两个Unicode替换字符,我们在Python中可以直接用它的码点 \uFFFD 来表示:

error_text = "\uFFFD\uFFFD"

接下来,程序将包含这两个替换字符的字符串,以标准的UTF-8格式写入文件,一个U+FFFD的UTF-8编码是 3个字节:EF BF BD

utf8_error = error_text.encode('utf-8')

现在,另一个程序(或用户)错误地使用 GBK 编码来读取这串字节,注意这里必须用 gbk 编码,如果用 gb2312会因字符不全而报错:

text = utf8_error.decode('gbk')
print(f"GBK解码结果: '{text}'")

结果:

锟斤拷

其中过程是怎么样的?

  1. 程序读取 EF BF BD EF BF BD这6个字节
  2. GBK 解码器按两个字节一组来处理,因为它认为最高位是 1 的字节都是双字节字符的一部分:
    • 读到**EF BF,去查GB码表,这对字节恰好对应了汉字**
    • 接着读到**BD EF,再次去查GBK码表,这对字节也恰好有对应汉字**
    • 最后读到**BF BD,第三次去查GBK码表,这对字节同样对应了一个汉字**
  3. 就这样,原本代表解码错误的、在UTF-8下有明确含义的字节序列,在 GBK解码器的一系列巧合误解之下,被翻译成了我们在座各位都无比熟悉的三个汉字——锟斤拷

锟斤拷是传播比较广泛的乱码,真正的乱码形式多种多样,网上有人整理了常见的乱码对照表:

乱码对照表_乱码符号-CSDN博客

如果你不记得什么是ISO-8859-1:地区性字符集时代

表里面提到锟拷码是gbk读取两次的结果,其实就是utf-8 ->错误字符 -> 替换字符 -> 锟斤拷

无能的编码检测

既然乱码是因编码不匹配产生的,那程序能自动检测文件的编码吗?

答案是可以,但永远不可靠

编码检测本质上是一种启发式猜测,线索极少

程序会读取一部分字节,然后用各种常见编码的规则去试,并根据以下线索进行打分:

  1. BOM (Byte Order Mark)

    这是最可靠的线索,如果文件开头有EF BB BF,那几乎可以100%确定是UTF-8

    但我们上面也说过了,绝大多数文件(特别是网页)为了兼容性,并不带BOM

  2. 无效字节序列

    编码有其严格的字节组合规则,如果在尝试用GBK解码时,出现了大量不符合其双字节规则的序列(像上面例子中的单个0xAA),那么程序就会认为这可能不是GBK

  3. 统计学特征

    分析字节的分布规律,例如,一段英文文本如果用UTF-8编码,大部分字节都会在0-127范围内。而一段中文GBK文本,则会出现大量连续成对的大于127的字节

然而,这种方法是不可靠的:

  • 对于短文本,样本太少,统计学方法完全失效

  • 巧合时有发生,有些字节序列在多种编码下恰好都是“合法”的,只是代表的意义不同

    在早期的windows版本中,如果使用写字板打开txt文件,输入“联通”两个字,此时是以GBK形式保存,但由于他们的编码恰好和utf-8的双字节格式相同(比如“联”是C1 AA,即11000001 10101010),阅读器错误的使用utf-8解码它们,就会导致乱码

    还有一个著名的例子就是短语Bush hid the facts,这也是一个在中文Windows系统上曾广为人知的文本显示乱码,当它以ASCII/UTF-8保存后,如果被程序错误地当作UTF-16LE来打开,会显示出几个汉字联 আরি󏐠或类似乱码

所以,永远不要依赖自动检测!

与其让接收方去费力猜测,不如让发送方在明确标注语言,现代协议和格式都会提供这种明确指定编码的机制

例如:

HTTP响应头:服务器在返回网页时,通过这个头信息告诉浏览器用什么编码来解析

Content-Type: text/html; charset=utf-8

HTML文件头:在HTML文件内部,通过meta标签再次声明编码,作为服务器未发送上述头信息时的备用方案

<meta charset="UTF-8">

XML文件头:XML文件在第一行就声明自己的编码

<?xml version="1.0" encoding="UTF-8"?>

说完了基础的,我们来说点高级的 —— 其实,Unicode远比“一个码点一个字”要复杂

除了我们看得见的字符,还存在一套系统,用于处理字符的等价性问题、组合显示以及控制文本的复杂行为

字符的变形

同形异义

同一个看起来一样的字符,在Unicode中可能有多种表示方式,这在计算机看来就是完全不同的东西

比如一个带扬抑符的字母é,它就有两种表示方式:

  • 方式1 —— 预组合

    直接使用单个码点 U+00E9é),就像一个现成的汉字一样

  • 方式2 —— 组合

    使用普通字母eU+0065),后面紧跟一个“组合扬抑符” ´U+0301

    这就像汉字的一个偏旁部首,会自动“贴”到前一个字母的身上,组合成需要的样子

这两种方式渲染出来的字形完全一样,但在计算机内部,它们的身份是完全不同的

如果你在一个文本中搜索 U+00E9,将无法匹配到由U+0065 + U+0301组成的那个é

历史渊源

你可能会奇怪为什么它会以两种方式存在,这其实和Unicode在设计时要兼顾的两个目标有关:

  1. 兼容性 在 Unicode 出现之前,许多老字符集(如 Latin-1)已经有é这种“预组合”的单个字符

    为了兼容旧标准,Unicode 直接收录了这些单字符形式(U+00E9)

  2. 可组合性 Unicode 希望支持所有语言的所有字符变化(比如加不同的重音、附加符号、数学标记等),但如果每种变化都单独收一个码点,字符集会无限膨胀

    所以,Unicode 定义了组合字符(Combining Characters)机制,允许用一个基本字母 + 一个或多个附加符号来动态组合成新字形(比如U+0065+U+0301

归一化标准

为了解决这个问题,Unicode 定义了四种归一化标准,用于将“看起来一样但编码不同”的字符串转换为统一的、规范的表示形式

  • NFC (Normalization Form C – Composition)组合

    先将字符分解,再尽可能组合为预组合形式(比如 e + ´é

    这是 W3C 推荐在网页存储和传输中使用的标准,能够保证跨平台一致性

  • NFD (Normalization Form D – Decomposition)分解

    将字符尽可能分解为基本字符和组合标记(比如 ée + ´

    这种形式常用于需要分析字符结构或对附加符号单独处理的场景

  • NFKC (Normalization Form KC – Compatibility Composition)兼容性组合

    在NFC的基础上,先进行兼容性分解(把全角字符、特殊样式字符等转换为标准字符),再尽可能组合

    例如,Hello(全角)会被转成Hello

    适合搜索、匹配、用户输入标准化等场景

  • NFKD (Normalization Form KD – Compatibility Decomposition)兼容性分解

    在NFD的基础上,额外进行兼容性分解,将外观差异但语义等价的字符统一为标准形式,并保持分解状态

    例如,罗马数字 会被分解为 I + V

    常用于文本分析、去除装饰性差异等任务

前两种是比较常用的归一化方式

特殊字符

Unicode中包含很多“看不见”但有特殊功能的字符,我们上面提到的用作BOM的就是其中一种

零宽字符

看不见的字符

它们不占用任何宽度,肉眼不可见,但能产生特殊效果

  • 零宽空格 (ZWSP, U+200B)

    它用于在长单词或 URL 的中间建议一个“可以换行”的位置,而不会产生一个可见的空格

    例如,在 URL thisisaverylongurlthatmightoverflow 中间插入 ZWSP,当容器宽度不够时,浏览器就可以在这里优雅地换行,而不会破坏链接

  • 零宽连字 (ZWJ, U+200D)

    它用于“粘合”两个独立的字符,告诉渲染引擎将它们显示为一个组合的字形

    最著名的应用就是 Emoji 的组合: 👨 (男人) + ZWJ + 👩 (女人) + ZWJ + 👧 (女孩) + ZWJ + 👦 (男孩) = 👨‍👩‍👧‍👦 (家庭) (零宽连字) 本身不可见,但它像胶水一样把前后四个独立的 Emoji 粘合成了一个新的 Emoji

  • 零宽非连字 (ZWNJ, U+200C)

    与 ZWJ 相反,它用于“切断”本应自动连接的字符

    例如,在波斯语中,某些字母会自动与后面的字母连接,插入 ZWNJ 可以强制它们断开,以显示其非连接形式

  • 从左到右标记 (LRM, U+200E)

    一个不可见字符,用来强制后续字符以从左到右显示

    常用于混合方向文本中,确保英文或数字按正常方向显示。例如,在阿拉伯语句子中插入 LRM 可以保证英文单词正常显示。

  • 从右到左标记 (RLM, U+200F)

    类似 LRM,但强制后续字符以从右到左方向显示

    常用于拉丁字母或数字出现在阿拉伯语或希伯来语文本时,保证它们正确的右到左排列

  • 从左到右嵌入 (LRE, U+202A)

    它开始一段左到右的文本嵌入,后续字符强制从左到右显示,直到遇到对应的结束符

    在主要是阿拉伯语的文本中插入LRE和PDF,可让一段英文保持左到右排列

  • 从右到左嵌入 (RLE, U+202B)

    它开始一段右到左的文本嵌入,后续字符强制从右到左显示,直到遇到结束符

    在主要为英文的文本中插入RLE和PDF,使其中一段希伯来语正常右到左排列

  • 弹出方向格式 (PDF, U+202C)

    它用来结束先前的方向嵌入(LRE或RLE),恢复之前的文本方向

    例如:文本中出现LRE…PDF或RLE…PDF包裹的区域,PDF结束该嵌入状态

  • 从左到右覆盖 (LRO, U+202D)

    它强制后续字符全部以从左到右方向显示,覆盖其默认方向,直到遇到PDF

    例如:在包含阿拉伯语或希伯来语的混合文本中,强制某一部分以从左到右显示

  • 从右到左覆盖 (RLO, U+202E)

    与LRO相反,强制后续字符全部以从右到左方向显示,覆盖默认方向,直到遇到PDF

    这在恶意文本或混淆攻击中经常被用来制造视觉欺骗,因它会反转文字显示方向

  • 不可见乘号 (INVISIBLE TIMES, U+2062)

    表示数学隐式乘法操作,但不显示任何符号,常用于数学公式排版,变量间的隐式相乘

  • 不可见分隔符 (INVISIBLE SEPARATOR, U+2063)

    用作文本逻辑分隔符,但不显示任何空白或符号,方便文本内部逻辑处理

  • 零宽不间断空格 (ZWNBSP, U+FEFF)

    既是零宽的不可换行空格,也常用于Unicode文档的字节顺序标记(BOM)

    在文件开头可标识文本编码,但不应随意插入文本中,以免引起显示问题

QQ的反转id

相信不少人都看见过qq群里有些人的id后面会跟着一个“喵”之类的恶搞字符

当你@他时,这个字符竟然脱离了id,出现在了我们输入的话后面!这怎么回事?

其实这就是使用了上面我们提到的零宽字符的两种:

  • U+202E:从右至左覆盖
  • U+202D:从左至右覆盖

比如,我有一个id是这样的:

快来艾特我\u202E喵~\u202D

因为是不可见字符,在别人眼里是看不见的(需要使用对应的编码工具,比如:unicode编码转换 ):

快来艾特我喵~

当别人@我发言的时候(比如说“你好”,显示应该是@快来艾特我喵 你好),文字显示默认从左向右

我们使用[]代表当前输入光标的位置,左右箭头代表输入方向:

[]@[]→
@快[]→
@快来[]→
@快来艾[]→
@快来艾特[]→
@快来艾特我[]→

读取到这里的时候,文本框看见了控制字符U+202E,它立刻变成了从左向右输入:

@快来艾特我←[]
@快来艾特我←[]

输入“喵”后,他又看见了控制字符U+202D,又变回了从右向左输入:

@快来艾特我[]→
@快来艾特我你[]→
@快来艾特我你好[]→

输入完成之后最终效果:

@快来艾特我你好喵

这就让别人喵喵叫啦

零宽字符隐写

这种零宽字符还诞生了一种隐写方式 —— 零宽字符隐写(Zero-Width Character Steganography)

隐写技术通过在正常文本的字符间插入零宽字符,或者利用不同零宽字符的组合,来编码隐藏信息

例如,用零宽空格代表二进制的0,零宽非连接符代表1,或者通过插入或不插入零宽字符表示二进制位

这样,隐藏的信息不会被普通阅读者察觉,因为文本外观没有变化

你可以使用linux的vim编辑器打开,观察到\u这样的隐写痕迹

常用的隐写工具:

https://330k.github.io/misc_tools/unicode_steganography.html

https://yuanfux.github.io/zero-width-web/

变体选择符

变体选择符是一类特殊的Unicode码点,通常紧跟在另一个字符后面,作用是“建议”或“指定”该字符的具体显示样式(字形变体),以保证不同平台或字体能正确显示

常见变体选择符:

  • U+FE0E(VS15,Variation Selector-15)表示文本形式,提示字符以普通文本风格显示
  • U+FE0F(VS16,Variation Selector-16)表示表情符号形式,提示字符以彩色Emoji风格显示

比如,Unicode字符U+2764是一个“心形”符号 ❤:

  • 单独使用:U+2764,显示可能是黑白符号,也可能是彩色Emoji,这依赖平台和字体
  • 加上变体选择符:U+2764 U+FE0F,强制显示为彩色Emoji❤️
  • 加上变体选择符:U+2764 U+FE0E,强制显示为黑白文本 ❤︎

讲了这么多,终于到实践的部分了

到目前为止,我们讨论的都是计算机如何“理解”和“存储”字符

现在,我们来看看在实际操作中,人类是如何与这个复杂的字符系统进行交互的

工具与应用

输入法(Input Method Editor, IME)

输入法并非一个普通的应用程序,而是一个专为文本输入设计的、集成在操作系统中的系统级服务或中间件

它的位置恰好位于物理键盘当前聚焦的应用程序之间,为了让输入法能够“拦截”和“处理”按键,所有现代操作系统都提供了专门的框架来管理和集成它们:

  • Windows: 文本服务框架 (Text Services Framework, TSF)

    这是一个先进的框架,允许输入法、手写识别、语音识别等多种文本服务以统一的方式与应用程序交互

    在早期,Windows使用的是一个较老的系统,名为输入法管理器 (Input Method Manager, IMM)

  • macOS: Input Method Kit

    这是苹果为开发者提供的、用于构建输入法的官方框架

  • Linux: IBus (Intelligent Input Bus) & Fcitx (Flexible Context-aware Input Tool for X)

    它们都采用了一种“总线”式的架构,输入法作为服务挂载在总线上,为所有应用程序提供输入服务

这些框架的核心作用,就是建立一套标准的通信协议,确保任何应用程序(只要它支持该框架)都能与任何输入法进行顺畅的对话,而无需关心对方的具体实现

当我们在键盘上敲下字母到最终输入文字,经历了什么?

我们以输入“你好”为例,一步一步看看它的过程:

步骤一:事件拦截

  1. 物理按键触发

    当我们按下n键时,键盘硬件会将该按键对应的扫描码(Scan Code) 通过键盘控制器发送到计算机

    这只是一个硬件级的信号,还不知道“n”是什么意思

  2. 操作系统接收与封装事件

    操作系统的输入子系统(如 Windows 的 Keyboard Class Driver、Linux 的 evdev)接收到扫描码,将其转换成虚拟键码

    这一阶段还只是“按下了哪个键”的信息,不涉及语言文字

  3. IME 输入法拦截

    在操作系统将键盘事件传递给当前活动应用程序(比如聊天窗口)之前,事件会先经过输入法框架

    如果当前有 IME 处于激活状态,它会拦截并消费这个按键事件 —— 这意味着这个按键不会直接传给应用,而是先进入输入法的内部处理逻辑

  4. 建立输入上下文

    IME会根据当前的焦点控件(例如一个可编辑文本框)建立一个输入上下文,用来记录本轮输入的状态:

    • 已输入的编码(例如拼音串nihao
    • 光标位置
    • 候选词列表
    • 用户选择历史

    这一步相当于输入法开了一个“草稿本”,专门记录你正在打的这段文字

  5. 生成组合字符串

    输入法会将已输入的编码(如nninihnihao)显示在屏幕上

    这段带虚线或高亮的临时文字被称为组合字符串,它不是应用程序缓冲区里的正式文本,而是由输入法通过IME接口直接绘制到光标位置上的

    在这个阶段,应用程序并不知道这些字母是什么 —— 它只知道光标处有一个正在编辑的输入会话

步骤二:转换引擎

这是输入法最核心的部分,转换引擎根据缓冲区中的nihao,开始计算所有可能的候选结果

转换引擎主要依赖两个东西 —— 词库和语言模型

1.词库

存储大量词语及其拼音(或其他编码)对应关系的数据集合

  • 静态词库

    输入法出厂时内置的、包含数百万词条的巨大数据库。它定义了拼音与汉字、词语之间的基础对应关系

  • 动态/用户词库

    记录你个人常用词汇的词典。你输入过的名字、昵称、专业术语都会被添加进来,下次输入时就会优先显示

  • 云端词库

    现代输入法的“联网大脑”。它能实时从互联网上抓取最新的流行语、新闻热点、人名地名,让你的输入法永远“与时俱进”

2.语言模型

如果说词典是认字,那么语言模型就是理解语法和语境,它负责从众多同音词中,猜出你最想要的那一个

  • N-gram 模型

    这是统计学模型,它通过分析海量文本数据,计算出词语组合的概率

    例如,它知道 P(好 | 你)(在“你”之后出现“好”的概率)要远远大于 P(耗 | 你),因此,你好的排序会非常靠前

  • 更先进的模型(HMM, CRF, AI)

    现代输入法普遍采用更复杂的统计模型,甚至是深度学习神经网络(如 RNN/LSTM)

    这些模型不仅看前一个词,还会综合分析整个句子的结构和语义,从而做出惊人准确的预测

    例如,当你输入jintianwanshangwomenyiqiqu时,它能直接预测出 今天晚上我们一起去,而不是一堆不相关的单字

步骤三:候选界面

  1. 生成列表

    转换引擎将计算出的结果,按照概率从高到低排序,生成一个候选列表

  2. 渲染窗口

    输入法的界面模块(UI)获取这个列表,然后在屏幕上绘制出我们熟悉的候选窗口

    这个窗口是一个独立的、悬浮在所有应用之上的图层,由输入法完全控制

步骤四:文本提交

  1. 用户选择

    用户通过按空格、数字键或鼠标点击,在候选窗口中选择了 你好

  2. 发送指令

    输入法收到用户的选择后,会向操作系统的文本服务框架发送一个提交文本的指令

  3. 内容交付

    这个指令里装的,就是最终确定的字符串 你好

    更底层地看,是这两个字符的Unicode 码点序列 (U+4F60, U+597D)

  4. 应用接收

    应用程序的文本框接收到这个指令,就像接收到用户用剪贴板粘贴进来一段文本一样

    它将这两个字符插入到自己的文本缓冲区中

  5. 最终渲染

    应用程序调用系统的字体渲染引擎,根据当前设置的字体,将U+4F60U+597D对应的字形绘制到屏幕上

至此,输入法绘制的临时草稿(带下划线的nihao和候选窗口)消失,你好这两个字被正式地写入了应用程序,整个输入流程完成

编码转换工具和库

在处理来自不同年代、不同地区、不同系统的数据时,编码不一致是家常便饭:

  • 我们可能需要将一个使用GBK编码的旧网站数据库,迁移到新的、使用UTF-8的系统中

  • 用户可能会上传各种奇奇怪怪编码的文本文件,我们的程序需要有能力正确识别和处理它们

  • 我们调用的某个古老API接口,可能只接受GBK编码的请求

因此,编码转换很重要

iconv

iconv是一个乎所有类Unix系统都自带的跨平台命令行工具和编程库,专门用于字符编码的转换

格式:

iconv -f <源编码> -t <目标编码> <输入文件> -o <输出文件>

常用参数:

参数 作用说明
-f 或 –from-code 指定输入文本的字符编码格式
-t 或 –to-code 指定输出文本的字符编码格式
-l 或 –list 列出支持的所有编码格式
-o 或 –output 指定输出文件(否则输出到标准输出)
-c 忽略非法输入字符(转换时跳过错误字符)
–verbose 显示详细的转换过程信息

eg:将一个GBK编码的文件转换为UTF-8编码的格式

iconv -f GBK -t UTF-8 gbk.txt -o utf8.txt

ICU

ICU(International Components for Unicode,Unicode国际组件)是一个由IBM(International Business Machines Corporation,国际商业机器公司)开发和维护的、功能极其强大的C/C++和Java库,后来交由了Unicode联盟管理

其他语言也可以通过绑定或封装调用ICU库来使用大部分核心功能,但可能需要额外安装或配置

ICU不止是一个编码转换工具,还是一套全方位的国际化解决方案

它提供比iconv更强大、更健壮的编码转换功能,支持超过200种编码,还有以下功能:

  • 字符归一化

    可以轻松地将字符串转换为NFC或NFD等范式

  • 排序

    它能实现真正符合语言习惯的排序

    例如,简单的按字节排序会将b 排在 á 前面,但ICU知道在西班牙语中它们应该如何正确排序

    对于中文,它可以实现按拼音、部首或笔画排序

  • 日期、时间、数字、货币格式化

    它可以根据不同国家/地区(Locale)的习惯,将同一个日期2025-08-13格式化为8/13/2025(美国)或 13/08/2025(英国)

  • 文本边界分析

    它能准确地识别出单词、句子、段落的边界,这对于文本处理和搜索引擎至关重要

编程语言

现代主流编程语言都内置了强大的字符串和编码处理能力,通常足以应对日常的转换需求

比如python内部使用unicode表示字符串,并提供了.encode().decode()方法,之前的示例中我们也使用过

eg1:将GBK字节流转换为unicode字符串

gbk = b'\xc4\xe3\xba\xc3'  # "你好" 的 GBK 字节
unicode= gbk.decode('gbk')

eg2:接上一例,将unicode字符串编码为UTF-8字节流

utf8 = unicode.encode('utf-8')

Java, JavaScript, Go, Rust 等也都提供了类似的API,使得我们可以方便地在代码层面处理编码问题


到这里,一切的一切也就结束了

这篇文章诞生原因其实是在学linux命令的时候时常涉及字符、字等等东西,再加上以前看过不少相关文章视频,兴趣使然才写下了这些文字

编码的世界远比我想象的复杂,本文也只是冰山一角、抛砖引玉之作,系统的学习还要综合网上的资料才行

不管怎么样,希望能对你有所帮助

最后更新于 2025-08-24
距离小站第一行代码被置下已经过去
使用 Hugo 构建
主题 StackJimmy 设计
...当然还有kakahuote🤓👆