字符编码笔记

想来想去终于决定写一个关于计算机字符编码的笔记了 ( 应该说终于愿意去学了..) , 原本的目的只是搞清楚Unicode的, 考虑到字符编码问题是一个有趣, 在开发中经常会遇到并且稍微有点麻烦和棘手的问题, 但是在网络上却很难找到将这个说的比较细致和成体系的文章, 所以我决定倾尽精力去写这篇笔记, 我会尽量的去查资料和参考他人的文章和博客, 将关于计算机编码的问题捣鼓清楚.

在笔记的后半部分, 我也会针对Python3的Unicode做一些说明, 并且当说到Unicode的时候, 我会尝试说明一些关于UTF-8 with BOM在Linux/Unix内核中的一些矛盾.

关于我搜寻和参考的资料, 都会贴在文章的最后.

这个世界上没有纯文本, 如果你想要读出它, 就要知道它的编码.

基本概念

首先我们要说明一些关于字符编码的基本概念, 有了这些概念的理解阅读后面的内容才不费劲~. 由于我默认将文章的受众定位成了有计算机科学基础的学生, 所以最基本的概念就直接一笔带过了.

就是我们熟知的比特(Bit), 也是二进制位, 因此有0和1两个值. Bit是Binary digit的缩写.

是计算机用来表示信息的最小单位.

字节, 字, 字长

将一连串的比特组合在一起, 就构成了位串. 由于一个位的表示能力有限, 所以我们更多的情况下使用的是特定长度的位串来表示信息. 为什么说是特定长度呢? 标准, 标准, 标准! 我们制定几种不同长度的位串, 来表示不同的信息, 比如说有:

  • 半字节(nibble, 使用4个比特, 我反正是没见过的..)
  • 字节(byte, 使用8个比特, 是现代个人计算机的最小的存取和寻址单位)
  • 字(WORD, 视具体操作系统位数决定字节数* 下面补充说明)
  • 双字(DWORD, 视具体操作系统位数决定字节数)
  • 四字(QWORD, 视具体操作系统的位数决定字节数)

补充一点, 关于字节的位数, 一般说来都是8个bit, 但是这也只是一个标准制定的而已, 其实过去也有非8位的字节标准. 在一些严谨的计算机文献中, 会使用八位组(Octet)来代替字节.

刚刚就说了, 一个位的表示能力有限, 所以我们想到使用字节, 而对于最小单位的字节来说, 仍然不能充分发挥计算机的运算能力, 所以对于计算机来说, 更高效处理的单位长度其实是的长度, 也就是字长. 这也就是上面说的视具体操作系统位数决定的. 其实这样说不精确, 甚至说不是很对. 因为字长实际上是由CPU对外的总线宽度决定的, 它决定了CPU一次处理的数据的实际比特位.

所以, 64位的CPU可以使用32位的操作系统, 但是这样不会发挥出它应有的性能.

字符集

字符集, 其实就是一堆字符的集合, 包括各种文字, 数字, 字母, 音标, 标点符号, 图形等等. 将这些字符进行编号放在一起形成的序列, 就是字符集 (Charset) .

常见的字符集有

  • ASCII
  • ISO8859-? 系列
  • GB(GB2312, GBK, GB18030)系列
  • BIG5
  • Unicode

只有字符集没有什么卵用.

编码&解码

这个很好理解了,就是将信息从一种格式转换成为另外一种格式.

字符编码

编码, 刚刚说了, 就是格式之间的转换, 而计算机中的字符编码, 就是将字符转换(或者说映射)成为二进制数的过程. 这样, 我们就可以在计算机中方便的表示, 存储, 处理, 以及在网络中传输字符信息了.

这也是一种抽象.

代码页

代码页 (code page), 真的是一个很迷的概念.主要是来源于IBM的字符数据表示体系结构. 经常是被理解成是字符集的别称, 但这是不对的. 维基百科给出的概念是:

In computing, a code page is a table of values that describes the character set used for encoding a particular set of characters, usually combined with a number of control characters.

大体翻译过来就是说是一个描述字符集的值表, 用于编码一组特定的字符(包含一些控制字符). 这么看来还真的就是字符集的意思.

但是仔细看一下, 它是在描述字符集, 就是说根据具体的情况, 代码页可能会对字符集做一定的扩展, 在早期计算机还没有出现图形界面的时候, IBM称呼BIOS所支持的字符集位代码页, 而由于该代码页是烧录在显卡上的, 所以也被称为是OEM代码页.

正因为此, 每个厂商有自己的代码页, 所以代码页也叫做”内码表”. 当我们想要知道一个二进制字节是什么字符的时候, 就要去根据系统设置的代码页去查表. 所以, 只有在操作系统的层面上, 我们才说代码页这个概念.

字符编码模型

这是一个极其重要的概念了, 模型的设计直接反应编码系统的组成结构和相关性. 例如, 在ASCII为代表的简单字符编码模型中, 刚刚所说的字符集字符编码就是一个玩意, 没有什么区分.

而在我们后面将要隆重介绍的Unicode和UCS(通用字符集)中, 这个模型将会稍显复杂, 因为他会考虑很多内容.

UCS其实就是ISO/IEC 10646标准, 全称是通用字符集. 和Unicode字符集保持同步和一致. 实际上这个标准就是Unicode联盟和ISO/IEC共同制定的. 因此UCS的具体实现就是Unicode的具体实现, 即UTF-8, UTF-16, UTF-32.

