Chaofan's

Bonvenon al la malpura mondo.

标签: Unicode

  • UTF、Emoji和神奇的连字

    Unicode是人所共知的标准,一般意义上的Unicode做了两件事:

    • 定义了一个集合,力图包括人类语言的大部分文字(学名叫Abstract Character Repertoire,ACR)
    • 为其中每个文字符号赋予一个惟一编号(学名叫Coded Character Set,CCS)

    概念上,这已经足够解决「某些语言的文字不能被正确表示」的问题了。但我们无法轻易地就在计算机程序里用编号表示字符,因为:

    • 整数编号需要确定的长度,如果编号是2字节整数,那么它就无法表示第65537个字符
    • 这个系统最好要与已有编码(尤其是ASCII)兼容
    • 表示方法要考虑到空间,如果编号是4字节整数,那么一个ASCII文件换成Unicode体积就会膨胀到原来的4倍

    UTF

    为了落地解决方案,UTF(Unicode Transformation Format)应运而生,UTF做的事情也有个学名,叫Character Encoding Scheme(CES)。因为单元长度不同,UTF也有几个变种。

    UTF-32

    这是最直接的方案,每个字符都用32位(4字节)表示,由于32位无符号整数上限(4294967296)在可见的未来都足以包括所有Unicode字符,所以这个方案不需要考虑任何额外情况。实际上,截至2023年,Unicode包括的所有字符也没有超过150000个。

    由于UTF-32是多字节编码,依据字节序的不同,UTF-32有little-endian和big-endian两个变种。如果你对两种字节序不了解,可以参看之前的文章

    UTF-32编码有个非常显而易见的好处:字符串(String)的长度就是字符数组(Character Array)的长度,这和ASCII时代的经验一致。为什么强调这点,因为其他的Unicode编码方式未必满足这个要求。

    至于为什么没有UTF-64、UTF-128,只能说可以,但没必要,仓颉从棺材里爬出来他也造不出那么多字。

    UTF-16

    使用16位(2字节)表示字符单元的方案自然就叫做UTF-16,2字节可表示的整数上限是65536。因为Unicode包括的字符总数比这个多,这就遇到问题了。解决方法是什么呢?如果一个双字节单元不够用,那用一对就行了。

    注意,这种情况和UTF-32的区别是:UTF-32是对所有字符都用4字节表示,而UTF-16仅对超过某个范围的字符用4字节表示,其他情况依然使用2字节。为什么说「某个范围」而不是65536?因为我们需要一点额外信息,来区分普通的UTF-16单元,和一对UTF-16单元的其中一部分,这样普通的UTF-16单元就表示不了65536那么多个字符了。

    这种「用一对双字节单元表示单个字符」的情况,叫做UTF-16代理对(surrogate pair)。它有一个具体的编码方案:

    • 如果一个字符的编码在[0, 0xD7FF][0xE000, 0xFFFF]中,则以单个双字节单元表示
    • 如果一个字符的编码在[0x10000, 0x10FFFF]
      • 编码减去0x10000,得到一个20位整数(最大值0xFFFFF
      • 把20位分成两半,前一半加上0xD800作为高部分,后一半加上0xDC00作为低部分
    • [0xD800, 0xDFFF]中单个元素不构成合法字符,仅作为代理对一部分存在

    UTF-16的使用场景比想象的多。Apple平台的NSString内部用UTF-16表示;JavaScript的字符串也是UTF-16;很多要处理字符串的Windows API都分A和W结尾两种变体,A结尾的版本表示接受ASCII字符串,W结尾表示接受UTF-16字符串。

    为什么没有UTF-24?因为3字节不方便。

    UTF-8

    UTF-8较前两者出现得更晚一些。因为用固定单字节表示字符的编码,已经被ASCII以及后续欧洲语言的扩展所占据,所以UTF-8必然是一个变长编码。UTF-32固定使用4字节,UTF-16在有代理对的情况下使用4字节,而它们都没有充分利用编码空间,类似地,UTF-8最多也可能占用4字节。

    UTF-8具体的编码方式更麻烦一些,同样考虑一个在[0, 0x10FFFF]之间的字符:

    • 如果在[0, 0x7F]之间,则用1字节表示,这点保证了和ASCII的兼容
    • 如果在[0x80, 0x7FF]之间,用2字节表示,第一个字节以110开头,后续字节以10开头
      • 110abcde 10fghijk,表示abc_defg_hijk(每4个二进制位构成1十六进制位)
    • 如果在[0x800, 0xFFFF]之间,用3字节表示,第一个字节以1110开头,后续字节以10开头
      • 1110abcd 10efghij 10klmnop表示abcd_efgh_ijkl_mnop
    • 如果在[0x10000, 0x10FFFF]之间,用4字节表示,第一个字节以11110开头,后续字节以10开头
      • 11110abc 10defghi 10jklmno 10pqrstu表示a_bcde_fghi_jklm_nopq_rstu

    UTF-8最早由Ken Thompson和Rob Pike提出,二位也同样是Go语言的发明者。

    UCS

    在某些地方可能还会出现UCS这个名词,它是Universal Coded Character Set的缩写。Unicode相关的各种组织、标准、概念的历史都相当复杂,UCS和Unicode起初也不是从属关系。但今天,大可以粗略地将UCS-4理解为UTF-32的同义词,而UCS-2代表「没有代理对的UTF-16」(也就是只能表示65536个字符)。

    BOM

    每个单元由2个或以上字节组成的编码方式,必然都要考虑字节序的问题。在UTF实践中,文件开头会有称作BOM (Byte-order mark)的标记以明确告知字节序。另一篇专门谈论字节序的文章也提到了BOM,简要说就是:文件开头的0xFE 0xFF表示big-endian,0xFF 0xFE表示little-endian。UTF-8因为最小单元为1字节,因此不需要BOM。

    不是你想的那个字符

    本文到此为止,明确区分了字节与字符的概念。但仍统一将整个文字体系中的多个概念称为字符,这依然是不精确的。所以再对几个都可以被称作字符的不同概念作出区分:

    • 编码单元 (code unit),是一种编码方式中的最小单元,比如UTF-8的编码单元就是1字节,UTF-16的编码单元是2字节
    • 编码点 (code point),是一个或多个编码单元组成的文本单位,也就是前文中介于[0, 0x10FFFF]之间,需要被编码的那个东西
    • 字位 (grapheme),是有意义的最小书写单位,一个字位可能由一个或多个编码点组成。字位和编码点的区别是:一个字位能对应一个可见的文本单元,而编码点则不一定
    • 字形 (glyph),是在文本中能独立显示的图形符号,一个字形可以由一个或多个字位构成。此概念已经超出字符编码的讨论范围,而更多和字体渲染技术相关

    了解完UTF-16和UTF-8这样的编码以后,我们很容易意识到将编码单元理解为想象中的字符并不合适。那编码点和字位又有什么区别呢?

    emoji

    你一定很熟悉emoji了。emoji涵盖了基本表情、旗帜、生活用品还有家庭标记,而对出现人的部分还具备不同肤色的变体。而这些emoji的具体编码并不是逐个对应到一个独立编码点那么简单。

    • 在JavaScript中,😁这个字符串的长度为2,因为它的编码是0x1F601,在UTF-16里需要代理对表示
    • 但国旗🇨🇳的长度就不是2了,而是4
    • 🤵‍♂️的长度是5,带肤色的变体🤵🏻‍♂️则是7
    • 表示情侣的emoji 👩‍❤️‍👨长度为8,如果带上肤色👩🏻‍❤️‍👨🏻则变成了12
    • 也不是所有旗帜的长度都是4,比如彩虹旗🏳️‍🌈长度就是6

    这到底是怎么回事!?


    答案不难理解:JavaScript使用UTF-16,字符串长度代表UTF-16编码单元的数量,因此长度大于2的emoji都不是单个编码点,而是靠多个编码点组合形成的,这也就是字位和编码点的不同。来一一解释前面提到的emoji:

    首先是国家和地区的旗帜,用两个特殊编码点表示,它们实际上是由两个特殊区域的字母拼出来的。在对emoji支持不佳的文本编辑器内,输入🇨🇳再按退格会留下字符🇨,被删掉的那个其实是🇳。这组字母位于[0x1F1E6, 0x1F1FF]的范围。其中所有的旗帜都按照ISO 3166-1中的二字符格式表示,比如中国为CN (🇨 🇳),美国为US (🇺 🇸),法国为FR (🇫 🇷),可以试着复制再删掉这几对特殊字符中的空格。Windows没有为国旗emoji设计内容,所以在Windows上这些字位会被实诚地显示为两个字母。

    然后是穿礼服的男性🤵‍♂️,长度为5,试着把它拆成编码点:0x1F935 0x200D 0x2642 0xFE0F

    • 0x1F935自身代表一个无性别的形象🤵
    • 0x200D在Unicode里代表「零宽度连字符」(zero-width joiner, ZWJ),表示将前后字符连接在一起,在其他语言中也有用到
    • 0x2642单独看是字符♂,表示男性,在这里和零宽度连字符一起修饰emoji的性别
    • 0xFE0F是Unicode中的变体修饰符,[0xFE00, 0xFE0F]中16个编码点都是变体修饰符,该修饰符表示按emoji图像显示(有些字符如0️⃣既可以表示为emoji图像也可以表示为传统字符如0️︎,它们以末尾的修饰符作区分),但因为礼服男性的emoji只有图像风格,所以删去也不影响显示

    带肤色的礼服男性🤵🏻‍♂️长度为7,在第一个码点后多了一个码点0x1F3FB,单独显示为🏻,在特定emoji后作为肤色修饰符。肤色修饰符位于[0x1F3FB, 0x1F3FF],共五种,不带肤色修饰符则以默认黄色显示。

    彩虹旗🏳️‍🌈分解出来也不复杂,它在JavaScript里长度为7,由4个编码点构成,分别是表示白旗🏳️的0x1F3F3,修饰符0xFE0F,零宽度连字符0x200D,以及彩虹🌈的0x1F308

    多人组合恐怕是emoji中最可怕的部分,因为要考虑多个性别、肤色的组合。拆开情侣emoji 👩‍❤️‍👨得到6个编码点:

    • 0x1f469表示女人的形象👩
    • 连字符0x200d
    • 桃心❤的0x2764
    • 修饰桃心为emoji的修饰符0xFE0F,两者组合构成❤️
    • 连字符0x200d
    • 0x1f468表示男人的形象👨

    带肤色的情侣emoji会比默认版本多两个编码点,分别是在左侧形象和右侧形象后面跟上肤色修饰符。因为多出来的两个编码点都需要代理对,所以总长度为12(个UTF-16编码单元),也就是24字节。

    这里的人-心-人的拼接并不是任意而为,而是Unicode为这个emoji特别设计的组合。但emoji中还有其他地方用到了类似的组合,比如男性技术人员👨‍💻实际上就是👨加上连字符再加上💻的组合。

    emoji到今天已经数量繁多,这里没有囊括它的所有规则,但以上几个例子足以说明emoji如何被「拼接」而成,以及字位和编码点在实际环境的不同。

    JavaScript的Unicode字符串常量"\u1234"仅支持小于0xFFFF的情况,编码点更高的字符需要手动拆成代理对,这里提供一段代码以方便实验emoji编码:

    function concatUnicodeScalar(values) {
      return values.map(scalar => {
        if ((scalar >= 0 && scalar <= 0xd7ff) || (scalar >= 0xe000 && scalar <= 0xffff))
          return String.fromCharCode(scalar)
        else if (scalar >= 0x10000 && scalar <= 0x10ffff)
          return String.fromCharCode(((scalar - 0x10000) >> 10) + 0xd800) + String.fromCharCode(((scalar - 0x10000) & 0x3ff) + 0xdc00)
        else
          throw 'Bad unicode scalar'
      }).join('')
    }
    // Run as concatUnicodeScalar([0x1F935, 0x200D, 0x2642, 0xFE0F])

    连接符

    但并不是只有emoji当中才体现了字位和编码点的区别。天城文、马来文等文字也使用了零宽度连接符。

    对于汉字,Unicode还有一组「表意文字描述字符」,可以通过描述结构和每个部分来造字。如0x2FFA表示左下的半包围结构,后跟「礼」与「分」即可组成「𥘶」这个汉字。从0x2FF00x2FFF,Unicode定义了16种描述字符,涵盖了不同的汉字结构。尽管编码层面支持了这套功能,但由于各种技术限制,目前的字体渲染并不支持显示出一个不存在的汉字。

    连字

    前面提到了,在字位之上还有个层次更高的概念叫做字形。这个概念通常和字体渲染相关。这两者的区别,就主要在连字 (ligature) 之上。

    最典型的连字就是小写拉丁字母的fi和fl,i头上的点会被隐藏,然后和f连在一起。如今许多编程字体也利用了这一特性,将多字符操作符渲染为具有数学公司色彩的单个符号。

    Unicode还专门定义了一个叫做零宽度非连字符 (zero-width non-joiner, ZWNJ) 的编码点,插入连字字符之中即可让它们不作为连字显示。

    总结

    • Unicode定义了一组可显示的符号作为字位,每个字位由一个或多个编码点构成,Unicode为每个编码点赋予一个惟一编号
    • 不同的UTF使用不同方法将每个编码点对应到一组字节,其中UTF-16和UTF-8是变长编码,UTF-16的变长情况称作代理对
    • emoji有套复杂的规则将若干编码点组合成一个emoji字位,这套规则使emoji有了创造不同肤色、性别变体的能力
    • 在实际的字体渲染中,因为连字的存在,多个字位也可能被当作一个整体进行渲染

    参考资料

  • 理解Big-endian和Little-endian

    Big-endianLittle-endian,你也许听说过这两个概念,可能也大概知道是什么意思,但未必清楚更深入的细节。这不奇怪,因为不直接和内存打交道的程序员不会对Endian有多少实践层面的理解,而即使写过相关的程序,因为抽象泄露的魔咒,也说不清底层原理。本文试图解释big-endian和little-endian的由来,在执行层面的原理,以及主流高级语言中如何处理Endian相关问题。

    在继续之前,先解释前文提及的概念「抽象泄露」。计算机科学本身可以理解为有关抽象的学问,C语言尽管今天看来相当底层,但也是建立在CPU指令上的抽象,人们用C写的程序,实际上是被编译器转写到机器指令执行。这也意味着,机器指令层面的一些概念,并不能在C语言的模型里体现,即C语言程序的某些问题无法用C语言本身的模型解释,必须依赖更底层的原理。此种情况下,C语言的抽象显得不完备了,这就是抽象泄露。

    典型的抽象泄露例子就是a=a+1语句中,如果变量a不是一个原子(atomic)变量,那么多线程环境中这个语句可能会造成数据不一致。这个问题很难用C语言本身的逻辑理解,我们只知道机器会给a的值加上1(C语言甚至不考虑有符号整数的溢出情况!),但具体CPU是如何计算的,我们不知道。只有当我们知道这个表达式在汇编层面是通过读取内存、执行加法,再写回内存之后能理解数据竞争的起因。这也是我认为编程初学者可以从汇编入门的原因——尽管汇编也有抽象泄露(如预测执行),但总归是比C的模型完备多了,对人也不难理解,C系语言的执行模型实在有点两头不占(另一头是函数式模型)的意思。

    回到正题。来解释big-endian和little-endian的区别。

    什么是Big-endian和Little-endian

    考虑如下一段C程序。

    #include <stdio.h>
    #include <stdint.h>
    uint8_t u8s[4] = { 0x12, 0x34, 0x56, 0x78 };
    uint32_t conv(void) { return *((uint32_t*)u8s); }
    int main(void) { printf("%x\n", conv()); }

    程序把一个4字节数组当作4字节整数读取,输出的内容会是多少呢?按照通常思路,uint32_t占内存中4字节的空间,4个uint8_t也是占4字节空间,那么这个指针强转的结果自然也是16进制的12345678。但实际上,在你的电脑上通常结果是78563412。是的,不是12345678,不是87654321,也不是1e6a2c48。而且这不是什么随机行为,而是基于某种逻辑的固定倒转。

    简单来说,对该程序输出78563412的CPU,就是Little-endian的;而输出12345678,则是Big-endian的。

    这两块内存不都是4字节吗,为什么会有这个区别?一个不精确但通俗的解释是:CPU对聚合类型(数组、结构体)和基本类型的变量有不同的处理方式。

    u8s[0]u8s[1]u8s[2]u8s[3]
    0x120x340x560x78

    以上是u8s这个数组的内存布局,从低地址到高地址排列。对于Big-endian的CPU,从内存读取一个int类型也按照这个顺序。但对Little-endian来说,就要反过来了。

    int[3]int[2]int[1]int[0]
    0x120x340x560x78

    顺序是倒过来的。把以上程序的4字节调整为8字节或2字节,也会得到类似的效果。但注意,这只针对各种内置类型有效,struct {int l; int r;}int[2]有一样的顺序。

    Endian名称的由来

    中文技术资料基本把Endian统一译作「字节序」。在英文语境里,Endian倒是个有典故的词,它来自《格列佛游记》第一卷第四章:

    我下面就要告诉你的是,这两大强国过去三十六个月以来一直在苦战。战争开始是由于以下的原因:我们大家都认为,吃鸡蛋前,原始的方法是打破鸡蛋较大的一端。可是当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个手指弄破了,因此他的父亲,当时的皇帝,就下了一道敕令,命全体臣民吃鸡蛋时打破鸡蛋较小的一端,违者重罚。人民对此法极为反感。历史告诉我们,由此曾发生过六次叛乱,其中一个皇帝送了命,另一个丢了王位。这些内乱常常是由不来夫斯库国的君王们煽动起来的。骚乱平息后,流亡的人总是逃到那个帝国去寻求避难。据估计,先后几次有一万一千人情愿受死也不肯去打破鸡蛋较小的一端。

    所以你吃鸡蛋时先打哪边?

    代码中的字节序

    C/C++

    由于C和C++支持对变量取地址和任意指针类型的互转,我们很容易在C或C++中遇到字节序问题。结合上面使用C语言的例子,要在C语言里检测当前CPU是little-endian还是big-endian也可以用类似的做法:

    bool is_little_endian(void) {
      uint32_t test = 0x12345678U;
      return ((uint8_t*)&test)[0] == 0x78;
    }

    在Little-endian平台上,编译器会聪明地把这个函数优化成return 1。但这对我们来说并不够,因为我们可能需要在不同字节序的机器上编译不同的代码,运行时判断显然不够。好在,主流编译器都有宏用来判断:

    #ifdef __GNUC__
    #if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
    #define LITTLE_ENDIAN 1
    #else
    #define LITTLE_ENDIAN 0
    #elif _MSC_VER || __INTEL_COMPILER
    #define LITTLE_ENDIAN 1
    #end

    Clang会伪装自己是GCC,二者都支持通过__BYTE_ORDER__宏判断字节序。实际上,除了__ORDER_LITTLE_ENDIAN____ORDER_BIG_ENDIAN__外,实际上GCC中还有一种可能取值是__ORDER_PDP_ENDIAN__。PDP是Unix诞生时的CPU架构,其对16位整数采用Little-endian,而对32位整数使用一种奇怪的字节序,即把0x12345678表示为0x34, 0x12, 0x78, 0x56。技术上,现在已不需要考虑这种字节序。

    对于Intel编译器(ICC)和Visual Studio (MSVC),它们只存在于little-endian平台上,所以发现编译器是它们就可以放心地确认字节序。早期版本的Windows NT支持过PowerPC和MIPS这类以big-endian为主的架构,但即使是这些版本的Windows,也是运行little-endian模式的。

    如果要在所有平台上以big-endian字节序读取整数,请使用位运算:

    uint32_t read32be(uint8_t input[]) {
      return ((uint32_t)input[0] << 24) |
             ((uint32_t)input[1] << 16) |
             ((uint32_t)input[2] << 8) |
             (uint32_t)input[3];
    }

    以Little-endian方式读取,请将数组下标倒过来。

    Rust

    Rust中,可使用target_endian指令在编译期判断字节序:

    #[cfg(target_endian = "little")]
    // Code for little endian
    
    #[cfg(target_endian = "big")]
    // Code for big endian

    要以某种字节序读取数据,可使用byteorder crate。

    Java

    尽管主流CPU都是little-endian,但Java大概因为靠近网络字节序(见下文)的原因,统一使用big-endian。通过java.nio.ByteOrder类的nativeOrder方法,Java程序可以得知当前系统硬件上使用哪种字节序:BIG_ENDIAN或者LITTLE_ENDIAN

    DataInputStream采用Big-endian,如果要以Little-endian方式读取数据,可采用同前面C语言相似的位拼接做法。

    JavaScript

    JavaScript这样的脚本语言通常不太会考虑字节序问题,因为我们无法对一个对象取地址,或者查看它的内存布局。但现代JavaScript依然提供了和字节流打交道的API,这里涉及到三个概念:TypedArrayArrayBufferDataView

    简单来说,TypedArray表示某种固定类型作为元素的数组(可以是8种整数和2种浮点其中之一),ArrayBuffer表示无类型的一串字节,DataView是在前者之上读写的适配器:

    let array = new Uint32Array(1)
    array[0] = 0x12345678
    let view = new DataView(array.buffer)
    view.getUint8(0) // => 120 (0x78)
    view.getUint8(3) // => 18  (0x12)
    view.setUint32(0, 0x12345678, true)  // set data as little-endian
    view.setUint32(0, 0x12345678, false) // set data as big-endian
    view.getUint8(0) // => 18  (0x12)

    在JavaScript中,对TypedArray元素的赋值,字节序由CPU的字节序决定,所以在little-endian机器上自然得到little-endian布局的ArrayBuffer。但DataView的set方法有第三个参数表示是否使用little-endian顺序写入,要注意的是,这个参数的默认值是false,也就是big-endian,这意味着在大多数平台上setUint32及其他类似函数的行为,同一般赋值是相反的。

    Ruby

    Ruby也同理,大部分情况下用不着和二进制表示直接打交道。但Ruby中数组类型有将对象转换为二进制字节的pack方法,字符串类型有把字节串解析回对象的unpack方法。这两个方法利用字符串参数指定要读取的类型,除了类型和长度外,不同记号还代表着不同字节序:

    [100].pack 'L' # => 32-bit int in native endian
    [100].pack 'V' # => 32-bit int in little-endian
    [100].pack 'N' # => 32-bit int in big-endian
    
    # double (1.1) in native endian
    "\x9A\x99\x99\x99\x99\x99\xF1?".unpack 'D'
    # double (1.1) in little endian
    "\x9A\x99\x99\x99\x99\x99\xF1?".unpack 'E'
    # double (1.1) in big endian
    "\x9A\x99\x99\x99\x99\x99\xF1?".reverse.unpack 'G'

    自然可以用同样的方法测试当前系统字节序:

    def is_little_endian?
      [1].pack('L') == [1].pack('V')
    end

    不同硬件架构的字节序

    x86

    主流的32位和64位x86都是little-endian字节序。64位x86指令集有时又被称作amd64,因为它最早是AMD发明的。曾经的Intel提出过支持两种字节序切换的IA-64架构作为x86的64位继承者,后来因为不向前兼容而陨落在历史之中。

    x86一直是little-endian,这意味着从最早最简单的ADD指令,到今天复杂的AVX512或者AMX,只要指令的某个操作数是内存地址,那指令就一定会按照低位在前高位在后的逻辑读取数据。

    ARM

    ARM在指令集层面对没有对字节序作强制要求,也就是说,一块ARM CPU可能是little-endian,可能是big-endian,也可能二者皆支持。可以从目标Triple区分:以aarch64_bearm64_bearmeb开头的,即为big-endian,其他不带be或者eb缩写的,则是little-endian。

    为了和x86兼容,主流ARM基本都是little-endian模式。

    RISC-V

    RISC-V是little-endian,尽管有说法曰作者更喜欢big-endian,但因为主流的ARM和x86都是little-endian,因此也选择了little-endian。

    POWER

    POWER诞生在90年代,那时候RISC工作站还不是个历史概念,各大厂商都在制造自家的RISC CPU和UNIX分支,x86还是穷小子,所以大家依然倾向于big-endian。不过为了便于移植x86 PC程序,Windows NT支持的PowerPC 610还的确支持过little-endian模式,然而PowerPC和Windows的合作并没有持续几代,这个开关也被移除,直到POWER 7。

    但POWER 7的little-endian支持并不完整,例如硬件虚拟化就没法支持little-endian模式。从POWER 8开始,POWER CPU开始完整支持little-endian字节序。也就是说,POWER 8及以上的CPU支持两种字节序,由一个特权寄存器控制。甚至理论上,POWER CPU可以在运行时切换字节序。(但显然没人愿意这么做)

    在POWER支持little-endian前,Linux已经支持32位和64位的big-endian POWER CPU。而在little-endian加入以后,因为两种字节序的程序几乎没法相互兼容,所以它们在Triple上被看作两种不同的架构:ppc64指big-endian,而little-endian常用ppc64le或ppc64el表示。

    长远来看,同样是出于兼容目的,Linux将逐渐去除对64位POWER big-endian模式的支持,little-endian才是未来。不过这个故事仅限于Linux和BSD等开源社区,传统的AIX依然只会支持big-endian模式,且在可见的未来还没有改变的迹象。

    一些应用场景的字节序

    Unicode

    UTF-16和UTF-32这两种格式都会采用多个字节表示单个码点(通俗点说就是字符,尽管并不准确),这就引出little-endian和big-endian两种格式。在多数编辑器或处理字符编码的程序里,UTF-16 LE和UTF-16 BE被看作两种编码。但根据Unicode标准,使用UTF-16和UTF-32的文件都需要在开头用2字节标记文件是big-endian还是little-endian:0xFE 0xFF表示big-endian,0xFF 0xFE表示little-endian。

    对于一个内容为「Test」的文件,以下是三种编码的字节内容:

    feff 0054 0065 0073 0074 (UTF-16 BE)
    fffe 5400 6500 7300 7400 (UTF-16 LE)
    6554 7473 (UTF-8)

    开头的这两个字节被称作BOM(Byte-order mark),显然UTF-8因为基本单位是字节,所以并不需要BOM。但一些Windows程序依然会给UTF-8文件加上0xEF 0xBB 0xBF作为BOM以标记这个文件使用UTF-8。

    网络字节序

    根据RFC 1700,socket通信中的数据都使用big-endian格式传输,因此使用socket的C API时,需要用htonl/htons/ntohl/ntohs系列函数将数据在big-endian和原生字节序之间作转换,比如要绑定的端口号。一些上层库可能已经封装了这个行为,但这一点仍需牢记在心。

    WebAssembly

    WebAssembly采用little-endian,但这指的是内存读写指令采用的字节序,WebAssembly字节码中的整数都用LEB128格式表示。

    字节序不是什么

    • 数组在内存中永远是从前向后排的,和字节序无关
    • C/C++中的结构体也永远是从前向后排的,其他高级语言可能会重排对象的内存布局,但这同样和字节序无关
    • 整数或浮点数在CPU寄存器中使用何种字节顺序或位顺序,和字节序无关,实际上这对程序员也不可见,概念上我们都完全可以认为寄存器都是从前向后,类似big-endian般排列
    • 字节序只和数据从内存被加载到寄存器这一过程有关,且单位是字节而不是位

    意义何在

    字节序是一个习惯远比实际好处重要的领域。如果一定要为两种字节序列出一些好处的话:

    • Big-endian更加直观,读取一个4字节整数和4字节数组采用同样的顺序,由于网络通信使用big-endian,因此big-endian的程序在处理网络数据时还能省去字节反转的开销
    • Little-endian环境下,长度不同的数据能够保证低位重合,也就是说32位的0x1234用16位指令去读取依然是0x1234,这节省了类型强制转换时的开销

    但站在今天的角度,这些优缺点都无伤大雅。最重要的原因,不过是x86活下来成了主流,因此little-endian碰巧也成了主流,一如Windows和C语言。计算机领域各个层次都充满了抽象,如果有什么东西能穿越潮起潮落流传至今,原因几乎就是兼容性而已。