字符编码

计算机只能处理数字,如果要处理文本,就必须先把文本编码转换为数字才能处理(图片、语音、视频…,这些多媒体文件也一样)。

摩尔斯电码

古代的长途通讯主要是用信鸽、骑马送报、烽烟等方式进行。直到 1837 年,世界第一条电报诞生 – 当时美国科学家莫尔斯尝试用一些“点”和“划”来表示不同的字母、数字和标点符号,这套表示字符的方式也被称为“摩尔斯电码”。

摩斯电码中定义了 A-Z、a-z、0-9、?、/ 这些字符:

字符 电码符号
A · -
B - · · ·
1 · - - - -
2 · · - - -
? · · - - · ·
/ - · · - ·
  • 电报的工作原理

“点”对应短的电脉冲信号,“划”对应长的电脉冲信号,这些信号传到对方,接收机把短的电脉冲信号翻译成“点”,把长的电脉冲信号转换成“划”,译码员根据这些点划组合就可以译成英文字母,从而完成了通信任务。这里把字符表示为“点”或“划”并对应为电脉冲信号的过程既是 ⌈编码⌋,而译码员把接收机接收到的脉冲信号转化成点划后译成字符的过程即为 ⌈解码⌋。

字符集和编码方案

莫尔斯编码中包含了大小写英文字母和数字等符号。这里的每一个符号其实就是“字符”,而这所有的字符的集合就叫做“字符集”,“点”和“划”与字符之间的对应关系即可以称为“字符编码”。计算机诞生之后,将摩斯电码中的“点”和“划”换成了以 8 位字节二进制流的方式表示,如数字 1(字符、字型)的二进制流是 0011 0001,对应的十进制流是 49,十六进制流是 31。

在讲解字符编码前,需要理解以下一些概念:

  • 比特、字节

比特 (bit) 也可称为“位”,是计算机信息中的最小单位,是 binary digit(二进制数位) 的缩写,指二进制中的一位。字节 (Byte) 计算机中信息计量的一种单位,一个位就代表 “0” 或 “1”,每 8 个位(bit)组成一个字节(Byte)。

  • 字符、字符集

字符 (Character) 是文字与符号的总称,可以是各个国家的文字、标点符号、图形符号、数字等。字符集 (Character Set) 就是字符的集合。表示涵盖了哪些字符,每个字符都有一个数字序号(ID),叫码点(code point)。字符集往往是一张码表,它规定了文字与数字的一一对应关系。

  • 编码、解码

编码 (Encoding) 是信息从一种形式或格式转换为另一种形式的过程。解码 (Decoding) 是编码的逆过程。

  • 字符编码方案

字符编码(Character Encoding)方案指的是按照何种规则存储字符。字符要怎样编码成二进制字节序,即一个数字序号(ID),要编码成几个字节,字节顺序如何。

在 Unicode 之前,一个字符集只使用一种编码方案(直接存储码点),对于 ASCII、GB 2312、Big5、GBK、GB 18030 这些的遗留方案来说,既是字符集又是编码方案。Unicode 中,字符集和编码是明确区分的,Unicode 是统一的字符集标准,它有几种可选的编码方案,包括 UTF-8、UTF-16、UTF-32,为了节省空间,不直接存储码点。

  • 字形字体

字符编码只是对字符的抽象,不规定字符具体的字体字形,这个由渲染程序实现。

根据数字序号(ID),调用字体存储的字形,就可以在页面上显示出来了,这就是字形字体(Font)。

ASCII

ASCII (American Standard Code for Information Interchange) 美国信息交换标准代码,是基于拉丁字母的一套电脑编码系统,主要用于显示现代英语和其他西欧语言。它是现今最通用的单字节编码系统,并等同于国际标准 ISO/IEC 646,1967 年被正式公布。

1946年,世界第一台计算机诞生,发明计算机的人用 8 个晶体管的“通”或“断”组合出一些状态来表示世间万物。

8 个晶体管的“通”或“断”即可以代表一个字节。刚开始,计算机只在美国使用,所有的信息在计算机最底层都是以二进制(“0”或“1”两种不同的状态)的方式存储,而 8 位的字节序一共可以组合出 2 的 8 次方 共 256 种状态,即 256 个字符(十进制编码为 0 - 255,二进制编码为 0 - 11111111,采用 8 bit 编码,不足的前面补 0,即 00000000 - 11111111),这对于当时的美国已经是足够的了,他们尝试把字母、数字、符号和一些终端的动作用 8 位(bit)来组合。

  • 控制字符
Bin(二进制) Oct(八进制) Dec(十进制) Hex(十六进制) Abbreviation(缩写) 字符解释
0000 0000 0 0 00 NUL 空字符
0000 0001 1 1 01 SOH 标题开始
0001 1111 37 31 1F US 单元分隔符
01111111 177 127 7F DEL (delete) 删除
  • 可显示字符
Bin(二进制) Oct(八进制) Dec(十进制) Hex(十六进制) Glyph(字形、字符) 字符解释
0010 0000 40 32 20 space 空格
0010 0001 41 33 21 ! 叹号
01111110 176 126 7E ~ 波浪号

这套标准一共规定了 128 个字符(0x00~0x7f,0 ~127)的编码,被称为 ASCII 编码。共包括 33 个(包括前 32 个 和第 127 个)控制字符(Control Code – 控制字符又被称为 Function Code – 功能字符,它们都是不可见的)和 95 个可显示字符(Printable Code - 可打印字符),只用到了一个字节中的后 7 位,最前面一位统一规定为 0。