Unicode技术报告#17中, 描述了Unicode的字符编码模型. 报告中阐述和引入了巨多名词和概念, 我们慢慢展开.

先来说一下字符编码模型考虑的内容

  • 有哪些字符
  • 字符编号是什么
  • 如何将编号映射成逻辑序列, 即码元序列
  • 如何将码元序列转换成物理层面的字节流
  • 在一些特殊或者复杂环境中, 如何将字节序列进行适应性的处理

仔细思考一下, 就不难发现, 现代字符编码模型和过去的简单字符编码模型不同的地方就是它更关注: 通用, 不同编码方式 这两点. 类似我们的解耦, 现代字符编码模型将字符集和字符编码方式进行了解耦, 使得更容易扩展, 更加通用, 一套字符集, 我们可以不同的编码方式进行处理. 也就是字符集和字符编码实现是一对多的形式.

根据上面所说的考虑内容, Unicode字符编码模型是这样分层(分级别)的:

  • 级别一 抽象字符表ACR(Abstract Character Repertoire):明确字符的范围(即确定支持哪些字符)
  • 级别二 编号字符集CCS(Coded Character Set):用数字编号表示字符(即用数字给抽象字符表ACR中的字符进行编号)
  • 级别三 字符编码方式CEF(Character Encoding Form):将字符编号编码为逻辑上的码元序列(即逻辑字符编码)
  • 级别四 字符编码模式CES(Character Encoding Scheme):将逻辑上的码元序列映射为物理上的字节序列(即物理字符编码)

说一下, 这里知乎作者Jacky lin的文章中, 说是5层, 但是我看根据UTR中的说法, Unicode字符编码模型应该是四个级别, J所说的第五个层次TES, 应该只能算作是一个重要的概念.

报告的原文(第7版)摘几段:

The four levels of the Unicode Character Encoding Model can be summarized as:…

In addition to the four individual levels, there are two other useful concepts:…TES

However, four levels need to be defined to adequately cover the distinctions required for the Unicode character encoding model.

感觉无论怎么看, TES都不是算是模型的层次…

现在我们慢慢展开说一下这四个等级.

1. 抽象字符表 ACR

在这里, 我们通过定义抽象字符的无序集合来确定字符的范围. 字符表可以是封闭的, 也就是在制定时就决定了, 典型的例子是ASCII以及ISO8859系列, 而Unicode为了达到通用的目的, 提出设计开放的字符表, 也就是说, 我们可以随时添加新的字符到表中.

这里我们所说的抽象字符是不具有的字形的, 所以才说是抽象的.

稍稍总结一下ACR的三个特点: 无序, 封闭和开放, 字符不具有具体的字形.

2. 编号字符集 CCS

一个编号字符集定义成是从上面的抽象字符集合到非负整数集合的映射. 这些整数不一定要连续, 我们把定义了整数的抽象字符的这个位置(想象一个表格的单元格)称为码点(code point)(暂时存个疑问), 所以这样就形成了一个编号空间(Code space, 也可以叫做码点空间). 一个存在上限的有多种方式描述的非负整数范围. 例如, 我们可以通过一对非负整数: GB2312的汉字编号空间就是94 x 94. 也可以直接使用一个非负整数: ISO-8859-1的256, 或者也可以使用字符的存储单元尺寸, 比如ISO-8859-1的范围是2^8 = 256.

回到上面的码点这个概念, 并不是说码点的数量和抽象字符的数量(编号)是一致的, 这是因为在我们的CCS中, 还存在了非字符码点和保留码点. 不仅如此, 多个码点可能还对应这同一个字符, 比如: \u51c9\uf979的这两个码点是同一个字符“凉”(注意, 这并不是汉字liáng, 这是一个字符.). 更多的例子可以在这里查询到: CJK兼容-F900-F921. 还有, 例如上面的注音符号, 他就是由多个码点表示的, 由基本符号的a加上注音符号.

最后稍微注意, 这里我们说的是编号, 而非编码. 这是两个截然不同的概念. 其实这一步和计算机丁点关系都没有, 因为没有涉及到下面说的字符编码方式和字符编码模式.

3. 字符编码方式 CEF

从这一步开始, 就开始进入到计算机的表示了. 我们知道常见的数据类型也就是单,双,四字节, 分别能够表示256, 65536, 4294967296个码位. 那么现在这一级别需要考虑的就是如何将无限扩展的(上面说了Unicode是开放的ACR, 也许现在就有新的emoji表情被添加到字符表中), 不仅如此, 过去诞生且正在使用的那些字符编码我们是抛弃不用(显然不可能), 还是向下兼容, 如果是兼容的话, 是完全兼容, 还是部分兼容 ?

这些问题的解决方案 就是我们的CEF了. CEF将字符集中字符的码点值转换成有限长度的编码值, 这个编码值就是之前提到的码元序列, 码元(code unit)就是这个序列的单位名称. 这里的转换也还没有这么具体, 只不过是逻辑上的方式.

对于ASCII这样的简单字符编码模型, 字符编码其实就是字符编号, 而它的编码方式就是简单的直接映射. 对于Unicode这样的现代复杂字符编码模型, 字符编号和字符编码并不一定相等, 而映射方式也不一定是直接的.

到这里你就更加清楚了, CEF其实就是字符编码标准的实现方式. 例如: UTF(Unicode/UCS Transformation Format)-8, UTF-16, UTF-32就是Unicode的编码方式.

