字符编码学习

在计算机的世界里, 信息的表示方式只有 0 和 1, 但是我们人类信息表示的方式却与之大不相同, 很多时候是用语言文字, 图像, 声音等传递信息的.

怎样将其转化为二进制存储到计算机中, 这个过程我们称之为编码. 更广义地讲就是把信息从一种形式转化为另一种形式的过程.

ASCII 编码

一个二进制有两种状态: 0 状态 和 1 状态, 那么它就可以代表两种不同的东西, 想赋予它什么含义, 就赋予什么含义, 比如: 0 代表 吃过了, 1 代表 还没吃. 这样就相当于把现实生活中的信息编码成二进制数字了, 并且这个例子中是一位二进制数字, 那么 2 位二进制数可以代表四种情况 (2^2) 分别是 00, 01, 10, 11, 那么 7 种是 2^7=128.

计算机中每八个二进制位组成了一个字节 (Byte), 计算机存储的最小单位就是字节

早期人们用 8 位二进制来编码英文字母 (最前面的一位是 0), 也就是说, 将英文字母和一些常用的字符和这 128 种二进制 01 串一一对应起来, 比如: 大写字母 A 所对应的二进制位 01000001, 转换为十六进制为 41.

在美国, 这 128 是够了, 但是其他国家不够, 他们的字符和英文是有出入的, 比如在法语中在字母上有注音符号, 如 é. 所以各个国家就决定把字节中最前面未使用的那一个位拿来使用, 原来的 128 种状态就变成了 256 种状态, 比如 é 就被编码成 130(二进制的 10000010).

为了保持与 ASCII 码的兼容性, 约定最高位为 0 时和原来的 ASCII 码相同, 最高位为 1 的时候, 各个国家自己给后面的位 (1xxx xxxx) 赋予他们国家的字符意义.

但是这样一来又有问题出现了, 不同国家对新增的 128 个数字赋予了不同的含义, 比如说 130 在法语中代表了 é, 但是在希伯来语中却代表了字母 Gimel(ג), 所以这就成了不同国家有不同国家的编码方式, 所以如果给你一串二进制数, 想要解码, 就必须知道它的编码方式, 不然就会出现我们有时候看到的乱码.

ASCII 速查表格请参考 here

Unicode 编码

Unicode(中文: 万国码, 国际码, 统一码, 单一码) 是计算机科学领域里的一项业界标准. 它对世界上大部分的文字进行了整理, 编码. Unicode 使计算机呈现和处理文字变得简单.

Unicode 为世界上所有字符都分配了一个唯一的数字编号, 这个编号范围从 0x0000000x10FFFF(十六进制), 有 110 多万(准确数字是 1,114,111), 每个字符都有一个唯一的 Unicode 编号, 这个编号一般写成 16 进制, 在前面加上 U+. 例如: 的 Unicode 是 U+9A6C, Unicode 就相当于一张表, 建立了字符与编号之间的联系

Unicode 至今仍在不断增修, 每个新版本都加入更多新的字符. 目前 Unicode 最新的版本为 15.0, 收录 149,186 个字符.

现在的 Unicode 字符分为 17 组编排, 每组为一个平面 (Plane), 而 每个平面拥有 65536(即 2^16) 个码值(Code Point). 然而, 目前 Unicode 只用了少数平面, 我们用到的绝大多数字符都属于第 0 号平面, 即 BMP 平面. 除了 BMP 平面之外, 其它的平面都被称为 补充平面.

