字符编码是数字世界的 “语言规则”:ASCII 搭了个基础框架,却装不下多语言;Unicode 给全球所有符号发了 “唯一身份证”,解决了混乱;UTF 家族则把这些 “身份证” 高效转成计算机能存能传的形式。三者是递进的技术升级,下文按这个脉络,讲清它们如何一步步解决信息传递的核心问题。
一、ASCII(1963 年)诞生
二进制是计算机信息的底层形态,1个字节由8位构成,具备 256 种状态的表征能力。上个世纪60年代美国制定的ASCII 码,通过字节低 7 位构建了 128 个字符编码体系,涵盖 32 个控制符号与 95 个可打印字符,例如空格对应 00100000(32)、小写 a 对应 01100001(97),字节最高位恒定为 0,该标准至今仍是跨系统文本交互的基础规范。
1、一个字节256种状态
在二进制中,每一位(bit)只有0或1两种值,8 位二进制也就是 1 个字节的取值范围计算方式为:
-
最小值:
00000000→ 转十进制0 -
最大值:
11111111→ 转十进制计算为:1×2⁷ + 1×2⁶ + 1×2⁵ + 1×2⁴ + 1×2³ + 1×2² + 1×2¹ + 1×2⁰=128 + 64 + 32 + 16 + 8 + 4 + 2 + 1= 255
从上面的推导可以看出1个字节的取值范围是0到255一共256个值,所以1个字节最多可以表示256种状态。用生活中例子比喻就是1个灯泡有两种(2¹ )状态亮或灭,2个灯泡就有四种(2²)状态,3个灯泡就有八种(2³)状态,如此类推8个灯泡就有 2⁸=256 种状态
2、ASCII的128种状态
ASCII码是通过1个字节的低7位最高位恒定为0构建128个字符编码体系
-
最小值:
00000000→ 十进制0 -
最大值:
01111111→ 十进制计算为:0×2⁷ + 1×2⁶ + 1×2⁵ + 1×2⁴ + 1×2³ + 1×2² + 1×2¹ + 1×2⁰=0+ 64 + 32 + 16 + 8 + 4 + 2 + 1= 127
因此ASCII码取值范围是0到127(或者7个灯泡2⁷)共128种状态
3、单字节128到255之间的值
由于单字节128 - 255没有被使用因此欧洲国家最早提出用EASCII(扩展 ASCII)方案来占用 128 - 255 编码区间。ASCII 仅用单字节的低 7 位(0 - 127),EASCII 则启用了闲置的最高位,让单字节能表示 256 个字符,其中 128 - 255 区间就用来存储欧洲各国的特殊字符。比如法语中的 é 编码为 130,德语中的特殊字母等也都被纳入这个区间。不过该方案没有统一标准,不同欧洲国家对 128 - 255 区间的字符映射自行定义,比如德国版 EASCII 的该区间适配德语变音字母,法国版则适配法语重音字母,导致不同国家的文档交互时容易出现乱码,后续逐渐被规范的编码方案替代。纵使有了早期的规范方案但是推行到亚洲地区又出现了新问题。例如我们国家常用的汉字就有6万多个如果算上生僻字多达10W+。单字节最多才能表示256个状态ASCII码自己就占用了一半剩下的想要涵盖所有汉字根本是不可能的。(因为ASCII码0到127是最早期被定义且应用的所以其他的编码方案为了向下兼容就得把它预留出来)
二、Unicode(1991 年发布诞生)
ASCII、EASCII 以及 ISO/IEC 8859-1、ISO/IEC 8859-2 等编码方案,均受限于单字节设计,仅能覆盖部分区域语言符号,无法囊括全球所有语言的字符与特殊符号,多语言交互时的编码冲突与乱码问题愈发突出。在此背景下,急需一种能统一全球符号的编码方案,那么Unicode 就在此时应运而生,为解决这一核心痛点提供了终极方案。
Unicode提出“为全球所有符号分配唯一码点”,不管什么语言、什么符号,都有一个专属的 Unicode 码点相当于为每个符号(文字、表情等)分配唯一的 “身份证号(如Unicode码点 U+4F60 对应“你”,U+1F600 对应笑脸😀表情)同时定义了字符的属性(类别、大小写、排序规则等)。Unicode它只解决了 “符号→唯一码点” 的映射问题,不关心码点如何存储为二进制数据。
1、码点展示
上面分别用html和JavaScript两种方式展示码点
2、码点获取
我们可以用最简单的JavaScript来获取一个字符的码点例如字母a的码点获取如下所示
const codePoint = 'a'.codePointAt(0);
//codePoint输出码点十进制表示为:97
const codePointStr = `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`;
//codePointStr输出码点十六进制表示为:U+0061
因为Unicode保留了ASCII码的范围所以0到127的码点表示的符号和ASCII码相同
我们再来看看“我”字的码点获取
const codePoint = '我'.codePointAt(0);
//codePoint输出码点十进制表示为:25105
const codePointStr = `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`;
//codePointStr输出码点十六进制表示为:U+6211
或者也可以用php里面的json_encode函数来获取码点
json_encode('我', JSON_UNESCAPED_SLASHES)
//输出十六进制码点为:\u6211
注意:php的json_encode函数输出的码点范围在0到127之间就会直接输出ASCII码对应的字符并不会输出十六进制码点
三、UTF编码方案
在Unicode诞生之初,便同步定义了 UTF-8、UTF-16 等编码方式,用以解决码点的计算存储问题。但受限于对 ASCII 的兼容需求、不同系统的编码使用惯性等现实阻力,UTF 家族在很长一段时间内未能广泛推行。彼时,多数计算机仍处于单机或局域网环境,文件的存储、修改与浏览局限在小范围场景中,编码差异的影响尚未凸显。直至互联网蓬勃兴起,文件传输与存储突破了设备与系统的边界 —— 当 Windows 以 GBK 编码的文件传到采用 MacRoman 编码的 macOS 时,乱码问题开始频繁出现;甚至在论坛等公共场景中,不同设备发布的内容相互查看时,大量字符都因编码不统一而显示错乱。这一系列痛点,才真正推动了 UTF 编码的普及。
四、UTF-8编码方案
互联网的规模化推动了编码统一,UTF-8 作为 Unicode 最主流的实现方案,在网络场景中占据绝对主导地位 —— 相比之下,UTF-16(2/4 字节可变)与 UTF-32(4 字节固定)几乎不用于网络传输。
utf8其核心特性为变长编码(1~4 字节 / 符号),编码规则可概括为:
-
单字节:首位 0,后 7 位直接映射 Unicode 码(兼容 ASCII);
-
n 字节(n>1):首字节前 n 位为 1、第 n+1 位为 0,后续字节前两位固定为 10,剩余位承载 Unicode 码的有效信息。
上面的定义过于简洁让我们细致解读下
既然utf8是1到4可变字节存储那我们就从1到4字节逐个分析存储过程
1、UTF-8单字节存储Unicode字符
如上定义所示utf8单字节存储范围是 0x0 到 0x7F 用十进制表示就是0到127并且最高位恒定为0,这与ASCII码使用的范围完全相同。因此utf8单字节存储Unicode的二进制写法就是 00000000 ~ 01111111 文章的末尾我会给出ASCII码十进制与字符对照表这里不再赘述
2、UTF-8双字节存储Unicode字符
当utf8用双字节存储Unicode码点时它的区间范围就是128(用十六进制表示就是0x80)到2047(用十六进制表示就是0x7FF)。接下来让我们具体解析下utf8双字节填充规则
第一步、确定utf8双字节最小值。
因为单字节的范围是0到127所以双字节的起始端必然是从128开始的。因此我们确定了双字节最小值128的二进制为10000000
第二步、按双字节规则分配有效位
utf8 双字节编码的有效位共 5 + 6 = 11 位(第一个字节的 xxxxx 是 5 位,第二个字节的 xxxxxx 是 6 位),需将 128 的二进制值填入这些位置。先将128的二进制 10000000 补足为 11 位(左侧高位补0因为左侧补0不影响原二进制的值):00010000000
第三步、填充 utf8 编码格式
-
第一个字节格式:
110xxxxx→ 填入00010000000前 5 位有效位00010→ 结果11000010 -
第二个字节格式:
10xxxxxx→ 填入00010000000后 6 位有效位000000→ 结果10000000
最终utf8双字节编码最小值128合起来表示就是 1100001010000000 那么最大值就很简单了。将有效位11个x都用1替换最终utf8双字节编码最大值为 1101111110111111 表示的二进制为11111111111(11个1够有效位不需要左侧补0)换算成十进制就是2047
3、UTF-8三字节存储Unicode字符
三个字节表示的范围是:0x800 到 0xFFFF 也就是十进制的2048和65535
2048的二进制为100000000000 将其补充到 4+6+6=16位 0000100000000000
再填充到utf8三个字节里面为 111000001010000010000000
65535的二进制为1111111111111111 够有效位不需要左侧补0
再填充到utf8三个字节里面为 111011111011111110111111
4、UTF-8四字节存储Unicode字符
四个字节表示的范围是:0x10000 到 0x10FFFF 也就是十进制的65536和1114111
65536的二进制为10000000000000000
将其补充到3+6+6+6=21位 000010000000000000000
再填充到utf8四个字节里面为 11110000100100001000000010000000
1114111的二进制为100001111111111111111 够有效位不需要左侧补0
再填充到utf8四个字节里面为 11110100100011111011111110111111
五、UTF-16编码方案
UTF-16 是 Unicode 码点的一种变长编码实现(使用 2 或 4 字节存储),其填充规则围绕码点范围划分,核心是通过 “基本平面” 和 “辅助平面” 来区分决定存储的长度,具体如下。
1、基本平面(BMP,U+0000 – U+FFFF)对应十进制范围0到65535
如果码点范围在上述区间则直接用2个字节存储无需额外处理
例如 “你” 字的Unicode码点为 U+4F60 转成二进制为 100111101100000
utf16存储为 100111101100000 不做任何处理
2、辅助平面(U+10000 – U+10FFFF)对应十进制范围65536到1114111
如果码点范围在上述区间则启用4个字节存储
由于辅助平面存储计算相对复杂且应用较少因此我们不做详细分析仅给出JavaScript计算公式如下
//高位计算公式
H = Math.floor((c-0x10000) / 0x400)+0xD800
//低位计算公式
L = (c - 0x10000) % 0x400 + 0xDC00
//里面的变量c就是我们的码点
例如:“😀” 笑脸的码点为 U+1F600
高位 = Math.floor((0x1F600 - 0x10000) / 0x400) + 0xD800
低位 = (0x1F600 - 0x10000) % 0x400 + 0xDC00
高位十进制结果为:55357
低位十进制结果为:56832
再用JavaScript输出为
String.fromCharCode(55357, 56832)
//输出结果为:😀
六、UTF-32编码方案
UTF-32 对 所有 Unicode 合法码点(U+0000 – U+10FFFF)均采用 4 字节(32 位二进制) 固定长度存储,码点的二进制值直接填充到 32 位中,高位补 0 补足 32 位即可—— 无需任何复杂转换、拆分或偏移计算,是 “一一对应” 的编码方式。Unicode 合法码点上限是 U+10FFFF,其 32 位二进制填充后仍有高位空闲,这些空闲位固定填 0,不影响编码有效性。
我们以大写字母 “A” 为例分析下utf32编码方案
A 的Unicode码点为:U+0041 对应十进制65 二进制 1000001(仅7位)
用utf32存储左侧高位补 32-7=25个0 如下所示
00000000 00000000 00000000 01000001
然后直接存储
七、总结:三者的递进关系
ASCII(解决英语编码)→ 不够用 → Unicode(统一全球符号码点)→ 需落地 → UTF 家族(把 Unicode 码点转成高效的二进制),是典型的“问题→方案→优化方案”的先后迭代顺序。建议大家读懂utf8、记住utf16转换公式和了解utf32即可。最后留给大家一个小问题:UTF-8 的多字节编码中,计算机是如何判断多个字节是合起来表示一个符号,而非将每个字节单独解析的呢?
八、ASCII码表