上面的说法似曾相识吧! 很多博客, 网站都是这么说的. 其实也勉勉强强可以这么理解.

4. 字符编码模式 CES

终于到了这一级别, 在这一个级别, 就会将物理层面上的具体实现纳入考虑. 包括对各种不同的硬件平台与操作系统设计上的差异考虑. 在这一层, 码元序列就会转换成为字节序列.

由于太具体了, 所以我们就把这一部分的内容丢到后面吧.

历史的车轮 — 编码史

最早最早, 我们的计算机仅仅是用来做数字运算的, 所以没有编码这回事. 后来人们发现可以使用计算机做更多的事情, 所以首先就是要想办法编码字符, 也就是使用二进制来表示字符. 所以在1963和1964年由IBM为大型机操作系统开发制定了一个编码标准, 类似ASCII, 叫做扩展二进制编码的十进制交换码 — (EBCDIC). 但是这个编码标准设计的太糟糕了, 就连英文字母都不是连续的, 造成很多困扰和麻烦. 所以在后来出现了个人计算机的时候, 大家都使用了ASCII编码标准.

顺便说个笑话:

教授:”所以美国政府去IBM提出了一个加密标准,他们想出了…”

学生:“EBCDIC!”

ASCII

事实上, ASCII的标准制定工作比上面的EBCDIC的制定还要早一点, 它的第一版和EBCDIC同一年出版, 接着在1967年进行重大更新, 目前最新的修订发生在1986年.

ASCII的全称是美国信息交换标准码, 可以说是最基础、最重要、应用最广泛一个字符编码方案. 所以现在大部分的通行编码方案都兼容ASCII编码. 至于EBCDIC, 你看我连个二级标题都没给他…应该是凉了.

接下来我们来简单说说ASCII编码方案吧, 它使用八个比特位, 但实际上只有7位在用, 因为规定最高位始终置零(在某些场合用来做奇偶校验). 这样就应该可以表示128个字符, 对于English来说, 完全够了.

  • 0-31 都是控制字符和不可打印字符
  • 32-126 是可打印可显示的字符 - 数字, 字母, 符号.
  • 127 控制字符DEL.

这里的转换很简单, 不存在什么编码算法, 也没有什么码元序列和字节序列的转换. ASCII字符集标准就是ISO/IEC-646标准.

EASCII

渐渐地, 计算机从美国流传到了欧洲各国, 由于各种欧洲国家的语言中存在英语中不存在的一些字符, 例如一些衍生的拉丁字符, 所以他们就想要使用ASCII标准没有使用的最高位. 可是这样终究还是有限的 表示的字符数量变成了256个.

多出来的那128个就是最高位置1的结果, 表示出来的符号包括表格符号、计算符号、希腊字母和特殊的拉丁符号.

不过这一个标准也很少使用了, 这是因为后来ISO制定和发布了著名的ISO-8859系列标准.

ISO-8859

这个和上面的两个就不一样了, ISO/IEC-8859是一套标准, 一共包含从ISO-8859-1到ISO-8859-16, 除去已经被废弃的ISO-8859-12, 总共有15个标准. 这些标准涵盖了欧洲各国使用的字符, 甚至包括一些外来语. 而且每一个具体的标准都只是用了扩展ASCII的0xA0-0xFF, 即160-255这96个编码.

GB系列

渐渐地, 计算机发展到了亚洲地区. 于是自然, 需要想个办法来表示这些亚洲地区文字. 以汉字为例, 汉字的表示和英语不同, 鉴于其表示的特殊性和复杂性, 中国相关部门设计了GB系列编码, GB就是国标的汉语拼音的缩写, 国家标准的意思. GB的编码规则向下兼容ASCII, 如果一个字节是0-127, 那么就字节的含义还是ASCII制定的含义. 但是当出现ASCII单字节和GB多字节混合使用的时候, 就要先将GB编码的最高位设置成1, 以防止冲突.

对了, 凡是GB系列的编码, 都是符合ISO-8859标准的.

GB2312

这是最早的中文国家标准, 诞生于1980年. 目前GB2312仍然被广泛使用, 它一共包括了6763个汉字, 还收录了包括拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母在内的682个字符.GB2312-1980通过 这6763个汉字已经足够日常使用. GB2312的编码方法和EASCII类似, 都是使用ASCII不使用的最高位, 它规定所有的汉字必须大于127, 然后必须和原本ASCII一起结合使用, 正因为此, 他不能兼容EASCII的扩展部分.

由于当时的计算机仍然是终端和文本模式的时代, 而每个字符都是一个小点阵, 占8个像素宽, 而汉字并不适合这样展示, 所以又出现了全角和半角的区别. 全角的标点符号和半角的标点符号在中文输入法下是一样的, 而在英文输入法中, 半角标点的宽度就是全角的一半大小. 关于全角半角, 我们在后面还会提到.

GB13000 / GBK

1993年, Unicode1.1标准(ISO/IEC 10646.1:1993)第一部分发布(关于Unicode和ISO10646的相关, 我们在后面会说), 随后中国就发布了新的国标: GB13000-1993. 这一个标准和前面的那两个相一致. 后来在2010年, 发布了替代标准GB13000-2010, 和ISO/IEC10646:2003保持一致

不知道为什么, 感觉GB13000的资料好少. 搜不到什么相关信息

