字节序 —— 大端与小端

1. 尾端的影响

尾端(endianness)这一词由Danny Cohen引入计算机科学,Cohen注意到计算机体系结构依照字节寻址和整型数定义之间在通信系统的关系,被划分为两个阵营。例如,一个32位的整数会占据4个字节,这样会有两种合理的方式来定义整数和各个字节之间的关系:有些计算机先从低位字节开始存放,有些则先从高位字节开始存放,Cohen将它们分别称为“小端(little-endian)”和“大端(big-endian)”。

所谓存在即合理,我们不去纠结那种方式更优,但我们必须要关注这可能会带来的问题。问题不仅关系到通信系统,还关系到可移植性。如果一台计算机可以写数据,而另一台计算机需要读这些数据,我们就得先知道第二台主机如何理解第一台写的数据。而对于可移植性,我更是遇到一个由于代码的缺陷,从大端系统向小端系统移植时,出现了大范围数据显示异常的案例。
注意,只有在按字节寻址的时候才需要考虑尾端问题,字节内部的位序与尾端没有关系。

为了解决通信的问题,TCP/IP协议规定使用“大端”字节序为网络字节序,这样一来,使用小端的计算机在发送数据的时候必须要将自己的多字节数据由主机字节序转换为网络字节序(即“大端”字节序),而在接收数据时,要转换为自己的主机字节序再进行后续处理。这样网络通信就与CPU、操作系统无关了,实现了网络通信的标准化。

2. 如何判断尾端

一个32位的整数0x11223344,在大端和小端系统中的存储方式分别如下:
字节序 —— 大端与小端_第1张图片

由此可知:
大端:高字节放在低地址。和我们从左到右阅读的习惯一致。
小端:低字节放在低地址。

下面两个小程序可判断出自己主机使用大端还是小端:

程序1:

#include 

int main()
{
    unsigned int x = 0x12345678;

    if (*(char *)&x == 0x78)
    {
        printf("little-endian.\n");
    }
    else if (*(char *)&x == 0x12)
    {
        printf("big-endian\n");
    }
    else
    {
        printf("confused.\n");
    }

    return 0;
}

程序2:

#include 

int main()
{
    union {
        int as_int;
        char as_char[4];
    } either;

    either.as_int = 0x12345678;

    if (either.as_char[0] = 0x78)
    {
        printf("little-endian\n");
    }
    else if (either.as_char[0] = 0x12)
    {
        printf("big-endian\n");
    }
    else
    {
        printf("confused.\n");
    }

    return 0;
}

编译器工具链也会提供宏定义供你直接使用:

#include 

#if __BYTE_ORDER == __BIG_ENDIAN
 ... ...
#elif __BYTE_ORDER == __LITTLE_ENDIAN
 ... ...
#else
#error "neither little endian nor big endian ?"
#endif

或者:

#include 

#if BYTE_ORDER == BIG_ENDIAN
 ... ...
#elif BYTE_ORDER == LITTLE_ENDIAN
 ... ...
#else
#error "neither little endian nor big endian ?"
#endif

3. 转换大小端的接口

标准库中提供了ntohl(x), ntohl(x), htons(x)和ntohs(x)宏用来对16bit和32bit的整数进行主机字节序(host,大端或小端)和网络字节序(network,大端)之间的转换。

Linux内核中也相应实现了这些宏,可直接拿来用:

#undef ntohl
#undef ntohs
#undef htonl
#undef htons

#define ___htonl(x) __cpu_to_be32(x)
#define ___htons(x) __cpu_to_be16(x)
#define ___ntohl(x) __be32_to_cpu(x)
#define ___ntohs(x) __be16_to_cpu(x)

#define htonl(x) ___htonl(x)
#define ntohl(x) ___ntohl(x)
#define htons(x) ___htons(x)
#define ntohs(x) ___ntohs(x)

所有从外部源或设备获取的数据的引用都是潜在尾端相关的,但我们最好写出尾端无关的程序,如果不得不考虑尾端,就得使用上述BYTE_ORDER的值写两套代码。

4. 注意事项

1) 定义好合适的数据类型,避免强制类型转换,看看下面的例子:

#include 

struct test_endian {
    unsigned short lower;
    unsigned short higher;
}

int main()
{
    struct test_endian test1;
    unsigned int num;

    test1.lower = 0x1122;
    test1.higher = 0x3344;

    num = *((unsigned int *)&test1);

    printf("num = %d, first byte = %#x\n", num, *((char *)num));

    return 0;
}

有人想用这段代码得到0x11223344,但是在小端系统上,结果是这样的(大家可以自己分析一下):
num = 0x33441122, first byte = 0x22

2) 不要走极端,别太谨慎,什么都考虑大小端
如果一个指针指向一个整形数,无论是大端还是小端,指针都是指向这个整数的低地址的。这样给我们带来的好处是,在将一段内存强制转换成字符串类型时就无需考虑大小端了。
左移和右移操作不用区分大小端。
数组和结构体也不区分大小端,如int a[3],则无论大端还是小端,a[1]的地址比a[0]大,a+1的地址比a大。

5. 位域?

有些人看到在定义包含位域的结构体的时候,也区分了大小端,例如:

#if __BYTE_ORDER == __BIG_ENDIAN
struct i_format {   /* Immediate format (addi, lw, ...) */
    unsigned int opcode : 6;
    unsigned int rs : 5;
    unsigned int rt : 5;
    signed int simmediate : 16;
};
#elif __BYTE_ORDER == __LITTLE_ENDIAN
struct i_format {   /* Immediate format */
    signed int simmediate : 16;
    unsigned int rt : 5;
    unsigned int rs : 5;
    unsigned int opcode : 6;
};
#else
#error "neither little endian nor big endian ?"
#endif

就容易和字节序的大小端混淆,实际上位域考虑的是比特域的尾端。比特序和字节序(the bitfields’ endianness and generic endianness)是两个不同的概念,前者是体系架构相关的,而后者更多是软件概念。但是正如Linux内核中说的:虽然内核中通过两套宏定义来分别定义字节序的大小端和比特序的大小端,但目前没有哪个架构是二者不一致的(没有出现大端系统是小端比特序)。
比特序的定义和字节序的大小端差不多,一个位域结构体,大端就是正常顺序定义,小端就是反着定义。千万不要试图对位域结构做强制类型转换,因为这不是软件层面的东西。
另外说明一下,内核中那些数据包的头部定义,例如tcp_hdr,都是按照大端来定义的,因为给这个包头赋值后就要直接发送出去的。因此给tcp_hdr的多字节字段赋值时,要先通过htons/htonl来做转换。同时我们发现,即使这样,在tcp_hdr的定义中仍然区分了位域的大端和小端情况,这说明了这二者是不一样的概念。

参考资料
[1]: See MIPS Run (second edition), chapter 10.

你可能感兴趣的:(Linux编程,C语言,字节序,大小端)