数据的大小端和对齐存储

前面所有小节讨论的都是一个数由哪些位组成。本节换一个问题:这些位放进内存时,占哪些地址、按什么顺序放。

两件事,互相独立:

  • 大小端决定多字节数据的字节次序;
  • 边界对齐决定它的起始地址。

它们互不决定,也互不影响——这是本节最常被混掉的一处,必须一开始就分开。

机制

大端与小端

计算机通常按字节编址,一个 32 位整数要占 4 个连续地址。这 4 个字节谁放低地址,有两种约定:

定义低地址放的是
大端(big-endian)最高有效字节存在最低地址最高有效字节(MSB)
小端(little-endian)最低有效字节存在最低地址最低有效字节(LSB)

记法:“端”指的是”哪一端放在低地址”。大端 = 大的那一端(高位字节)在前;小端 = 小的那一端(低位字节)在前。

以 0x12345678 存放在起始地址 0x100 为例:

地址0x1000x1010x1020x103
大端12345678
小端78563412

大端的字节顺序与人的书写顺序一致(从左到右先写高位),所以看内存转储时读起来顺;小端的优势在于类型转换:把 int 当 short 读时,低地址处正好就是低 16 位,指针不用调整。

采用方端序
x86 / x86-64小端
ARM / RISC-V可配置,实际几乎都是小端
网络字节序(TCP/IP)大端
PowerPC、SPARC(传统)大端

“网络字节序是大端”直接导致跨机通信必须做转换,这就是 htons/htonl/ntohs/ntohl 存在的理由:在小端机器上它们真的交换字节,在大端机器上它们什么也不做。

怎么判断本机是大端还是小端

思路是同一块内存用两种类型去读——正是 位型重解释:

int  x = 1;                          /* 0x00000001 */
char *p = (char *)&x;
if (*p == 1)  puts("小端");          /* 最低地址是 01 → LSB 在前 */
else          puts("大端");          /* 最低地址是 00 → MSB 在前 */

关键在于”取地址后按字节读”。若写成 (char)x,那是值转换,两种机器上都得 1,测不出任何东西。

大小端不是什么

这一段是本节的失分重灾区,四条否定要能立刻答出:

① 不改变位序。 大小端只规定字节之间的顺序,一个字节内部的 8 位次序在两种机器上完全一致。位序(bit order)只在串行传输的物理层才有意义。

② 不改变寄存器里的数值。 数据一旦装入寄存器就是一个整体的数,没有”顺序”可言。大小端只在内存与寄存器之间搬运时起作用。

③ 对单字节数据无影响。 char、unsigned char 只占一个地址,无所谓次序。所以字符串在两种机器上的内存布局完全相同——字符串是 char 数组,逐元素存放。

④ 不改变数组元素之间的顺序。 a[0] 永远在 a[1] 的低地址侧。大小端只作用在”一个多字节标量内部”,不作用于元素之间。

这四条合起来给出一个统一判据:大小端只在”一个多字节标量对象的内部”生效。

浮点数的字节序

float、double 同样是多字节标量,遵循与整数完全相同的端序。

1.0f 的位串是 0x3F800000,在小端机器上内存里依次是 00 00 80 3F。IEEE 754 只规定位的含义,不规定字节在内存中的排列,后者由机器的端序决定。

边界对齐

自然对齐:一个 字节的数据,其起始地址必须是 的整数倍。

类型大小对齐要求
char1任意地址
short2地址为 2 的倍数
int / float4地址为 4 的倍数
double / 指针(64 位)8地址为 8 的倍数

判断某地址是否对齐,只看地址的低几位是否为 0:4 字节对齐 地址低 2 位为 0;8 字节对齐 低 3 位为 0。

为什么必须对齐

原因在存储器的访问粒度:主存虽然按字节编址,但一次访问取出的是一整个存储字(如 4 字节或 8 字节),且这个存储字的边界是固定的。

设存储字长 4 字节,一个 int 存在地址 0x102:

  • 它横跨 0x100~0x103 和 0x104~0x107 两个存储字。
  • CPU 必须访存两次,再把两半拼接并移位,才能得到这个 int。

代价至少是访存次数翻倍。 而在某些架构(早期 ARM、MIPS、SPARC)上,非对齐访问直接触发异常,根本不允许。x86 允许非对齐访问但会变慢。