由于汉字的数量实在是太庞大了, 而一些人名, 古汉语, 生僻字都没有被包括在GB2312标准中, 所以同一年(1993), GBK, (即国家标准扩展的汉语拼音缩写)被定义, 尽管这个时候这并不是官方的标准, 但微软已经在Windows 95中实现了GBK的代码页(cp936). 由于Windows的广泛使用, 使得GBK成为了当时的事实标准. 于是, 在1995年, 国家发布GBK1.0规范, 对于cp936做了轻微的扩展. 但是目前的状态是, cp936就等同于GBK, 尽管GBK比cp936多出95个字符. IANA也把cp936作为GBK的别名.

GBK不同于GB2312, 汉字依然从127开始, 但是不要求使用原来的ASCII的比特位, 这样才能表示更多的字符.

GB18030

这个是目前最新的国家标准, 制定于2000年, 以取代GBK.在2005年, 国家对GB18030进行了补充, 在GBK的基础上增加了CJK.

微软也对GB18030制定了代码页(CP54936), 不过, 在(Windows7)系统的设置中并不能找到这个代码页, 只不过在Command Line中可以进行切换.

GB系列的编码方式(以GB2312为例)

首先我们明确, GB系列的汉字编码都是双字节编码, 也就是1个汉字相当于是2个英文字符.

接下来, 开始了.

高迷预警.

我们要先来说几个概念, 再逐渐引入这些概念的过程中, 就来解释GB2312的编码.

  • 区位码
  • 国标码
  • 内码
  • 外码
  • 字形码

就直接从上到下说吧, 对于GB2312字符集, 我们把它分成94个区, 至于为什么是94个区, 是因为GB2312是7位双字节编码, 也就是128*128, 由于需要避开ASCII的控制字符和空格(至于为什么后面再解释), 所以就只剩下了94*94了. 结合上面所说的概念 — 编号空间, 码点空间. 这个94*94的空间就是GB2312的编号空间了. 这样就可以通过一个横坐标一个纵坐标来唯一定位一个点, 这个横坐标就是, 纵坐标就是, 加在一起就是区位码. 这样说有点抽象. 其实, 高位字节就是这里的横坐标, 而低位字节就是纵坐标. 举个例子, 汉字字符: “万” 它的区位码是45 82, 所以45就代表高字节, 而82就代表低位字节.

在GB2312中, 是这样分区的:

1)01~09区(682个): 特殊符号、数字、英文字符、制表符等,包括拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母等在内的682个全角字符.

2)16~55区(3755个): 常用汉字(也称一级汉字), 按拼音排序

3)56~87区(3008个): 非常用汉字(也称二级汉字), 按部首/笔画排序

剩余的都是空区, 留作扩展.

你可能这个时候做了实验, 但是发现”万”好像并不是45 82, 而是cd f2. 这个继续往下看就知道了.

接下来回到刚刚说的, GB2312避开了ASCII的控制字符以及空格(从0H-20H, 0-32), 所以就导致整个编码开始于21H, 也就是10进制的33.

注意: 这里我为了简便, 略了另外一个字节, 正确的表示应该是(21H,21H)这样的, 因为双字节编码, 下同.

因此, 我们的编码就必须向后移动20H的位置(至于为什么要避开, 后面再说, 先别急), 即范围是从(21H, 21H)到(7E, 7E). 这样就进行了区位码到国际码的转换. 国际码又称作交换码, 那么还是用之前的”万”来做例子, “万”的国际码就是77 114, 也就是十六进制的4d 72.

说道这里, 可能敏感的你已经发现了, 第一: 4d 72和之前的cd f2还是不对应, 第二: 77 114和ASCII已存在的字符发生了冲突, 例如, 这就和M, r发生了冲突. 所以这个国际码并不能直接拿来用. 那咋办? 我们之前就说过了, 把ASCII没使用的最高位置1就行了, 即加上128, 80H.

值得一提的是, 这里的加上80H, 是微软提出的解决方案. 本质上他是改变了GB2312的编码方式. GB2312真正的规范编码, 到这里就结束了 — 国际码就是GB2312的规范编码.

这个时候”万”的GB2312编码就变成了之前的cd f2了, 我们把这个最后的编码叫做内码. 意思就是计算机内部使用的编码.

现在我们就解释了GB系列的编码方式, 接下来就来回答一下上面的疑问: 为什么要避开那20H个. 说来也简单, 其实上面也已经解释了, 原本我们可以直接保留所有的ASCII编码的, 也就是直接从128开始编码, 但是考虑到全角和半角的英文字符, 所以GB2312就把这些重新进行编码加入到双字节编码的队列中. 而不可打印的控制字符, 也就是那20H, 32个就进行了保留, 这就是避开的原因.

最后我们再把外码和字形码说一下: 最简单的理解方式, 我们再输入的时候使用的代码就是外码, 例如五笔输入法, 拼音输入法这些, 规定的代码就是外码. 而字形码可以简单的理解成是我们的字体, 也即是具体的显示形状.

Big5

1984年策划制定, 主要用于台湾地区, 用于繁体汉字的显示, 可以读作大五码. 名字来自于台湾五家公司的财团.

这个就不展开说了, 他定义的汉字数量真的好少…好像还需要各种奇怪的扩展才能满足需要.

ANSI

