Skip to content
返回博客
教程

大小端字节序:同样的字节为什么读出两个不同的数

同样的四个字节 12 34 56 78,读出来可能是 0x12345678,也可能是 0x78563412。在线看清 JavaScript、Python、Go 以及 PNG、GZIP 里的字节序,全部本机实测。

13 分钟

大小端字节序:同样的字节为什么读出两个不同的数

内存里躺着四个字节:12 34 56 78。用 JavaScript 的三个不同 API 去读,回来的是两个不同的数字。这就是字节序(endianness),整个问题在下面这张表里就说完了。

读法结果
new DataView(buf).getUint32(0)0x12345678
new DataView(buf).getUint32(0, true)0x78563412
new Uint32Array(buf)[0]0x78563412

三次调用都没报错,也没抛异常。它们只是各自套用了一条不同的规则:多字节数字的哪一端排在前面。

换个问法能省掉大半麻烦:字节序问的不是「我这台机器是什么」,而是「这些字节是按谁的约定写下来的」。你硬盘上的 PNG 是 big-endian,旁边的 GZIP 文件是 little-endian,两种情况下你的 CPU 都没有投票权。

下文所有结果均在 macOS(darwin arm64)上实测,环境为 Node v26.7.0 与 Python 3.14.6,其中 os.endianness() 返回 LEsys.byteorder 返回 little

1. 大小端到底指什么:big-endian 和 little-endian 的字节排布

拿 32 位的值 0x12345678 来说。它是四个字节:12 是最高位字节,78 是最低位字节。字节序决定的是哪一个落在最低的地址上。

排列方式地址 0地址 1地址 2地址 3
big-endian(大端)12345678
little-endian(小端)78563412

big-endian 先存大的那一端,和你在纸上写数字的顺序一致;little-endian 先存小的那一端。两者都不动字节内部的比特:0x12 在两种排列里都是 0x12,移动的只有整个字节。

如果十六进制到二进制这一步还不够熟,进制转换器会把每个字节的二进制形式和十六进制并排列出来,而二进制、十六进制与八进制转换指南讲的是这套记法本身。

1.1 大端序和小端序为什么会有两种

这个分裂是历史造成的,不是从原理推出来的。big-endian 的读法和人写数字的方式一致,而且它成为网络协议约定的时间足够早,于是就固定了下来。little-endian 在 CPU 这一侧胜出,因为 x86 用它,ARM 默认也用它。几乎每篇讲字节序的文章都会重复的那个效率论证,第 8 节做了实测,结果是大约 2%。

2. 字节序是格式的属性,不是平台的属性

大小端由写下这些字节的人定,不由读它们的机器定。短篇的解释里,这一条最常被跳过。

证明只需要一台笔记本和两个文件。在同一台 arm64 机器上、同一个进程里,这两个文件需要相反的解码方式。

2.1 PNG 是 big-endian

RFC 2083 要求多字节整数使用网络字节序,所以 PNG 里每一个长度、宽度和高度都是 big-endian。文件头的布局是固定的:八个签名字节,然后是四字节的 chunk 长度,接着是四个字符的 chunk 类型,再往后是宽和高。

const fs = require('node:fs');
const png = fs.readFileSync('public/og/base-converter.png');

png.subarray(0, 8).toString('hex'); // '89504e470d0a1a0a' — PNG signature
png.readUInt32BE(8);                // 13   — IHDR chunk length
png.readUInt32BE(16);               // 1200 — image width
png.readUInt32BE(20);               // 630  — image height

png.readUInt32LE(16);               // the same four bytes, read the wrong way
字段字节big-endianlittle-endian
IHDR 长度00 00 00 0d13218,103,808
图像宽度00 00 04 b012002,953,052,160

2.2 GZIP 是 little-endian

RFC 1952 §2.3.1 写得很直白:最低位字节在前。gzip 流的最后四个字节是 ISIZE,也就是解压后的大小。压缩 300 个 A 然后验证一下:

python3 -c "import gzip,sys; sys.stdout.buffer.write(gzip.compress(b'A'*300))" > a.gz
const gz = fs.readFileSync('a.gz');

gz.subarray(-4).toString('hex');   // '2c010000'
gz.readUInt32LE(gz.length - 4);    // 300 — correct
gz.readUInt32BE(gz.length - 4);    // the same four bytes, read the wrong way
字段字节little-endianbig-endian
尾部 ISIZE2c 01 00 00300738,263,040

同一台机器上的同一个进程,用的是同一个四字节读取原语。如果你的经验法则是「我的机器是 little-endian,所以我按 little-endian 读」,这两个文件里必然有一个会被解成乱码。每一次都是格式说了算。

3. JavaScript:两套 API,两个相反的默认值

