理解大端(Big Endian)与小端(Little Endian)字节序

字节顺序问题令人沮丧,我想让你免于我所经历的痛苦。关键如下:

  • 问题:计算机讲不同的语言,就像人一样。 有些计算机以“从左到右”的方式写入数据,而其他以“从右到左”的方式。
    • 一台机器可以很好地读取自己的数据——问题出现在当一台计算机存储数据而另一台不同类型的计算机尝试读取它时。
  • 解决方案
    • 同意一个通用格式(即所有网络流量遵循单一格式),或者
    • 始终包含一个描述数据格式的头部。如果头部看起来是倒置的,意味着数据是以另一种格式存储的,需要进行转换。

数字与数据

最重要的概念是认识到数字与表示它的数据之间的区别。

一个 数字 是一个抽象概念,比如某个东西的计数。你有十根手指。“十”这个想法不会因为你使用的表示方式而改变:ten、10、diez(西班牙语)、ju(日语)、1010(二进制)、X(罗马数字)……所有这些表示都指向同一个“十”的概念。

将此与数据对比。 数据 是一个物理概念,是存储在计算机上的一串原始比特和字节。 数据本身没有固有的含义 并且必须由读取它的人来解释。

数据就像人类的书写,只是纸上的标记。这些标记没有固有的含义。如果我们看到一条线和一个圆圈(就像这样:|O),我们可能会将其解释为“十”。

但我们假设这些标记指的是一个数字。它们可能是字母“IO”,木星的一颗卫星。或者希腊女神。或者可能是输入/输出的缩写。或者某人的名字缩写。或者是二进制数字2(“10”)。可能性列表还在继续。

关键在于,一个单一的数据(|O)可以有多种解释方式,并且在有人澄清作者的意图之前,其含义是不明确的。

计算机面临同样的问题。它们存储数据,而不是抽象概念,并且是通过一串1和0来存储的。之后,它们读取这些1和0,并尝试从原始数据中重建抽象概念。根据所做的假设,这些1和0可能意味着非常不同的东西。

为什么会出现这个问题?嗯,没有规定所有计算机必须使用同一种语言,就像没有规定所有人类都需要一样。每种类型的计算机内部是一致的(它可以读取自己的数据),但无法保证 另一个 另一种类型的计算机将如何解释它创建的数据。

基本概念

  • 数据(比特和字节,或纸上的标记)是没有意义的;必须解释它才能创建一个抽象概念,比如数字。
  • 像人类一样,计算机有不同的方式来存储相同的抽象概念。(例如,我们有多种方式说“十”:ten, 10, diez 等。)

将数字存储为数据

值得庆幸的是,大多数计算机都同意一些基本的数据格式(情况并非总是如此)。这为我们提供了一个共同的起点,使我们的生活更轻松一些:

  • 一个比特有两个值(开或关,1 或 0)
  • 一个字节是8个比特的序列
    • 一个字节中“最左边”的比特是最大的。因此,二进制序列00001001是十进制数字9。00001001 = (23 + 20 = 8 + 1 = 9).
    • 比特从右到左编号。比特0是最右边且最小的;比特7是最左边且最大的。

我们可以使用这些基本协议作为交换数据的构建块。如果我们一次存储和读取一个字节,它在任何计算机上都能工作。字节的概念在所有机器上都是相同的,哪个字节是第一、第二、第三(字节0、字节1、字节2……)的想法在所有机器上也是相同的。

如果计算机同意每个字节的顺序,那还有什么问题呢?

嗯,这对于单字节数据(如ASCII文本)来说没问题。然而,很多数据需要使用多个字节来存储,比如整数或浮点数。而对于这些序列应该如何存储,并没有一致的约定。

字节示例

考虑一个4字节的序列,命名为W、X、Y和Z——我避免用A、B、C、D命名,因为它们是十六进制数字,会造成混淆。所以,每个字节都有一个值,由8位组成。

 Byte Name:    W       X       Y       Z
 Location:     0       1       2       3
 Value (hex):  0x12    0x34    0x56    0x78

