字节序与比特序

更新于: 2018.03.09

字节序和比特序是编写跨平台, 可移植代码时必须面对的重要问题, 本文将结合实例对此进行详尽剖析.

字节序

现代计算机存储数据的基本单元是字节. 字节序(byte order)是指大于1个字节的数据在内存中的存储方式, 主要分为小端字节序(little endian)和大端字节序(big endian)两种.

  • 小端字节序: 最高位字节存储在最高内存地址处
  • 大端字节序: 最高位字节存储在最低内存地址处

x86处理器为小端字节序, ARM, MIPS, PowerPC等处理器为大端字节序或者可以配置字节序. TCP/IP网络传输数据时也使用大端字节序.

举例来说, 32位的整数0x12345678, 在小端字节序机器上的内存存储情况如下所示:

  地址增长方向 ->
 1000 1001 1002 1003
+----+----+----+----+
| 78 | 56 | 34 | 12 |
+----+----+----+----+

而在大端字节序机器上的内存存储情况如下所示:

   地址增长方向 ->
 1000 1001 1002 1003
+----+----+----+----+
| 12 | 34 | 56 | 78 |
+----+----+----+----+

当这个整数写在文件中时, 也和在内存的存储情况一致.

以下代码test.c可以演示字节序的概念.

test.c
#include <stdio.h>

#define MAGIC 0x12345678

int main(int argc, char** argv)
{
    FILE* fp;
    int n = MAGIC;
    char* p = (char*)&n;

    printf("n: 0x%08x, ", n);
    printf("%x %x %x %x\n", p[0], p[1], p[2], p[3]);

    fp = fopen("data", "w");
    fwrite(&n, sizeof(int), 1, fp);
    fclose(fp);

    n = 0;
    fp = fopen("data", "r");
    fread(&n, sizeof(int), 1, fp);
    fclose(fp);

    p = (char*)&n;
    printf("n: 0x%08x, ", n);
    printf("%x %x %x %x\n", p[0], p[1], p[2], p[3]);

    return 0;
}

在x86 32位系统上编译运行test.c的执行结果如下:

n: 0x12345678, 78 56 34 12
n: 0x12345678, 78 56 34 12

生成的文件内容如下:

$hexdump -Cv data
00000000  78 56 34 12                                       |xV4.|
00000004

而在MIPS大端字节序机器上执行结果如下:

n: 0x12345678, 12 34 56 78
n: 0x12345678, 12 34 56 78
$hexdump -Cv data
00000000  12 34 56 78                                       |.4Vx|
00000004

所以, 当一台机器生成的文件送到另一台不同字节序的机器上读取时, 就会发生问题, 需要处理字节序问题. 一种方法是类似于交叉编译的思想, 比如在小端字节序机器上以大端字节序生成文件, 再交给大端字节序读取.

比特序

字节序的概念很多开发者都十分理解了, 但比特序(bit order, 又称位序)却少有人真正理解. 比特序是指1个字节的8个比特的解释顺序, 也分为大端序和小端序两种, 一般和字节序保持一致.

  • 小端比特序: 最低位(LSB: Least Singificant Bit)在最左边
  • 大端比特序: 最高位(MSB: Most Significant Bit)在最左边

举例来说, 设字节值为0x35, 在小端比特序机器上解释如下:

+-----------------+
| 1 0 1 0 1 1 0 0 |
+-----------------+

而在大端比特序机器上解释如下:

+-----------------+
| 0 0 1 1 0 1 0 1 |
+-----------------+

比特序对于编写跨平台可移植的代码非常重要, 以下用示例来说明这个问题.

以下代码test2.c在不同比特序的机器上执行结果不同:

test2.c
#include <stdio.h>

struct foo
{
    unsigned char a: 2;
    unsigned char b: 3;
    unsigned char c: 1;
};

int main(int argc, char** argv)
{
    unsigned char ch = 0x35;
    struct foo* p = (struct foo*)&ch;

    printf("sizeof(struct foo): %zu\n", sizeof(struct foo));
    printf("a: %u, b: %u, c: %u\n", p->a, p->b, p->c);

    return 0;
}

在x86 32位系统上运行结果如下:

sizeof(struct foo): 1
a: 1, b: 5, c: 1

而在MIPS 32位大端机器上运行结果如下:

sizeof(struct foo): 1
a: 0, b: 6, c: 1

为什么会有如此不同呢? 我们来分析一下. 在x86小端机器上, 结构体foo的3个位域(bit field)与0x35的8个比特的对应关系如下:

+-----+-------+---+-----+
| 1 0 | 1 0 1 | 1 | 0 0 |
+-----+-------+---+-----+
   a      b     c

注意每个位域的值的解释也是从右往左的, 所以a=01B=1, b=101B=5, c=1B=1.

而在MIPS大端机器上3个位域的对应关系如下:

+-----+-------+---+-----+
| 0 0 | 1 1 0 | 1 | 0 1 |
+-----+-------+---+-----+
   a      b     c

同理每个位域的值也是从左往右的, 所以a=00B=0, b=110B=6, c=1B=1

编写可移植代码

由上可见, 字节序,比特序,位域等都对代码的可移植性有很大影响, 另外可能还要考虑数据填充,字节对齐等事项. 下面以另一个示例的不断改进来说明在这些复杂情况下如何编写可移植性代码.

代码的最初版本是test3.c, 试图以条件编译解决字节序/比特序的差异问题:

test3.c
#include <stdio.h>