浏览器和 Node 在这里最容易绊人,大多数讲字节序的文章也没提:查看同一个 ArrayBuffer 的两种方式,默认行为彼此相反。

3.1 DataView 的字节序:第三个参数说了算

DataView 的各个方法把可选的 littleEndian 标志放在最后一个参数上。不写就是 big-endian。setUint32(0, x)setUint32(0, x, false) 是同一个调用。

const buf = new ArrayBuffer(4);
new Uint8Array(buf).set([0x12, 0x34, 0x56, 0x78]);
const dv = new DataView(buf);

dv.getUint32(0).toString(16);       // '12345678'  — big-endian, the default
dv.getUint32(0, true).toString(16); // '78563412'  — littleEndian: true

写入的行为反过来也一样:

const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, '0')).join(' ');

dv.setUint32(0, 0x12345678);
hex(buf); // '12 34 56 78'

dv.setUint32(0, 0x12345678, true);
hex(buf); // '78 56 34 12'

3.2 TypedArray 跟着平台走,而且你改不了

Uint32ArrayInt16ArrayFloat64Array 这些类型用的就是 CPU 用的那一套。它们不收字节序参数,构造函数里也没有开关可拨。在这台 arm64 机器上就意味着 little-endian,正好和 DataView 的默认值相反。

new Uint32Array(buf)[0] = 0x12345678;
hex(buf); // '78 56 34 12'

于是同一个 ArrayBuffer,通过 new DataView(buf).getUint32(0) 读出 0x12345678,通过 new Uint32Array(buf)[0] 读出 0x78563412。两个都是对的,它们回答的是不同的问题。

单字节视图对此免疫,因为字节序只对宽于一个字节的单位才存在。Uint8ArrayInt8Array 永远不需要标志位。往上宽一档,问题就回来了:

const two = new ArrayBuffer(2);
new Uint16Array(two)[0] = 0x00ff;
hex(two); // 'ff 00'

3.3 Node Buffer:把顺序写进方法名

Buffer 干脆不设默认值,直接把顺序写进方法名里,这也是 Node 代码通常最好审的原因。

const b = Buffer.from([0x12, 0x34, 0x56, 0x78]);

b.readUInt32BE(0).toString(16); // '12345678'
b.readUInt32LE(0).toString(16); // '78563412'

b.swap32().toString('hex');     // '78563412' — mutates b in place

swap32() 会把每四个字节一组就地反转,返回的是同一个 buffer 而不是副本。手上有一整个数组的反向字节序整数时很好用;如果你忘了这个 buffer 是共享的,那就危险了。

4. Python struct:五个前缀,以及 @ 真正的代价

4.1 < > ! = @:struct 的五个字节序前缀

0x12345678 按 32 位无符号整数打包,每个前缀一行:

import struct

struct.pack('<I', 0x12345678).hex(' ')  # '78 56 34 12'  little-endian
struct.pack('>I', 0x12345678).hex(' ')  # '12 34 56 78'  big-endian
struct.pack('!I', 0x12345678).hex(' ')  # '12 34 56 78'  network order
struct.pack('=I', 0x12345678).hex(' ')  # '78 56 34 12'  native order, standard sizes
struct.pack('@I', 0x12345678).hex(' ')  # '78 56 34 12'  native order, native alignment

!> 产出的字节完全相同,因为网络字节序就是 big-endian。解包是对称的,int.from_bytes 也给出同样的一对结果:

hex(struct.unpack('>I', b'\x12\x34\x56\x78')[0])    # '0x12345678'
hex(struct.unpack('<I', b'\x12\x34\x56\x78')[0])    # '0x78563412'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'big'))     # '0x12345678'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'little'))  # '0x78563412'

4.2 @= 的区别在填充,不在字节序

两者都跟着平台走,所以在这台机器上都写出 little-endian。区别在对齐,而对齐会改变你这个结构体的大小:

struct.calcsize('@ci')  # 8
struct.calcsize('=ci')  # 5
struct.calcsize('<ci')  # 5

一个 char 后面跟一个 int,数据本身是五个字节。在 @(你什么前缀都不写时的默认值)之下,Python 会插入三个填充字节,让 int 从四字节边界开始。换成 = 或者任何一个显式的字节序前缀,填充就消失了。

这就是那个看上去不可能发生的 bug 背后的机制:有人加了个 < 想修字节序问题,结果记录长度在脚下变了。这跟字节顺序没有任何关系。从 @ 换走的同时,也顺手把原生对齐一起关掉了。

5. 网络字节序,以及其他语言怎么表达它