例如,W是一个完整的字节,十六进制为0x12或二进制为00010010。如果W被解释为一个数字,它将是十进制“18”(顺便说一下,没有规定我们必须把它解释为一个数字——它可能是一个 ASCII 字符或完全是别的东西)。

到目前为止跟上我了吗?我们有4个字节,W、X、Y和Z,每个都有不同的值。

理解指针

指针是编程的关键部分,尤其是C编程语言。指针是一个引用内存位置的数字。由我们(程序员)来解释该位置的数据。

在C语言中,当你将一个指针转换为某种类型(如char *或int *)时,它告诉计算机如何解释该位置的数据。例如,让我们声明

void *p = 0; // p is a pointer to an unknown data type
             // p is a NULL pointer -- do not dereference
char *c;     // c is a pointer to a char, usually a single byte

注意,我们无法从p获取数据,因为我们不知道它的类型。p可能指向一个数字、一个字母、字符串的开头、你的星座、一张图片——我们不知道要读取多少字节,也不知道如何解释其中的内容。

现在,假设我们编写

c = (char *)p;

啊——现在这个语句告诉计算机指向与 p 相同的位置,并将数据解释为单个字符 (char 通常是单个字节,使用 uint8_t 如果你的机器上不是这样)。在这种情况下,c 将指向内存位置 0,即字节 W。如果我们打印 c,我们将得到 W 中的值,即十六进制 0x12(记住 W 是一个完整的字节)。

这个例子不依赖于我们计算机的类型——同样,所有计算机都一致认为单个字节是什么(过去并非如此)。

这个例子很有帮助,尽管在所有计算机上都是一样的——如果我们有一个指向单个字节的指针(char *,单个字节),我们可以遍历内存,一次读取一个字节。我们可以检查任何内存位置,而字节序(Endianness)并不重要——每台计算机都会返回相同的信息。

那么,问题是什么?

当计算机尝试读取多个字节时,问题就出现了。某些数据类型包含多个字节,比如长整数(long integer)或浮点数(floating-point number)。单个字节只有 256 个值,因此可以存储 0-255。

现在问题开始了——当你读取多字节数据时,最大的字节出现在哪里?

  • 大端机器:存储数据 大端优先(Big-End First)当查看多个字节时,第一个字节(最低地址)是最大的。
  • 小端机器:存储数据 小端优先(Little-End First)当查看多个字节时,第一个字节是 最小.

这个命名很有道理,对吧?大端(Big-endian)认为大端在前。(顺便说一句,大端/小端命名来自《格列佛游记》,其中小人国争论是在小头还是大头打碎鸡蛋。有时计算机的争论也差不多那么有意义:-))

再次强调,如果你只有一个字节,字节序并不重要。如果你有一个字节,它是你读取的唯一数据,所以只有一种解释方式(同样,因为计算机一致认为一个字节是什么)。

现在假设我们的大端和小端机器上以相同方式存储了我们的 4 个字节 (W X Y Z)。也就是说,两台机器上内存位置 0 都是 W,内存位置 1 都是 X,等等。

我们可以通过记住字节是机器无关的来创建这种排列。我们可以一次一个字节地遍历内存,设置我们需要的值。这将在任何机器上工作:

c = 0;     // point to location 0 (won't work on a real machine!)
*c = 0x12; // Set W's value
c = 1;     // point to location 1
*c = 0x34; // Set X's value
...        // repeat for Y and Z; details left to reader

这段代码将在任何机器上工作,并且我们都将字节 W、X、Y 和 Z 设置在位置 0、1、2 和 3。

解释数据

现在让我们做一个多字节数据的例子(终于!)。快速回顾:一个“短整型(short int)”是一个 2 字节(16 位)数字,范围可以从 0 到 65535(如果无符号)。让我们用在一个例子中:

short *s; // pointer to a short int (2 bytes)
s = 0;    // point to location 0; *s is the value

