一文读懂字符编码
前言
说起字符编码,让我想起了科幻巨作《三体-黑暗深林》人类遇到外星文明魔戒的画面
人类第一次近距离看到四维物体魔戒,在人类探索外星文明,迈向星辰大海的宇宙征程里,也离不开这种最最基础的编码问题。前一阵跟同事碰到了字符乱码的问题,了解后发现这个问题存在两年了,我们每天都在跟编码打交道,但是大家对字符编码都是一知半解,我们“天天吃猪肉却很少见过猪跑”,今天我们就把它讲讲透。
卓文用中频电波发送了一个问候语。这是一幅简单的点阵图,图中由六行不同数量的点组成了一个质数数列:1,3,5,7,11,13. 他们没有指望得到应答,但应答立刻出现了 ..... 太空艇收到了来自“魔戒”的一系列点阵图,第一幅是很整齐的一个8×8点阵,共六十四个点;第二幅图中点阵的一角少了一个点,剩下六十三个;第三幅图中又少一点,剩六十二个……“这是倒计数,也相当于一个进度条,可能表示‘它’已经收到了罗塞塔,正在译解,让我们等侍。”韦斯特说。 “可为什么是六十四点呢?” “使用二进制时一个不大不小的数呗,与十进制的一百差不多。” 卓文和关一帆都很庆幸能带韦斯特来,在与未知的智慧体建立交流方面、心理学家确实很有才能。 在倒计数达到五十七时,令人激动的事情出现了:下一个计数没有用点阵表示,“魔戒”发来的图片上赫然显示出人类的阿拉伯数字56! .....
什么是字符编码
我们知道计算机的世界只有0和1,如果没有字符编码,我们看到的就是一串"110010100101100111001....",我们的沟通就好像是在对牛弹琴,我看不懂它,它看不懂我。字符编码就好比人类和机器之间的翻译程序,把我们熟知的字符文字翻译成机器能读懂的二进制,同时把二进制翻译成我们能看懂的字符。 以下是百科对字符编码的解释
字符编码(Character encoding)也称字集码,是把字符集中的字符,编码为指定集合中的某一对象(例如:比特模式、自然数序列、8位组或者电脉冲),以便文本在计算机中存储或者通信网络的传递。常见的例子是将拉丁字母表编码成摩斯电码和ASCII,比如ASCII编码是将字母、数字和其它符号进行编号,并用7比特的二进制来表示这个整数。 字符集(Character set)是多个字符的集合,字符集种类较多,每个字符集包含的字符个数不同,常见字符集名称:ASCII字符集、GB2312字符集、BIG5字符集、 GB18030字符集、Unicode字符集等。计算机要准确的处理各种字符集文字,就需要进行字符编码,以便计算机能够识别和存储各种文字。
为什么计算机需要编码
因为二进制只有两种状态,使用有两个稳定状态的物理器件就可以表示二进制中的每一位,例如用高低电平或电荷的正负性、灯的亮和灭都可以很方便地用「0」和「1」来表示,这为计算机实现逻辑运算和逻辑判断提供了便利条件。
| 字符 | 编号 |
| 你 | 1 (00000001) |
| 好 | 2 (00000010) |
| ... | ... |
输入码
我们模拟下汉字的输入过程,首先打开txt文本敲下「nihao」的拼音字母,然后输入栏会弹出多个符合条件的汉字词组,最后我们会选择相应的编号,就能实现汉字的输入。那这过程又是如何实现的呢? 计算机领域有一句如同摩西十诫般的神圣哲言: "计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决"。 这里我们再加一层按键字母组合和汉字的映射表,好比英汉字典,这层我们称为输入码,输入码到内码的过程就是一次查表转换操作,比如「nihao」这几个ASCII字符,大家可以随便修改映射表以及候选编号,我可以把他映射成「你好骁飏」。机内码
字形码
计算机中的字符都是以内码的二进制形式表示,我们怎么把数字对应的字符在显示器上显示出来呢,比如数字「1」代表汉字「你」,怎么把「1」显示成「你」? 这就需要依赖字形码, 字形码 本质上是一个n*n 的 像素点阵 ,把某些位置的像素设置为白色(用 1 表示),其它位置像素设置为黑色(用 0 表示),每一个字符的字形都是预先存放在计算机内,而这样的字形信息库我们称为 字库 。 比如中文「你」的点阵图,这样一个 16*16 的像素矩阵,需要 16 * 16 / 8 = 32 字节的空间来表示,右边的字模信息称为 字形码 。不同的 字库 (如宋体、黑体)对同一个字符的字形编码是不同的。| 字形编码 | 字形码 |
| 国 |
|
| 你 |
|
| 中 |
|
| .... | .... |
字符编码的历史
字符编码模型
| 字符 | 十进制 | 二进制 |
| A | 65 | 01000001 |
| B | 66 | 01000010 |
| C | 67 | 01000011 |
| ... | ... | ... |
- 第一层,抽象字符集 ACR(Abstract Character Repertoire):定义抽象字符集合,明确各个抽象字符;
- 第二层,编号字符集 CCS(Coded Character Set):将抽象字符集进行数字编号
- 第三层,字符编码方式 CEF(Character Encoding Form):将字符编号编码为逻辑上的码元序列
- 第四层,字符编码方案 CES(Character Encoding Scheme):将逻辑上的码元序列编码为物理字节序列
第一层:抽象字符集 ACR
所谓抽象字符集,就是抽象字符的合集,是一个无序集合,这里强调了字符是抽象的,也就是不仅包括我们视觉上能看到的狭义字符,比如「a」这样的有形字符,也包括一些我们看不到的无形字符,比如一些控制字符「DELETE」、「NULL」等。 抽象的另一层含义是有些字形是由多个字符组合成的,比如西班牙语的 「ñ」 由「n」和「~」两个字符组成,这一点上 Unicode 和传统编码标准不同,传统编码标准多是将 ñ 视作一个独立的字符,而 Unicode 中将其视为两个字符的组合。 同时一个字符也可能会有多种视觉上的字形表示,比如一个汉字有楷、行、草、隶等多种形体,这些都视为同一个抽象字符(即字符集编码是对字符而非字形编码),如何显示是字形库的事。第二层:编号字符集 CCS
第三层:字符编码方式 CEF
抽象字符集和编号字符集是站在方便我们理解的角度来看的,所以最后我们需要翻译成计算机能懂的语言,将十进制的编号转换成二进制的形式。 因此 字符编码方式就是将字符集的码点编号,转换成二进制码元序列( Code Unit Sequence )的过程。码元:字符编码的最小处理单元,比如ASCII一个字符等于一个字节,属于单字节码元;UTF-16一个字符等于两个字节,处理过程是按字「word」来处理,所以是双字节码元;UTF-8是多字节编码,有单字节字符,也有多字节字符,每次处理是按单个单个字节解析处理,所以处理最小单位是字节,也属于单字节码元这里大家可能会有疑问,十进制直接转二进制不就好了吗,为什么要单独抽出这么一层? 早期的字符编码确实也是这么处理的,十进制和二进制之间是直接转换过去的,比如ASCII码,字符「A」的十进制是「65」,那对应的二进制就是「1000001」,同时存储到硬盘里的也是这个二进制,所以那时候的编码比较简单。 随着后来多字节字符编码(Muilti-Bytes Character Set,MBCS多字节字符集)的出现,字符编号和二进制之间不是直接转换过去的,比如GB2312编码,「万」字的区位编号是「45,82」,对应的二进制机内码却是「1100 1101 1111 0010」(其十进制是「205,242」)。 如果这里不转换直接映射成二进制码会出什么问题呢?「万」字的字符编号「45,82」,45在ASCII里是「-」,82是「U」,那到底是显示两个字符「-U」还是显示一个字符「万」字,为了避免这种冲突 所以增加了前缀处理,详细的过程会在下文具体来讲解。
第四层:字符编码方案 CES
字符编码方案也称作“序列化格式“( Serialization Format ),指的是将字符编号进行编码之后的码元序列映射为字节序列(即字节流)的形式,以便经过编码后的字符能在计算机中进行处理、存储和传输。 字符编码方式CEF有点像我们数据库结构设计里的逻辑设计,而这一层编码方案CES就像是物理设计了,将码元序列映射为跟特定的计算机系统平台相关的物理意义上的二进制过程。 这里大家可能又会有疑问,为什么二进制的码元序列和实际存储的二进制又会不一样呢?这主要是计算机的大小端序造成的,具体端序内容会在UTF-16编码部分详细介绍。 大小端序名词出自Jonathan Swift的《格列夫游记》一书 :所有人都认为,吃鸡蛋前,原始的方法是打破鸡蛋较大的一端。可是当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个手指弄破了,因此他的父亲,当时的皇帝,就下了一道敕令,命令全体臣民吃鸡蛋时打破鸡蛋较小的一端,违令者重罚。 老百姓们对这项命令极为反感。历史告诉我们,由此曾发生过六次叛乱,其中一个皇帝送了命,另一个丢了王位…关于这一争端,曾出版过几百本大部著作,不过大端派的书一直是受禁的,法律也规定该派的任何人不得做官。
常见字符编码
ISO8859-1 字符集,也就是 Latin-1,是西欧常用字符,包括德法两国的字母。 ISO8859-2 字符集,也称为 Latin-2,收集了东欧字符。 ISO8859-3 字符集,也称为 Latin-3,收集了南欧字符。 ISO8859-4 字符集,也称为 Latin-4,收集了北欧字符。 ISO8859-5 字符集,也称为 Cyrillic,收集了斯拉夫语系字符。 ISO8859-6 字符集,也称为 Arabic,收集了阿拉伯语系字符。 ISO8859-7 字符集,也称为 Greek,收集了希腊字符。 .......
GB2312
GB2312是使用两个字节来表示汉字的编码标准,共收入汉字6763个和非汉字图形字符682个,为了避免与 ASCII 字符编码(0~127)相冲突,规定表示一个汉字的编码字节其值必须大于127(即字节的最高位为 1 ),并且必须是两个大于 127 的字节连在一起来共同表示一个汉字( GB2312 为双字节编码),所以GB2312 属于变长编码,当是英文字符的时候占一个字节,中文字符的时候占两个字节,可以认为 GB2312是对 ASCII 的中文扩展。 GB2312字符集编号空间是一个94*94的二维表,行表示区(高位字节),列表示位(低位字节),每区有94个位,每个区位对应一个字符,称为 区位码 。区位码上加2020H,就得到 国标码 ,国标码上加8080H,就得到常用的计算机 机内码 。这里引入了区位码、国标码、机内码概念,下面我们说下三者的关系国标码
区位码
由于上述国标码的16进制可编码区不够直观不方便我们使用,所以我们把他映射成了十进制的94*94二维表编号空间,我们称之为区位码,同时区位码也可以当成一种外码使用,输入法可以直接切换成区位码进行汉字输入,不过这种输入法无规则可言 人们很难记住区位编号,用的人也不多了。 下图是区位码的二维表,比如「万」字是45 区 82 位,所以「万」 字的区位码是「45,82」。其中:
- 01~09区(682个):特殊符号、数字、英文字符、制表符等,包括拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母等在内的682个全角字符;
- 10~15区:空区,留待扩展;
- 16~55区(3755个):常用汉字(也称一级汉字),按拼音排序;
- 56~87区(3008个):非常用汉字(也称二级汉字),按部首/笔画排序;
- 88~94区:空区,留待扩展。
机内码
GBK
GBK即“ 国标扩展 ”的意思,因为GB2312双字节的最高位都要求大于1,上限也不会超过1万个字符,所以对此进行了扩展,对GB2312的字符不重新编码直接沿用,因此完全兼容GB2312。GBK虽然也是 双字节编码 ,但是只要求第一个字节大于 127 就固定表示这是一个汉字的开始,正因为如此,GBK的编码空间比GB2312大很多。 GBK 整体编码范围为 8140-FEFE,首字节在 81-FE 之间,尾字节在 40-FE 之间,剔除 xx7F 一条线,总计 23940 个码位,共收入 21886 个汉字和图形符号;其中 GBK/1 收录除 GB 2312 字符外的其他增补字符,GBK/2 收录 GB2312 字符,GBK/3 收录 CJK 字符,GBK/4 收录 CJK 字符和增补字符,GBK/5 为非中文字符,UDC 为用户自定义字符。 详细如下如所示:GB18030
随着计算机的发展,GBK的2万多个字符也还是扛不住,于是2000年我国又制定了新标准 GB18030,用来替代 GBK 标准。GB18030是强制性标准,现在在中国大陆销售的软件都支持 GB18030。 GB18030其实是对齐Unicode标准的,里面包括了所有Unicode字符集,也算是Unicode的一种实现(UTF)。 那既然有了UTF我们为什么还要搞一套Unicode实现? 主要是UTF-8/UCS-2他们是不兼容GB2312的,如果直接升级那么就全乱码了,所以GB18030是为了兼容GB系列,是GBK、GB2312的超集,当我们原先的GB2312(GBK)软件考虑升级到国际化Unicode时,可以直接使用GB18030进行升级。 GB18030虽然也是GB2312的扩展,但它和GBK的扩展方式不一样,GBK主要是充分利用了GB2312的一些没定义的编码空间,而GB18030采用的是字节变长编码,单字节区兼容ASCII、双字节区兼容GBK、四字节区对齐所有Unicode 码位。 实现原理上主要是采用第二字节未使用到的0x30~0x39编码空间来判断是否四字节。| 字节数 | 编码空间 | 码点数 | |||
| 单字节 | 0x00 ~ 0x7F | 128 | |||
| 双字节 | 第一字节 | 第二字节 | 23940 | ||
| 0x81 ~ 0xFE | 0x40 ~ 0x7E(排除掉0x7F) | ||||
| 四字节 | 第一字节 | 第二字节 | 第三字节 | 第四字节 | 1587600 |
| 0x81 ~ 0xFE | 0x30 ~ 0x39 | 0x81 ~ 0xFE | 0x30 ~ 0x39 | ||
背景介绍
在统一码之前,各国创造了大量的节编码标准,有单字节的、双字节的(如 GB 2312、Shift JIS、Big5 、ISO8859等),各自又相互不兼容。在1987 年,苹果、Sun、微软等公司开始讨论囊括全世界所有字符的统一编码标准,组成了 Unicode 联盟,这个期间做了很多研讨工作,讨论核心要点如下:- 目前世界上有多少个字符,需要几个字节存储?
- 采用固定长度编码还是变长编码?
- 中、日、韩中有很多相近的表意文字是否可以统一?
例如下面这句话如果不做字源分离,会是什么情况呢? 原句 :户有三种写法,分别是“戶”、“户”、“戸”, 改写后:户有三种写法,分别是“户”、“户”、“户”
Unicode介绍
Unicode 称为统一码(也叫万国码),是按现代编码模型进行设计的一套字符编码体系,涵盖抽象字符集、编号、逻辑编码、编码实现。Unicode是为了解决传统的字符编码方案的局限而产生的,在这种语言环境下,不会再有语言的编码冲突,可以在同屏下显示任何国家的语言。 UTF-n编码(Unicode Transformation Format Unicode字符集转换格式,n表示码元位数)是Unicode这套编码体系里的编码实现CES部分,像UTF-8、UTF-16、UTF-32都是将数字转换到实际的二进制编码实现,Unicode的编码实现除了UTF系列之外,还有UCS-2/4,GB18030等。但是现在很多人误把Unicode当成只是一个字符编号,这其实是不对的。 Unicode可以容纳世界上所有国家的文字和符号,其编号范围是0-0x10FFFF,有1,114,112个码位,为了方便管理划分成17个 平面 ,现已定义的码位有238,605个,分布在平面0、平面1、平面2、平面14、平面15、平面16。其中平面0又称为 基本多语言平面 (Basic Multilingual Plane,简称BMP),这个平面基本涵盖了当今世界上正在使用中的常用字符。我们平常用到的字符,一般都是位于 BMP 平面上的,其范围拥有 65,536 个码点,其他平面统称增补平面,关于平面的概念会在UTF-16章节详细介绍。与UCS的关系
UTF-16(Java内部编码)
UTF是Unicode Transfer Format的缩写,即把Unicode转做某种格式的意思,所以 UTF-16是Unicode编码里的其中一种实现方式,16代表的是字节位数 ,占两个字节(UTF-32则表示4个字节)。 Unicode 设计之初是采用UTF-16这种双字节定长编码 的,其字符编号就是对应的二进制编号,也就是说第二层的CCS和第三层的CEF是一致的。比如汉字「万」的 Unicode 码点是 「U+4E07」,其二进制序列就是直译的「0100 1110 0000 0111 」,这种编码方式的优点是高效,不需要检查标志位,但缺点是不兼容ASCII,ASCII编码的文本都会显示乱码。 不过后来Unicode联盟发现 16 位编码空间根本不够用,与此同时 ISO/IEC组织也觉得 UCS的 32 位编码空间太多了,实际中根本没有几十亿字符,也挺浪费空间的,所以最终 Unicode 联盟和 ISO/IEC 工作组达成一致:两者使用统一的编码空间「 0000 ~ 10FFFF」(即 UCS 保证永远不分配大于 10FFFF 的字符码点),而且双方在字符编码上保持同步,即一方标准中增加了字符,也要通知另一方同步。 于是Unicode在UTF-16基础上拓展编码空间到 21 位,UCS则搞了一个双字节的UCS-2编码实现。 → UTF-16 编码是双字节的,上限也只有6w多个码点,怎么让他支持到10FFFF(100w+)个码点呢? 本质就是多加几个字节来表示更多的字符,只是UTF-16不像UCS那样采用定长4字节,而是使用变长的形式,但是这个跟UTF-8变长方式又不太一样,他是采用代理对的方式实现,大部分常用字符用一个码元表示(定长2个字节),其他扩展的特殊字符用两个码元表示(定长4字节)。代理对
UTF-16跟UTF-8、GB系列等都算是变长字节,但是设计初衷却不一样,像GBK是为了兼容ASCII,但是UTF-16一开始就没考虑要兼容ASCII,所以他的变长是为了节约存储空间而采用的自然增长方案,当空间不够的时候增长到4个字节。平面空间
UTF-16把编码空间0000 ~ 10FFFF切成了17个平面,其实就是划分成17个区块,每个平面空间码点数都是=65536个,第一个平面称为 基本多语言平面 (Basic Multilingual Plane,简称BMP),这个平面涵盖了当今世界上最常用的字符,固定使用定长两个字节,除此之外的字符都放到 增补平面 里,都是使用两个码元的定长4个字节,下面是各个平面的用途字节序
字节序顾名思义是指字节的顺序,对于单字节编码来说,一个字符对应一个字节,也就不存在字节序问题;但是对于UTF-16这种定长多字节编码,就有字节顺序问题了。字节序其实跟操作系统和底层硬件有关,不仅只是UTF-16这种多字节编码存在字节序,只要是多字节类型的数据都存在字节顺序问题,比如short、int、long。 为了方便说明,我们这里举个例子,比如存一个整数值「305419896」对应16进制是0x12345678,有人习惯从左到右按顺序去存,也有人说高位当然要放到高位地址而低位放到低位地址,要从右往左存。于是就有了下面两种存取方式。UTF-8
简介&规则
Unicode还是UCS最初都是采用多字节定长编码,由于没有兼容现有的 ASCII 标准的文件和软件,新标准很难被推广,于是兼容ASCII版本的UTF-8就诞生了。UTF-8(8-bit Unicode Transformation Format)是一种针对Unicode的可变长度字符编码,是现代字符编码模型中的第三层 CEF 。它可以用一至四个字节对 Unicode 字符集中的所有有效编码点进行编码,属于Unicode标准的一部分,UTF-8 就是为了解决向后兼容 ASCII 码而设计,Unicode 中前 128 个字符(与 ASCII 码一一对应),使用与 ASCII 码相同的二进制值的单个字节进行编码,这使得原来处理 ASCII 字符的软件无须或只须做少部分修改,即可继续使用。因此,它逐渐成为电子邮件、网页及其他存储或发送文字优先采用的编码方式。 —— 维基百科
UTF-8需要兼容ASCII,所以也需要有 前缀码 来控制,前缀规则如下:
- 如果首字节以 0 开头,则是 单字节编码 (即单个单字节码元);
- 如果首字节以 110 开头,则是 双字节编码 (即由两个单字节码元所组成的双码元序列);
-
如果首字节以 1110 开头,则是 三字节编码 (即由三个单字节码元所组成的三码元序列),以此类推。
程序算法
用文字不太好描述算法结构,我们就直接来欣赏一下UTF-8鼻祖写的这段解析代码,这是Ken 和 Rob 用一个晚上写出来的编解码算法,代码非常简短精炼,为了方便阅读我加了注释解读。typedefstruct{int cmask; //前缀码掩码int cval; //前缀码int shift; //移动位数long lmask; //Unicode值掩码long lval; //Unicode下限值} Tab;staticTab tab[] ={0x80, 0x00, 0*6, 0x7F, 0, /* 1 byte sequence */0xE0, 0xC0, 1*6, 0x7FF, 0x80, /* 2 byte sequence */0xF0, 0xE0, 2*6, 0xFFFF, 0x800, /* 3 byte sequence */0xF8, 0xF0, 3*6, 0x1FFFFF, 0x10000, /* 4 byte sequence */0xFC, 0xF8, 4*6, 0x3FFFFFF, 0x200000, /* 5 byte sequence */0xFE, 0xFC, 5*6, 0x7FFFFFFF, 0x4000000, /* 6 byte sequence */0, /* end of table */};
/*** 把一个多字节序列转换为一个宽字符** @param p 存放计算后的unicode值* @param s 需要解析的UTF-8字节序列* @param n 字节长度* @return 解析的字节长度*/int mbtowc(wchar_t *p, char *s, size_t n){long l; int c0, c, nc; Tab *t;if(s == 0) return 0;nc = 0;//异常校验(可不用关注)if(n <= nc) return -1;//c0 此处备份一下首字节,后续需要用到前缀码c0 = *s & 0xff;//l 保存 Unicode 结果l = c0;/* 遍历tab,从单字节结构->2字节结构->..依次检查找到对应tab */for(t=tab; t->cmask; t++) {//字节数+1,字节数和tab结构是对应的,也就是当nc=1时 tab结构是单字节,nc=2是tab是两字节nc++;/* 判断前缀码跟当前的tab是否一致, 如果一致计算最终unicode值并返回*/if((c0 & t->cmask) == t->cval) {//通过 & Unicode有效值掩码,移除高位前缀码,得到最终unicode值l &= t->lmask;//异常校验if(l < t->lval) return -1;//保存结果并反回*p = l;return nc;}//异常校验if(n <= nc) return -1;//读取下个字节;如果上面判断前缀码不一致,说明需要再读取下个字节s++;//计算有效位的值,目的是去除UTF-8 编码从第二个字节开始的高两位10// 例如 s=10101111、0x80=10000000 计算结果是00101111,这样就去除了高位前缀10c = (*s ^ 0x80) & 0xFF;//异常校验if(c & 0xC0) return -1;//重新计算unicode值,根据UTF-8规则c只有低 6 位有效,所以通过移位把c填入到l的低6位l = (l<<6) | c;}//返回异常return -1;}
容错性
通过上面的程序我们知道解析过程是一个字节一个字节往下处理的,我们在传输过程中如果发生局部的字节错误、丢失,或者中间有一个字节规则对不上,会不会影响整个文本的解析? 我们先来看下其他编码的容错情况,从对于单字节的ASCII码来说,丢失一个字节就丢失一个字符,并不影响后续文本的内容,比如Hello world,丢失b2字节后内容是Hllo world少个e而已 我们再来看GB2312这种多字节编码,如果丢失了b2字节那么整个文本都乱套了,这是最糟糕的,大部分多字节编码都有类似问题,一旦出现错误可能导致整个文件都需要重传。总结
单单一个字符编码,深入了解之后发现也有这么浓重的发展历程,试想一下,如果计算机还是跟之前大型机一样,个人计算机没有井喷式发展起来就没有这些字符编码的事了,如果ASCII当初就设计成多字节编码,也没有后面UNICODE什么事了。
- 有人说靠演化出来的,没有设计的产品架构是没有灵魂的,发展的路上死的很快。
- 有人说靠设计出来的,这是一种完美主义者,你超前设计个50年、100年等你设计出来了,说不定公司都已经倒闭了,有很多叫好不叫做的产品、架构也比比皆是。
千万阅读量、百万下载量、上百本电子书,近200位阿里专家参与编写。多元化选择、全领域覆盖,汇聚阿里巴巴技术实践精华,读、学、练一键三连。开发者藏经阁,开发者的工作伴侣~
点击阅读原文查看详情。