前段时间用C++写了个批量改文件名的小工具,发现在改英文文件名的时候正常,但是遇到中文文件名就会失败。调试过程中发现,中文文件名在程序里得到的是乱码。这个问题在VS Code中很明显,只要是在VS Code的控制台里输出中文字符,大概率乱码,所以在在使用VS Code练习C++的时候,很少使用中文字符。这个问题我也一直没有深究,最近看了字符编码的相关知识,这里总结记录一下。为了了解乱码,首先需要了解什么是字符编码,以及一些常见的字符编码系统。

字符编码


字符编码有很多相关的概念,比如现代编码模型,这里不多讲述。大体上来看,字符编码经历了从最开始基本的ASCII码,发展到对其的各种扩展,如EASCII、GBK、Big5等,再到最后的大一统Unicode的过程。这里主要讲解这其中每个阶段的常用的编码,比如ASCII,GBK,UTF等。

ASCII码


ASCII码 (American Standard Code for Information Interchange,美国信息交换标准码)由美国国家标准学会ANSI(American National Standard Institute)于1968年正式制定。1972年,ASCII编码标准被ISO/IEC采用,制定为ISO/IEC 646标准 (ISO,International Standardization Organization,国际标准化组织;IEC,International Electrotechnical Commission,国际电工技术委员会;ISO/IEC往往用来表示由这两大国际组织联合制定的标准)。因此,ISO/IEC 646(常简称为ISO 646)与ASCII,实际上指的是同一个编码标准。

计算机是美国人发明的,由于他们的语言是英语,字符比较少,所以一开始就设计了一个不大的二维表, 它使用7个二进制位(bit)来表示一个字符, 总共表示128个字符(2^7 = 128,二进制编码为0000 0000 ~ 0111 1111,对应十进制就是0~127)。由于计算机通常采用8位作为1个字节来进行存储和处理,所以剩下的最高位的1比特一般设置为0,在一些通讯系统中也可以用来作奇偶校验位。

▲ ASCII编码

这128个字符分为几组:

  • 0~31:不可显示、不可打印的控制字符或通讯专用字符,如0x07(BEL响铃)会让计算机发出哔的一声、0x00(NUL空,不是空格)通常用于指示字符串的结束、0x0D(CR回车,转义字符'\r')和0x0A(LF换行,转义字符'\n')用于指示打印机的打印针头退到行首(即回车)并移到下一行(即换行)等;比如以下代码会让计算机响铃:
    int main()
    {
        std::cout<<(char)7;  //或者 std::cout<<'\a';
        return 0;
    }
  • 32:可显示但不可打印的空格字符;
  • 33~126:可显示可打印字符,其中48~57为0-9的阿拉伯数字,65~90为26个大写英文字母,97~122为26个小写英文字母(大写和小写字母ASCII值相差32,大写字母的ASCII值+32恰好等于对应的小写字母),其余的是一些标点符号、运算符号等;
  • 127:不可显示不可打印的控制字符DEL。

ASCII字符的编解码非常简单,比如要将字符序列写入存储设备,只需要将该字符序列里的各个字符在ASCII字符集中的字符编号(即码点编号)直接以二进制字节写入存储设备即可,字符编号就是字符编码,中间不需要经过特别的编码算法进行字符编号到字符编码的转换计算,更不存在所谓码元序列到字节序列的转换

ASCII编码方案是最基础、最重要、应用最广泛的字符编码方案。目前所有通行的其它字符编码方案,比如ISO-8859系列、GB系列(GB2312、GBK、GB18030、GB13000)、Big5、Unicode等等,均直接或间接兼容ASCII码,而与ASCII完全不兼容的编码方案,基本上处于已淘汰或将要淘汰的境地。

ASCII的局限在于只能显示26个基本拉丁字母、阿拉伯数字和英式标点符号,因此只能用于显示现代美国英语(且处理naïve、café、élite等外来语时,必须去除附加符号)。虽然EASCII解决了部分西欧语言的显示问题,但对更多其他语言依然无能为力,所以出现了一些对ASCII进行扩充的编码。

EASCII及ISO 8859


计算机出现之后,逐渐从美国发展到了欧洲。欧洲很多国家所用到的字符中,除了基本的、美国也用的那128个ASCII字符之外,还有很多衍生的拉丁字母等字符。比如在法语中,字母上方有注音符号,欧洲其它国家也有各自特有的字符。考虑到一个字节8位能够表示的编码实际有256个(2^8 = 256),而ASCII字符却只用到了其中的低7位(ASCII码中最高位总是为0 ,编号为0x00~0x7F,十进制为0~127)。也就是说,ASCII只使用了一个字节所能表示的256个编码中的前128个(2^7 =128)编码,而后128个编码相当于被闲置了。

因此,欧洲各国纷纷打起了后面这128个编码的主意。在原来的二维表的基础上进行了扩展,利用ASCII编码字节中闲置的最高位来编入新的符号。这种扩展后的编码被称为EASCII(Extended ASCII,延伸美国标准信息交换码)。

EASCII是将ASCII码由7位扩充为8位而成。EASCII的内码是由0到255共有256个字符组成。256个码位正好可以使用一个字节表示,其表示范围为00000000-11111111或0x00-0xFF。EASCII码比ASCII码扩充出来的符号包括表格符号、计算符号、希腊字母和特殊的拉丁符号。

Extend ASCII

不过,EASCII码目前已经很少使用,原因在于其容纳的字符太少,而且国际化和标准化程度不够,不同厂商和平台在实现上有差异。因此,EASCII码目前已基本被容纳字符更多、设计更为优良、更具有国际化和标准化特点的ISO/IEC 8859字符编码方案取代了。

ISO/IEC 8859字符编码方案与EASCII字符编码方案类似,也同样是在ASCII码的基础上,利用了ASCII的7位编码所没有用到的最高位(首位),将编码范围从原先ASCII码的0x00~0x7F(十进制为0~127),扩展到了0x80~0xFF(十进制为128~255)。ISO/IEC 8859字符编码方案所扩展的这128个编码中,实际上只有0xA0~0xFF(十进制为160~255)被实际使用。也就是说,只有0xA0~0xFF(十进制为160~255)这96个编码定义了字符,而0x80~0x9F(十进制为128~159)这32个编码并未定义字符。

显然,ISO/IEC 8859字符编码方案同样是单字节编码方案,也同样完全兼容ASCII。与ASCII、EASCII字符编码方案只包括单个独立的字符集不同,ISO/IEC 8859字符编码方案包括了一组字符集,或者说ISO/IEC 8859相当于是一组字符集的总称,其内共包含了15个字符集,即ISO/IEC 8859-n,n=1、2、3...15、16,其中12未定义,实际上共15个。