所以,s 是一个指向短整型的指针,现在正在查看字节位置 0(其中有 W)。当我们读取 s 处的值时会发生什么?

  • 大端机器:我认为一个短整型是两个字节,所以我会读取它们:位置 s 是地址 0(W,或 0x12),位置 s + 1 是地址 1(X,或 0x34)。由于第一个字节是最大的(我是大端!),这个数字必须是 256 * 字节 0 + 字节 1,或 256*W + X,或 0x1234。我将第一个字节乘以 256(2^8),因为需要将其移动 8 位。

  • 小端机器:我不知道大端先生在抽什么烟。是的,我同意一个短整型是 2 个字节,我会像他一样读取它们:位置 s 是 0x12,位置 s + 1 是 0x34。但在我的世界里,第一个字节是最小的!短整型的值是字节 0 + 256 * 字节 1,或 256*X + W,或 0x3412。

请记住,两台机器都从位置 s 开始向上读取内存。关于位置 0 和位置 1 的含义没有混淆。关于短整型是 2 个字节也没有混淆。

但你看到问题了吗?大端机器认为 s = 0x1234,而小端机器认为 s = 0x3412。完全相同的数据给出了两个不同的数字。这可能不是一件好事。

另一个示例

让我们再用 4 字节整数做个例子,“为了好玩”:

int *i; // pointer to an int (4 bytes on 32-bit machine)
i = 0;  // points to location zero, so *i is the value there

再次询问:i 处的值是多少?

  • 大端机器:一个int占4字节,第一个字节最大。我读取4个字节(W X Y Z),W是最大的。数字是0x12345678。
  • 小端机器:当然,一个int占4字节,但第一个字节最小。我也读取W X Y Z,但W属于最后面——它是最小的。数字是0x78563412。

相同的数据,不同的结果——这可不是件好事。这里有一个使用上述数字的交互式示例,欢迎输入你自己的数据:

NUXI问题

字节顺序的问题有时被称为 NUXI 问题: UNIX 存储在大端序机器上的数据在小端序机器上显示为 NUXI

假设我们要将4个字节(U、N、I和X)存储为两个短整型:UN和 IX. 每个字母都是一个完整的字节,就像我们上面的 WXYZ 示例。要存储这两个短整型,我们会写:

short *s; // pointer to set shorts
s = 0;    // point to location 0
*s = UN;  // store first short: U * 256 + N (fictional code)
s = 2;    // point to next location
*s = IX;  // store second short: I * 256 + X

这段代码并不针对特定机器。如果我们将“UN”存储在一台机器上,然后读取回来,它应该仍然是“UN”!我不关心字节序问题,如果我们在一台机器上存储一个值,并在同一台机器上读取回来,它必须是相同的值。

然而,如果我们一次查看一个字节的内存(使用我们的char*技巧),顺序可能会有所不同。在大端序机器上,我们会看到:

Byte:     U N I X
Location: 0 1 2 3

这很好理解。U是“UN”中最大的字节,所以首先存储。IX同理:I是最大的,首先存储。

在小端序机器上,我们会看到:

Byte:     N U X I
Location: 0 1 2 3

这也很好理解。“N”是“UN”中最小的字节,所以首先存储。再次强调,即使字节在内存中“反向”存储,小端序机器 知道 知道自己是小端序,在读取值时能正确解释它们。另外,请注意我们可以在任何机器上指定十六进制数,如x = 0x1234。即使是小端序机器也知道你写0x1234是什么意思,不会强迫你自己交换值(你指定要写的十六进制数,它会处理细节并在底层交换内存中的字节。有点狡猾)。

这种情形被称为“NUXI”问题,因为字节序列 UNIX 在另一种类型的机器上被解释为 NUXI 。再次强调,这只有在交换数据时才会成为问题——每台机器内部是一致的。

在端机之间交换数据

计算机是互联的——机器只需担心读取自己数据的日子已经一去不复返了。大端序和小端序机器需要通信并和谐共处。它们如何做到这一点?

方案1:使用通用格式

最简单的方法是约定一个通用的数据格式用于网络传输。标准的网络字节序实际上是大端序,但有些人会不高兴,因为小端序没有胜出……我们就称之为“网络字节序”。