注:完整的 ASCII 码表

扩展 ASCII 码

虽然刚开始计算机只在美国使用,128 个字符的确是足够了,但随着科技惊人的发展,欧洲国家、亚洲国家也开始使用上计算机了,128 个字符明显不够,比如法语中字母上方有注音符号,至于亚洲国家的文字,使用的符号就更多了,汉字就大多 10 万左右。

将 ASCII 中没有利用的最高位利用起来,把原来的 7 位扩充到 8 位(兼容 ASCII),不够就再加一个字节(比如中文),这套编码范围从 0x80~0xFFFF 的编码叫扩展 ASCII 码(各类扩展 ASCII 码在 Windows 中被统称为 ANSI 编码)。

EASCII

欧洲国家将 ASCII 最前面的一位利用起来,这样就多出了 128 位字符,其中 0 – 127 表示的符号与 ASCII 是一样的,不一样的只是 128~255 (0x80 ~ 0xFF) 的这一段。这套编码标志被称为 EASCII(Extended ASCII,延伸美国标准信息交换码),共由 256 个字符组成。比较著名的 EASCII 有 CP437(英文版 Windows 系统默认的字符编码)和 ISO/8859-1(Latin-1)

GB2312

作为一种象形文字,汉字是世界上包含符号最多的文字,这不同于通过字母组合的西文单词。据不完全统计,汉字共包含了古文、现代文字等近 10 万个文字,就是我们现在日常用的汉字也有几千个,那么对于只包含 256 个字符一个字节进行编码的 EASCII 码显然不能满足需求。

在 Unicode 之前,一共存在过 3 套中文编码标准:大陆的 GB2312-80(后来又被扩展成 GBK、GB18030)、台湾的 Big5、香港的 HKSCS。

国家标准化管理委员会在 1981 年,正式制订了中华人民共和国国家标准简体中文字符集,项目代号为 GB2312 或 GB2312-80。GB2312 是对 ASCII 的简体中文扩展,只收录简化汉字,以及一般常用字母和符号,共收录有 7445 个字符,其中简化汉字 6763 个,字母和符号 682 个(全角),主要通行于中国大陆地区和新加坡等地。

  • 区位码

汉字用两个字节表示,理论上,两个字节可以表示 256×256=65536 种不同的符号,作为汉字编码表示的基础是可行的。但考虑到汉字编码与其它国际通用编码,如 ASCII 西文字符编码的关系,我国国家标准局采用了加以修正的两字节汉字编码方案,只用了两个字节的低 7 位。这个方案可以容纳 128×128=16384 种不同的汉字,但为了与标准 ASCII 码兼容,每个字节中都不能再用 32 个控制功能码和码值为 32 的空格以及 127 的操作码。所以每个字节只能有 94 个编码。这样,双七位实际能够表示的字数是:94×94=8836 个。

GB2312 将所收录的字符分为 94 个区,编号为 01 区至 94 区;每个区收录 94 个字符,编号为 01 位至 94 位。这种表示方式也称为区位码。GB2312 的每一个字符都由与其唯一对应的区号和位号所确定。例如:汉字“啊”,编号为 16 区 01 位,它的区位码就是 1601(由 16、01 两部分组成,不能读做一千六百零一)。

分区:

区号 字数 字符类别
01 94 一般符号
02 72 顺序号码
03 94 拉丁字母
04 83 日文假名
05 86 Katakana
06 48 希腊字母
07 66 俄文字母
08 63 汉语拼音符号
09 76 图形符号
10-15 - 备用区
16-55 3755 一级汉字,以拼音为序
56-87 3008 二级汉字,以笔划为序
88-94 - 备用区

GB2312 字符集和编码对照表:

第 01 区 +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F
A1A0 ˉ ˇ ¨
A1B0
A1C0 ± × ÷
第 16 区 +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F
B0A0

注:GB2312 兼容 ASCII 是通过混用的方式,不像 UTF-8 那种重新收录(虽然 GB2312 有重新收录 ASCII 吗,但都是 2 个字节的,即所谓的全拼)

  • 国标码

国标码是汉字信息交换的标准编码。区位码将 ASCII 码原来所代表的东西都占用了,不兼容 ASCII,不能直接存储。于是将区位码偏移 32 位(32D = 20H,将十六进制的区位码,区码和位码分别加上 20H),避开 ASCII 前面的控制字符部分,这样就兼容了 ASCII 前 32 位了。

1
2
3
4
5
6
7
8
9
10
# 区码和位码的十进制转十六进制
16..toString(16); // 0x10
1..toString(16); // 0x1

# 分别加 20H
(0x10 + 0x20).toString(16); // 0x30
(0x1 + 0x20).toString(16); // 0x21

# 就得到了国标码
1601D -> 1001H -> +20H -> 3021H

为什么只偏移 20H,不直接偏移 80H?其实,GB2312 虽说是对中文编码,但是里面有对 ASCII 码中的 26 个英文字母和一些特殊符号的重新编码,这么做的目的就是要覆盖掉 ASCII 码 32 位后的符号和英文字母部分,不对 ASCII 兼容。而对于 ASCII 码中前 32 个控制字符则继续沿用(为什么不全部覆盖),所以保留前 32 字符,就需要将汉字编码向后偏移 32(20H),这也就是区位码要加上 20H 得到国标码,这就是 GB2312 的编码规范。