flowchart TD
    subgraph OK["<b>对齐</b>:int 存于 0x104"]
        direction LR
        W1["存储字 0x104~0x107<br/><b>一次访存取回</b>"]:::ok
    end
    subgraph BAD["<b>不对齐</b>:int 存于 0x102"]
        direction LR
        W2["存储字 0x100~0x107<br/><b>两次访存 + 拼接移位</b>"]:::bad
    end
    OK ~~~ BAD

    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d

计算模板:一个数据跨越几个存储字

这是本节唯一的计算题型,步骤固定:

设存储字长 字节,数据长 字节,起始地址 。

  1. 求起始所在存储字:。
  2. 求结束所在存储字:。
  3. 跨越的存储字数 = 差 + 1,访存次数等于它。

例:,一个 double()存于 (十进制 258)。

要访存 3 次;若对齐存放( 为 8 的倍数),只需 2 次。

结构体的填充

编译器为满足每个成员的对齐要求,会在成员之间插入填充字节;并且整个结构体的大小必须是”最大成员对齐要求”的整数倍(这样数组里的每个元素都仍然对齐)。

struct A { char c; int i; short s; };   /* sizeof == 12 */
struct B { char c; short s; int i; };   /* sizeof == 8  */

逐字节展开:

struct A偏移struct B偏移
c0c0
填充 31~3填充 11
i4~7s2~3
s8~9i4~7
尾部填充 210~11——
合计12合计8

成员完全相同,只是顺序不同,大小差了 50%。 规律是按对齐要求从大到小排列成员,填充最少。

struct A 尾部那 2 字节填充常被忘记:它存在的理由是让 struct A arr[2] 中的 arr[1].i 仍落在 4 的倍数上。

边界

大小端 vs 边界对齐

大小端边界对齐
管什么多字节标量内部的字节次序数据的起始地址
影响跨机器数据交换访存次数、是否异常
谁决定CPU 架构(硬件固定)编译器(可用 #pragma pack 改)
对单字节数据无影响无影响(任意地址都对齐)

两者正交:一台小端机器上的数据既可以对齐也可以不对齐;一台大端机器同理。题目把两者放在一起考时,要分别作答,不要互相推导。

对齐是空间换时间

对齐要付出填充字节的代价。在内存紧张或需要精确控制布局的场合(网络协议报文、文件格式、硬件寄存器映射),会用 #pragma pack(1) 强制取消填充,代价是访问变慢或在某些架构上不可用。

这个取舍与本章反复出现的”面积换时间”是同一类判断,只是这次换的是内存空间。

与操作系统内存管理的分界

本节的对齐是编译器在一个数据对象内部做的事,粒度是字节。

操作系统的内存管理关心的是页、段、页框,粒度是 KB。两者层次不同,互不相干:一个 int 是否 4 字节对齐,与它落在哪个页里没有任何关系。

唯一的交叉点是”跨页”:一个数据对象若跨越了页边界,可能需要两次地址转换,甚至触发两次缺页——这与本节”跨存储字要两次访存”结构上相似,但发生在完全不同的层次。

大小端与数据传输的实际影响

只有以字节流形式跨越机器边界时,端序才成为问题:网络传输、二进制文件、共享内存。

用文本格式(JSON、XML)传输就没有端序问题,因为文本是字符序列,属于上面”不改变数组元素顺序”那一条。这也是文本协议在互操作性上的优势之一。

对照速查

0x12345678 存于 0x1000x1000x1010x1020x103
大端12345678
小端78563412
判据结论
大小端改变位序吗不改变
大小端影响 char 吗不影响
大小端影响数组元素顺序吗不影响
网络字节序大端
x86小端
跨几个存储字
结构体大小是最大成员对齐要求的整数倍

考点

  • 大端 = 高位字节在低地址;小端 = 低位字节在低地址
  • 大小端只作用于多字节标量内部:不改位序、不改数组顺序、不影响 char、不影响寄存器数值
  • 网络字节序是大端,x86 是小端
  • 判断端序要取地址后按字节读,不能用值转换
  • 自然对齐 = 起始地址是数据长度的整数倍
  • 不对齐的代价是访存次数增加,部分架构直接异常
  • 跨存储字数
  • 结构体成员顺序影响 sizeof;按对齐要求从大到小排最省
  • 结构体尾部填充是为了数组中下一个元素仍对齐
  • 大小端与对齐正交,由架构 / 编译器分别决定

链接