我们在这里扩展一下, 说一下这个玩意. 在我们使用Windows的notepad的时候, 默认的存储编码方式是ANSI. 这是个什么玩意? 严格的说, 这不是一个编码方式, ANSI是美国国家标准协会的缩写. 它规定了各国字符编码对应的代码页标准, 例如我们在之前的概念中说过, GB系列的编码方式在Win中就是CP943, 当然这是指简体中文系统, 而繁体中文的Windows中, ANSI就对应Big5的代码页, 这就是说, 即使你们都是ANSI编码, 但不一定你们对应的文件编码是一致的.

所以, 你也可以不严谨的说: ANSI是系统的默认编码方式, 不过这个也是会依靠系统语言而改变的. 另外只有Windows中存在这样的说法.

Unicode

由于各个国家, 各个地区的编码方式都不一样, 于是我们自然就会想, 如果能有一种编码标准能够收纳全世界的语言, 每一个字符全球都使用那独一无二的一个就好了. 于是, 统一编码标准 - Unicode就横空出世了.

Unicode到底代表什么, 根据不同的上下文环境, 代指不同的东西. 不过很多的时候都会发生概念的误用. 如果说直接定义Unicode的话, 他应该是个编码标准. 而在这个标准定义的字符集, 就是Unicode字符集. 接下就可能引起大家困扰的Unicode标准和ISO/IEC-10646标准, 我们来理一下他们的关系.

最初最初, 在1989年ISO策划搞一个通用字符集, 并且这个通用字符集就在1990年出版了, 这就是ISO10646的最早版本, 但是这个标准和现在的ISO10646存在明显的不一样. 而在同一年, Unicode也是存在的,只不过尚未正式出版. 而软件公司拒绝接受ISO复杂的设定和限制, 要求一些国家机构进行投票反对. 所以ISO的制定者们意识到, 需要和Unicode进行协商统一. 后来Unicode联盟和国际标准化组织就在1991年联合开发并且发布了Unicode1.0.0标准, 最初的标准中并不包含中文, 接着在次年定义了2w余个CJK中日韩表意字符(1.0.1), 到这里Unicode标准和UCS标准并没有严格意义上的对应. 而到了1993年, Unicode1.1标准发布并且和ISO/IEC-10646-1:1993保持了基本的一致, 而到了Unicode2.0发布的时候, 它的字符名称和代码点已经和ISO/IEC-10646:1993的前七个修订版完全匹配. 至此之后, Unicode标准和ISO/IEC-10646标准拥有近乎是一样的字符集, 并且大致同步. 今年(2017年)6月份, Unicode第10个版本发布, 相对应的ISO-10646:2017.

目前, 两个项目都依然存在, 但统一码联盟Unicode Consortium和ISO/IEC都同意保持两者的通用字符集相互兼容, 并共同调整未来的任何扩展.

接下来我们就来解释下, Unicode的字符集是怎么涵盖目前人类所使用的全部字符的.

Unicode一共设置了17个平面(Plane), 从0-16, 每一个平面上都有2^16=65536个码点, 而我们平常所使用的大部分字符, 都定义在第0个平面上, 这个平面叫做: BMP(Basic Multilingual Plane, 基本多语言平面). 除此之外, 其他的16个平面都是增补性质的, 一些象形文字, 埃及文字, 表情游戏符号(emoji, 扑克牌, 麻将, 多米诺骨牌)等等.

在基本的BMP平面中, 有一部分用来是保留的范围, 这一段从0xE000~0xF8FF, 一共有6400个码点, 被定义成是私有区(Private use zone).

Plane 0 Plane 1 Plane 2 Planes 3–13 Plane 14 Planes 15–16
0000–FFFF 10000–1FFFF 20000–2FFFF 30000–DFFFF E0000–EFFFF F0000–10FFFF

上面的表格就是各个平面的码点范围.

我们着重关注的其实就是Plane 0, 也就是上面刚刚说的BMP.

最早, Unicode字符编码是双字节编码也就是16位, 这个其实就是我们后面要说的UTF-16, 等说到了的时候再解释吧. 这样当在考虑原先的ASCII编码的时候, 为了考虑兼容(其实UTF-16也没有很好的兼容, 这个后面再说)于是就保持和它的原来的编号不变, 只不过在编码上, 将原来的8位扩展到了16位, 但是我们知道ASCII字符只需要用到UTF-16的16位编码中的低8位, 所以其高8位永远是0, 实际上也只用到了低8位中的低7位,因此准确地说其高9位永远是0.

注意到我刚刚才说的是编号一致, 其实对于ASCII这样的传统字符编码模型, 我们可以同时使用编号和编码的, 他们没有什么大的区别.

ASCII还好办, 毕竟是8位编码, 但是对于中文就麻烦了, GB系列的标准本身就是双字节编码, 所以就没法提供一个算法进行互相转换. 其实ASCII码也不能说是兼容了, 这种解决方法也是不完全的.

Unicode给每一个码点都编了一个唯一的字符编号(码点编号), 并且在表示这个的时候, 在前面加上一个U+.

我们回到之前说的UCS字符集, 这个玩意其实有两个组成: UCS-2和UCS-4, 他们分别定义2字节和4字节的码点编号, 其中UCS-2和我们的BMP是对应关系, 它们保持一致. 注意: UCS-2和UCS-4不是字符编码方式, 也不能把他们和UTF-16和UTF-32画等号, UCS和Unicode字符集一样, 也是可以使用多种方式对字符编号进行编码的.

再说到具体的编码方式之前, 我们要先来说一个超级重要的概念 — 字节序

字节序

