作者: Kevin He , 2003-09-02
原文地址: http://www.linuxjournal.com/article/6788
译者: Love. Katherine , 2007-04-14
译文地址: http://blog.youkuaiyun.com/lovekatherine/archive/2007/04/14/1564731.aspx
转载时务必以超链接形式标明文章原始出处及作者、译者信息。
讨论大端与小端、比特序与节序的区别,以及它们的作用范围
编辑提示:本文自最初发表后已
做过修改
那些不得不和比特序、字节序问题打交道的软件或硬件工程师,都很清楚这过程就像是走迷宫。尽管通常我们都能走出迷宫,但是每次都要牺牲数量可观的脑细胞。本文试图概括需要处理比特序和字节序问题的领域,包括 CPU 、总线、硬件设备以及网络协议。我们将深入问题的细节,并希望能在这个问题上提供有价值的参考。本文同时还试图提供一些从实践中总结出的指导和拇指法则。
大小端
我们对 "endianness" 这个名词估计都很熟悉了。它首先被 Danny Cohen 于 1980 引入,用来表述计算机系统表示多字节整数的方式。
endianness 分为两种:大端和小端。 ( 从字节序的角度来看 ) 大端方式是将整数中最高位 byte 存放在最低地址中。而小端方式则相反,将整数中的最高位 byte 存放在最高地址中。
对于某个确定的计算机系统,比特序通常与字节序保持一致。换言之,在大端系统中,每个 byte 中最高位 bit 存放在内存最低位;在小端系统中,最低位 bit 存放在内存最低位。
在设计计算机系统时,应该尽一切可能避免通过软件方式执行 bit 换位,因为这样不仅会产生巨大开销,也是件令程序员感到乏味的工作。后文将介绍如何通过硬件方式处理这一问题。
书写规则
正如大部分人是按照从左至右的顺序书写数字,一个多字节整数的内存布局也应该遵循同样的方式,即从左至右为数值的最高位至最低位。正如我们在下面的例子中所看到的,这是书写整数最清晰的方式。
根据上述规则,我们按以下方式分别在大端和小端系统中值为 0x0a0b0c0d 的整数。
在大端系统中书写整数:
byte addr 0 1 2 3
bit offset 01234567 01234567 01234567 01234567
binary 00001010 00001011 00001100 00001101
hex 0a 0b 0c 0d
在小端系统中书写整数
byte addr 3 2 1 0
bit offset 76543210 76543210 76543210 76543210
binary 00001010 00001011 00001100 00001101
hex 0a 0b 0c 0d
以上两种情形,我们都是按从左至右的顺序读,整数值为 0X0a0b0c0d
假设我们不遵循上述的规则,也许我们会以如下方式书写整数:
byte addr 0 1 2 3
bit offset 01234567 01234567 01234567 01234567
binary 10110000 00110000 11010000 01010000
正如你所看到的,这种方式下想要看出我们要表达的整数是件困难的事情。
本文中使用的简化计算机系统
在不失一般性的前提下,在本文中使用下图所描述的简化计算机系统:

CPU 、内部总线和内存 /Cache 这些部件由于通常拥有相同的 endianness ,可以作为一个整体用 CPU 来代表。而对于总线 endianness 讨论,只涉及外部总线。 CPU 寄存器宽度、内存字宽和总线宽度在本文中被设定为 32bits 。
CPU 的 endianness
CPU 的 endianness 是指它在寄存器、内部总线、 Cahce 和内存中表示多字节整数时所采取的字节序和比特序。
小端的 CPU 包括 Intel 和 DEC 。大端 CPU 包括 Motorola 680x0, Sun Sparc and IBM ( 如 PowerPC) 。 MIPs and ARM 可以设定为任选其一。
CPU 的 endianness 影响着 CPU 的指令集。对于使用不同 endianness 的 CPU ,应该使用不同的 GNU 工具包来编译代码。例如, mips-linux-gcc 和 mipsel-linux-gcc 分别用来编译生成运行于大端和小端模式的 MIPS 之上的代码。
如果我们 ( 程序员 ) 需要访问多字节整数的一部分时,也必须考虑 CPU 的 endianness 。以下的程序展示了该种情形。注意,在访问 32-bit 整数的整体时, CPU 的 endianness 对于软件 ( 程序员 ) 是不可见的。
union {
uint32_t my_int;
uint8_t my_bytes[4];
} endian_tester;
endian_tester et;
et.my_int = 0x0a0b0c0d;
if(et.my_bytes[0] == 0x0a )
printf( "I'm on a big-endian system/n" );
else
printf( "I'm on a little-endian system/n" );
总线的 Endianness
此处我们所谈论的总线是在上图中显示的外部总线。下文以 PCI 总线为例。正如我们所知,总线是联接 CPU 、外设以及其它各种设备的媒介部件。总线的 endianness 是由总线协议定义的、所有联接到其上的部件都必须遵守的比特 / 字节序标准。
以类型为小端的 PCI 总线为例:对于 PCI 的 32 位地址 / 数据线 AD[31:0] ,要求所有联接到 PCI 上的 32-bit 设备将其最高位数据线联接到 AD31 ,最低位数据线联接到 AD0 。类型为大端的总线协议则有相反的要求。
对于一个数据宽度不满总线宽度的设备,例如一个 8-bit 设备,小端的总线如 PCI 规定设备的 8 根数据线应联接到 AD [ 7 : 0 ],而对于大端的总线协议,则要求联接到 AD [ 24 : 31 ]。
此外,对于 PCI ,总线协议要求每个 PCI 设备实现可配置空间——即一组与总线具有相同字节序的可配置寄存器。
正如所有的设备都需要遵守 ( 外部 ) 总线所规定的比特 / 字节序标准, CPU 也一样。如果 CPU 与 ( 外部 ) 总线工作于不同的 endianness 模式,那么总线控制器 / 桥通常是完成转换的部件。
一个机敏的读者现在会提出这样的疑问:“既然如此,如果设备的 endianness 模式与总线的 endianness 模式不匹配,会怎样?“ 在这种情况下,必须执行额外的转换工作才能进行信息传递,这将在下一节谈到。
设备的 Endianness
Kevin 定理 #1: 当一个多字节数据单元在两个具有相反 endianness 系统之间传输时,需要执行转换以维护数据单元的内存空间连续性。
我们在下面的讨论中假设 CPU 和总线具备相同的 endianness 。如果设备的 endianness 与 CPU/bus 相同,那么不需要执行转换。
在设备与 CPU/bus 的 endianness 不同的情形下,从硬件接线的角度,我们在此提供两种解决方式。以下的讨论假设 CPU/bus 类型为小端,而设备类型为大端。
字一致方案
在该解决方案中,我们对整个 32-bit 的设备数据线进行变换。我们用 D [ 0 : 31 ]表示设备的数据线,其中 D [ 0 ]存放最高位,而对于总线用 AD [ 31 : 0 ]表示。该方案建议将 D [ i ]联到 AD [ 31-i ],其中 i=0,...,31 。字一致意味着整个 (32-bit) 字的语义得到了维护。
下图显示的是一个类型为大端的 NIC card 中的 32-bit 描述符寄存器。

在执行字一致交换后 , 在 CPU/bus 上的结果数据为:

注意,转化的结果自动符合 CPU/bus 的字节序和比特序要求,而不需要通过软件 ( 程序员 ) 进行字节或比特的交换。
上述例子是针对数据并未超过 32-bit 内存边界的简单情形。现在我们看一个穿越边界的例子。在下面的例子中, vlan[0:24] 的值为 0xabcdef, 并且穿越了 32-bit 内存边界。
在字一致转化后,结果为:
看到这里发生了什么?转换后的 vlan 被分割为两个非连续的内存空间: bytes[1:0] 和 byte[7] 。这违背了 Kevin 定理 #1, 而且我们无法定义一个结构良好的 C 结构来访问内存空间非连续的 vlan 。
因此,字一致方案只适用于数据位于字边界之内的情形,对于存在边界穿越的数据并不适用。第二种方案可解决该问题。
字节一致方案
在该方案中,我们不执行字节间的变换,但是我们还是要对每个字节中的比特通过硬件绕线进行变换 ( 设备中偏移量为 i 的比特转换为 bus 中偏移量为 7-i 的比特, i=0...7) 。字节一致意味着字节的语义得到了维护。
在应该了该方案后,上图所示大端 NIC 设备中的值转换后的结果为:
现在, vlan 的三个字节位于连续的内存空间,并且每个字节的内容可以被正确读出。但是转换后的记过在字节序角度看来依然很乱。然而,由于我们现在拥有一块连续的内存空间,可以交给软件来完成图中 5 字节数据交换的任务。最终结果为:
我们看到,在这种解决方案中软件执行的字节交换作为第二阶段。字节交换是由软件完成的,这不同于比特交换。
Kevin 定理 #:2 在 C 中一个包含位域的结构中,如果位域 A 在位域 B 之前定义,那么位域 A 所占据的内存空间永远低于 B 所占用的内存空间。
现在一切都已经分类的井井有条,我们可以定义如下的 C 结构来访问 NIC 中的描述符:
struct nic_tag_reg {
uint64_t vlan:24 __attribute__((packed));
uint64_t rx :6 __attribute__((packed));
uint64_t tag :10 __attribute__((packed));
};
网络协议的 Endianness
网络协议的 endianness 定义了网络协议头部中整数域发送和传输时所遵循的比特序和字节序。我们在此还要引入一个概念:绕线地址。一个低绕线地址比特或字节在发送和接受时永远位于高绕线地址比特或字节之前。
实际上,对于网络 endianness, 它于我们之前所看到的 endianness 有些许不同。对于网络 endianness ,还存在另外一个影响因素:物理连线上比特的发送和接受顺序。底层协议,例如以太网,对于比特的传输和接受顺序有特定规定,有时这个规定是与上层协议的 endianness 相反的。我们将在下面的例子中考虑这种情形。
NIC 设备的 endianness 通常遵循它们所支持的网络协议所使用的 endianness 类型,因此可能与系统中 CPU 的 endianness 不同。多数网络协议是大端的。此处我们以以太网和 IP 为例。
以太网的 endianness
以太网是大端的。这意味着一个整数域的最高字节存放于低绕线地址,并且在接受和发送时位于最低字节之前。例如,以太网头部值为 0x0806(ARP) 协议域有如下的绕线布局:
wire byte offset: 0 1
hex : 08 06
注意,以太网头部中 MAC 地址被视为字符串,因此不受字节序的影响。例如, MAC 地址 12 : 34 : 56 : 78 : 9a :bc 有如下的绕线布局,并且值为 12 的字节被首先传输。
比特传输 / 接收序
比特传输 / 接受序规定了一个字节内的所有 bit 在物理线路中传输的顺序。对于以太网,顺序是由最不重要 bit( 低绕线地址 ) 至最重要 bit( 高绕线地址 ) 。这显然属于小端的类型。字节序仍保持为大端,如前所叙。因此,我们看到在这种情况下,字节序和比特传输/接收序是相反的。
下图展示了以太网的比特传输 / 接收序:
我们看到, MAC 地址第一个字节中的最不重要 bit ,即组 ( 多播 ) 位,作为第一个 bit 出现在物理线路上。以太网和 802.3 硬件按照上述字节发传输 / 接受顺序一致性的工作。
在协议字节序与比特传输 / 接收序不同的情形下: NIC 必须在传输时完成由主机 (CPU) 至比特序到以太网比特比特传输序的转换,而在接受时完成由以太网比特接受序至主机 (CPU) 比特序的转换。这样,上层协议就不用担心比特序而只需保证字节序的正确。实际上,这是另一种形式的字节一致转换方案,它保证了数据通过不同 endianness 时字节级语义的完整性。
比特传输 / 接受序通常对于 CPU 和软件是不可见的,但是对于硬件而言是个重要的问题,例如物理层的串并转化, NIC 的数据线与总线的联接。
基于软件的以太网头部语法分析
对于任何类型的 endianness ,以太网头部可以用下面的 C 结构来完成软件的语法分析:
struct ethhdr
{
unsigned char h_dest[ETH_ALEN];
unsigned char h_source[ETH_ALEN];
unsigned short h_proto;
};
h_dest 和 h_source 域是字节数组,因此不需要转换。 h_proto 域是整数,因此在主机访问该域前需调用 ntohs(), 而在填充该域前需调用 htons() 。
IP 的 endianness
IP 的字节序也为大端。而 IP 的比特序从 CPU 处继承,并由 NIC 负责其与物理传输线路中的比特传输 / 发送序进行转化。
对于大端主机, IP 头部中的域可以被直接访问。对于小端主机 ( 多数为基于 x86 的 PC) 需要对 IP 头部中的整数域进行字节变换才能进行访问和填充。
下面是 Linux Kernel 中定义的 iphdr 结构。我们在读取整数前调用 ntohs() ,在填写整数前调用 htons() 。本质上,这两个函数在大端主机上不执行任何操作,而在小端主机上执行字节变换。
struct iphdr {
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u8 ihl:4,
version:4;
#elif defined (__BIG_ENDIAN_BITFIELD)
__u8 version:4,
ihl:4;
#else
#error "Please fix <asm/byteorder.h>"
#endif
__u8 tos;
__u16 tot_len;
__u16 id;
__u16 frag_off;
__u8 ttl;
__u8 protocol;
__u16 check;
__u32 saddr;
__u32 daddr;
/*The options start here. */
};
我们来查看 IP 头部中一些有意思的域。
version and ihl :根据 IP 标准, IP 头部第一个字节中的最高 4bit 表示 IP 协议的版本。 ihl 表示第一个字节低 4 位 bit 。
有两种方法可以用来访问这些域。方法 1 直接从数据中进行提取。假设 ver_ihl 存放着 IP 头部的第一个字节,那么 (ver_ihl & 0x0f) 可得到 ihl 域,而 (ver_ihl>>4) 可得到 verion 域。这种方法对于任何一种 endianness 类型都适用。
方法二是定义上述的结构,然后通过结构来访问这些域。在上述结构中,如果主机为小端,那么我们定义 ihl 在 version 之前;如果主机为大端,我们定义 version 在 ihl 之后。如果我们在此应用 Kevin 定理 #2 —— 一个先定义的的域永远占据低地址空间,我们可以发现以上的 C 结构定义很好的符合了 IP 标准。
saddr and daddr fields :这两个域可以被视为整数或字节数组。如果视为字节数组的话,没有必要进行转化。如果被视为整数,那么则需要转化,以下是一个基于整数解释的函数
/* dot2ip - convert a dotted decimal string into an
* IP address
*/
uint32_t dot2ip(char *pdot)
{
uint32_t i,my_ip;
my_ip=0;
for (i=0; i<IP_ALEN; ++i) {
my_ip = my_ip*256+atoi(pdot);
if ((pdot = (char *) index(pdot, '.')) == NULL)
break;
++pdot;
}
return my_ip;
}
下面则是基于字节数组的函数:
uint32_t dot2ip2(char *pdot)
{
int i;
uint8_t ip[IP_ALEN];
for (i=0; i<IP_ALEN; ++i) {
ip[i] = atoi(pdot);
if ((pdot = (char *) index(pdot, '.')) == NULL)
break;
++pdot;
}
return *((uint32_t *)ip);
}