目录

深入理解大小端序与位操作

最近在读代码的时候对端序(endianness)的转换部分产生了一些疑惑,主要原因是对相关知识的理解还不够透彻。查找阅读了一些文献之后,有了更深刻的理解。

概念引入

在讨论端序之前,我们先规定一些最基本的顺序,这一点很重要。

  • 人的书写以及阅读是从左往右的。
  • 机器读写数据是从低地址到高地址。

类似的,人们习惯按照从高位到低位来表示一个数字,比如数字 256,百位是 2,十位是 5,个位是 6。再比如一个十六进制数 0x12345678 ,最高位是 1 ,最低位是 8

这种先写(读)高位数据,再写(读)低位数据的顺序,我们称之为 大端序(Big-Endian)。字面意思理解,就是把大的放在前面。结合上面对于基本顺序的规定,前面就是人们书写的左边,或者是机器的低地址。

聪明的你肯定马上会想到,那么 小端序(Little-Endian)相应的就是先写(读)低位,再写(读)高位。结合上面的例子,对于数字 256,如果按照 “个十百” 的表示顺序,就应该写成 652

计算机中的字节序

然后让我们回到计算机学科中来讨论。虽然计算机中数据的最小表示单位是 比特(bit),但由于 现代计算机体系架构的寻址粒度 一般是 字节(byte),所以我们所说的端序通常指的是 字节序

对于比特位的前后顺序(Bit Endieanness),一般在数据序列化的时候讨论,而不是在计算机体系结构中。所以在本文中,我们规定,字节内的比特位的排列为从高位到低位。例如,170 用单字节表示为 (参考 RFC 1700: Assigned Numbers (rfc-editor.org)):

1
2
3
4
				  0 1 2 3 4 5 6 7
				 +-+-+-+-+-+-+-+-+
				 |1 0 1 0 1 0 1 0|
				 +-+-+-+-+-+-+-+-+
信息

事实上,如果字节和比特位均为小端序,即先低位,再高位,这种顺序称之为严格小端序。可是这篇文章指出,严格大端序很常见,但是严格小端序没有(截止到该文)。

因此,我们上述的规定是合理的。

上节例子中的十六进制数 0x12345678 ,共占用了四个字节,从最高位字节(Most Significant Byte, MSB)到最低位字节(Least Significant Byte, LSB)分别为 0x12 0x34 0x55 0x78

如果按照 Big-Endian 来存储,假设从左到右为低地址到高地址,那么其在存储设备中存储为:0x12 0x34 0x56 0x78 。如果按照 Little-Endian 来存储,其存储为:0x78 0x56 0x34 0x12

直到这里还算是云淡风轻,岁月静好。

小端序的移位操作

对于移位操作,大家应该并不陌生,比如 C 语言中的:

1
short i = 0x1080 << 1;

这条语句将 0x1080左移一位,然后赋值给两字节变量 i,这里我们主要关注移位操作。

首先分析 Big-Endian 下的情况,为了直观观察,我们将 0x1080 转换为二进制,如下图第一行所示。

1
2
3
4
5
0001 0000 | 1000 0000 (0x1080)
		  V
Op: Left Shift 1 bit (<< 1)
		  V
0010 0001 | 0000 0000 (0x2100)

然后将该值左移一位,变成如最后一行所示的比特串。最终的结果也变为 0x2100,左移相当于 *2,变成原来的两倍,没问题。

然后再看 Little-Endian 下的情况:

1
2
3
4
5
1000 0000 | 0001 0000 (0x1080)
		  V
Op: Left Shift 1 bit (<< 1)
		  V
0000 0000 | 0010 0000 (0x2000)

我们发现,直接进行左移的话结果不对呀!可见真实的计算机不是这么做的。

回过头思考左移的本质,我们发现,左移并不是简单的把二进制数往左移动,而是把数据从低位(LSB)往高位(MSB)移动。只不过人们的书写习惯是 Big-Endian,恰好是从大往小写,所以才造成了左移就是往左移动的错觉。

意识到这一点,我们再次回归上面的 Little-Endian 例子。如果按照从低位往高位移动的规律,我们发现数据的移动是这样的:

1
2
3
4
<- Shift 1 bit
1000 0000 | 0001 0000 (0x1080)
|                   ^		
+-------------------+

