← 返回博客CPU 体系结构

字节序:大端与小端(附总线图)

大端序把最高有效字节放在最低地址,小端序则相反。通过总线图、0x12345678 布局,理解 x86 与 TCP/IP 为何不同。

DennyDenny
阅读约需 11 分钟
Chalkboard feature illustration for “字节序:大端与小端(附总线图)”

概要: 字节序规定多字节值在内存中的排列顺序。大端序把最高有效字节置于最低地址,许多网络协议字段和历史上的许多 PowerPC 系统采用这一方式;小端序把最低有效字节置于该地址,x86 与标准 RISC-V 配置采用这一方式。字节一旦跨越接口,就必须分别核对体系结构、运行模式与协议规定。

一个 32 位整数占 4 个字节,而内存每次按一个字节寻址。把 32 位值写入内存时,哪个字节应位于最低地址? 这项选择就是字节序。它是计算机架构中少数两种合理答案长期并存的决定之一,而且一旦选错,程序会悄无声息地产生错误数字。

“字节序”是什么意思

取 32 位数值 0x123456780\text{x}12345678,它包含四个字节:

  • 0x120\text{x}12——最高有效字节(MSB)
  • 0x340\text{x}34
  • 0x560\text{x}56
  • 0x780\text{x}78——最低有效字节(LSB)

要把它存到地址 AA,必须在 AA、A+1A+1、A+2A+2 、A+3A+3 四个地址分别放入一个字节。两种主要选择是:

大端序。 先存最高有效字节。

地址AAA+1A+1A+2A+2A+3A+3
字节0x120\text{x}120x340\text{x}340x560\text{x}560x780\text{x}78

小端序。 先存最低有效字节。

地址AAA+1A+1A+2A+2A+3A+3
字节0x780\text{x}780x560\text{x}560x340\text{x}340x120\text{x}12

同一个 32 位值、同样四个字节,内存布局却完全不同。如果程序在大端机器上写入 0x123456780\text{x}12345678,另一个程序再在小端机器上逐字节读取 AA 到 A+3A+3 并重组,就会得到 0x785634120\text{x}78563412 。字节相同,数字错误。

这些名称来自 Danny Cohen 1980 年的一篇文章,他借用了《格列佛游记》的故事:小人国居民为煮鸡蛋应该从哪一端敲开而开战。这个类比想说明,选择本身是任意的,但群体会坚定拥护自己的选择,甚至为此争斗。

读取过程的总线图

字节序约定由 CPU 的加载/存储单元执行。CPU 从地址 AA 执行 32 位加载时,会把 AA 放到地址总线,并从数据总线取得 4 个字节。

   CPU                         Memory
+--------+   address=A     +---------+
|  load  | ───────────────>|  byte 0 | A    : 0x12 (big) or 0x78 (little)
| 32-bit |                 |  byte 1 | A+1  : 0x34 (big) or 0x56 (little)
|        |   data[31:0]    |  byte 2 | A+2  : 0x56 (big) or 0x34 (little)
|        | <───────────────|  byte 3 | A+3  : 0x78 (big) or 0x12 (little)
+--------+                 +---------+

在大端 CPU 中,位于最低地址的字节 0 被移入 32 位寄存器的最高有效通道,字节 3 落到最低有效通道。在小端 CPU 中,字节 0 落到最低有效通道,字节 3 落到最高有效通道。

REGISTER 与 RAM 元件参考介绍了参与这次传输的存储原语。字节序只是二者之间的一项接线约定:宽寄存器的哪个字节连接到内存的哪个字节地址。

同步内存访问通常通过宽数据路径同时在总线上呈现这些字节;在较窄的 8 位总线上,相同字节会分四个周期依次到达。两种情况下字节序约定完全相同。

完整示例:C 中的 0x12345678

考虑下面的伪代码:

uint32_t x = 0x12345678;
uint8_t  *p = (uint8_t *)&x;
printf("%02x %02x %02x %02x\n", p[0], p[1], p[2], p[3]);

在大端机器上输出 12 34 56 78,在小端机器上输出 78 56 34 12 。指针 pp 逐字节走过内存,看到的顺序就是内存布局,而布局随字节序不同。