这15个字符集,大致上包括了欧洲各国所使用到的字符(甚至还包括一些外来语字符),而且每一个字符集的补充扩展部分(即除了兼容ASCII字符之外的部分),都只实际使用了0xA0~0xFF(十进制为160~255)这96个编码,而0x80~0x9F(十进制为128~159)这32个编码并未实际定义字符

ISO 标准ISO 8859的许多语言版本中,目前使用得最为普遍的是ISO/IEC 8859-1字符集(“ISO Latin 1”) ,其支持大多数西欧语言 (包括德法两国的字母), 在西欧最为人所知。ISO/IEC 8859-1往往简称为ISO 8859-1,而且还有一个称之为Latin-1(也写作Latin1)的别名。从ISO 8859-1到ISO 8859-16各自所收录的字符分别如下:

  • ISO 8859-1字符集,也称Latin-1字符集,收录了西欧常用字符(包括德法两国的字母);
  • ISO 8859-2字符集,也称Latin-2字符集,收录了东欧字符;
  • ISO 8859-3字符集,也称Latin-3字符集,收录了南欧字符;
  • ISO 8859-4字符集,也称Latin-4字符集,收录了北欧字符;
  • ISO 8859-5字符集,也称Cyrillic字符集,收录了西里尔(斯拉夫语)字符;
  • ISO 8859-6字符集,也称Arabic字符集,收录了阿拉伯语字符;
  • ISO 8859-7字符集,也称Greek字符集,收录了希腊字符;
  • ISO 8859-8字符集,也称Hebrew字符集,收录了希伯来语字符;
  • ISO 8859-9字符集,也称Latin-5或Turkish字符集,收录了土耳其字符;
  • ISO 8859-10字符集,也称Latin-6或Nordic字符集,收录了北欧(主要指斯堪地那维亚半岛)字符;
  • ISO 8859-11字符集,也称Thai字符集,几乎与泰国国家标准TIS-620(1990)字符集等同, 唯一的区别是,ISO 8859-11定义了不间断空格NBSP(non-breaking space)字符(码点值为0xA0),而TIS-620中则未定义该字符;
  • ISO 8859-12字符集,目前尚未定义(未定义的原因目前有两种说法:一是原本要设计成一个包含凯尔特语字符的“Latin-7字符集”,但后来凯尔特语变成了ISO 8859-14 / Latin-8字符集;二是原本预留给印度天城体梵文的,但后来被搁置了);
  • ISO 8859-13字符集,也称Latin-7字符集,主要函盖波罗的海(Baltic)诸国的文字符号,也补充了一些被Latin-6字符集遗漏的拉脱维亚(Latvian)字符;
  • ISO 8859-14字符集,也称Latin-8字符集,它将Latin-1字符集中的某些符号换成凯尔特语(Celtic)字符;
  • ISO 8859-15字符集,也称Latin-9字符集,或者被戏称为Latin-0字符集,它将Latin-1字符集中较少用到的符号删除,换成当初遗漏的法文和芬兰字母,还把英镑和日元之间的金钱符号,换成了欧盟货币符号;
  • ISO 8859-16字符集,也称Latin-10字符集,涵盖了阿尔巴尼亚语、克罗地亚语、匈牙利语、意大利语、波兰语、罗马尼亚语及斯洛文尼亚语等东南欧国家语言。

然而,单个字节表示256个字符的ASCII系编码,对于欧洲各国的语言尚可表示,然而对于亚洲地区的语言来说则远远不够,中国的汉字就多达10万左右。因此,就必须使用多个字节表示一个字符。为此在亚洲地区又出现了很多编码,大陆的GB2312和GBK(GB2312的扩展,Kuozhan)、港台的BIG5、日本的Shift JIS等等。例如,GB2312编码使用两个字节表示一个汉字,所以理论上最多可以表示65536个汉字。

汉字编码方案


当计算机被引入中国之后,相关部门设计了GB系列编码(GB为“国标”的拼音,意为“国家标准”)。在GB系列编码的方案中,如果一个字节是0~127,那么这个字节的含义与ASCII编码相同,否则,这个字节和下一个字节共同组成一个汉字。因此,GB系列编码方案向下完全兼容ASCII码。也就是说,如果一段采用GB编码方案编码的文本里的所有字符都在ASCII中,那么这段GB编码实际上就是ASCII编码。

GB2312

GB2312编码方案,即《信息交换用汉字编码字符集——基本集》,是由中国国家标准总局于1980年发布、1981年5月1日开始实施的一套国家标准,标准号为GB2312-1980。GB2312编码为了避免与ASCII字符编码(0~127)相冲突,规定表示一个汉字的编码(即汉字内码)的字节值必须大于127(即字节的最高位为1),并且必须是两个大于127的字节连在一起来共同表示一个汉字(GB2312为双字节编码),前一字节称为高字节,后一字节称为低字节而一个字节的值若小于等于127(即字节的最高位为0),自然是仍表示一个原来的ASCII字符(ASCII为单字节编码)。因此,可以认为GB2312是对ASCII的中文扩展(即GB2312完全直接兼容ASCII),正如EASCII是对ASCII的欧洲文字扩展一样。

▲ GB2312 图形字符代码表,图很大,忍耐一下...

如上图所示,整个GB2312字符集分为94个区(Section),每个区有94个位(Position),每个区位上只有一个字符,用所在的区和位对字符进行编码,因此称为区位码。换就话说,GB2312将包括汉字在内的所有字符编入一个94*94的二维表,行就是“区”,列就是”位“,每个字符由区、位唯一定位,区、位编号合并就是区位码

比如上图中,”万“字在45区82位,所以”万“的区位码是45 82,因为GB类汉字编码方案是双字节编码,所以45相当于高位字节,82相当于低位字节。

GB2312标准共收录6763个汉字(72行*94列-5个空格=6768-5)和682个字符,整个表分为以下几个区:

  1. 01~09区(682个):特殊符号、数字、英文字符、制表符等,包括拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母等在内的682个全角字符;
  2. 10~15区:空区,留待扩展;
  3. 16~55区(3755个):常用汉字(也称一级汉字),以拼音字母为序进行排列,同音字以笔形顺序横竖撇捺折为序,起笔相同的按第二笔,依次类推;
  4. 56~87区(3008个):非常用汉字(也称二级汉字),按部首/笔画排序;
  5. 88~94区:空区,留待扩展。

除汉字之外的682个字符中,GB2312甚至还包括了ASCII里本来就有的数字、标点、字母等字符。也就是说,这些ASCII里原来就有的单字节编码的字符,又再编了两个字节长的GB2312编码版本这682个双字节编码字符就是常说的“全角”字符,而这些字符所对应的单字节编码的ASCII字符就被称之为“半角”字符

