天天看點

位元組序 —— 大端與小端

1. 尾端的影響

尾端(endianness)這一詞由Danny Cohen引入計算機科學,Cohen注意到計算機體系結構依照位元組尋址和整型數定義之間在通信系統的關系,被劃分為兩個陣營。例如,一個32位的整數會占據4個位元組,這樣會有兩種合理的方式來定義整數和各個位元組之間的關系:有些計算機先從低位位元組開始存放,有些則先從高位位元組開始存放,Cohen将它們分别稱為“小端(little-endian)”和“大端(big-endian)”。

所謂存在即合理,我們不去糾結那種方式更優,但我們必須要關注這可能會帶來的問題。問題不僅關系到通信系統,還關系到可移植性。如果一台計算機可以寫資料,而另一台計算機需要讀這些資料,我們就得先知道第二台主機如何了解第一台寫的資料。而對于可移植性,我更是遇到一個由于代碼的缺陷,從大端系統向小端系統移植時,出現了大範圍資料顯示異常的案例。

注意,隻有在按位元組尋址的時候才需要考慮尾端問題,位元組内部的位序與尾端沒有關系。

為了解決通信的問題,TCP/IP協定規定使用“大端”位元組序為網絡位元組序,這樣一來,使用小端的計算機在發送資料的時候必須要将自己的多位元組資料由主機位元組序轉換為網絡位元組序(即“大端”位元組序),而在接收資料時,要轉換為自己的主機位元組序再進行後續處理。這樣網絡通信就與CPU、作業系統無關了,實作了網絡通信的标準化。

2. 如何判斷尾端

一個32位的整數0x11223344,在大端和小端系統中的存儲方式分别如下:

位元組序 —— 大端與小端

由此可知:

大端:高位元組放在低位址。和我們從左到右閱讀的習慣一緻。

小端:低位元組放在低位址。

下面兩個小程式可判斷出自己主機使用大端還是小端:

程式1:

#include <stdio.h>

int main()
{
    unsigned int x = ;

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

    return ;
}
           

程式2:

#include <stdio.h>

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

    either.as_int = ;

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

    return ;
}
           

編譯器工具鍊也會提供宏定義供你直接使用:

#include <endian.h>

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

或者:

#include <endian.h>

#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 <stdio.h>

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

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

    test1.lower = ;
    test1.higher = ;

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

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

    return ;
}
           

有人想用這段代碼得到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 : ;
    unsigned int rs : ;
    unsigned int rt : ;
    signed int simmediate : ;
};
#elif __BYTE_ORDER == __LITTLE_ENDIAN
struct i_format {   /* Immediate format */
    signed int simmediate : ;
    unsigned int rt : ;
    unsigned int rs : ;
    unsigned int opcode : ;
};
#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.

繼續閱讀