而这样产生一个弊端,ASCII 码下的英文在 GB2312 下乱码,微软为了解决这个问题,将字节的最高位设为 1,因为 ASCII 中使用 7 位,最高位为 0,这样就区分开了 ASCII 和 GB2312,这也是为什么要加上 8080H。

也就是说,国标码才是 GB2312 的编码标准,后来的机内码是微软为了兼容 ASCII 采用的方式,本质上是修改了 GB2312 的编码标准,而这种方法最后产生的编码最后就被一些教科书称为内码。

  • 机内码

机内码又叫内码,GB2312 的机内码是为了解决国标码与 ASCII 码不兼容问题出现的。

国标码前后字节的最高位为 0,注定会与 ASCII 码冲突,虽然偏移 32 位,解决了与 ASCII 中控制字符的冲突,但是没解决 ASCII 码 32 位之后的字符兼容,不可能在计算机内部直接采用的。如“啊”字,国标码为 30H 和 21H,而西文字符 “0”和 “!” 的 ASCII 也为 30H 和 21H,现假如内存中有两个字节为 31H 和 23H,这到底是一个汉字,还是两个西文字符,就出现了二义性。

为了解决上面问题,计算机内部使用机内码来处理 GB2312 字符。机内码采用变形国标码,将国标码的每个字节都加上 128(10000000B = 128D = 80H),即将两个字节的最高位由 0 改 1,其余 7 位不变,这样,两个大于 127 的二进制序列连在一起时,就表示一个汉字。台湾的 Big5 方法也是类似的,都是用 0x80 到 0xFF(128D-255D、10000000B-11111111B)这个区间。

机内码的这种编码方式就是所谓的 EUC 编码方法(具体来说是 EUC-CN,即在每个区位加上 0xA0 来表示,区和位分别占用一个字节),以便兼容于 ASCII。对于标准 ASCII 字符(小于 127 的字符,即 0x00 - 0x7F)其意义与原来相同,还是按一个字节进行编码。

回到上面例子,“啊”字的国标码为 3021H,前字节为 00110000B,后字节为 00100001B,高位改 1 为 10110000B 和 10100001B 即为 B0A1,因此,“啊”字的机内码就是 B0A1:

1
2
3
4
5
6
7
8
9
# 国标码上加 0x80 (0b100000000)

# 区字节和位字节的十六进制转为二进制
0x30.toString(2); // 110000
0x21.toString(2); // 100001

# 区字节和位字节二进制的高位补 1(十六进制就是加 80H)就得到机内码
0b10110000.toString(16); // B0
0b10100001.toString(16); // A1
1
2
3
# 区位码上直接加 0xA0
16 + 0xA0; // B0
1 + 0xA0; // A1

机内码中,前面的一个字节称之为“高位字节/区字节”,从 0xA1 到 0xF7(把 01-87 区的区号加上 0xA0),后面一个字节称为“低位字节/位字节”,从 0xA1 到 0xFE(把 01-94 加上 0xA0)。

1
2
# 以下字符保存为 GB2312 时,file length (in byte) 为 6 个字节。这说明 GB2312 中,前 127 位还是是 ASCII 的 单字节编码,127 位以后是双字节的 GB2312 编码
2,啊,
  • 区位码、国标码、机内码的转换

区位码、国标码、机内码在十六进制下的转换:

1
2
国标码 = 区位码 + 20H
机内码 = 国标码 + 80H = 区位码 + A0H # (0x80 + 0x20).toString(16) = A0H

举例,已知机内码 B0A1,求区位码。

1
2
3
4
5
# 解法 1(注意:计算时,区字节和位字节分开减,(0xB0 - 0xA0).toString(16) = 10H)
B0A1H-A0A0H = 1001H = 1601D

# 解法 2
B0A1H-8080H = 3021H(国标码),3021H-2020H = 1001H = 1601D
  • 全角和半角

ASCII 中 127 号以下的那些字符叫“半角”字符,127 号以后,像 GB2312 中的图形符号、汉字字符、英文字符都是“全角”字符。一般的系统命令是不用全角字符的,只是在作文字处理时才会使用全角字符。

  • 通过机内码输入 GB2312/GBK 字符

Windows 小键盘上,用 Alt + GBK 字符的十进制机内码,能够输入任何 GBK 字符(含 ASCII 字符,因为 GBK 兼容 ASCII)。

1
2
# “啊”在 GBK 下的机内码的十进制是 45217。按住 Alt,分别在小键盘上输入 45217,再松开 Alt,即可输入“啊”
b0a1H = 45217D

附:字符转 GB2312 十六进制编码:encodeToGb2312

1
2
# “我爱你”这三个字的机内码就是 52946 45230 50403
encodeToGb2312("我"); // CED2,parseInt('CED2', 16) 结果为 52946

GBK、GB18030

GB2312 字符有限,不够用(对于人名、古汉语等方面出现的罕用字,GB2312 就不能处理),于是干脆不再要求低字节一定是 127 号之后的内码,只要第一个字节是大于 127 就固定表示这是一个汉字的开始,不管后面跟的是不是扩展字符集里的内容。这个编码方案被称为 GBK 标准,GBK 包括了 GB2312 的所有内容,同时又增加了近 20000 个新的汉字(包括繁体字)和符号。 后来 GBK 加了几千个新的少数民族的字,扩成了 GB18030。

