字節序 —— 大端與小端

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

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 <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 = 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.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章