看起来像是循环移位一样,其实不然,如果字节数多的话,我们会发现从一个字节左边移出来的比特位总会补到该字节右边的字节上。

这个过程让一个简单的移位操作看起来很复杂,但其实有更好的理解方式。

参考 c - Does bit-shift depend on endianness? - Stack Overflow 这个问题中被采纳的回答。我们只需要将字节序简单地理解为数据在内存中的存储顺序。

对于该移位操作,CPU 首先将两个字节的数据以大端序存放到寄存器中(如果是 Big-Endian,直接按顺序存放即可;如果是 Little-Endian,将顺序颠倒下)。

然后对寄存器中的值执行移位操作。最后,再将值按照规则存放到原来的内存中。

总结下来,不管小端序还是大端序存储,左移和右移的结果跟我们手动算出来是一样的。如果在内存上的小端序存储的移位操作不能直观理解,那么就假设我们先把数据取出来,按照 Big-Endian(人们看起来自然的方式) 放置,然后再移位。

小端序的按位运算

笔者正是在阅读代码的时候,看到对大小端序的转化,以及小端序的按位运算操作,才被搞得晕头转向。所以这里结合一个实际的例子来看。

我是在看网络编程相关的代码时遇到的该问题,因此,有必要先介绍下网络中的字节序。

当数据在网络中传输时,仍然需要规定一个顺序。对于独立的字节来说,字节之间的顺序没有意义,就是简单的从前往后,从左往右。

而对于一组字节(比如 IP ID 具有两个字节),其字节之间的顺序就是我们要讨论的内容。

RFC 791 以及 RFC 1700 中均提到:报文头和数据的传输顺序都是以八位字节为单位来说的,对于一组字节来说,其传输顺序与人的阅读习惯相同(即从左到右)。

这表明网络中数据的传输是 Big-Endian 的(Big-Endian 也可称为 Network Byte Order,网络字节序)。因此,为了能在 Little-Endian 计算机上正确处理从网络中来的数据,我们需要对数据进行一定的转换。

代码中有那么一行:

1
2
3
4
5
/* 
* iph 是 iphdr 类型的变量
* __be32 是由 __u32 定义的类型
*/
id = ntohl(*(__be32 *)&iph->id);

为了更好的理解,有必要列出 iphdr 结构中关于 id 字段的部分:

1
2
3
4
5
6
struct iphdr {
...
__be16 id; // __be16 等同于 __u16
__be16 frag_off;
...
};

那么对上面那行代码的理解就是,从 id 的起始地址开始,取出 32 比特数据,并将其作为参数传到 ntohl 函数中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |Version|  IHL  |Type of Service|          Total Length         |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |         Identification        |Flags|      Fragment Offset    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |  Time to Live |    Protocol   |         Header Checksum       |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                       Source Address                          |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Destination Address                        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

(上图是 IPv4 报文头)

我们先来看看取出来的这 32比特数据都是什么,根据 IPv4 报文头以及上述 iphdr 结构体可知,我们所取出来的值包括 IP ID分段标志位、以及 段偏移。也就是这一行上的数据:

1
2
3
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |         Identification        |Flags|      Fragment Offset    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

现在假设 IP ID0x1234,标志位和段偏移分别为 b010b0100010001000。并且 iph→id 字段的起始地址为 0xe0 。那么,其在内存中的值为:

1
2
3
Value: 0001 0010 | 0011 0100 | 0100 1000 | 1000 1000
       \--------/ \--------/  \--------/  \--------/
Addr:     0xe0       0xe1        0xe2         0xe3

现在如果我们直接以 __be32 的方式将该值从 Little-Endian 机器上读出来,那么该值为:0x88483412。这个值跟我们读到的字节顺序正好是反过来的。

而我们在写代码的时候希望比较直观的操作。比如,如果我们想看一下 Flags 中第二个标志位(即 Donot Fragment, DF 标志)是多少,我们希望的操作是:使用按位与操作符,看一下从右往左边数第 15 个比特位是否为 1 。其实更准确的说法是:从低位往高位数,第 15 个比特位。代码形式如下:

1
2
// bit32 = *(__be32 *)&iph->id,即我们读出来的值。
int DF = bit32 & (1 << 14);

左移操作可以将 1 的值从最右边移动到右起第 15 位,或者说从最低位起,第 15 位。