GB18030 是 GBK 的超集,GBK 是 GB2312 的超集。GBK 在 Windows、Linux 等多种操作系统中被实现。

这一系列汉字编码的标准通称叫做 “DBCS”(Double Byte Charecter Set 双字节字符集)。在 DBCS 系列标准里,最大的特点是两字节长的汉字字符和一字节长的英文字符并存于同一套编码方案里,因此写的程序为了支持中文处理,必须要注意字串里的每一个字节的值,如果这个值是大于 127 的,那么就认为一个双字节字符集里的字符出现了(一个汉字算两个英文字符,strlen(“2,啊,”) = 6)。

Unicode

ASCII 码和扩展 ASCII 码(ESCII、GBK、JIS…)的时代是一个混乱的时代,没有统一标准,各国编码标准互不兼容,同一个二进制数字可以被解释成不同的符号(Eg:GB2312 编码下的中文,得到二进制序列到了 Big5 编码下解析时,这些二进制序列全部有了新的意义,导致乱码。编码和解码方案的不统一是乱码的主要原因)。

制定一个包括了地球上所有文化、所有字母和符号的字符集编码方案势在必行。历史上存在两个独立的字符集组织来做这件事:ISO/IEC(国际标准化组织,国际电工委员会) 和 Unicode 协会(一个由多个软件制造商组成的协会),前者开发了“ISO 10646 项目”,后者开发了“Unicode 项目”。

1991 年前后, 两个项目的参与者都认识到, 世界上不需要两个不同的通用字符集,所以双方合并工作成果,为创立一个单一编码表而协同工作。虽然两个项目都独立地公布各自的标准, 但 Unicode 协会和 ISO/IEC JTC1/SC2 都同意保持 Unicode 和 ISO 10646 标准的码表兼容,从 Unicode 2.0 开始,Unicode 采用了与 ISO 10646-1 相同的字库和字码,ISO 也承诺,ISO 10646 将不会替超出 U+10FFFF 的 UCS-4 编码赋值,以使得两者保持一致。

1
2
3
4
5
6
7
8
9
10
11
12
# 发展过程

ASCII
|
|
ESCII、GB2312、GBK...
|
|
UCS (UCS-2、UCS-4)
|
|
Unicode (UTF-8、UTF-16、UTF-32)

Unicode 是国际组织制定的可以容纳世界上所有文字和符号的字符编码方案,1990 年开始研发,1994 年正式公布。目前的 Unicode 字符分为 17 组编排,每组称为一个平面(Plane),每平面拥有 65536(2^16)个码位,共 1114112 个(65536 * 17)码位。

每一个码位都可以用 16 进制 xy0000 到 xyFFFF 来表示,这里的 xy 是表示一个 16 进制的值,从 00 到 10。平面 0(xy 为 00 时)被称为 BMP (基本多文种平面, Basic Multilingual Plane),它包含了最常用的字符(兼容 ISO-8859-1 字符集),码点范围从 U+0000 到 U+FFFF。剩下还有 16 个“辅助平面”,码点范围从 U+010000 一直到 U+10FFFF。

平面 始末字符值 中文名称 英文名称 备注
0号平面 U+0000 - U+FFFF 基本多文种平面 BMP 绝大部分日常现代文字,比如英文、中文、日语、法语等
1号平面 U+10000 - U+1FFFF 多文种补充平面 SMP 古代字体、音符、扑克牌、麻将
2号平面 U+20000 - U+2FFFF 表意文字补充平面 SIP CJKV 的增补文字,比如《康熙字典》中出现的非现代文字
3号平面 U+30000 - U+3FFFF 表意文字第三平面 TIP 可能存放甲骨文、金文、小篆
4~13号平面 U+40000 - U+DFFFF (尚未使用) 可能存放甲骨文、金文、小篆
14号平面 U+E0000 - U+EFFFF 特别用途补充平面 SSP 特殊的控制字符,比如字形变换选取器
15号平面 U+F0000 - U+FFFFF 保留作为私人使用区(A区) PUA-A 随意使用的私人区域
16号平面 U+100000 - U+10FFFF 保留作为私人使用区(B区) PUA-B 随意使用的私人区域

在 Unicode 5.0.0 版本中,已定义的码位只有 238605 个,分布在平面 0、平面 1、平面 2、平面 14、平面 15、平面 16。其中平面 15 和平面 16 上只是定义了两个各占 65534 个码位的专用区(Private Use Area),分别是 0xF0000-0xFFFFD 和 0x100000-0x10FFFD。所谓专用区,就是保留给大家放自定义字符的区域,可以简写为 PUA。

平面 0 也有一个专用区 0xE000-0xF8FF,有 6400 个码位。另外平面 0 的 0xD800-0xDFFF,共 2048 个码位,是一个被称作代理区(Surrogate)的特殊区域。代理区的目的是用两个 UTF-16 字符表示 BMP 以外的字符,具体参考 UTF-16 章节。