网络字节序就是 big-endian。TCP、UDP 和 IP 头里的多字节字段都按这个顺序排,源头可以追到 big-endian 硬件还很常见、总得有人拍板选一边的年代。C 语言通过 htonshtonlntohsntohl 暴露这套转换:主机序到网络序再转回来,分别对应 short 和 long。在 little-endian 主机上它们会交换字节,在 big-endian 主机上则是空操作。漏掉它们的代码因此可以一直好好的,直到遇上另一台机器。

Go 走的是相反的路子,干脆拒绝设默认值:

import "encoding/binary"

v := binary.BigEndian.Uint32(b)
binary.LittleEndian.PutUint32(b, v)

binary.BigEndianbinary.LittleEndian 是你在调用处指名的值。平台不参与,也没有哪个可选标志能被忘掉。在 Go 里审字节序就是读一个标识符的事。

定点数适用同一条纪律。Q15 或者 Q31 采样值离开你的代码之后就是一个普通的 16 位或 32 位整数,字节序的问题会连同其他一切一起继承下来;Q 格式转换器展示的是分数背后的那个整数,而被排序的正是这个整数。

6. 浮点数同样有字节序

IEEE 754 只管比特模式长什么样。定完之后,那四个或八个字节照样要按格式要求的顺序铺进内存,浮点数在这件事上没有豁免。

struct.pack('>f', 1.0).hex(' ')  # '3f 80 00 00'
struct.pack('<f', 1.0).hex(' ')  # '00 00 80 3f'
struct.pack('>d', 0.1).hex(' ')  # '3f b9 99 99 99 99 99 9a'
struct.pack('<d', 0.1).hex(' ')  # '9a 99 99 99 99 99 b9 3f'

IEEE 754 转换器回答的是问题的前一半:选中 FP32 输入 3.14159,得到 0x40490FD0。这篇文章回答的是后一半,也就是这四个字节以什么顺序进入文件。工具给你数值,字节序给你排列。

double 0.1 那一行也正是 0.1 + 0.2 出问题的原因。那串重复的 99 字节是一个永不终止的二进制展开,浮点数精度指南把它拆开讲了。

6.1 float 1.0 是 3f 80 00 00,或者 00 00 80 3f

FP32 下的 1.0 是个很好用的探针,因为它的字节形态极不对称。big-endian 写成 3f 80 00 00,little-endian 写成 00 00 80 3f。把一个陌生的二进制格式 dump 出来,找一个你确定应该是 1.0 的字段,末尾那两个零字节就告诉你这份数据站在哪一端。这招对 double 同样有效,同一个值在那里有六个零字节聚在一侧。

7. 文本编码里的字节序:BOM 只是一个声明

UTF-16 和 UTF-32 由多字节单元构成,所以正好撞上本文描述的这个问题。它们的解法是让文件用字节顺序标记(byte order mark,BOM)自己声明顺序:

Buffer.from('\uFEFF', 'utf16le').toString('hex'); // 'fffe' — U+FEFF is the BOM
Buffer.from('A', 'utf16le').toString('hex');      // '4100'

开头是 fffe 表示 little-endian,feff 表示 big-endian。这让 BOM 成了「格式自己声明字节序而不是假定一种」最常见的例子。完整的来龙去脉,包括为什么 UTF-8 不需要 BOM、以及这个标记不请自来时会让你付出什么代价,都在UTF-8、UTF-16 与 Unicode 编码指南里。

8. little-endian 真的更快吗?四千万次迭代给出的答案

「little-endian 更高效」这个说法,搜索引擎的摘要里有,绝大多数中文文章里也有。它是可以测的。在这台 arm64 机器上,对单次 32 位读取做四千万次迭代:

路径ns/op
DataView.getUint32(4, true)(little-endian)4.4267
DataView.getUint32(4)(big-endian)4.5200
Buffer.readUInt32LE(4)1.5173
Buffer.readUInt32BE(4)5.0893
比值倍数
DataView big-endian / little-endian1.021×
Buffer big-endian / little-endian3.354×

这两个比值测的是不同的东西,只报告第二个,就会重复那些文章犯的错。

DataView 那一对才是字节序成本的诚实测量。两次调用在 V8 里编译成同一条内联路径,big-endian 那次多带一条 ARM 的 REV 指令用来交换字节。这就是 1.021×,大约 2%。很小,但不是零,别把它约等成「免费」。

Buffer 那一对测的完全是另一回事。V8 给小端序的 readUInt32LE 准备了专门的快速路径,大端序的 readUInt32BE 没有,所以 3.354× 是某一个运行时里的实现差异,不是 CPU 上交换字节的价钱。拿它当成「big-endian 慢」的证据是错的。换个运行时,这个数字就跟着变。

两个数放在一起看,结论是:在现代硬件上,字节序转换便宜到不该进入格式设计的讨论。那个历史上的效率论证来自还没有专用交换指令的年代。选你的协议、或者你的邻居已经在用的那个顺序就好。