平面始末字符值中文名称英文名称
0 号平面U+0000 ~ U+FFFF基本多文种平面BMP(Basic Multilingual Plane)
1 号平面U+10000 ~ U+1FFFF多文种补充平面SMP(Supplementary Multilingual Plane)
2 号平面U+20000 ~ U+2FFFF表意文字补充平面SIP(Supplementary Ideographic Plane)
3 号平面U+30000 ~ U+3FFFF表意文字第三平面TIP(Tertiary Ideographic Plane)
4 号平面 ~ 13 号平面U+40000 ~ U+DFFFF尚未使用-
14 号平面U+E0000 ~ U+EFFFF特别用途补充平面SSP(Supplementary Special-purpose Plane)
15 号平面U+F0000 ~ U+FFFFF保留作为私人使用区(A区)PUA-A(Private Use Area-A)
16 号平面U+100000 ~ U+10FFFF保留作为私人使用区(B区)PUA-B(Private Use Area-B)

Unicode 本身只规定了每个字符的数字编号是多少, 并没有规定这个编号如何存储. UTF-8 就是在互联网上使用最广的一种 Unicode 的实现方式. 其他实现方式还包括 UTF-16(字符用两个字节或四个字节表示)和 UTF-32(字符用四个字节表示), 不过在互联网上基本不用. 重复一遍, 这里的关系是, UTF-8 是 Unicode 的实现方式之一.

UTF-32

这个就是字符所对应编号的整数二进制形式, 四个字节, 这个就是直接转换. 比如: 马的 Unicode 为: U+9A6C, 那么直接转化为二进制, 它的表示就为: 1001 1010 0110 1100.

注意: 转换成二进制后计算机存储的问题. 计算机在存储器中排列字节有两种方式: 大端法和小端法, 大端法就是将高位字节放到底地址处, 比如 0x1234, 计算机用两个字节存储, 一个是高位字节 0x12, 一个是低位字节 0x34, 它的存储方式为下:

UTF-32 用四个字节表示, 处理单元为四个字节 (一次拿到四个字节进行处理), 如果不分大小端的话, 那么就会出现解读错误, 比如我们一次要处理四个字节 12 34 56 78, 这四个字节是表示 0x12 34 56 78 还是表示 0x78 56 34 12, 不同的解释最终表示的值不一样.

我们可以根据他们高低字节的存储位置来判断他们所代表的含义, 所以在编码方式中有 UTF-32BEUTF-32LE, 分别对应大端和小端, 来正确地解释多个字节 (这里是四个字节) 的含义.

UTF-16

UTF-16 使用变长字节表示

  1. 对于编号在 U+0000U+FFFF 的字符 (常用字符集), 直接用两个字节表示.
  2. 编号在 U+10000U+10FFFF 之间的字符, 需要用四个字节表示.

同样, UTF-16 也有字节的顺序问题 (大小端), 所以就有 UTF-16BE 表示大端, UTF-16LE 表示小端.

UTF-8

UTF-8 就是使用变长字节表示, 顾名思义, 就是使用的字节数可变, 这个变化是根据 Unicode 编号的大小有关, 编号小的使用的字节就少, 编号大的使用的字节就多. 使用的字节个数从 1 到 4 个不等.

UTF-8 的编码规则

UTF-8 最大的一个特点, 就是它是一种变长的编码方式. 它可以使用 1~4 个字节表示一个符号, 根据不同的符号而变化字节长度. UTF-8 的编码规则很简单, 只有二条:

  1. 对于单字节的符号, 字节的第一位设为 0, 后面的 7 位为这个符号的 Unicode 码, 因此对于英文字母, UTF-8 编码和 ASCII 码是相同的.
  2. 对于 n 字节的符号, 第一个字节的前 n 位都设为 1, 第 n+1 位设为 0, 后面字节的前两位一律设为 10, 剩下的没有提及的二进制位, 全部为这个符号的 Unicode 码.

举个例子: 比如说一个字符的 Unicode 编码是 130, 显然按照 UTF-8 的规则一个字节是表示不了它 (因为如果是一个字节的话前面的一位必须是 0), 所以需要两个字节 (n = 2).

根据规则, 第一个字节的前 2 位都设为 1, 第 3(2+1) 位设为 0, 则第一个字节为: 110XXXXX, 后面字节的前两位一律设为 10, 后面只剩下一个字节, 所以后面的字节为: 10XXXXXX. 所以它的格式为 110XXXXX 10XXXXXX.