这是标准的字节序探测方法。顺带一提,它也是常见错误来源:通过指针转换技巧从整数中“提取”单个字节的代码依赖字节序,如果不明确交换字节,就只能在一种主机上正确工作。

哪些架构选择哪一种?

架构默认字节序
x86、x86-64小端序
RISC-V小端序(存在大端变体)
ARM双端可配置;绝大多数系统使用小端序
PowerPC历史上采用大端;较新内核为双端
MIPS双端;默认值因 SoC 而异
SPARC大端序(V9 为双端)
Motorola 68k大端序
z/Architecture(IBM 大型机)大端序
网络字节序(TCP/IP、IPv4、IPv6 首部)大端序

如今明显偏向小端序。x86 与 ARM 共同占据现役 CPU 的绝大多数。网络协议则因历史原因保持大端:TCP/IP 在 20 世纪 70 年代末制定时,大端 DEC PDP 与 IBM 大型机仍然影响深远。

为什么两大阵营长期并存

双方都有真实优点,但没有一项足以彻底决定胜负。

大端序的理由

从高到低读取与人类书写数字一致。 0x120\text{x}12 是内存中首先看到的字节,也是写在纸上时首先写出的数字。大端结构的十六进制内存转储可以直接阅读。

网络字节序。 互联网规范采用大端(RFC 791、RFC 1700)。当二进制协议被串行化到导线上时,大端序是既定约定。

符号比较直接。 二进制补码整数的符号位位于最高有效字节;大端机器会在最低地址看到它,因此带符号扩展的加载只需检查最先取回的字节。

小端序的理由

最低有效位对应最低地址。 地址 AA 处的字节正是从 AA 开始的整数最低有效字节。这意味着指向同一地址的 32 位指针和 8 位指针都指向最低有效字节。把 int* 转换为 char* 不会移动地址,所以从 16 位寄存器中读取 32 位值的低字节无需调整。

算术进位顺序。 多精度加法从最低有效字节开始,并把进位向高位传播,也就是朝更高地址移动。小端机器可以按照自然的内存遍历顺序读取操作数并写入结果。对于缓冲能力有限的 1978 年 8086,这曾是一项真实性能理由。

不依赖宽度的加载。 地址 AA 上的 32 位值,可以看作地址AA 上一个 16 位值后接 A+2A+2 上另一个 16 位值,也可看作从 AA 开始的四个 8 位值。在最低字节处,不同宽度之间的类型重解释不改变地址。这也是 x86 软件过去能容忍大量随意类型转换的原因之一。

两大阵营都没有完全获胜。ARM 是架构上的折中方案:支持双端,由内核在启动时选择。

字节序什么时候重要

在单台机器上的日常编程中,字节序通常不可见。CPU 会为自己的整数类型正确排列字节,编译器生成正确的加载与存储,程序无需考虑它。

一旦读写的是字节而不是整数,并且这些字节必须在其他地方表达相同含义,字节序就会显现。

二进制文件格式。 ELF 首部、PNG 块、MP4 box、GIF、BMP、ZIP。有些使用大端(PNG),有些使用小端(BMP、ZIP),还有少数混用。在异端序主机上读取 ELF 首部却不交换字节,会得到毫无意义的偏移量。

网络协议。 TCP、UDP、IP、ICMP、DNS、NTP 全部采用大端。C 库函数 htonl 、 htons 、 ntohl 、 ntohs(分别表示“主机到网络长整数/短整数”及其逆过程)专门用于主机字节序与网络字节序之间转换。在大端主机上它们不做任何事,在小端主机上则交换字节。

#include <arpa/inet.h>

uint32_t host_value = 0x12345678;
uint32_t net_value  = htonl(host_value);  // 0x78563412 on x86
                                          // 0x12345678 on a big-endian host

两种主机在线路上发送的字节完全相同,内存表示则不同。

设备协议。 SPI 闪存、I2C EEPROM、传感器寄存器通常会在数据手册中指定字节序,并要求主机遵守。许多采用大端,也有些使用混合方式。

类型重解释。 通过指针或联合在不同宽度整数类型之间转换,并假设字节位于固定偏移。这种模式在一台主机上“刚好有效”,在另一台主机上则会静默失败。