但以现在的值来看,这种操作明显得不到我们想要的值,因为对于现在取出来的值 0x88483412 来说,从低位起第 15 位对应的是 IP ID 的值。

因此,如果想要实现一种直观的操作方式,我们可以将所取出来的值的 字节序颠倒 过来,即将 0x88483412 变成 0x12344888

这样的话,我们看到的值 0x12344888 就跟 IP 头中的值一一对应上了(IP ID 对应 0x1234,标志位和段偏移对应 0x4888)。

至此,大功告成,但其实这种对应关系经历了两次翻转:

  • 一次是我们上面所述的字节序颠倒操作。
  • 另一次是当我们从 Little-Endian 中取出数据的时候,也会有一个从 Little-Endian 转换为 Big-Endian 的颠倒操作。

那么现在还剩一个问题,如何翻转呢?

这其实就是函数 ntohl 函数做的事情,该函数全称为 network to host long。很直观,就是将一个网络字节序(Big-Endian)的值转换为 Little-Endianlong 类型。

该函数的实现也挺直观,就是通过移位将字节顺序颠倒:

1
2
3
4
5
#define ___constant_swab32(x) ((__u32)(				\
	(((__u32)(x) & (__u32)0x000000ffUL) << 24) |		\
	(((__u32)(x) & (__u32)0x0000ff00UL) <<  8) |		\
	(((__u32)(x) & (__u32)0x00ff0000UL) >>  8) |		\
	(((__u32)(x) & (__u32)0xff000000UL) >> 24)))

类似的函数还有 ntohs, htons, htonl 等。

小端序的优点

大小端序是由 CPU 架构来决定的,例如我们通常所接触到的 x86 架构,就是采用的 Little-Endian。上面介绍了那么多关于 Little-Endian 的性质,但是有一个重要问题还没有回答:为什么 CPU 要采用这种反直觉的存储顺序呢?

查阅资料,发现 Little-Endian 有以下优点:

  1. 在进行数据类型转换的时候,不同类型的数据的地址是一样的,比如 C 语言中的 int、short、long、char。比如现在有一个 short 类型(2 字节)的变量 num,其值为 0x80。假设其初始地址为 0xe0,按照小端序,该变量在内存中存储为:
1
2
3
4
1.
Value: 0000 1000 | 0000 0000
       \--------/ \--------/
Addr:     0xe0       0xe1

此时变量的地址为 0xe0,即:&num == 0xe0;

现在如果想要将其扩充为 int 类型(4 字节)的变量,即 int num_t = (int) num; 那么其在内存中的值为:

1
2
3
4
2.
Value: 0000 1000 | 0000 0000 | 0000 0000 | 0000 0000
       \--------/ \--------/  \--------/  \--------/
Addr:     0xe0       0xe1        0xe2         0xe3

此时,变量的地址仍为 0xe0

类似,如果想要将其转换为 char 类型(1 字节),其在内存中的值将变为:

1
2
3
4
3.
Value: 0000 1000 
       \-------/ 
Addr:     0xe0

变量地址不变。

C 语言中,以上操作也可以换一种说法:假设有一个数据存储在地址 0xe0 (代码行 1)),如果我们以 short 类型来对该地址的值进行读写(即 2) 中的 num_s ),那么其值为 0x0080,对应上述 图 1;如果以 int 或者 char 类型来进行操作(3) 和 4) 中的 num_i 和 num_c),其值分别为 0x000000800x80,对应图 2 和 3

1
2
3
4
1) addr_t addr = 0xe0;
2) short *num_s = (short *) addr;
3) int *num_i = (int *) addr;
4) char *num_c = (char *) addr;

但是不管以何种方式操作,其地址值是相同的,即 (addr_t) num_s == num_i == num_c

  1. 进行加法操作的时候,可以很方便的从小端开始计算。

进行加法计算的时候,都是从低位到高位进行计算,所以在读取数据的时候,从低位到高位依次取出数据也符合操作逻辑。

参考

  1. Endianness - Wikipedia
  2. Why were microprocessors built as big or little endian? Why did Intel choose little endian architecture? - Quora
  3. architecture - What is the advantage of little endian format? - Software Engineering Stack Exchange
  4. c - Bitwise operation on big endian and little endian differences - Stack Overflow
  5. ntohs(3) - Linux man page (die.net)