如前所述在 Unicode 5.0.0 版本中,238605-65534*2-6400-2408=99089。余下的 99089 个已定义码位分布在平面 0、平面 1、平面 2 和平面 14 上,它们对应着 Unicode 目前定义的 99089 个字符,其中包括 71226 个汉字。平面 0、平面 1、平面 2 和平面 14 上分别定义了 52080、3419、43253 和 337 个字符。平面 2 的 43253 个字符都是汉字。平面 0 上定义了 27973 个汉字。

这 17 个平面结合起来至少需要占据 21 位的空间(0x10FFFF.toString(2).length),也就是差不多 3 个字节(24位),而辅助平面实际上是用 4 个字节表示,方便以后向后扩展。

Unicode 字符集,中文范围 4E00-9FA5(BMP 中):

U+ 0 1 2 3 4 5 6 7 8 9 A B C D E F
4e00
4e10
4e20 - - - - - - - - - - - - - - - -
1
2
3
'一'.charCodeAt().toString(16);    // 4e00
'丑'.charCodeAt().toString(16); // 4e11
'丟'.charCodeAt().toString(16); // 4e1f

Unicode 包含了世界上所有的语言字符的字符集,Unicode 给每个字符分配一个唯一的 ID/码点 (中文版) — 用一个十六进制数的前面加上 U+ 的 Unicode 表示,例如 “U+0041” 代表字符 “A” 。

注:Widows 下可通过 charmap (Win + R,输入 charmap) 来查看一个字符的 Unicode 编码。

UCS

UCS 通用字符集 (Universal Character Set),是由 ISO 10646 标准所定义的标准字符集,其所对应的编码方式为 UCS-2、UCS-4。

UCS-2 用 2 个字节为字符编码,可以容纳的字符数为 65536 个(2^16),所以 UCS-2 只能编码 BMP 范围内的字符(在 Unicode 中被 UTF-16 所取代)。UCS-4 用 4 个字节为字符编码,实际上只用了 31 位,最高位必须为 0,它可以容纳的字符数为 2147483648 个(2^31,20 多个亿),但实际使用范围并不超过 0x10FFFF,并且为了兼容 Unicode 标准,ISO也承诺将不会为超出 0x10FFFF 的 UCS-4 编码赋值。UCS-2、UCS-4 都是定长的,无论是半角英文字母,还是全角汉字。

在实际使用中,UCS 已被 Unicode 所替代,二者的字符集一样,Unicode 的编码方案比 UCS 更灵活(UTF-32 等同于 UCS-4,UTF-16 是 UCS-2 的父集,UTF-8 在 UCS 中没有实现)。

UTF

UTF (Unicode 转换格式,Unicode Transformation Format) 是 Unicode 的实现方式,是 Unicode 在计算机中的存储和传输标准。其实就是不改变字符集中各个字符的代码的情况下,建立一套新的编码方式,把字符的代码通过这个编码方式映射成传输时的编码,主要目的就是在使用 Unicode 字符集保持通用性的同时节约流量和硬盘空间。

在 Unicode 中,字符集和编码方案是分离的,Unicode 只是一个字符集,只规定了字符的码点,没有规定这个码点应该如何存储(8 bit、16 bit、32 bit)。像“Unicode 用两个字节编码”、“Unicode 中一个字符等于两个字节”,这些说法是不成立的。

之所以不对 Unicode 码点直接编码,是从“分词”和“空间”上考量的。比如,汉字“严”的 Unicode 是十六进制数 4E25,转换成二进制数足足有 15 位(100111000100101),也就是说这个符号的表示至少需要 2 个字节,表示其他更大的符号,可能需要 3 个字节或者 4 个字节。

  • 分词

如何才能区别 Unicode 和 ASCII?计算机怎么知道 2 个或 4 个字节表示一个符号,而不是分别表示 2 个或 4 个符号呢?

  • 空间

要编码 Unicode 那么多字符,至少要用到 2 个字节,而 UCS-2、UCS-4 这种定长的编码方案都不适合在计算机中存储和传输,原先 ASCII 码中的 0 - 127 位,如果直接使用 UCS 的码点直接存储、传输,对英语国家是种巨大的浪费(UCS-2 下 1 GB 变成 2 GB,UCS-4 下 1 GB 变成 4 GB)。

为了解决上述问题,对 Unicode 的字符码再做二次编码,这就诞生了 UTF-8、UTF-16、UTF-32 编码标准。

UTF-32

Unicode 最直观的编码方式是每个码点使用 4 个字节表示,字节内容与码点一一对应。这种编码方法就叫做 UTF-32。

1
2
# 4 个字节表示一个字符,比如,字母 a 的 UTF-32 为:
0x00000061

优点:转换规则简单直观,查找效率高,时间复杂度o(1)。缺点:浪费空间,同样内容的英语文本,它会比 ASCII 编码大四倍。这个缺点很致命,导致实际上没有人使用这种编码方法,HTML 5 标准就明文规定,网页不得编码成 UTF-32。

UTF-32 可以看作是 UCS-4,或者它的子集(虽然 UCS-4 本身可以编码到 0x7FFFFFFF,但是 ISO 承诺,ISO 10646 将不会替超出 0x10FFFF)。

UTF-8

UTF-8 是一种变长的编码方法(变长节省空间,一般用于外码,来进行存储和传输,而定长方便实现和处理效率高,一般用于内码,比如 JavaScript 中),字符长度从 1 个字节到 4 个字节不等。越是常用的字符,字节越短,最前面的 128 个字符,只使用 1 个字节表示,与 ASCII 码完全相同。由于 UTF-8 这种节省空间的特性,导致它成为互联网上最常见的网页编码。

