【网络编程】字节序:大端序和小端序

端序(Endianness),又称字节顺序,又称尾序,在计算机科学领域中,指存储器中或在数字通信链路中,组成多字节的字的字节的排列顺序。

在几乎所有的机器上,多字节对象都被存储为连续的字节序列。例如在C语言中,一个类型为int的变量x地址为0x100,那么其对应地址表达式&x的值为0x100。且x的四个字节将被存储在存储器的0x100, 0x101, 0x102, 0x103位置。

计算机硬件的字节的排列方式有两个通用规则:

  • 大端序(Big-endian):将数据的低位字节存放在内存的高位地址,高位字节存放在低位地址。这种排列方式与数据用字节表示时的书写顺序一致,符合人类的阅读习惯。
  • 小端序(Little-Endian),将一个多位数的低位放在较小的地址处,高位放在较大的地址处,则称小端序。小端序与人类的阅读习惯相反,但更符合计算机读取内存的方式,因为CPU读取内存中的数据时,是从低地址向高地址方向进行读取的。因为计算机电路先处理低位字节,效率比较高,因为计算都是从低位开始的。所以,计算机的内部处理都是小端字节序

在计算机内部,小端序被广泛应用于现代 CPU 内部存储数据;而在其他场景,符合人类的习惯还是读写大端字节序,所以,除了计算机的内部处理,其他的场景下几乎都是大端字节序,比如网络传输和文件存储。

在网络应用中,字节序是一个必须被考虑的因素,因为不同机器类型可能采用不同标准的字节序,所以均按照网络标准转化。例如假设上述变量x类型为int,位于地址0x100处,它的值为0x01234567,地址范围为0x100~0x103字节,其内部排列顺序依赖于机器的类型。大端序从首位开始将是:0x100: 0x01, 0x101: 0x23, 0x102: 0x45, 0x103: 0x67。而小端序将是:0x100: 0x67, 0x101: 0x45, 0x102: 0x23, 0x103: 0x01

上面的文字描述有点抽象,举个例子:
大端序:

c 复制代码
//对于一个整数 0x12345678,在内存中的存储顺序是:
地址:  0   1   2   3
数据: 12  34  56  78

//网络协议通常采用大端序(例如,TCP/IP)。

小端序:

c 复制代码
//对于一个整数 0x12345678,在内存中的存储顺序是:
地址:  0   1   2   3
数据: 78  56  34  12

//大多数现代PC(如x86架构)使用小端序。

在内存中存放整型数值168496141 需要4个字节,这个数值的对应的16进制表示是0X0A0B0C0D,这个数值在用大端序和小端序排列时的在内存中的图示更易于理解:

字节序的作用

了解字节序对于数据存储和处理非常重要,理解字节序有助于调试跨平台应用和网络协议。在数据处理时,在不同字节序的系统间传输数据时,需转换字节序以确保正确性。

由于计算机处理字节序的时候,不知道什么是高位字节,什么是低位字节。它只知道按顺序读取字节,先读第一个字节,再读第二个字节。如果是大端字节序,先读到的就是高位字节,后读到的就是低位字节。小端字节序正好相反。

字节序的处理,只有读取的时候,才必须区分字节序,其他情况都不用考虑。

处理器读取外部数据的时候,必须知道数据的字节序,将其转成正确的值。然后,就正常使用这个值,完全不用再考虑字节序。即使是向外部设备写入数据,也不用考虑字节序,正常写入一个值即可。外部设备会自己处理字节序的问题。

将数据的外部格式 (文件格式、网络协议、硬件寄存器)转换为内部格式 (软件操作的数据结构)的过程实际上比你想象的要困难得多。黑客利用的软件漏洞大多也是由于解析错误造成的。由于程序员没有正式学习解析,他们自己摸索,创建了容易出错的临时解决方案。例如,程序员假设外部缓冲区不能大于内部缓冲区,从而导致缓冲区溢出。 因此, 外部格式必须定义明确。第一个字节的含义必须写在某处,然后是第二个字节的含义,依此类推。