Unicode 编号范围与对应的 UTF-8 二进制格式:

Unicode 编号范围(编号对应的十进制数)Unicode bit 数表示 Unicode 的二进制格式表示 UTF-8 的二进制格式UTF-8 Byte 数
0x00 ~ 0x7F(0~127)0~700000000 00000000 0zzzzzzz0zzzzzzz(00~7F)1
0x80 ~ 0x7FF(128~2,047)8~1100000000 00000yyy yyzzzzzz110yyyyy(C0~DF) 10zzzzzz(80~BF)2
0x800 ~ 0xFFFF(2,048~65,535)12~1600000000 xxxxyyyy yyzzzzzz1110xxxx(E0~EF) 10yyyyyy 10zzzzzz3
0x10000 ~ 0x10FFFF(65,536 ~ 1,114,111)17~21000wwwxx xxxxyyyy yyzzzzzz11110www(F0~F7) 10xxxxxx 10yyyyyy 10zzzzzz4

跟据上表, 解读 UTF-8 编码非常简单: 如果一个字节的第一位是0, 则这个字节单独就是一个字符; 如果第一位是1, 则连续有多少个1, 就表示当前字符占用多少个字节

对于一个具体的 Unicode 编号, 具体进行 UTF-8 的编码的方法

首先找到该 Unicode 编号所在的编号范围, 进而可以找到与之对应的二进制格式, 然后将该 Unicode 编号转化为二进制数 (去掉高位的 0), 最后将该二进制数从右向左依次填入二进制格式的 X 中, 如果还有 X 未填, 则设为 0.

比如: 的 Unicode 编号是: 0x9A6C, 整数编号是 39532, 对应第三个范围 (2048 ~ 65535), 其格式为: 1110XXXX 10XXXXXX 10XXXXXX, 39532 对应的二进制是 1001 1010 0110 1100, 将二进制填入进入就为: 11101001 10101001 10101100. 可以看到 的 Unicode码 是 0x9A6C, UTF-8 编码是 0xE9A9AC, 两者是不一样的..

由于 UTF-8 的处理单元为一个字节 (也就是一次处理一个字节), 所以处理器在处理的时候就不需要考虑这一个字节的存储是在高位还是在低位, 直接拿到这个字节进行处理就行了, 因为大小端是针对大于一个字节的数的存储问题而言的.

多个 Unicode 代码点表示同一个字符

从理论上说, Unicode 应该是代码点和字符之间的一一映射 (译注 4), 不过在许多情况下, 一个字符可能有多种表现方式. 前一节中我们看到 可以表示为 U+0061 加上 U+0300. 不过, 它也可以用单个代码点 U+00E0. 为什么会出现这种情况? 是为了保证 Unicode 和 Latin-1 之间转换的简易性. 如果我们有需要转换为 Unicode 的 Latin-1 文本, à 可能被转换为 U+00E0. 不过, 也可以转换为 U+0061U+0300 的组合.

特殊 Unicode 字符示例

  • à: U+00E0, U+0061 + U+0030
  • 罗马数字 I: U+0049
  • 希腊字母 Ι: U+0399
  • Ϊ: U+0399 + U+0308
  • : U+0049 + U+0308
  • Ï: U+00CF
  • Ϊ: U+03AA
  • : U+3390
  • ǰ: U+01F0
  • : 是 U+01F0 的大写形式, U+004A + U+030C

Emoji

emoji 是由日本电信公司推出, e 表示绘, moji 表示文字, 连在一起表示绘文字. 2007 年由苹果公司置入 iPhone 中发扬光大, 2010 年 Unicode 为其设置码点, 2015 年统一规范.

Unicode 只规定了码点及其含义, 但是并没有规定如何实现, 因此同一个码点在 iOSAndroidFacebook 均有不同的效果(差异有但是比较小). 输入 emoji 的方法最好使用输入法插入, 使用码点输入也可以但是比较麻烦而且记不住.