UTF-8 作为最流行的编码方案,其优点有:良好的多语种支持(相对 GBK 等跟语种绑定的编码方式);兼容 ASCII;没有字节序的问题;以英文和西文符号比较多的场景下(HTML/XML),编码较短。

  • UTF-8 转码规则

1)对于单字节的符号,字节的第一位设为 0,后面 7 位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的;
2)对于 n 个字节的字符(n>1),第一个字节的前 n 位设为 1,第 n+1 位设为 0,后面字节的前两位都设为 10,这 n 个字节的其余空位填充该字符 Unicode 码,高位用 0 补足。

UTF-8 以字节为单位对 Unicode 进行编码,从 Unicode 到 UTF-8 的编码方式如下(字母 x 表示可用编码的位):

Unicode 编码(十六进制) UTF-8 字节流(二进制)
000000-00007F 0xxxxxxx
000080-0007FF 110xxxxx 10xxxxxx
000800-00FFFF 1110xxxx 10xxxxxx 10xxxxxx
010000-10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

跟据上表,解读 UTF-8 编码非常简单。如果一个字节的第一位是 0,则这个字节单独就是一个字符;如果第一位是 1,则连续有多少个 1,就表示当前字符占用多少个字节。下面,还是以汉字“严”为例,演示如何实现 UTF-8 编码。

已知“严”的 Unicode 是 4E25,根据上表,可以发现 4E25 处在 000800-00FFFF 范围内,因此“严”的 UTF-8 编码需要三个字节,即格式是 “1110xxxx 10xxxxxx 10xxxxxx”。然后,从“严”的最后一个二进制位开始(100 1110 0010 0101),依次从后向前填入格式中的 x,多出的位补 0。这样就得到了,“严”的 UTF-8 编码是”11100100 10111000 10100101”,转换成十六进制就是 E4B8A5。

UTF-16

UTF-16 编码介于 UTF-32 与 UTF-8 之间,同时结合了定长和变长两种编码方法的特点,大部分常用字符都以固定的 2 个字节长度储存,是一种变长表示。

在 UTF-16 中,基本平面(BMP,U+0000 到 U+FFFF)的字符使用 2 个字节编码,辅助平面(U+010000 到 U+10FFFF)的字符使用 4 个字节编码。和 UTF-32 一样,UTF-16 也不兼容 ASCII 编码(都 Unicode 时代了,为什么还要兼容 ASCII)。

UTF-16 的优点:UTF-16 的前身 UCS-2 是定长的,处理起来容易,遍历速度要快,这使得 UTF-16 在编程语言内部被大量应用(Java、JavaScript、Windows 操作系统内部…)。另外空间上,UTF-16 在编码中文时比 UTF-8 要少 50%(BMP 的字符,以 UTF-16 编码时使用 2 字节,以 UTF-8 编码时使用 1 至 3 字节。超出 BMP 的字符,以 UTF-16 或 UTF-8 编码都需要 4 字节)。

  • 与 UCS-2 的区别

UTF-16 可看成是 UCS-2 的父集,在 0x0000 到 0xFFFF 码位范围内,UTF-16 与 UCS-2 所指的是同一的意思(严格的说这并不正确,因为在 UTF-16 中从 U+D800 到 U+DFFF 的码位不对应于任何字符,而在使用 UCS-2 的时代,U+D800 到 U+DFFF 内的值被占用),当引入辅助平面字符后,对 0x10000 之外的字符,UCS-2 没有与之对应的编码,UTF-16 实现了编码。

  • UTF-16 转码规则

Unicode 码点转 UTF-16,首先区分这是基本平面字符,还是辅助平面字符。对于基本平面的字符,直接将码点转为对应的十六进制形式,以 2 个字节来编码。

1
U+597D = 0x597D

对于辅助平面的字符,以代理对 (surrogate pair) 的形式用 4 个字节来编码。

Surrogate pair 是 UTF-16 中用于扩展字符而使用的编码方式,其采用四个字节来表示一个字符。具体的做法是取 BMP 范围里的 0xD8000xDBFF 和 0xDC000xDFFF 的 code point (总范围为 D800-DFFF,这部分码点在 BMP 内是保留的,不映射到任何字符),前者称为高位代理 high surrogates,后者称为低位代理 low surrogates,一个 high surrogate 接一个 low surrogate 拼成四个字节表示超出 BMP 的字符。

两个 surrogate range 都是 1024 个 code point,所以 surrogate pair 可以表达 1024 x 1024 = 1048576 = 0x100000 个字符,这就是 Unicode 的字符集范围上限是 0x10FFFF 的原因 ((0x100000 + 0xffff).toString(16) = 0x10ffff)。为了照顾 UTF-16 以及一大堆采用了 UTF-16 的语言、操作系统(比如 Windows),这个上限不能突破。

UTF-16 对于辅助平面的编码转换,Unicode 3.0 版给出了转码公式:

1
2
3
4
5
6
H = Math.floor((c - 0x10000) / 0x400) + 0xD800
L = (c - 0x10000) % 0x400 + 0xDC00