字节序, Byte-order. 或者叫做Endianness, 翻译过来是端序, 在1980年短语Big-endianLittle-endian第一次出现, 来表示异构计算机之间的通信和交换数据的重要概念, 在现代网络中, 字节序也是一个十分重要的概念.

简单来说, 字节序就是规定多字节数据在计算机存储, 读取的时候的各个字节的排列顺序. 我们也可以把字节序称为端序, **”端”**就是指多字节数据的两边(端)字节, 一般情况下, 我们人读取数据是从左边往右边, 或者是从上到下, 这就是从大端到小端, 从高位到低位, 而对于内存地址而言, 从左到右, 从上到下, 就是从低位地址到高位地址. 这都是几乎确定的.

我们要先解释一下, 上面这一段其实解释的很迷(SO SORRY, 我的极限语言表达能力). 所谓大端, 其实就是指代更大属主的那一端, 而小端自然就是更小的那一边.

那么接下里, 我们就可以引入UTF中两个令人迷惑的概念了: 大端序小端序(其实还有一个中间序, 不过使用的不多).

大端序就是指按照我们正常的读写顺序(即刚刚说的从左到右, 从上到下), 正好大端在前面(也就是左或上). 而小端序就是说按照我们正常的读写顺序, 正好小端在前面.

其实说到底, 如果我们的计算机是按照字节为单位来处理数据的话, 就不存在这么复杂的概念了, 因为处理数据的单位是单个字节, 有什么大端小端关系? 当然也是没有什么顺序的了. 但是问题就是计算机处理数据是按照数据类型来的, 而这些数据类型是多字节数据类型, 而一旦把多个单字节当做一个整体处理, 就必定会牵扯到排列顺序的问题.

那么现在就来具体的说一下大端序和小端序吧:

大端序

大端序

看图就觉得很清晰, 而且很符合正常的读写习惯, 也即是按照顺序把字节填充进去.

小端序

小端序

小端序就是反过来的, 字节头位于内存地址的高位, 而尾部位于低位.

中端序

中端序也称为是混合序, 其实就是像这样的:
混合序

而具体是什么序, 取决于软件层面操作系统和硬件层面的CPU. 一般来说, 大部分的操作系统(NT, Linux)都是小端序的, 而且Intel X86架构的CPU也都是小端序的, 但例如Power PC这样的架构就是大端序的, 而且Mac OS X也是大端序的.

这样的话, 就很麻烦, 尤其是对于那些设计系统的工程师, 以及一些做跨平台, 异构应用的编写人员就不可避免的考虑到字节序的问题.

Unicode的CEF

由于Unicode是这篇文章的一个侧重点, 而且Unicode比较复杂, 所以我们就单独开一个小节, 来介绍他的CEF选择和实现方案.

回过头来看看Unicode的平面(Plane)表, 其中定义了很多码点, 另外我们再之前的Unicode编码模型中说了, CEF就是将字符编号转换成为码元序列的方式. 在具体的说大家熟知的UTF之前我们来较为深入的重新看下这两个概念, 顺便再复习一下, 这样就更容易理解UTF的设计啦.

我们说在一个编号空间中, 码点就是那个横纵坐标相交的那个点, 通过这个唯一的坐标对来表示, 这个坐标对就是这个码点所对应的字符编号(除了字符码点还有别的, 对于他们就不能这么说了, 但是这样表述比较直接), 然后Unicode在这些字符编号面加上了个U+来声明这是一个Unicode码点, 17个平面, 每个平面有65535个码点 这样就是一共1114112个码点. 另外最长的甚至需要21位二进制来表示, 也就是用三个字节以上来表示.

那么在计算机中, 码点需要转换成为码元. 这样才可以在计算机存储以及网络中传输, 怎么来理解码元呢? 就像是我们编程语言中的数据类型, 或者是汇编语言中的WORD, DWORD这些. 所以当然, 我们的码元在机器中也存在单字节和多字节的类型的, 而就像上面说的, 多字节就存在一个关于字节序的问题, 这也就是为啥我们要强调这个码元的原因.

就像基本的数据类型: BYTE, WORD, DWORD一样, 我们的码元类型也就分成这些: 对应上面的这三个, 分别有单字节, 2字节和4字节的码元. 是不是想到什么了? 没错, 这三种码元类型就对应UTF-8, UTF-16, UTF-32这三种Unicode Transform Form, 也就是Unicode码转换格式, 你也可以把这个叫做UCS Transform Form, 即通用字符集转换格式. 说到这里你就明白了, 所谓Unicode的CEF, 其实就是选择了一个类型的码元来编码.

至于具体是怎么样进行编码的, 我们在下面一个部分来说. (以UTF-8为重点, 毕竟使用最为广泛, 至于UTF-32暂不讨论)

你可能会说, 我还听说过UTF-7, UTF-1这些, 怎么上面没有提到呢? 实际上, UTF-1其实就是UTF-8的退役前身. 目前已经不再是Unicode的标准, 而UTF-7是为了电子邮件而设计的, 目前是deprecated的状态, 不再是Unicode的标准, 作为信息RFC存在.

UTF-8和本不应存在的BOM

utf-8_web_growth.png

先来看一下UTF-8在前几年的增长势头(Web页面, 由Google统计).

UTF-8在目前仍然是最广泛使用的具体编码方案, 但他却不是UTF最早的, 最早的是UTF-16. 最标准的称呼方法应该是UTF-8, 在一些不区分大小写的语言中, 例如: HTML, CSS, XML等, 都可使用utf-8这样的说法. 但是utf8或者UTF8这样缺失连字符的说法不被认为是正确的, 一些现代的浏览器或者是一些相关的标准(HTML5比如) 也可以有效的识别 但最好还是使用标准名称.