Little endian 和 Big endian

以汉字严为例, Unicode 码是 4E25, 需要用两个字节存储, 一个字节是 4E, 另一个字节是 25. 存储的时候, 4E 在前, 25 在后, 这就是 Big endian 方式; 25 在前, 4E 在后, 这是 Little endian 方式.

这两个古怪的名称来自英国作家斯威夫特的《格列佛游记》. 在该书中, 小人国里爆发了内战, 战争起因是人们争论, 吃鸡蛋时究竟是从大头(Big-endian)敲开还是从小头(Little-endian)敲开. 为了这件事情, 前后爆发了六次战争, 一个皇帝送了命, 另一个皇帝丢了王位.

第一个字节在前, 就是”大头方式”(Big endian), 第二个字节在前就是”小头方式”(Little endian). 那么很自然的, 就会出现一个问题: 计算机怎么知道某一个文件到底采用哪一种方式编码? Unicode 规范定义, 每一个文件的最前面分别加入一个表示编码顺序的字符, 这个字符的名字叫做”零宽度非换行空格”(zero width no-break space), 用FEFF表示. 这正好是两个字节, 而且FF比FE大1. 如果一个文本文件的头两个字节是FE FF, 就表示该文件采用大头方式; 如果头两个字节是FF FE, 就表示该文件采用小头方式.

编码对比真实案例

下面, 举一个实例.

打开 vim, 新建一个文本文件, 内容就是一个 字, 依次采用 gb2312, ucs-4le, ucs-4beutf-8 编码方式保存(方式为 set fileencoding=..., 记得保存, 否则不会触发 vim 的编码转换执行). 然后使用 %!xxd 检查十六进制内容

  1. gb2312: 文件的编码就是两个字节 d1cf, 这正是严的 GB2312 编码, 这也暗示 GB2312 是采用大头方式存储的
  2. ucs-4le: 编码是四个字节 254e 0000, 小头方式存储, 真正的编码是 4E25
  3. ucs-4be: 编码是四个字节 0000 4e25, 大头方式存储
  4. utf-8: 编码是三个字节 e4b8 a5

Unicode 存储过程中的转换逻辑

在计算机内存中, 统一使用 Unicode 编码, 当需要保存到硬盘或者需要传输的时候, 就转换为 UTF-8 编码.

用记事本编辑的时候, 从文件读取的 UTF-8 字符被转换为 Unicode 字符到内存里, 编辑完成后, 保存的时候再把 Unicode 转换为 UTF-8 保存到文件

浏览网页的时候, 服务器会把动态生成的 Unicode 内容转换为 UTF-8 再传输到浏览器

GB2312 使用 2 个字节存储一个汉字, 最多可表示 216 = 65536 个字符, 目前 GB2312 收录了六千多常用汉字, 覆盖日常使用率 99.75%, 但是对于繁体字和罕见自就无能为力了, 因此后来有了超集 GB18030.

选择建议: 通用性第一, 处理简单, 选择 UTF-8

LF 与 CRLF

就是换行方式的区别而已

  • LF: line feed, 意思是换行只使用 \n, 所有类 unix 系统都使用此种换行方式
  • CRLF: carriage return, line feed, 换行使用 \r\n, 只有 Windows 系统在使用此种方式进行换行

实际上真实的打字机也是通过 CRLF 来进行换行的, 就是先退回到行首, 然后下移一行以开启新一行的输入

完全同样的文件内容, 由于换行方式的不同会导致文件大小不同, git diff 也会认为这是每一行都完全不同的两个文件.

如果 LF 格式的文件放到 Windows 上查看的话, 有可能出现所有内容都在同一行的情况(大部分编辑器还是智能的, 不会出现这种状况)

使用 tr -d '\r’ test.txt 可以清理掉文件内的所有 \r 字符

Ref

本博客文章采用 CC 4.0 协议,转载需注明出处和作者。

鼓励作者