# 比如辅助平面上的 💩,码点为 U+1f4a9(JS 中表示为 '\u{1f4a9}''\ud83d\udca9'
H = Math.floor((0x1f4a9 - 0x10000) / 0x400)+0xD800 // 0xd83d
L = (0x1f4a9 - 0x10000) % 0x400 + 0xDC00 // 0xdca9

注:辅助平面之所以采用 Surrogate pair 的方式编码,而不直接用 4 个字节编码码点,目的是为了区分 BMP 里面 2 个字节的编码,要不然不知道怎么分词。

字节序

BOM (Byte Order Mark) — 字节顺序标记,用在文件的开头,用于标记大小端序的。

字节序就是数据在内存、磁盘、网络传输中存放的顺序,“多字节为编码单元”的编码方案都会存在大小端问题,比如 UTF-16 字符编码方案就分为 UTF-16BE 和 UTF-16LE,如果 BOM 是 FEFF,则表示大端序,如果 BOM 是 FFFE,则表示小端序。

1
2
Little-Endian  # 小端。就是高位字节排放在内存的高地址端,低位字节排放在内存的低地址端(符合人类的感官思维)
Big-Endian # 大端。就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端

Unicode 规范中定义,每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做“零宽度非换行空格”(ZERO WIDTH NO-BREAK SPACE),用 FEFF 表示。这正好是两个字节,而且 FF 比 FE 大 1。如果一个文本文件的头两个字节是 FE FF,就表示该文件采用大端方式,如果头两个字节是 FF FE,就表示该文件采用小端方式。

例如,一个“奎”的 Unicode 编码是 594E,“乙”的 Unicode 编码是 4E59。如果我们收到 UTF-16 字节流 “594E”,那么这是“奎”还是“乙”?如果 BOM 是大端序,那么代码点就应该是 594E,那么就是“奎”,如果 BOM 是小端序,那么代码点就应该是 4E59,就是“乙”了。

  • UTF-16 和 UTF-32 有字节序问题,UTF-8 和 GBK 没有

为什么 UTF-16 和 UTF-32 有字节序问题,而 UTF-8 和 GBK 没有?要想搞清楚这个问题,需要先搞清楚什么是 Code Unit。

Code Unit(编码单元/码元):是编码使用的最短比特组合单元。UTF-8 中一个编码单元是 8 bit 长,UTF-16 中一个编码单元是 16 bit 长,UTF-32 是 32 bit(UTF 后边的数字代表的就是码元的大小)。换一种说法就是 UTF-8 的是以一个字节为最小单位的,UTF-16 是以两个字节为最小单位的。

单个码元可以表示完整的码点,也可以表示码点的一部分。例如,雪人字形(☃)这个码点,在 UTF-8 中需要 3 个码元,在 UTF-16 中需要 1 个。

以单字节为编码单元的 UTF-8、GB2312 不存在字节序问题,因为字节顺序已经规定好了,不存在谁在高位、谁在低位的问题。比如 UTF-8 的处理过程是这样的:读入第一个字节,该字节中包含了该 Unicode 字符总共用几个字节编码的信息,然后根据上述信息再读入接下来的字节,由此完成一个字符的解码,以此类推。因此整个 Unicode 文件对解码器来说只是一个字节(8 bit)流,所以不涉及字节序的问题。

而以多字节为编码单元的 UTF-16、UTF-32 中,就需要考虑字节序的问题了。比如,以 2 个字节为编码单元的 UTF-16,这 2 个字节哪个存高位哪个存低位(使用大端还是小端)。Unicode 规范中没有规定字节序,这导致各个平台和 CPU 的实现不一致,有的使用大端有的使用小端,所以必须使用 BOM 来区分。

注:JavaScript 中字符串的 length 属性返回的是 UTF-16 下字符串的 Code Unit 个数,而不是 Code Point 个数。

  • Windows 平台下特有的 UTF-8 width BOM

BOM 除了标记大小端序外,还可用在 Windows 系统中(记事本)用来标记编码方案。

UTF-8 本身不存在字节序问题,Windows 记事本中给 UTF-8 带上 BOM 是为了区分编码方案。在 Windows 记事本中一段二进制编码,如何确定它是 GBK 还是 BIG5 还是 UTF-16 还是 UTF-8呢,记事本的做法是在 TXT 文件的最前面保存一个标签,如果记事本打开一个 TXT,发现这个标签,就说明是 Unicode(其中 0xFF 0xFE 代表 UTF16LE,0xFE 0xFF 代表 UTF16BE,0xEF 0xBB 0xBF 代表 UTF-8),如果没有这个标签,那么就是 ANSI,使用操作系统的默认语言编码来解释。

Unicode 标准中 BOM 只是用来标记字节序,微软用 BOM 来标记编码方案是不标准的,这是 Windows 记事本特有的,带 BOM 的 UTF-8 不但多出 3 个字节,最关键是,这样的文件在 Windows 之外的操作系统里可能会带来问题,不含 BOM 的 UTF-8 才是标准形式。

UTF 编码 Byte Order Mark (BOM)
UTF-8 without BOM
UTF-8 with BOM EF BB BF
UTF-16LE FF FE
UTF-16BE FE FF
UTF-32LE FF FE 00 00
UTF-32BE 00 00 FE FF

注:可以通过一个 Hex Editor 来查看一个文本文件的 BOM。

关于 Windows 记事本再多说几句,Windows notepad 另存为时编码可以选择 ANSI、Unicode、Unicode big endian、UTF-8,其含义如下:

1
2
3
4
ANSI                # ANSI 是遗留(legacy)编码
Unicode # UTF-16 LE
Unicode big endian # UTF-16 BE
UTF-8 # UTF-8 width BOM

其中,ANSI 是遗留(legacy)编码,对应当前系统 locale 遗留编码,在不同语言系统中编码不同(简中下是 GBK、繁中是 Big5、日文 Windows 操作系统中代表 Shift_JIS),微软术语叫 code page。另外,关于 UTF-8,当涉及到跨平台兼容性时,请不要用记事本编辑 UTF-8 文件,应用专业的文本编辑器保存为不带 BOM 的 UTF-8。

注:记事本下,依次采用 ANSI,Unicode,Unicode big endian 和 UTF-8 编码方式保存”啊”,然后查看其文件大小(带 BOM 的 UTF-8 会比不带 BOM 的多出 3 个字节)和观察该文件的内部十六进制编码方式。

  • 主机字节序和网络字节序

主机字节序是不确定的,而网络字节序是确定的。

不同的主机有不同的字节序,如 x86 为小端字节序,Motorola 6800 为大端字节序,ARM 字节序是可配置的。

TCP/IP 在 RFC1700 中规定使用“大端”字节序为网络字节序,它与具体的 CPU 类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释。在不使用大端的计算机中,发送数据的时候必须要将自己的主机字节序转换为网络字节序(即“大端”字节序),接收到的数据再转换为自己的主机字节序。

字符的编码解码和编码转换

字符的编码解码

  • Unicode

JavaScript 中可通过 charCodeAt、escape 和 String.fromCharCode、unescape 来编解码 Unicode 字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 编码:字符转 Unicode 的十六进制编码
function encodeUnicode(str) {
let result = '';
for (let i = 0; i < str.length; i++) {
let point = ('00' + str.charCodeAt(0).toString(16)).slice(-4);

result += '\\u' + point;
}

return result;
}

encodeUnicode('严'); // \u4e25
1
2
3
4
5
6
7
# 解码
function decodeUnicode(str) {
str = str.replace(/\\/g, '%');
return unescape(str);
}

decodeUnicode('\u4e25'); // 严

注:JavaScript 中字符使用 UTF-16 存储,charCodeAt 和 escape 其实返回的其实是 UTF-16 十六进制编码,不过在 BMP 中,UTF-16 十六进制编码和 Unicode 的 Code Point 是一样的。另外,上面的方法不支持辅助平面上的字符。

  • UTF-8
1
2
3
4
5
# 编码:字符转 UTF-8 的十六进制编码
encodeURI('严'); // "%E4%B8%A5"

# 解码:UTF-8 字符的十六进制编码转字符
decodeURI('%E4%B8%A5'); // 严

编码转换

  • Unicode 和 UTF-8 互转
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function unicodeToUTF8(hex) {
if (hex >= 0x00000000 && hex <= 0x0000007F) {
return hex;
} else if (hex >= 0x00000080 && hex <= 0x000007FF) {
var r1 = (((hex & 0x7C0) >> 6) | 0xC0) << 8;
var r2 = (hex & 0x03F) | 0x80;
return r1 | r2;
} else if (hex >= 0x00000800 && hex <= 0x0000FFFF) {
var r1 = (((hex & 0xF000) >> 12) | 0xE0) << 16;
var r2 = (((hex & 0x0FC0) >> 6) | 0x80) << 8;
var r3 = ((hex & 0x003F) | 0x80);
return r1 | r2 | r3;
} else if (hex >= 0x00010000 && hex <= 0x0010FFFF) {
var r1 = (((hex & 0x1C0000) >> 18) | 0xE0) << 24;
var r2 = (((hex & 0x03F000) >> 12) | 0x80) << 16;
var r3 = (((hex & 0x000FC0) >> 6) | 0x80) << 8;
var r4 = ((hex & 0x00003F) | 0x80);
return r1 | r2 | r3 | r4;
} else {
return false;
}
}

unicodeToUTF8(0x4e25); // 14989477,14989477..toString(16); e4b8a5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function utf8ToUnicode(hex) {
let utf8Binary = hex.toString(2);

let arr = [];
let arrItem = '';
for (let i = 0; i < utf8Binary.length; i++) {
arrItem += utf8Binary[i];
if ((i+1) % 8 === 0) {
arr.push(arrItem);
arrItem = '';
}
}

let result = '';

for (let i = 0; i < arr.length; i++) {
if (i === 0) {
result += arr[i].slice(arr.length + 1)
} else {
result += arr[i].slice(2);
}
}

return parseInt(result, 2).toString(16);
}

utf8ToUnicode(0xE4B8A5); // 4e25
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function utf8ToUnicode(hex) {    
let utf8Binary = hex.toString(2);  
let arr = [];  
let arrItem = '';  
for (let i = 0; i < utf8Binary.length; i++) {      
arrItem += utf8Binary[i];      
if ((i+1) % 8 === 0) {          
arr.push(arrItem);          
arrItem = '';      
}  
}  
let result = '';  
for (let i = 0; i < arr.length; i++) {      
if (i === 0) {          
result += arr[i].slice(arr.length + 1)      
} else {          
result += arr[i].slice(2);      
}  
}  
return parseInt(result, 2).toString(16);
}

utf8ToUnicode(0xE4B8A5); // 4e25