struct foo
{
#ifndef BIG_ENDIAN
    unsigned short a: 3;
    unsigned short b: 7;
    unsigned short c: 5;
#else
    unsigned short c: 5;
    unsigned short b: 7;
    unsigned short a: 3;
#endif
};

int main(int argc, char** argv)
{
    unsigned short n = 0x1234;
    struct foo* p = (struct foo*)&n;

    printf("sizeof(struct foo): %zu\n", sizeof(struct foo));
    printf("a: 0x%x, b: 0x%x, c: 0x%x\n", p->a, p->b, p->c);

    return 0;
}

然而, 此代码是有问题的. 在x86小端机器上运行结果如下:

sizeof(struct foo): 2
a: 0x4, b: 0x46, c: 0x4

在MIPS大端机器上运行结果如下:

sizeof(struct foo): 2
a: 0x2, b: 0x23, c: 0x2

造成此问题的原因是因为忽视了数据填充(padding). foo结构体默认占用2字节, 位域一共15bit, 还有1bit的填充. x86小端机器上字节/比特对照情况如下:

       34                12
0 0 1 0 1 1 0 0    0 1 0 0 1 0 0 0
----- ---------------- ---------
  a          b             c

a=100B=4H, b=1000110B=46H, c=100B=4H

MIPS大端机器上字节/比特对照情况如下:

       12                 34
0 0 0 1 0 0 1 0    0 0 1 1 0 1 0 0
--------- ---------------- -----
     c           b           a

a=10B=2H, b=100011B=23H, a=10B=2H

请注意, 两种情况下, 填充数据都位于最后一个bit, 导致条件编译功亏一篑. 可以修改一下代码, 显示指明填充数据:

test3b.c
struct foo
{
#ifndef BIG_ENDIAN
    unsigned short a: 3;
    unsigned short b: 7;
    unsigned short c: 5;
    unsigned short pad: 1;
#else
    unsigned short pad: 1;
    unsigned short c: 5;
    unsigned short b: 7;
    unsigned short a: 3;
#endif
};

// 其余代码相同, 略

这时, 两边运行结果就一样了, 都是:

sizeof(struct foo): 2
a: 0x4, b: 0x46, c: 0x4

读者可以画出此时的字节/比特与位域的对比图, 这里省略了.

那么接下来的问题是, 和字节对齐还有关系吗? 以下两段代码, 从输出可知默认对齐于2个字节边界, 如果强制以4字节对齐, 会怎么样呢? 把代码改为test3c.c:

test3c.c
struct foo
{
#ifndef BIG_ENDIAN
    unsigned short a: 3;
    unsigned short b: 7;
    unsigned short c: 5;
    unsigned short pad: 1;
#else
    unsigned short pad: 1;
    unsigned short c: 5;
    unsigned short b: 7;
    unsigned short a: 3;
#endif
} __attribute__ ((aligned (4)));

// 其余代码相同, 略

在两个平台上编译运行, 执行结果是相同的:

sizeof(struct foo): 4
a: 0x4, b: 0x46, c: 0x4

所以此时只是在后面2个字节填充了数据, 不影响有效数据.

然而事情往往是复杂的, 比如以下这个代码test4.c, 它虽然没有使用位域, 但也存在问题, 尽管现在是没问题的:

test4.c
#include <stdio.h>

struct foo
{
#ifndef BIG_ENDIAN
    unsigned char a;
    unsigned short b;
    unsigned char pad;
#else
    unsigned char pad;
    unsigned short b;
    unsigned char a;
#endif
} __attribute__ ((packed));

int main(int argc, char** argv)
{
    unsigned int n = 0x12345678;
    struct foo* p = (struct foo*)&n;

    printf("sizeof(struct foo): %zu\n", sizeof(struct foo));
    printf("a: 0x%x, b: 0x%x\n", p->a, p->b);

    return 0;
}

它使用__attribbute__ ((packed))来使foo结构体使用最小对齐, 此时这个代码在大端小端机器上运行结果都是一样的:

sizeof(struct foo): 4
a: 0x78, b: 0x3456

因为除了我们显式填充的数据pad, 编译器没有填充在数据成员之间填充任何东西. 然而, 如果把代码改成以下这样, 就出错了(或者使用默认对齐):

test4b.c
struct foo
{
#ifndef BIG_ENDIAN
    unsigned char a;
    unsigned short b;
    unsigned char pad;
#else
    unsigned char pad;
    unsigned short b;
    unsigned char a;
#endif
} __attribute__ ((aligned (4)));

// 其余代码不变

此时x86小端机器执行结果为:

sizeof(struct foo): 8
a: 0x78, b: 0x1234

MIPS大端机器执行结果为:

sizeof(struct foo): 8
a: 0x0, b: 0x5678

原因在于, 编译器以4字节对齐方式填充了额外的字节, 在x86小端机器上结构体被填充为:

+----+----+-------+----+----------+
| 78 | 56 | 34 12 | ?? | ** ** ** |
+----+----+-------+----+----------+
  a           b    pad

在MIPS大端机器结构体被填充为:

+----+----+-------+----+----------+
| 12 | 34 | 56 78 | ?? | ** ** ** |
+----+----+-------+----+----------+
  pad         b     a

读者可分析默认情况下的对齐情况和执行结果.

综上所述, 要编写完美兼容不同字节序/比特序的C代码, 还是需要非常仔细的, 必须综合考虑多种情况的影响.