GB2312的区位码的编码范围本来是(1-96,1-96),但是为了避开ASCII码中ASCII值为0-31的不可显示的,以及值为32空格字符。所以需要将”区码“和”位码“各往后移32位,范围变为了(33-126,33-126)这就是国标码,又叫交换码,它的出现就是为了避免与ASCII码中的0~32的不可显示字符和空格字符的冲突。

这样,”万“字的区位码为(45,82),各加32 变为国标码为(77,114)。

但是,往后移32位的国标码仍然不能直接在计算机上使用,因为这样还是会和早已通用的ASCII码冲突,从而导致乱码。比如,“万”字国标码中的高位字节77与ASCII的“M”冲突,低位字节114与ASCII的“r”冲突。因此为避免与ASCII码冲突,规定国标码中的每个字节的最高位都从0换成1,即相当于每个字节都再加上128(十六进制为80,即80H;二进制为1000 0000),从而得到国标码的“机内码”表示,简称“内码”

由于ASCII码只用了一个字节中的低7位,所以这个首位(最高位)上的“1”就可以作为识别汉字编码的标志,计算机在处理到首位是“1”的编码时就把它理解为汉字,在处理到首位是“0”的编码时就把它理解为ASCII字符。对于“万”字的国标码77和114,分别加上128变为:

  • 77 + 128 = 205(二进制为1100 1101,十六进制为CD)
  • 114+ 128 = 242(二进制为1111 0010,十六进制为F2)

在记事本中,输入“万”,然后以“ANSI”格式保存。然后用二进制编辑器(比如UltraEdit)打开刚才保存的文件,切换到十六进制模式,会看到:CD F2,这就是“万”字的内码,如下图所示。

▲ 汉字“万”的ANSI编码

注意,GB2312并没有对ASCII中的128个字符进行重新编码,它只是提供了其中一些符号的用2个字节表示的全角表示。比如全角1,在GB2312中的区位码是(3,17),区码位码分别加上160得到(163,177)十六进制表示就是(A3, B1)。在记事本中,切换到中文输入法,选中全角,然后输入1,查看对应的十六进制编码:

▲ 全角1的编码

可以看到,大小是2个字节,编码是A3 B1,和我们计算的完全相同。在半角状态下,输入1,占1个字节,值就是ASCII编码31,如下:

▲ 半角的1的编码

总结一下就是:在GB2312 区位码的区码和位码分别+32(+20H)得到国标码再分别+128(+80H)得到机内码(与ACSII码不再冲突)。也可以在区位码的区和位分别+160(即+A0H,32+128=160)直接得到内码。

其实想想还挺蛋疼的,要避免跟ASCII冲突,直接咔咔在区码和位码上+128不就好了,也不会和ASCII码冲突,为啥还要先+32搞个半吊子的国标码?然后再在这个基础上+128等于总共在区位码的基础上+160得到内码。本来+128就完事了,+160这前面的32个不也浪费了嘛?

▲ 费那劲干嘛~

GBK

GB2312收录了不到一万个汉字,基本能满足日常使用,但对于人名、古汉语等方面出现的罕用字、生僻字,GB2312不能处理,如部分在GB2312-1980推出以后才简化的汉字(如“啰”)、部分人名用字(如陶喆的“喆”字)、台湾及香港使用的繁体字、日语及朝鲜语汉字等,并未收录在内。因此后来又在GB2312上进行了扩展,在GB2312基础上扩展的编码方案称为GBK(K为“扩展”之意),后来又在GBK基础上进行了进一步扩展,称为GB13000方案。每扩展一次都完全保留之前版本的编码,因此每个版本都向下兼容。

虽然GBK跟GB2312一样是双字节编码,但GBK只要求第一个字节即高字节大于127就固定表示这是一个汉字的开始(即GBK编码高字节的首位必须是1;0~127当然表示的还是ASCII字符),不再像GB2312一样要求第二个字节即低字节也必须大于127(即GBK编码低字节首位既可以是0,也可以是1)。

在GB2312中,区位码的范围是1-94,然而其中真正用到编码的区码范围是1-87,位码范围是1-94。区码和位码分别+160得到的区码的内码范围是161~247,即A1~F7,位码的内码范围是161-254,即A1~FE。

▲ GBK编码

GBK 采用双字节表示,总体编码范围为 0x8140 - 0xFEFE,首字节在 0x81 - 0xFE 之间,尾字节在 0x40 - 0xFE 之间,剔除 0x7F 一条线。总计 23940 个码位,共收入 21886 个汉字和图形符号,其中汉字(包括部首和构件)21003 个,图形符号 883 个。全部编码分为三大部分,如上图:

1. 汉字区:

  • GB 2312汉字区,即GBK/2, 就是原来GB2312的中文字符的区码(16-87)位码(1-94)经过+160之后的内码范围,即第一字节范围为B0~F7,第二字节范围为A1~FE,共6763个。
  • GB 13000.1 扩充汉字区,包括
    • GBK/3:0x8140 - 0xA0FE。收录 GB 13000.1 中的 CJK 汉字 6080 个。
    • GBK/4:0xAA40 - 0xFEA0。收录 CJK 汉字和增补的汉字 8160 个。

2. 图形符号区:

  • GB 2312 非汉字符号区,即GBK/1:0xA1A1 - 0xA9FE。其中除 GB 2312 的符号外,还有 10 个小写罗马数字和 GB 12345 增补的符号。计符号 717 个。
  • GB 13000.1 扩充非汉字区。即 GBK/5: 0xA840 - 0xA9A0。BIG-5 非汉字符号、结构符和“○”排列在此区。计符号 166 个。

3. 用户自定义区,分为3个小区

  • UDC-1,0xA140 - 0xA7A0,码位 672 个。
  • UDC-2,0xF8A1 - 0xFEFE,码位 658 个。
  • UDC-3,0xAAA1 - 0xAFFE,码位 564 个。

UDC 区尽管对用户开放,但限制使用,因为不排除未来在此区域增补新字符的可能性。

GBK相比GB2312,增加了大概一万多个编码格,缺点如前所述,对比GB2312的内码编码规则,与GB2312一样,第一个字节都必须大于128,但GBK并不要求第二个字节也必须大于128,这就导致第二个字节可能与ASCII编码产生冲突。但问题不大,文本处理器如果不知道这当前字节是ASCII码还是GBK码,只要看看它的前一字节和后一字节,如果都比0x80大,那应该就是汉字编码而不是ASCII码了(ASCII码 都在0x80以下)。因此解读程序是可以正确设置编码方案的。