9. 怎么区分字节序 bug 和普通 bug

有个问题这一节不打算展开。「怎么查我的机器是 big-endian 还是 little-endian」,Node 里是 os.endianness(),Python 里是 sys.byteorder,各一行,搜索引擎也早把答案印在结果上方了。放进真实的调试场景,它多半还是个问错了的问题:解析一个文件或者一个数据包时,格式说了算,CPU 根本没参与。

值得练的是认出大小端出错时的症状。

9.1 两种症状:荒谬的数字和差 256 倍的数字

吵闹的那一种很好认。把第 2 节里 PNG 的宽度按错误的方式读出来,一张 1200 像素的图会给你 2,953,052,160。任何本该是个不大的计数、却回来几十亿的字段,在被证明是别的原因之前,都先当成一个反了字节序的 32 位整数。

安静的那一种才是贵的。字节 00 00 01 00 按 big-endian 读是 256,按 little-endian 读是 65,536。两个看着都像合理的缓冲区大小。什么都不抛,什么断言都不响,而值差了 256 倍。这类 bug 能活着穿过代码评审,因为屏幕上的数字看着很正常。它和「一个 UTF-8 BOM 搞崩 JSON 解析」属于同一个家族:不可见的字节级细节,配上彻底误导人的报错表象,UTF-8 BOM 导致 JSON 解析失败的排查指南讲的就是同一类问题。

带三个前导零字节的小值最容易悄悄翻转,因为两种读法都还落在合理范围内。另外,如果手工把字节倒过来就能得到一个说得通的数字,那不用碰调试器,答案已经有了。

9.2 该按什么顺序排查

  1. 先查格式规范。RFC 2083 说 PNG 是 big-endian,RFC 1952 §2.3.1 说 GZIP 是 little-endian。你的机器是什么,对这两条都无关紧要。
  2. 再查你用的读取器的默认值。DataView.getUint32(0) 是 big-endian,Uint32Array 是平台顺序,struct.pack('@I', ...) 是平台顺序,binary.BigEndian.Uint32 写着什么就是什么。多数字节序 bug 是少了个第三参数或者少了个前缀,而不是理解上出了大偏差。
  3. 最后才怀疑平台。用 Uint32Array 或者 @ 写出一个文件、再把它送到另一种架构上,平台才有关系;拿一份内存 dump 去对照规范,平台也有关系。而读一个已经写明用哪种顺序的成熟格式时,它几乎从来都无关紧要。

常见问题

big-endian 和 little-endian 哪个更好?

大小端哪个都不更好。格式规定了哪一个,哪一个就是这个格式的正确答案,而且你很少有得选。性能方面,在这台机器上 big-endian 的 DataView 读取实测是 little-endian 的 1.021×,大约 2%,小到不足以驱动任何设计决策。

怎么判断我的机器是 big-endian 还是 little-endian?

Node 里 os.endianness() 在这里返回 LE,Python 里 sys.byteorder 返回 little,都是一行的事。不过这个问题没看上去那么重要:解析一个文件或者一个数据包时,字节序由格式决定,CPU 没有发言权。

DataView 和 Uint32Array 从同一个 buffer 读出不同的数字,这是 bug 吗?

不是,这是 DataView 有文档的既定行为。DataView.getUint32(0) 默认 big-endian,而 Uint32Array 永远跟着平台走,在 x86 和 Apple Silicon 上是 little-endian。同样的字节,两套约定。把第三个参数传 trueDataView 就和它一致了。

我加了个 <,为什么 struct 的布局大小变了?

因为你从默认的 @ 换走了,而 @ 会按原生对齐做填充。struct.calcsize('@ci') 是 8,struct.calcsize('=ci')struct.calcsize('<ci') 都是 5。int 前面那三个填充字节跟着原生对齐一起走了。

网络字节序是 big-endian 还是 little-endian?

big-endian。这是 TCP/IP 头使用的约定,也是 C 里 htonshtonl 存在的原因。在 Python 里 !> 前缀产出完全相同的字节:struct.pack('!I', 0x12345678)struct.pack('>I', 0x12345678) 都给出 12 34 56 78

字节序会影响 UTF-8 吗?

不会。UTF-8 是字节流,每个码点被写成一串有序的单个字节,已经没有多字节单元可以重排了。UTF-16 和 UTF-32 确实有这个问题,这正是它们带 BOM 的原因,第 7 节讲过。

单字节数组需要处理字节序吗?

不需要。字节序只对宽于一个字节的单位才存在,所以 Uint8ArrayInt8Array 和 Python 的 bytes 对象都免疫。往上宽一档它立刻就回来了:new Uint16Array(two)[0] = 0x00ff 在这台机器上落到内存里是 ff 00

标签: endianness byte-order binary-data file-formats cross-platform