混合端序系统中的内存映射寄存器。 大端 CPU 控制小端外设,或反过来,都会要求每次访问寄存器时显式执行字节交换指令。

一般规则是:只要字节离开 CPU 的独占管辖,字节序就成为必须执行的契约。在一台主机的单一进程内部可以忽略它;跨机器、文件、协议或外设时则不能。

混合端与中端序

字节序故事还有一个注脚:少数历史系统采用中端序,即 32 位值的两个 16 位半字按大端顺序存储,但每个半字内部又是小端。PDP-11 在某些工作模式下对 32 位值使用这种布局。因此,0x123456780\text{x}12345678 的四个存储字节是 0x34,0x12,0x78,0x560\text{x}34, 0\text{x}12, 0\text{x}78, 0\text{x}56,既非纯大端也非纯小端。现代系统不再使用中端序,但 PDP 时代工具生成的旧二进制文件格式偶尔仍会暴露它。

一些 Arm 系统还区分传统 BE-32 与字节不变的 BE-8。重要教训不是根据 CPU 名称推断外设或文件布局,而是查阅架构、总线和设备文档,确定精确访问规则。

取指—译码—执行周期中的字节序

CPU 执行指令时,必须从内存获取指令字。在大端 CPU 上,指令字按高字节优先排列;在小端 CPU 上,则按低字节优先排列。译码逻辑必须与之匹配。正常运行时这项差异不可见,因为 CPU 只会译码自己的指令流,自身字节序始终一致;但调试器或反汇编器在一台主机上检查为另一种目标构建的二进制文件时,字节序就很重要。

完整指令取回流水线见取指—译码—执行周期文章;字节序是内存与程序计数器之间必须对齐的最后一项细节。

为什么两种顺序都不是通用硬件赢家

在 x86 上查看 0x123456780\text{x}12345678 的十六进制转储 78 56 34 12,会觉得方向反了。眼睛从左向右阅读,数字却按字节对翻转,心算转换很别扭。

小端序确实提供一项便利:多字节整数的地址也是其最低有效字节的地址。逐字节多精度加法可以朝递增地址移动并传播进位。原地加宽小端整数时,低位部分的地址也保持不变。

大端序提供互补特性:字节按有效程度排列,所以内存转储或字典序比较会先遇到最高有效部分。网络字节序对多字节值使用的正是最高有效八位组优先约定。

现代处理器通常一次加载整个字,并按需在加载/存储路径中重排字节。字节序在内存与接口边界仍然至关重要,却不会改变寄存器内行波进位加法的布尔结果。历史采用情况反映的是架构与兼容性决定,而不是某一种电路普遍优越。

在运行时检测字节序

C 语言中的小型运行时检查可以通过 unsigned char 指针观察对象表示;C 允许这种指针为任意对象建立别名:

int is_little_endian(void) {
    unsigned int x = 1;
    return *(const unsigned char *)&x == 1;
}

如果最低地址处的字节为 1,最低有效字节就在最前面。在罕见的混合端实现上,结果为假本身并不能证明系统采用普通大端序。

不要把 <endian.h> 、 <sys/endian.h> 或 __BYTE_ORDER__ 等编译器宏归入 C99:它们是平台或编译器设施。C23 通过 <stdbit.h> 增加了本机端序标识符,但已部署编译器的支持程度和项目语言模式并不统一。对可移植序列化而言,更稳健的设计是明确规定线路字节,再使用移位、掩码或 htonl/ntohl 等有文档说明的库例程组装或拆解整数。

在 DigiSim 中仿真时,可以构建一个小型读—改—写循环:从低地址加载一个字节,把它放入寄存器高字节,再与预期模式验证结果。由此可直接观察接线差异如何改变数值。二进制 3,500 年之旅文章则从更长历史背景说明,人类可读的数字顺序如何与机器便利性相互作用。

下一步

字节序是交换二进制数据所需的字节级约定之一。其他约定还包括整数编码本身(二进制补码有符号算术)与浮点格式(IEEE 754)。取指—译码—执行深入解析会把这些表示连接到指令处理。

打开基础 RAM 存储系统模板观察按字节寻址的内存,再把一个 32 位值写入四个地址,并用不同字节顺序读回,直接体验接线差异。

主要参考资料

将想法变成真正可运行的电路。