另外,可以看到GBK编码中排除了第二个字节为0x7F的那一条线,这是因为0x7F代表了ASCII码中的DEL,各种编码应该都要避免0x08和0x7F这两个字符,以及其它的控制字符。0x08就是'\b',表示BackSpace,向前删除一个字符;0x7F则表示DEL,向后删除一个字符。另外说一下DEL的编码为什么是0x7F,我们都知道ASCII码是7位的,以前打纸带的时候,如果打错了,就要把所有位置全打上洞,表示这一个字节是无效的。所以7位的全1就用来表示DEL。 因为GBK的第二个字节大于0x40(已经跳过了很多控制字符),这其中就包含了0x7F这一特殊字符DEL,为了避免出现问题,所以需要排除。

ANSI


如前所述,在统一的UCS/Unicode编码方案问世之前,各个国家、地区为了用计算机记录并显示自己的字符,都在ASCII编码方案的基础上,设计了各自的编码方案。比如欧洲先后设计的EASCII和ISO/IEC 8859-1~16系列编码;中国大陆地区设计的GB系列编码;日文、韩文以及其他世界各个国家和地区的文字都有它们各自的编码。所有这些各个国家和地区所独立制定的既兼容ASCII又互相之间不兼容的字符编码,微软统称为ANSI编码。

在微软Windows系统的编码处理中,ANSI编码一般代表系统默认的编码方式,而且并不是确定的某一种编码方式:在简体中文操作系统中ANSI编码默认指的是GB系列编码(GB2312、GBK、GB18030);在繁体中文操作系统中ANSI编码默认指的是Big5编码(港澳台地区使用的繁体汉字编码);在日文操作系统中ANSI编码默认指的是Shift JIS编码,等等。系统默认编码方式可在系统区域设置的系统Locale中查看、更改。

▲ 区域语言设置

代码页

代码页(Code Page,简称CP)也称为“内码表”,是计算机中与特定字符集(准确地说是字符集的某个字符编码方式CEF)相对应的一张字符编码对照表。不同的厂商有自己的代码页,比如ANSI代码页(即微软所采用的代码页标准),以及IBM代码页,Oracle代码页、SAP代码页等等。

微软基于ANSI组织的代码页标准,定义了一系列支持世界各国和地区独立的字符编码的代码页,因而被称作“ANSI代码页”。代表性的是实现了ISO 8859-1(即Latin-1)的代码页1252(即CP 1252),实现了GBK的代码页936(即CP 936),以及实现了UTF-8的代码页65001(即CP 65001),微软公司定义的代码页一览表:Code Page Identifiers - Windows applications。现代操作系统中,不同的国家或地区,使用不同的语言和区域设置,可能对应不同的代码页。 

在Windows中,在命令行中可以chcp(change code page)来显示和设置活动代码页。

▲ 代码页

▲ UltraEdit 显示的代码页

在区域语言设置中,当勾选“使用Unicode UTF-8 提供全球语言支持之后(需要重启),再次执行chcp,可以看到活动代码页已经变成了65001了。

大一统Unicode字符


随着计算机发展到世界各地,于是各个国家和地区搞出了很多既兼容ASCII但互相之间又不兼容的各种编码方案(微软统一称之为ANSI编码,具体体现为各种ANSI代码页)。这样一来,同一个二进制编码在不同的ANSI编码方案中就有可能被解释成不同的字符,从而对采用不同ANSI编码方案的系统之间的数据交换和信息交流带来了极大的不便。因此,要想打开一个文本文件,就必须首先知道它所采用的ANSI编码方案,否则用错误的ANSI编码方案进行解码,就会出现乱码。而且一个文件中,如果存在两种或以上采用不同的ANSI编码方案的字符,则同一时间只能显示其中的一种字符,要显示另一种,必须先切换操作系统的ANSI编码方案,或者加装额外的ANSI编码处理软件。

如果有一种编码,将世界上所有的符号都纳入其中。每一个符号都给予一个独一无二的编码,那么就可以彻底解决乱码问题,这就是Unicode。Unicode字符集(CCS)到目前为止定义了包括1个基本多语言平面BMP(Basic Multilingual Plane)和16个增补平面SP(Supplementary Planes)在内的共17个平面。每个平面的码点(code point)数量为2^16=65536个,因此17个平面的码点总数为共65536*17=1114112个。其中,基本平面码点为65536个(码点编号范围为0x0000~0xFFFF),增补平面码点为1114112-65536=65536*16=1048576个(码点编号范围为0x10000~0x10FFFF)。

Unicode是一个很大的集合,现在的规模可以容纳100多万个符号。每个符号的编码都不一样,比如,U+0639表示阿拉伯字母Ain,U+0041表示英语的大写字母A,U+4E25表示汉字"严"。具体的符号对应表,可以查询unicode.org,或者专门的汉字对应表

从字符集的角度上来讲,Unicode字符集不同于ASCII这样不能再增加字符的封闭字符集,它是一个开放字符集,是可以不断地增加字符的。因此,Unicode字符集也在不断发展中(比如随着互联网即时聊天工具的发展而流行起来的很多Emoji表情符,就被不断地增加到了Unicode字符集里),理论上支持的字符数量是没有上限的,未来还可不断地扩展。

▲ 部分汉字Unicode编码表,比如“严”的编码为 “4E25”

▲ Unicode中的emoji字符

Unicode只是一个符号集,只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。Unicode方案,有点象GB2312的是,它们给字符编了码值,但在计算机上存储时,并不是直接采用这个码值,而是使用经过转换的数值才用于存储。大家经常接触的UTF8,UTF16等就是具体存贮Unicode编码时的转换方案。

事实上,Unicode在实现上存在两个需要解决的问题:

  1. 如果采用多字节定长存储,则面临着极大的存储空间浪费以及字符集扩展问题。如:单字节的ASCII编码将浪费大量存储空间、如果是定长存储,当字符集扩充时有可能长度不能编码。
  2. 如果采用多字节非定长存储,则面临着不定长编码的识别问题。即,如何知道一个编码使用了多少字节。

目前,有三种被广泛认知的Unicode实现方式:UTF-8,UTF-16(字符用两个字节或四个字节表示),UTF-32(字符用四个字节表示)。然而只有UTF-8有效地解决了上述两个问题,使得其成为目前最广泛的Unicode实现方式。