接下来步入正题, 我们来说说UTF-8的一些组成和特性.

其实我们刚刚提到了先出现的UTF-16, 那为啥没有坚持使用下去呢, 为啥要重新设计一个UTF-8呢? 这是因为UTF-16的编码方式采用双字节编码, 导致原来的ASCII编码都要变成双字节, 从而导致存储和效率低下, 其实这也没什么, 更严重的问题是C语言在解析文本的时候, 由于将高位的0x00解析成字符串结尾导致问题频频出现, 这就使得Unicode的推行遇到问题, 从而重新设计了UTF-8

UTF-8使用单字节编码, 可变长或者不定长的码元序列. 所以在使用UTF-8对ASCII字符集进行编码的时候, 是透明的. 这既是它的第一个特性. 不仅如此, 由于UTF-8在表示非ASCII字符的时候使用多个单字节码元来构成码元序列, 从而可以很好的进行兼容. 再加上单字符编码节省空间, UTF-8很快就收到了推崇.

另外我们说, UTF-8的编码空间远远大于UTF-16. (这里的编码空间和之前的Code Space是两个层次的概念, 我们现在在探讨CEF, 别忘记了) 至于为啥, 我们再具体的阐述UTF-8的编码算法的时候再说.

现在站在高层想一想, 现在知道UTF-8是变长编码的, 那么一个很明显的缺点就是: 我们无法轻易的得知一段文本的字符数量, 同样, 对这一段文本的索引也会成为一个比较棘手的问题.

好了, 接下来我们就来拓展之前的字节序这个话题. 我们都已经知道UTF-8是单字节编码, 那么按理说是不存在字节序这个问题的呀? 这个问题我们会在后面探讨, 姑且就先默认, 来看一下Unicode是怎么解决字节序这个问题的.

Unicode/UCS标准规定, 使用一个叫做字节序标记(Byte-Order Mark, BOM)的东西来进行规范, 在文本的开头加上一个不占宽度的’’标记’’, 这样一来这个标记就会具有以下的三个基本作用:

  • 文本流是使用什么字节序(或者说端序)存储的.
  • 以一种高可信度来说明文本流是遵循Unicode标准编码的.
  • 该文本流是使用哪一个Unicode编码来进行编码的.

有关于这个标记的具体内容, 其实大家可能都见过的: U+FEFF. 至于为啥是这个, 我们还是要先保留一下.

补充一下, 在旧版本(Unicode 3.2)之前, 这个U+FEFF还有一个名字叫做零宽度不中断空格(ZERO WIDTH NO-BREAK SPACE) 也就是说, 如果在文本中(而原本应该出现在文档流头部的)出现了这个字符, 就会将其视作成一个不产生任何空间占用的(这里的空间是说字符和字符之间的间隙), 不允许换行的特殊字符. 但是, 在新版本(3.2)的Unicode 标准中, 这个名称不再被建议使用. 而是由另外一个新的名词来代替: word joiner(怎么翻译啊..词连接器吧) 具体的码点值是: U+2060

那么怎么表示文本流的字节序是大端序还是小端序呢? 使用下面的声明方式:

  • 0xFE 0xFF => UTF-16 大端序
  • 0xFF 0xFE => UTF-16 小端序
  • 0x00 0x00 0xFE 0xFF => UTF-32 大端序
  • 0xFF 0xFE 0x00 0x00 => UTF-32 小端序

那么UTF-8呢? BOM对于UTF-8的表现形式是: 0xEF 0xBB 0xBF. 但是我们知道UTF-8采用单字节编码, 应该是不需要这个东西的, 那为什么还有这个呢? 说道这个话题可能就要扯的远了一点. 首先我们要明确的是, BOM对于UTF-8只有一种作用, 那就是声明这一段文本是使用UTF-8编码的. Unicode标准允许UTF-8出现BOM, 但是不推荐使用. 在某些情况下(尤其是类Unix系统下), 很多程序会因为文本头部的这一段而解析错误.

在这里插一下我在网上搜集到的, 关于为什么UTF-8和BOM的一些相关:

在之前说道代码页这个概念的时候, 还有ANSI的设定, 我们都可以看出来微软的操作系统-Windows的一些设计思想.

Windows强调一个很重要的东西 — 兼容性.而且对兼容性的支持到了一个很执着的地步. 很多人说Windows为啥就不能使用Unicode编码呢? 事实上是, Windows的内核就是UTF-16编码写的, 也就是说, 整个内核态全部都是默认使用Unicode. 但是微软控制不了用户态运行的各个应用程序, 总不能强制要求所有的开发者使用Unicode编码吧. 因此他就使用了两种不同的编码方式, 即Unicode和Code page. 这样, 当程序和不支持Unicode的程序进行交互的时候, 就会使用到Code page了. 此时才会回到我们之前说的缺省代码页的问题.

(所以说你们也别老说人家微软了…为了兼容真的好麻烦的) 这里才开始正经的说UTF-8和BOM. 前面只是为了说一下微软对Unicode态度.

从Windows的API中其实就可以看出来一点, 比如CreateFile, 就会自动识别并把BOM剔除. 所以UTF-8 with BOM对于Windows来说不是那么影响, 反而, 为了达到兼容的目的, 加上BOM这么一个trick还是挺合理的, 尽管使用字节序标记作为识别头部并不是很优雅.

