作者:Kevin He, 2003-09-02
原文地址:http://www.linuxjournal.com/article/6788
译者:Love. Katherine, 2007-04-14
译文地址:http://blog.csdn.net/lovekatherine/archive/ 2007/04/14 /1564731.aspx
转载时务必以超链接形式标明文章原始出处及作者、译者信息。
讨论大端与小端、比特序与节序的区别,以及它们的作用范围
编辑提示:本文自最初发表后已
做过修改
那些不得不和比特序、字节序问题打交道的软件或硬件工程师,都很清楚这过程就像是走迷宫。尽管通常我们都能走出迷宫,但是每次都要牺牲数量可观的脑细胞。本文试图概括需要处理比特序和字节序问题的领域,包括CPU、总线、硬件设备以及网络协议。我们将深入问题的细节,并希望能在这个问题上提供有价值的参考。本文同时还试图提供一些从实践中总结出的指导和拇指法则。
大小端
我们对"endianness"这个名词估计都很熟悉了。它首先被Danny Cohen于1980引入,用来表述计算机系统表示多字节整数的方式。
endianness分为两种:大端和小端。(从字节序的角度来看)大端方式是将整数中最高位byte存放在最低地址中。而小端方式则相反,将整数中的最高位byte存放在最高地址中。
对于某个确定的计算机系统,比特序通常与字节序保持一致。换言之,在大端系统中,每个byte中最高位bit存放在内存最低位;在小端系统中,最低位bit存放在内存最低位。
在设计计算机系统时,应该尽一切可能避免通过软件方式执行bit换位,因为这样不仅会产生巨大开销,也是件令程序员感到乏味的工作。后文将介绍如何通过硬件方式处理这一问题。
书写规则
正如大部分人是按照从左至右的顺序书写数字,一个多字节整数的内存布局也应该遵循同样的方式,即从左至右为数值的最高位至最低位。正如我们在下面的例子中所看到的,这是书写整数最清晰的方式。
根据上述规则,我们按以下方式分别在大端和小端系统中值为0x 0a 0b 0c 0d的整数。
在大端系统中书写整数:
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
以上两种情形,我们都是按从左至右的顺序读,整数值为0X 0a 0b 0c 0d
假设我们不遵循上述的规则,也许我们会以如下方式书写整数:
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 = 0x 0a 0b 0c 0d;
if(et.my_bytes[0] == 0x 0a )
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 &0x 0f )可得到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);
}
总结
本文所讨论的关于字节序和比特序的问题还可以进一步深入 。希望本文已经介绍了该问题的主要方面。迷宫里下次见吧。
Kevin Kaichuan He 是一名Solustek Corp的高级软件工程师。他目前的工作致力于borad bring-up、嵌入式Linux和网络协议栈工程。他之前曾是Cisco公司的软件工程师、Purdue大学计算机系的助教。业余时间,他喜欢数码摄像,PS2游戏和电影。