假如说,传递或存储一个整数,定义必须包括大小、有符号/无符号、位的含义(几乎总是 2 的补码)和字节顺序。超过取值范围0~255的整数则必须用多个字节表示。这些字节是从左到右还是从右到左称为字节顺序。 我们也称之为字节序,一种形式是大端序 ,另一种形式是小端序

在 1970 年代,当 CPU 只有几千个逻辑门时,小端序对于逻辑电路来说可以更高效。因此,许多内部处理都是小端序的,并且这也渗透到外部格式中。

另一方面,大多数网络协议和文件格式仍然是大端序的。因为格式规范是为人类理解而编写的,大端序对我们人类来说更容易阅读和理解。

因此,一旦理解了外部格式中的字节顺序问题,下一个问题就是弄清楚如何解析它,将其转换为内部数据结构。首先,我们必须要了解它是如何解析的:

解析有两种方式:缓冲或流式传输。

  • 在缓冲模型中,你首先读入整个输入(例如整个文件或整个网络数据包),然后解析它。
  • 在流式传输模式下,你一次读取一个字节,解析该字节,然后读取下一个字节。流式传输模式最适合非常大的文件或跨 TCP 网络连接的流式传输数据。

缓冲解析是大多数人使用的一般方法。假设你已经将文件(或网络数据)读入我们称为buf缓冲区。你在当前偏移量处解析该缓冲区 ,直到到达末尾

假设如此,我们在处理器中读入一个16位整数。如果是大端字节序,就按下面的方式转成值。那么解析大端序 整数x 的方式 就是以下代码行:

c 复制代码
 x = buf[offset] * 256 + buf[offset+1];
 // buf是整个数据块在内存中的起始地址,offset是当前正在读取的位置。第一个字节乘以256,再加上第二个字节,就是大端字节序的值。

或者,也可以使用逻辑运算符的形式进行改写,则按以下方式执行:

c 复制代码
x = buf[offset]<<8 | buf[offset+1];
// 第一个字节左移8位(即后面添8个0),然后再与第二个字节进行或运算。

编译器始终将 2 的幂乘法转换为移位指令,因此两个语句都会执行相同的操作。某些编译器足够智能,可以将此模式识别为解析整数,并且可能会将其替换为从内存中加载两个字节并进行字节交换。

对于外部数据中的小端序整数,你可以反转解析方式,例如以下两个语句之一。

c 复制代码
x = buf[offset+1] * 256 + buf[offset];
x = buf[offset] + buf[offset+1] * 256;

32位整数的求值公式也是一样的:

c 复制代码
/* 大端字节序 */
i = (data[3]<<0) | (data[2]<<8) | (data[1]<<16) | (data[0]<<24);

/* 小端字节序 */
i = (data[0]<<0) | (data[1]<<8) | (data[2]<<16) | (data[3]<<24);

对于 JavaScript、C# 或其他一些语言而言,关于字节序的讨论到此就结束了。但如果你使用的是 C/C++,我们还需要处理一些额外的问题。因为 C 的问题在于它是一种低级语言 。这意味着它向程序员公开了整数的内部格式。换句话说,上面的代码关注整数的外部表示,而不关心内部表示。它并不关心你使用的是 x86 小端序 CPU 还是某些 RISC 大端序 CPU。

但在 C语言 中,你可以依靠内部 CPU 表示来解析整数。它看起来类似于以下内容:

c 复制代码
 x = *(short*)(buf + offset);

这段代码在小端序机器和大端序机器上产生了不同的结果。

如果两个字节分别为 0x22 和 0x11,那么在大端序机器上会产生一个值为0x2211的短整数,但小端序机器会产生值为0x1122。如果外部格式是大端序,那么在小端序机器上,你必须对结果进行字节交换

那么,代码看起来像是:

c 复制代码
 x = *(short*)(buf + offset);
 #ifdef  LITTLE_ENDIAN
 x = (x >> 8) | ((x & 0xFF) << 8);
 #endif