UTF-8

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

  1. 对于单字节的符号,字节的第一位设为0,后面7位是这个符号的Unicode码。因此对于英语字母,UTF-8编码和ASCII码是相同的(0x00~0x7F)。并且,0x00~0x7F不会出现在UTF-8编码的非ASCII字符的首字节与非首字节的任意一个字节中(非ASCII字符的UTF-8编码为由两个或两个以上的码元所组成的码元序列),这样就保证了与早已应用广泛且已成为工业标准的ASCII编码的完全兼容,且避免了歧义,同时纠错能力也强。
  2. 对于n字节的符号(n>1),第一个字节的前n位都设为1,第n+1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的Unicode码。

UTF-8的这两条编码规则,使得它相比其它的多码元编码方式有一些优点:

  1. UTF-8的编码空间足够大,未来Unicode新标准收录更多字符,UTF-8也能适应,因此不会再出现UTF-16那样的尴尬(UTF-16是封闭的字符集)。
  2. UTF-8是变长编码(准确地说是变长码元序列,而码元本身是固定长度为8位单字节的,也就是说,UTF-8采用的是单字节码元),比如一个字节足以容纳所有的ASCII码字符,就用一个字节来存储,不必在高位补0以浪费更多的字节来存储,因此在英语作为国际语言的现实情况下,UTF-8因其ASCII字符的单字节编码这一特性可节省大量存储空间。
  3. UTF-8完全直接兼容ASCII码,而非不完全间接兼容。
  4. UTF-8的码元序列的第一个字节指明了后面所跟的字节的数目(即带有前缀码),这对字节流的前向解析非常有效。
  5. 因为UTF-8编码带有前缀码,所以容错性好,即使在传输过程中发生局部的字节错误,比如即便丢失、增加、改变了某些字节,也不会导致所有后续字符全部错乱这样传递性、连锁性的错误问题(否则,若存在错误传递性、连锁性的话,一旦中间某些字节出错,则必须丢弃从出错点开始到结尾的所有编码字节,比如GB码、UTF-32码就是如此),因此很容易重新同步,具有很强的鲁棒性(即健壮性)。
  6. 由于UTF-8编码没有状态,从UTF-8字节流的任意位置开始可以有效地找到一个字符的起始位置,字符边界很容易界定、检测出来,所以具有很好的“自同步性”。
  7. UTF-8是字节顺序无关的(因为采用的是单字节码元,而非像UTF-16、UTF-32采用的是多字节码元),它的字节顺序在所有系统中都是一样的,其码元序列与字节序列相同,因此它实际上并不需要字节顺序标记BOM(Byte Orde Mark),虽然Windows系统经常“多此一举”地加上BOM。字节序问题在进行信息交换时会带来不小的麻烦。如果字节序未协商好,将导致乱码;若协商结果为双方一个采用大端一个采用小端,则必然有一方要进行大小端转换,性能损失不可避免(字节序的大小端问题其实不像看起来那么简单,有时会涉及硬件、操作系统、上层应用软件多个层次,可能会导致多次转换,影响性能)。
  8. 字节FE(二进制为1111 1110)和FF(二进制为1111 1111)在UTF-8编码中永远不会出现(因为UTF-8编码方式中,每个字节只能以0、110、1110、11110或10开头)。因此可以用称之为零宽度不中断空格(ZERO WIDTH NO-BREAK SPACE)的字符(Unicode字符名称为U+FEFF)作为字节顺序标记BOM来标明UTF-16或UTF-32文本的字节序。
  9. UTF-8编码可以通过屏蔽位和移位操作快速读写。
  10. 字符串比较时strcmp()和wcscmp()的返回结果相同,因此使排序变得更加容易。

UTF-8编码最短的为一个字节,最长的为四个字节,从首字节就可以判断一个UTF-8编码有几个字节:

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

另外,UTF-8编码中,除了单字节编码外,由多个单字节码元所组成的多字节编码其首字节以外的后续字节均以10开头(以区别于单字节编码以及多字节编码的首字节)。0、10、110、1110以及相当于UTF-8编码中各个字节的前缀,因此称之为前缀码。其中,前缀码10、110、1110及中的0,是前缀码中的终结标志。

UTF-8编码中的前缀码起到了很好的区分和标识的作用:

  • 当解码程序读取到一个字节的首位为0,表示这是一个单字节编码的ASCII字符;
  • 当读取到一个字节的首位为1,表示这是一个非ASCII字符的多字节编码字符中的某个字节(可能是首字节,也可能是后续字节),接下来若继续读取到一个1,则确定为首字节,再继续读取直到遇见终结标志0为止,读取了几个1,就表示该字符为几个字节的编码
  • 当读取到一个字节的首位为1,紧接着读取到一个终结标志0,则该字节显然是非ASCII字符的后续字节(即非首字节)。

下表总结了UTF-8编码规则,字母x表示可用编码的位。

目前Unicode字符集码点编号的最大值为0x10FFFF,实际尚未编号到0x1FFFFF;这说明作为变长字节数的UTF-8编码其未来扩展性非常强,即便目前的四字节编码也还有大量编码空间未被使用,更不论还可扩展为五字节、六字节。

UTF-8的编码规则:如果第一个字节的第一位是0,则这个字节单独就是一个字符;如果第一位是1,则连续有多少个1,就表示当前字符占用多少个字符。

下面,还是以汉字"严"为例,演示如何实现UTF-8编码。已知"严"的Unicode是4E25(100111000100101),根据上表,可以发现4E25处在第三行的范围内(0000 0800-0000 FFFF),因此"严"的UTF-8编码需要三个字节,即格式是"1110xxxx 10xxxxxx 10xxxxxx"。然后,从"严"的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0。这样就得到了"严"的UTF-8编码是"11100100 10111000 10100101",转换成十六进制就是E4B8A5。

打开记事本,输入汉字“严”,另存为编码为UTF-8的文件。然后用UltraEdit打开,使用十六进制格式查看,可以看到三个字节编码,”E4 B8 A5“.

▲ ”严“ 字的UTF-8编码

BOM

在了解什么是字节序标记之前,需要了解什么是字节顺序。对于跨越多字节的对象,必须建立两个规则:

  • 这个对象的地址是什么
  • 在内存中如何排列这些字节

在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址即连续的字节序列中最小的地址。比如,假设一个类型为int的变量x的地址为0x100,也就是说,地址表达式&x的值为0x100。假设int为32位,则x的4个字节将被存储在内存为0x100、0x101、0x102和0x103的位置。

排列一个对象的字节有两种方法,以w位的整数为例,其位表示为[xw-1,xw-2,...x1,x0],其中xw-1是最高有效位,x0是最低有效位。

  • 某些机器选择在内存中按照从最低有效字节到最高有效字节的顺序存储对象,即最低有效字节在最前面,低位字节存入低位地址,称为小端法(little endian),比如Intel x86系列CPU,Android,IOS。
  • 某些机器选择在内存中按照从最高有效字节到最低有效字节的顺序存储对象,即最高有效字节在最前面,高位字节存入低位地址,称为大端法(big endian),比如PowerPC。