而且, 前面也说过了, Windows原生支持的是UTF-16, 因此就算是为了自己和自己兼容, 他也是需要BOM这个东西的.

但是对于Unix来说就不是这么回事了. 这个我们放在后面说.

接下来就来具体的说一下UTF-8的编码方式吧.

先来解决一个重要的问题: UTF-8的变长编码, 我怎么知道接下来的长度是多少? 那就是, 通过首字节来判断.

  • 如果首字节以0开头,肯定是单字节编码(即单个单字节码元);
  • 如果首字节以110开头,肯定是双字节编码(即由两个单字节码元所组成的双码元序列);
  • 如果首字节以1110开头,肯定是三字节编码(即由三个单字节码元所组成的三码元序列),以此类推。

使用这样的规律来进行. 提问! 仅仅按照这样的规律, 有没有发现什么问题呀?

是了, 最大的问题就在于, 如果在多字节的情况下第二个字节或者后续字节仍然是110/1110这样开头, 那不是乱套了吗. 所以, 规定后续字节均使用10开头.

上面的四个前缀(0/10/110/1110)的每一个0, 就是前缀码的终结标志. 另外考虑到单字节的情况, 前缀码0和ASCII字符集的设定完全相符, 真是巧妙.

这样, 将前缀码全部剔除掉之后的剩余的内容, 就是Unicode码点值了. 如果难以理解, 请看这样的例子:

1
2
3
4
5
s = "屎"
s.encode("unicode-escape")
# b'\\u5c4e'
s.encode("utf-8")
# b'\xe5\xb1\x8e'

通过unicode-escape我们可以得到码点值, 通过utf-8我们可以得到UTF-8编码拿到的具体的字节序列.

接下来分析一波:

我们先把得到的字节序列写成二进制:

1
2
3
4
5
6
e5
1110 0101 => 1110 表示由三个字节组成, 剔除掉之后是 0101
b1
1011 0001 => 剔除10, 得到 1100 01
8e
1000 1110 => 剔除10, 得到 00 1110

接着我们看一下码点的值:

1
2
3
4
5c
0101 1100
4e
0100 1110

OK! 我们来吧剔除之后的比特拼起来:

1
0101 1100 0100 1110

和码点对比, 当当当! 恭喜你得到了!

UTF-16

略 ( 而且UTF-16的编码算法还是挺复杂的… )

BOM和Unix的矛盾

在Windows上, BOM的加入可以解决很多问题, 但是在Unix上确是一个很矛盾和麻烦的东西. 没错, Unix/Linux是原生支持UTF-8的, 这里的UTF-8是说标准UTF-8, 也就是不携带BOM的UTF-8.

熟悉Linux的朋友都知道, Linux的设计哲学中有这样的: “一切文档中的数据都可见“. 从Shell解析的#!幻数设定就看的出来. 而BOM的到来使得很多Unix程序都傻眼了, 他们自己明明都添加了对BOM的支持, 但是却因为BOM的加入, 使得Shell的#!失效了(因为这个幻数必须放在文档的头部). 这样还没到自己处理, 在Shell这里就卡住了. 很多解释器对于编码类型都有他们自己的声明:

  • -*- coding: utf-8 -*- Python
  • use utf-8 Perl

等等, 看上去这样的处理都比加上一个不可见的BOM头部要可靠和合理的多

另外, BOM的加入对于文本流式的处理变得麻烦, Linux是很强调这个概念的.

一个最典型的例子, 在Windows上使用notepad默认编码编写的C源程序, 在Mac OS X或者Linux/Unix上使用gcc去编译的时候, 会因为出现非法字符而编译失败.

参考资料

以下是(部分)参考资料:

百度百科-区位码 区位码
Wikipedia-Character_encoding 字符编码
Wikipedia-EBCDIC EBCDIC编码标准
Wikipedia-Unicode-历史 Unicode发展历史
Wikipedia-UCS-历史 UCS历史
Wikipedia-Endianness 字节序
Wikipedia-Byte_order_mark 字节序标记
Wikipedia-Word-joiner
UTR#17 Unicode技术报告 #17 — Unicode字符编码模型
中日韩Unicode对照表 这个页面会渲染个几秒钟, 浏览器可能会卡
CJK兼容表-F900-F921
知乎专刊-刨根究底字符编码 这篇笔记的主要参考来源以及结构组织来源, 感谢作者-Jacky lin
阮一峰-字符编码笔记 ASCII,Unicode 和 UTF-8
阮一峰-Unicode和ES6-Slide
知乎-GB2312区位码转机内码为什么要同时加上2020H和8080H
知乎-为什么使用 JavaScript 中 string 的 trim 方法时要替换 \uFEFF 呢?
知乎-微软为什么用带 BOM 的 UTF-8,造成和多数系统的不兼容?
知乎-「带 BOM 的 UTF-8」和「无 BOM 的 UTF-8」有什么区别?网页代码一般使用哪个? 陈甫鸼的回答
AND 一大波的知乎回答, 太多了不好贴…对不起了各位答主🤦‍
吐槽一下: 字符编码的前世今生-TGideas-腾讯游戏官方设计团队 我看到腾讯官方的文章, 还兴冲冲的点了进去, 说实话, 随便的翻阅了一下感觉作者对编码的一些概念还不是很清楚…有点误人子弟的感觉…(别揍我)