当然,我们不会这样写代码。相反,你应该使用宏,如下所示:

c 复制代码
 x = ntohs(*(short*)(buf + offset));

该宏表示network-to-host-short,其中 网络 字节序为大端字节序,主机 字节序未定义。在小端字节序主机 CPU 上,字节交换方式如上所示。在大端字节序 CPU 上,该宏未定义任何内容。该宏在标准套接字库(如 <arpa/inet.h>)中定义。其他库中还有大量用于字节交换整数的类似宏。

事实上,这并不是真正的做法,一次解析一个整数。相反,程序员要做的是定义一个与 他们试图解析的外部格式相对应的 打包 C 结构,然后将 缓冲区 转换为该 结构

例如,在 Linux 中,包含文件<netinet/ip.h>定义了Internet协议头:

c 复制代码
struct ip {
#if BYTE_ORDER == LITTLE_ENDIAN 
 u_char ip_hl:4,  / *请求头长度* /
  ip_v:4;   / *版本* /
#endif
#if BYTE_ORDER == BIG_ENDIAN 
 u_char ip_v:4,   /*版本*/
  ip_hl:4;  /*请求头长度 */
#endif
 u_char ip_tos;   /* 服务类型 */
 short ip_len;   /* 总长度 */
 u_short ip_id;   /* 标识*/
 short ip_off;   /*片段偏移量字段*/
 u_char ip_ttl;   /*生存时间 */
 u_char ip_p;   /* 协议 */
 u_short ip_sum;   /* 校验和* /
 struct in_addr ip_src,ip_dst; / *源和目标地址* /
};

要"解析"协议头,需要执行以下操作:

c 复制代码
 strict ip *hdr = (struct ip *)buf;
 printf("checksum = 0x%04x\n", ntohs(ip->ip_sum));

这被认为是"优雅"的执行方式,因为根本没有"解析"。在 big-endian CPU 上,它也是一个无操作------它精确地花费零指令来"解析"协议头,因为内部和外部结构都完全映射。

然而,在 C 中,结构的确切格式未定义。结构成员之间通常会有填充,以使整数在自然边界上对齐。因此,编译器有指令将结构声明为"packed"以摆脱这种填充,这严格定义内部结构以匹配外部结构。

然而,这是错误的做法。因为它仅仅在 C 中是可能的,但这并不代表它是个好主意。

有些人认为它的效率可以更快。它并不是真的更快了。如今,即使是低端 ARM CPU 也非常快,深度管道也带来了多重问题。决定其速度的往往是分支预测错误和长链依赖性等因素。指令数量几乎是事后才考虑的。因此,在外部数据之上"零开销"映射结构与一次解析一个字节之间的性能优化差异几乎是无法估量的。

另一方面,存在"正确性"成本。C 语言中,没有定义强制转换整数的结果,正如上例所示。所以,这里有一个笑话提到:"可以接受擦除整个硬盘的行为,却不是返回预期的两字节数字。"

在现实世界中,未定义的代码会导致编译器问题,因为它们试图优化问题。有的时候,重要的代码行会从程序中删除,因为编译器严格解释 C 语言 标准的规则。在 C 中使用未定义的行为确实会产生未定义的结果------与程序员的预期完全相反。

一次解析一个字节的结果是定义的。转换整数和结构的结果不是。因此,应该避免这种做法。它使编译器感到困惑。它使试图验证代码正确性的静态和动态分析器感到困惑。

此外,实际问题是转换这些东西会让程序员感到困惑。程序员 相当了解解析外部格式,但混合内部/外部字节序会导致无尽的混乱它会导致有缺陷的代码无穷无尽。 它会导致屎山代码无穷无尽。许开源代码中同样是如此,以正确方式解析整数的代码始终比使用ntohs()等宏的代码更容易阅读。这样的代码混乱且容易出现各种问题,困惑的程序员不断地来回交换整数,不理解到底发生了什么,并且只要函数的输入顺序错误,就简单地添加另一个字节交换。

所以,字节序也是一个解析器问题,处理外部数据格式/协议。在 C/C++ 中处理它的方式与在 JavaScript、C# 或任何其他语言中相同。这样的处理方式是最优的。

还有一种字节序的错误方式,这是 C/C++ 中的 CPU 问题,将内部和外部结构混合在一起,交换字节。多年以来,这造成了无尽的麻烦。

所以,我们也需要停止旧方法并采用新方法。

在C语言中实现大端序和小端序的转换,可以使用按位操作:

c 复制代码
#include <stdio.h>

// 交换端序的函数
unsigned int swap_endian(unsigned int num) {
    return ((num >> 24) & 0xFF) |       // 将 字节3 移动到 字节0
           ((num << 8) & 0xFF0000) |    // 将 字节1 移动到 字节2
           ((num >> 8) & 0xFF00) |      // 将 字节2 移动到 字节1
           ((num << 24) & 0xFF000000);  // 将 字节0 移动到 字节3
}

int main() {
    unsigned int original = 0x12345678;
    unsigned int swapped = swap_endian(original);

    printf("Original: 0x%x\n", original);
    printf("Swapped: 0x%x\n", swapped);

    return 0;
}

//使用移位和按位与操作,将各字节重新排列
//应用场景:
//	在需要处理不同字节序的数据时使用,比如网络通信或文件读写。
了解字节序的意义

了解字节序的意义在于确保数据在不同计算机系统之间能够正确地传输和解释。字节序决定了多字节数据类型(如整数、浮点数)在内存中的存储顺序。

跨平台兼容性问题,不同系统可能使用不同的字节序,了解字节序可以避免跨平台数据传输中的错误;在网络通信中,网络协议通常采用大端序(网络字节序),需要在发送和接收数据时进行转换。在数据存储中,在文件中存储多字节数据时,使用一致的字节序可以确保在不同平台上读取时一致。对开发人员而言,知道系统的字节序,在调试开发低级别代码时能够理解数据的内存布局。

理解和正确处理字节序是系统编程、网络编程和跨平台开发中的一个重要环节。
正因为以上种种原因,所以才有了字节序。

我们在阅读代码时,也不需要搞得这么底层,大部分时候,我们只需要知道计算机处理字节序的时候,如果是大端字节序,先读到的就是高位字节,后读到的就是低位字节。小端字节序则正好相反。

以上。

我是一个十分热爱技术的程序员,希望这篇文章能够对您有帮助,也希望认识更多热爱程序开发的小伙伴。
感谢!

相关推荐
命里有定数2 分钟前
Ubuntu问题 -- 允许ssh使用root用户登陆
linux·ubuntu·ssh
小珑也要变强6 分钟前
shell数组
linux·运维·服务器·windows·struts
时差95318 分钟前
Kafka节点服役和退役
大数据·linux·分布式·kafka·负载均衡·服役·退役
宁静致远202131 分钟前
VMware 17虚拟Ubuntu 22.04设置共享目录
linux·ubuntu·嵌入式linux开发
2401_8582861139 分钟前
L11.【LeetCode笔记】有效的括号
c语言·开发语言·数据结构·笔记·算法·leetcode·
y0ungsheep1 小时前
[NSSCTF Round#16 Basic]了解过PHP特性吗 详细题解
计算机网络·web安全·网络安全·系统安全·php
小小宇宙中微子1 小时前
简单理解回调函数
linux·服务器·数据库
week_泽1 小时前
DHCP、DNS域名系统(Domain Name System)、Samba、SSH (Secure Shell)
linux·ubuntu·ssh
芋头莎莎1 小时前
STM32低功耗设计NFC与无线距离感应智能钥匙扣
c语言·stm32·单片机·嵌入式硬件·51单片机
赵闪闪1681 小时前
快速上手:Docker 安装详细教程(适用于 Windows、macOS、Linux)
c语言