例如,假设变量x的类型为int,值为0x01234567,它位于0x100地址处,地址范围0x100~0x103的字节顺序,用大端法和小端法分别表示为:

▲ 大端法和小端法表示0x01234567 

在0x01234567中,高位字节为0x01,地位字节为0x67。

另外,网络协议都是采用big endian方式传输数据,所以有时候也把big endian方式成为网络字节序。可以通过一个简单的例子来看little endian。

#include <Windows.h>

BYTE b = 0x012;
WORD w = 0x01234;
DWORD dw = 0x01234567;
char str[] = "abcde";
int main()
{
	BYTE lb = b;
	WORD lw = w;
	DWORD ldw = dw;
	char* lstr = str;
	return 0;
}

以上代码,在Visual Studio中设置”断点“,然后开启调试,并打开”调试“->”窗口“->”反汇编“和”内存“窗口。

▲ 使用Visual Studio查看反汇编代码和内存

可以看到,全局变量b、w、dw、str的内存地址分别为07FF674A0C000h、07FF674A0C004h、07FF674A0C008h,和07FF674A0C00Ch。在dw变量0x1234567,可以看到它在内存中的数据是67 45 23 01。即地址低位存储数据低位,地址高位存储数据高位。另外,可以看到字符串”abcde“被保存在了一个字符char数组中,字符数组在内存中是连续的,向字符数组存放数据,无论是采用大端还是小端序,存储顺序都是相同的。

▲ 数字0x01234567在内存中以little endian的表示

介绍完了大端序和小端序,在多字节码元编码中,也必须考虑字节序。

不同于UTF-8是单字节码元的编码方式,UTF-16和UTF-32是多字节码元的编码方式,在将逻辑形式的码元序列(或可称之为逻辑编码)映射为物理形式的字节序列(或可称之为物理编码)时,因系统平台的差异,存在一个字节序(Byte Order)的问题。而字节序问题的存在,导致在某些场合下,需要对文本字符所采用的字节序予以明确说明。Unicode/UCS规范中所推荐的用于说明字节序的方法是使用BOM字节序标记(Byte Order Mark)。BOM用来标明是大端序(Big-endian)和小端序(Little-endian),它采用Unicode码点值为FEFF的字符(十进制为65279,二进制为1111 1110 1111 1111),因此BOM实际上可认为是该字符(U+FEFF)的别名。

字符U+FEFF如果出现在字节流的开头,则用来标识该字节流的字节序——是高位在前还是低位在前;如果它出现在字节流的中间,则表达为该字符的原义——零宽度不中断空格(ZERO WIDTH NO-BREAK SPACE零宽度无断空白)。该字符名义上是个空格,实际上是零宽度的,即相当于是不可见也不可打印字符(平常使用较多的是ASCII空格字符,是非零宽度的,需要占用一个字符的宽度,因此为可见不可打印字符)。不过,从Unicode 3.2开始,U+FEFF只能出现在字节流的开头,且只能用于标识字节序,就如它的别名——字节序标记——所表示的意思一样;除此以外的用法已被舍弃。取而代之的是,使用U+2060来表示零宽度不中断空格。

如果UTF-16编码的字节序列为大端序,则该字节序标记在字节流的开头呈现为0xFE 0xFF;若字节序列为小端序,则该字节序标记在字节流的开头呈现为0xFF 0xFE。

如果UTF-32编码的字节序列为大端序,则该字节序标记在字节流的开头呈现为0x00 0x00 0xFE 0xFF;若字节序列为小端序,则该字节序标记在字节流的开头呈现为0xFF 0xFE 0x00 0x00。

需要特别注意的是,UTF-8编码本身并不存在字节序的问题,但仍然有可能会用到BOM——有时被用来标示某文本是UTF-8编码格式的文本,形式为0xEF 0xBB 0xBF。

在UFT-8编码格式的文本中,如果添加了BOM,则只用它来标示该文本是由UTF-8编码方式编码的,而不用来说明字节序,因为UTF-8编码根本就不存在字节序问题。

▲ UTF BOM

比如对于前面的”严“,如果按照UTF-8编码,就是”E4 B8 A5“,如果按照带BOM的UTF-8编码,就是”EF BB BF E4 B8 A5“

▲ 带BOM的UTF-8 “严”的编码

UTF-16

ISO 10646标准为“通用字符集”(UCS)定义了一种16位的编码形式(即UCS-2),其编码固定占用2个字节,它包含65536个编码空间(可以为全世界最常用的63K字符编码,为了兼容Unicode,0xD800-0xDFFF之间的码位未使用)。例如“汉”的UCS-2编码为6C49。

但2个字节共16位编码并不足以正真地“一统江湖”(a fixed-width 2-byte encoding could not encode enough characters to be truly universal,UCS-2的16位二进制最多只能表示2^16(即65536)个码点,而Unicode有多达17×65536=1114112个码点),于是UTF-16诞生了,与UCS-2一样,它使用两个字节为全世界最常用的63K字符编码,不同的是,它使用4个字节对不常用的字符进行编码,UTF-16属于变长编码

前面提到过:Unicode编码点分为17个平面,每个平面包含2^16(即65536)个码位,其中第一个平面称为“基本多语言平面”,其余16个平面称为“辅助平面”(Supplementary Planes)。其中“基本多语言平面”(0~0xFFFF)中0xD800~0xDFFF之间的码位作为保留,未使用。UCS-2只能编码“基本多语言平面”中的字符,在这个范围内,UTF-16与UCS-2的编码是一样的(都直接使用Unicode的码位作为编码值),例如“汉”在Unicode中的码位为6C49,而在UTF-16编码也为6C49。另外,UTF-16还可以利用保留下来的0xD800-0xDFFF区段的码位来对“辅助平面”的字符的码位进行编码,因此UTF-16可以为Unicode中所有的字符编码。

UTF-16使用所谓代理机制来对增补平面的码点进行编码。代理机制实际上就是用两个对应于基本平面BMP代理区(Surrogate Zone)中的码点编号的16位码元,来表示一个增补平面码点,这两个用来表示一个增补平面码点的特殊16位码元,就被称之为代理对(Surrogate Pair)。

UTF-16编码方式及其代理机制是在Unicode 2.0中为支持字符编号超过U+FFFF的增补字符而引入的,于是从此就由UCS-2的等宽(16位)码元序列编码方式,变成了UTF-16的变宽(16位或32位)码元序列编码方式。由于UTF-16是多字节码元,所以存在BOM字节序的问题。例如:"ABC"这三个字符的UTF-16编码(码元序列)为 00 41 00 42 00 43,对应的各种字节序列为:

▲ Windows 10中,记事本程序的各种编码方式

▲ 字符“ABC”在各种编码方式下的码元序列

可以看到,对于ABC三个字符,UTF-16使用2个字节表示一个字符。对于字符"A",对应的编码为00 41,小端序是低位字节(41)保存在低位地址(前面),所以小端序是41 00。小端字节序的标志是FF FE,大端字节序的标识是 FE FF。

ABC这些字符是属于基本多平面BMP内的字符,所以固定的用2个字节16位来表示一个字符,16位二进制最多只能表示2^16字符这些字符恰好等于基本平面的字符总数,对于另外的16个增补平面SP的字符的表示,需要使用代理(Surrogate)来表示。具体的方法就是使用两个基本平面BMP的代理区(基本平面区中保留的未被编码的区域)中的码点来表示一个增补平面的码点这两个用来表示一个增补平面码点的特殊码元对,就是“代理对”。简单概括来说,就是对所有大于0XFFFF的码点值(即增补平面码点编号,范围为0x10000~0x10FFFF,十进制为65536~1114111,0xFFFF就是2^16)要编码成UTF-16编码方式的话,就必须使用代理机制,也就是使用代理对来表示。

这样就产生了一个冲突,某个UTF-16的码元究竟是代表基本平面BMP中字符的码元,还是用来表示增补平面SP字符的BMP代理对中的代理码元?因此为了避免冲突,这些被用作“代理”的任意一个码元对应的码点,在BMP中均未定义字符。“代理”的含义就是用两个基本平面BMP中未定义字符的码点,合起来代表增补平面SP中的一个码点。因此,BMP中这些被用作“代理”的码点区域,被称之为“代理区(Surrogate Zone)”,编号范围为0xD800~0xDFFF(十进制为55296~57343),共2048个码点。

一共有16个增补平面(第2-17个平面),码点编号范围为0x1000~0x10FFFF(十进制为65536~1114111,码点总数为1048576个)用两个代理码元来表示:

  • 第一个代理码元的取值范围为0xD800~0xDBFF,二进制为1101 1000 0000 0000~ 1101 1011 1111 1111,十进制为 55296~56319
  • 第二个代理码元的取值范围为0xDC00~0xDFFF,二进制为1101 1100 0000 0000~ 1101 1111 1111 1111,十进制为 56320~57373

比如,增补平面SP的第一个码点的编号0x10000,其UTF-16编码就是0xD800 0xDC00,即0x1000经UTF-16编码后的码元序列为0xD800 0xDC00。

具体的编码算法分为两个代理码元的计算。两个代理码元,转换为二进制形式后,固定格式如下:

  • 代理码元1,格式为 1101 10pp ppxx xxxx
  • 代理码元2,格式为 1101 11xx  xxxx  xxxx

代理码元1的前六位为1101 10、代理码元2的前六位1101 11 是固定的(因此110110称为代理定数前缀1,110111称为代理定数前缀2), p、x是变数。去掉定数后,拼起来就是pppp xxxx xxxx xxxx,共20位(2^20=1048576),刚好能够表示目前16个增补平面中的全部码点(0x10000~0x10FFFF,共1048576=16*65536=16*2^16个)。

其中,p共4位,表示16歌增补平面之一的编号(2^4=16),紧接着的16位x,表示某个增补平面内的某个码点(2^16=65536个码点)。

代理码元对里的两个码元,前面一个称为高16位代理码元,也称为引导代理(lead surrogates)、前导代理,后面一个称为低16位代理码元,也成为尾随代理(trail surrogates,尾随代理、后尾代理),由于引导代理和尾随代理的值分别在0xD800~0xDBFF之间和0xDC00~0xDFFF之间,所以首尾两个代理,总共可以得到(56319-55296+1)*(57343-56320+1)= 1048576个代理对,也即可以表示1048576个增补码点,而目前Unicode标准所确定的16个增补平面的码点总数也是16*65536=1048576个。

由于Unicode是开放式字符集,未来还会不断增补字符进来,如果增补平面超过16个,那么按照上面的UTF-16的编码规则,是无法编码的。所以UTF-16编码的扩展性适应性相比UTF-8存在明显不足。

从增补平面的码点值,通过基本平面中的代理对,编码为增补平面字符的码元序列,算法如下:

  1. 增补平面中的码点值(0x10000~0x10FFFF,二进制位0001 0000 0000 0000 0000~1 0000 1111 1111 1111 1111,对应的码点名称位U+10000~U+10FFFF),由于需要将增补平面的码点统一为20位长的比特组,因此减去0x10000(二进制位0001 0000 0000 0000 0000)。得到的20位长的比特组的取值范围为0x0000.~0xFFFFF,二进制位0000 0000 0000 0000 0000~ 1111 1111 1111 1111 1111.
  2. 将得到的20位比特组拆分为两部分:高位10比特和低位10比特。
  3. 将高位10比特(值的范围为0x000~0x3FF,二进制为00 0000 0000 ~ 11 1111 1111),加上0xD800(二进制为1101 1000 0000 0000,前六位为代理定数前缀1),得到第一个代理码元,即引导代理(值的范围是0xD800~0xDBFF,二进制为1101 1000 0000 0000 ~ 1101 1011 1111 1111)。
  4. 将低位10比特(值的范围为0x000~0x3FF,二进制为00 0000 0000~ 11 1111 1111),加上0xDC00(二进制为1101 1100 0000 0000,前六位为代理定数前缀2),得到第二个代理码元,即尾随代理(值的范围是0xDC00~0xDFFF,二进制为1101 1100 0000 0000 ~1101 1111 1111 1111)。
  5. 将引导代理与尾随代理按照前后顺序拼接在一起成为代理对,就得到了增补平面字符的码元序列。

比如汉字“𠮷“的Unicode编码为扩展B区 "U+20BB7”,将其编码为UTF-16的码元序列的算法如下:

  1. 0x20BB7,减去0x10000,结果为0x10BB7,二进制为 0001 0000 1011 1011 0111。
  2. 分拆为高10位和低10位两部分:0001 0000 10 (0x0042) 和11 1011 0111 ( 0x03B7 )。
  3. 高10位0x0042,加上0xD800,以形成高位的引导代理:0xD800+0x0042=0xD842(二进制为1101 1000 0100 0010)。
  4. 低10位0x03B7,加上0xDC00,以形成地位的尾随代理:0xDC00+0x03B7=0xDFB7(二进制为1101 1111 1011 0111) 。
  5. 将高位的引导代理和低位的尾随代理按照前后顺序拼接在一起形成代理对,就得到了增补平面字符”𠮷“的码元序列为 0xD842 0xDFB7,二进制为1101 1000 0100 0010 1101 1111 1011 0111。