为了将数据转换为网络字节序,机器会调用hton(主机到网络)函数。在大端序机器上,这个函数实际上什么都不做,但我们这里就不讨论了(小端序用户可能会生气)。

但在发送数据之前使用hton很重要,即使你的机器是大端序。你的程序可能会很流行,以至于它在不同的机器上编译,而你希望你的代码是可移植的(不是吗?)。

类似地,有一个函数ntoh(网络转主机)用于从网络读取数据。你需要它来确保正确地将网络数据解释为主机格式。你需要知道接收的数据类型才能正确解码,转换函数如下:

 htons() - "Host to Network Short"
 htonl() - "Host to Network Long"
 ntohs() - "Network to Host Short"
 ntohl() - "Network to Host Long"

记住,单字节就是单字节,顺序无关紧要。

这些函数在进行底层网络编程时非常关键,例如验证IP数据包中的校验和。如果你不理解字节序问题,你的生活会很痛苦——请相信我的话。使用这些转换函数,并理解为什么需要它们。

方案2:使用字节顺序标记(BOM)

另一种方法是在每段数据前包含一个魔数,例如0xFEFF。如果你读取到魔数为0xFEFF,意味着数据与你的机器格式相同,一切正常。

如果你读取到魔数为0xFFFE(反向的),意味着数据是以不同的格式写入的。你需要进行转换。

需要注意几点。首先,这个数字并不真的神奇,但程序员常用这个词来描述一个任意数字的选择(这个数字本来可以是任何不同的字节序列)。它被称为字节序标记,因为它指示了数据存储的字节顺序。 BOM 其次,

会给所有传输的数据增加开销。即使你只发送2字节的数据,也需要包含一个2字节的 BOM 哦! BOM。 Unicode在存储多字节数据时会使用

(某些Unicode字符编码每个字符可能有2、3甚至4字节)。 BOM 通过默认以 XML -8存储数据来避免这种混乱,它一次存储一个字节的Unicode信息。为什么这很酷? UTF(第56次重复)“因为字节序问题对单字节不适用”。

说得对。

再次强调,

如果你忘记包含 BOM。 会引发其他问题。你是否假设数据是以你自己的格式发送的?你是否读取数据并查看它是否看起来“反向”(无论这意味着什么)并尝试转换?如果普通数据本身就包含 BOM呢? BOM 巧合吗?这些情况并不有趣。

为什么会有端序问题?我们就不能好好相处吗?

啊,多么哲学的问题。

每种字节序系统都有其优势。小端机器可以让你先读取最低位字节,而无需读取其他字节。你可以非常容易地检查一个数字是奇数还是偶数(最后一位是0),如果你喜欢这类操作的话,这很酷。大端系统存储数据的方式与我们人类思考数据的方式相同(从左到右),这使得底层调试更加容易。

但为什么大家不统一采用一种系统呢?为什么某些计算机非要去尝试与众不同?

让我用一个问题来回答一个问题:为什么每个人不说同一种语言?为什么有些语言从左到右书写,而另一些从右到左?

有时通信系统是独立发展的,后来才需要交互。

尾声:临别感言

字节序问题是一般编码问题的一个例子——数据需要表示一个抽象概念,而后需要从数据中重建该概念。这个话题值得单独写一篇文章(或系列文章),但你现在应该对字节序问题有了更好的理解。更多信息:

本系列其他文章

  1. 数制(Number Systems)与进制(Bases)
  2. GUID 快速指南
  3. 理解 Quake 的快速平方根倒数(Fast Inverse Square Root)算法
  4. 计算机网络简明入门
  5. 使用异或(XOR)交换两个变量
  6. 理解大端(Big Endian)与小端(Little Endian)字节序
  7. Unicode 与你
  8. 关于二进制文件格式的一点小曲
  9. 排序算法(Sorting Algorithms)

加入 45 万月度读者

喜欢这篇文章?还有更多内容能帮你建立持久、直观的数学理解。加入通讯以获取额外内容和最新更新。