在记事本中,输入汉字”𠮷“然后另存为"UTF-16 LE",然后在UltraEdit中打开,以十六进制模式查看,可以看到它的编码与我们计算的结果一致。

▲增补平面汉字“𠮷”的UTF-16 的码元序列0xD842 0xDFB7 的小端序编码

本质上来讲,之所以用基本平面中的两个代理码元,就能表示所有16个增补平面字符,是因为UTF-16设计的这个代理对编码机制,将码元的有效位数从原来的16位,扩展到了20位(32位中减去两个代理定数前缀,也就是减去12位代理附加位,剩下的有效位数为20位),其中前4位刚好可以表示16个增补平面的编号之一(因为2^4=16),后16位刚好可以表示一个增补平面中的所有码点(因为2^16=65536)。

因此,增补平面中的码点值从0x10000到0x10FFFF,共计0xFFFFF + 0x1个,即1,048,576个,刚好也就是需要20位来表示(2^20=1,048,576)。如果用两个16位长的码元组成的序列来表示,意味着引导代理要容纳上述20位中的前10位,尾随代理要容纳上述20位中的后10位。为避免冲突,因此需要在基本多语言平面BMP中,保留未定义Unicode字符的1024+1024=2048个码点,就可以容纳引导代理与尾随代理所需要的编号空间(码点空间、代码空间),也就是16个增补平面所需要的编号空间,共计1024*1024=2^20=1048576个码点。这BMP中的2048个码点对于BMP总计65536个码点来说,仅占3.125%(2048/65536=0.03125)。

▲ UTF-16 字符编码方案

在UTF-16编码方式中,引导代理的后面应该是一个尾随代理,而尾随代理的前面就应该是一个引导代理;

  • 不能出现一个引导代理的后面是一个非代理的普通UTF-16码元的情况,也不能出现一个引导代理的后面还是一个引导代理的情况。
  • UTF-16文本(字符串)的最后一个码元不能是引导代理,第一个码元不能是尾随代理。
  • 不允许出现一个尾随代理的前面是一个尾随代理的情况。
  • 也不允许出现一个尾随代理的前面是一个非代理的普通UTF-16码元的情况。
  • 单独的一个代理码元(不管是引导代理还是尾随代理)是不合法的。
  • 代理必须以一个“引导代理+尾随代理”编码对(即代理对)的形式出现。

UTF-16的这种“代理对”编码规则,保证了文本处理程序能够正确地访问和处理包括了基本平面和增补平面在内的全部UTF-16码元序列,并消除了基本平面字符和增补平面字符之间发生冲突的可能性。

因为引导代理和尾随代理码元被各自规定在一个特定范围内取值,所以很简单的一个原则就是:凡是在代理编码范围内的码元就是“代理”增补平面SP字符的“代理码元”,否则就是基本平面BMP字符的“字符码元”。

由于BMP中的字符码元和代理码元分别在各自独立的编码范围内进行编码,所以对于一个符合格式规范的UTF-16码元来讲,它必须满足以下条件:

  • 非代理码元(BMP字符码元)必须避开代理码元所占用的范围0xD800~0xDFFF(二进制为1101 1000 0000 0000 ~ 1101 1111 1111 1111,共2048个)
  • 引导代理必须是代理对中的第一个码元
  • 尾随代理必须是代理对中的第二个码元

在处理UTF-16文本时,为了确保文本数据的完整性,绝对不能把任意一个代理从代理对中拆出来,也不能在代理对中间插入另一个字符的码元或码元序列。

在UTF-16编码方式里面,一个Unicode字符码点值由一个或两个16位码元编码。所以,如果想在一个UTF-16码元序列里面判断某个码元是属于哪个字符的话,就需要检查那个码元的值,然后根据码元的类型即是否具有代理标志位,决定是否还需要向前或向后检查一个相邻的码元的值。

由于引导代理、尾随代理、BMP字符码元,三者互不重叠,搜索就很简单,这意味着UTF-16具有“自同步”(self-synchronizing)性:通过仅检查一个码元就可以判断当前字符的下一个字符的起始码元,每个字符码元的边界很明确。

同时,还具有“非传递”性:单独的一个UTF-16码元出错涉及的只是一个字符,不会传递到文本的其他部分去,因此,即使文本中某些字符数据遭到破坏,其影响也只是局部性的。UTF-8也有类似优点。但许多早期的编码方式就不是自同步的,比如大多数的多字节编码标准如GBK、Big5等,必须从头开始分析文本才能确定不同字符的码元的边界;也不具有非传递性,局部字符数据被破坏,很可能传递到整个文件,导致整个文件无法正确显示。

因此,UTF-8和UTF-16编码方式所具有的“自同步性”、“非传递性”等特点除了增强抗干扰能力外,也提供了随机访问的能力。

UTF-32


UTF-32编码是目前UTF常用的编码方案(UTF-8、UTF-16、UTF-32)中最简单的一种编码方式,它就是直接将每个Unicode的字符码点值直接表示为一个32位长的码元序列。因此UTF-32是一种固定宽度码元序列的Unicode字符编码方式。

UTF-32中的码元由32位组成,理论能支持2^32=4294967296约42亿个字符。因为这个数量足够大,所以Unicode字符集中所收录的每个字符的码点值都可以直接映射为单个码元,而无需像UTF-16那样使用复杂的代理算法来间接表示。因此即使是ASCII字符,同样也需要占用32位即4个字节。这无疑是非常浪费空间的。但是因为它是定长编码,所以逻辑简单,在文本处理的速度上是最快的。

与UTF-16类似,作为逻辑意义上的UTF-32码元序列,由于历史的原因,在映射为物理意义上的字节序列时,也分为UTF-32BE大端序、UTF-32LE小端序两种编码模式,因此UTF-32也同样需要使用BOM。比如,“ABC”这三个字符的UTF-32码元序列为:00 00 00 41 00 00 00 42 00 00 00 43;其对应的各种字节序列如下:

▲ 字符“ABC”在各种UTF-32 编码方式下的码元序列 

每个UTF-32码元的值与Unicode码点的值完全相同,但其字节序列因字节序的不同而表现为有相同也有不同。由于UTF-32在三大UTF编码方式中,既不是最早推出的编码方式(最早推出的是UTF-16),也不是最优设计的编码方式(公认为最优设计的是UTF-8),因此在实践中使用得最少,目前几乎已处于淘汰状态。

 

参考