你在固件里写下 uint32_t t = 1000000;,把它从 UART 发出去,对面打印出
1078071040。数据没坏,也没丢。你只是刚刚认识了字节序(endianness):
一个多字节值的各个字节在内存里按什么顺序摆放,也正是 htonl 这类函数存在的
理由。这篇文章把这件事讲全,所有输出都来自 x86_64 机器上真实跑过的程序——
不含任何”看平台而定”式的含糊其辞。
下面每个例子都是真的。C 程序用 gcc 16.2.1 编译,在这台 x86_64 Linux 上运行;
Python 程序跑在 Python 3.14 下。我选择把字节打印出来而不是断言它们应该是什么,
因为只有这一样东西能让这个教训真正留下来。
大小端:字节序到底是什么
一个 32 位的值有四个字节。字节序只回答一个问题:当你把这个值存到地址 A 时,
哪个字节放在 A?
- 小端(little endian)——最低有效字节排在最前面,
0x11223344变成44 33 22 11。x86、x86-64,以及主流的 ARM 都用它。 - 大端(big endian)——最高有效字节排在最前面,
0x11223344变成11 22 33 44。网络协议栈用它(这就是它被称为网络字节序的原因)、Modbus 用它、很多老式 MCU 用它,还有大量外设的数据手册上直接就写着”MSB first”。
两台机器可以在类型、宽度、对齐上完全一致,却依然在字节上对不上号。问题全在这 里。
实际都是谁在用:x86 和 x86-64 永远是小端。ARM 在常见配置(Cortex-M、Cortex-A、
Apple silicon)下默认小端,但架构本身是双端的——某些工具链可以编译出另一种。
PowerPC 和 Motorola/Freescale 的芯片通常是大端。RISC-V 在所有主流实现里都是
小端。MIPS 则以”同一个型号既有大端又有小端”出名,这也是为什么给 MIPS 交叉编译
算嵌入式入门仪式。网络协议栈在任何地方都是大端,htonl 和”网络字节序”这个名字
正是从这里来的。
字节序不等于位序
这是一类容易混起来的问题,因为两者都被随口叫做”那个端序的事”。字节序说的是 一个多字节值里哪个字节先走。位序说的是单个字节里的哪个 bit 先上线—— 异步 UART 是先低位(LSB first),而 SPI、I2C 和 Modbus 都是先高位(MSB first)。 两者互相独立:UART 完全可以在每个字节内部先发低位的同时,按大端发送一个 16 位 的值。逆向一条未知总线时,我会把这两个问题分开问:哪一位先出去,哪一个字节先 出去。
怎么看见它:把字节打印出来
别再去推理字节序,直接把它打出来。诀窍是通过 unsigned char * 去看这个对象,
这是标准里唯一保证可以别名任何对象的指针类型。
#include <stdint.h>
#include <stdio.h>
#include <arpa/inet.h>
int main(void) {
uint32_t v = 0x11223344u;
unsigned char *p = (unsigned char *)&v;
printf("bytes at increasing addresses: ");
for (size_t i = 0; i < sizeof v; i++) printf("%02x ", p[i]);
printf("\n");
printf("htonl(0x11223344) bytes: ");
uint32_t n = htonl(v);
unsigned char *q = (unsigned char *)&n;
for (size_t i = 0; i < sizeof n; i++) printf("%02x ", q[i]);
printf("\n");
return 0;
}
$ gcc -O2 -o dump dump.c && ./dump
bytes at increasing addresses: 44 33 22 11
htonl(0x11223344) bytes: 11 22 33 44
最低有效字节 0x44 待在最低的地址上。这就是小端主机,x86 给你的就是这个。
而 htonl 交回的正是反过来的一组字节,也就是大端对端期待的样子。
在 Python 里,同样的信息只隔着一个属性,所以当脚本和设备对不上时,这是最快的 检查点:
$ python3 -c "import sys; print(sys.byteorder)"
little
xxd 对磁盘上的任何文件都能说同样的事。一个 Python 程序把 0xA1B2C3D4 写两
遍:
import struct
open("le.bin", "wb").write(struct.pack("<Ii", 0xA1B2C3D4, 305419896))
open("be.bin", "wb").write(struct.pack(">Ii", 0xA1B2C3D4, 305419896))
$ xxd le.bin
00000000: d4c3 b2a1 7856 3412 ....xV4.
$ xxd be.bin
00000000: a1b2 c3d4 1234 5678 .....4Vx.
$ file le.bin be.bin
le.bin: pcap capture file, microsecond ts (little-endian) - version 22136.4660
be.bin: pcap capture file, microseconds ts (big-endian) - version 4660.22136
44 33 22 11 对 11 22 33 44,整篇文章就在这儿。白送的彩头是:file 仅凭这个
魔数就认出了我这个随手写的文件是个 pcap,还顺手告诉了我字节序。嗅探字节序正是
我写的那个 pcap 解析器判断一份别人机器写出来
的抓包该按什么顺序读的方法。
网络字节序,以及 htonl 到底做了什么
htons(host to network short)和 htonl(host to network long)把你的 CPU 使
用的顺序转换成大端。这是上面那个程序稍微加长一点的版本,同时打印网络序和字节
交换后的形式:
$ ./bytes
uint32 0x11223344: 44 33 22 11
htonl(0x11223344): 11 22 33 44
endianness: little
htonl(1) = 16777216
__builtin_bswap32(v) = 1144201745
union { u32 i; u8 c[4]; }: c[0] = 1 -> little endian
有两件事值得从这份输出里拿走。
这里的 htonl(1) 是 16777216。 这就是可移植的字节序测试:在大端主机上它会
是 1,在小端主机上它是 1 << 24。如果你需要在运行时知道字节序,这是一行就
能搞定、任何地方都成立、连 configure 都不需要的写法。
htons 和 htonl 不是字节交换。 它们是主机到网络的转换,在大端主机上
是彻彻底底的空操作。这不是细节——这正是 htons 单元测试几乎毫无价值的原因:
uint16_t v = 0x1234;
assert(htons(v) == 0x3412); /* passes on x86, fails nowhere-yet */
这条断言只能证明你正在一台小端机器上。它对测试来说是个真实的坑:一套在 x86 上
写好的测试,将来搬到大端的 CI runner 上跑,会因为一条从来没有真正检验过协议本
身的断言而失败。要断言的是你实际放到线上的字节,而不是 htons 的返回值。
这个转换之所以存在,是因为当年的互联网工程师选定了一种字节序并坚持至今,而
BSD socket 一路把这个选择贯彻到底:struct sockaddr_in 里的 sin_port 和
sin_addr 都是网络字节序,所以你传给 bind() 的是 htons(8080) 而不是
8080。socket 之上的部分是你自己的事,所以协议只要走 TCP,你就只需要在一个边
界上转换,只需要在一个地方做对。
反向的 ntohs/ntohl 是同一组函数换个方向,并且能往返:
ntohl(htonl(x)) == x 在任何平台都成立。
经典 bug:把裸结构体丢过 socket
这是字节序从冷知识变成事故的地方。它太诱人了:write(fd, &msg, sizeof msg) 几
秒钟就写好。下面就是它真正放到线上的东西。
#define _GNU_SOURCE
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/wait.h>
struct reading { /* 1 + 4 + 2 = 7 bytes of fields */
uint8_t id;
uint32_t t; /* milliseconds */
uint16_t value;
};
static void show(const char *label, const void *b, size_t n) {
printf("%-26s", label);
for (size_t i = 0; i < n; i++) printf("%02x ", ((const unsigned char *)b)[i]);
printf("\n");
}
int main(void) {
struct reading r = { .id = 7, .t = 1000000u, .value = 0x1122 };
unsigned char dirty[sizeof r];
memset(dirty, 0xAA, sizeof dirty); /* pretend stack garbage */
memcpy(dirty, &r, sizeof r);
show("we send (12 bytes):", dirty, sizeof r);
int sv[2]; /* child = the peer */
socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
if (fork() == 0) { close(sv[0]); write(sv[1], dirty, sizeof r); _exit(0); }
unsigned char got[64]; /* parent = the receiver */
ssize_t n = read(sv[0], got, sizeof got);
wait(NULL);
show("peer reads (12 bytes):", got, n);
struct reading back;
memcpy(&back, got, sizeof back);
printf("receiver decodes: id=%u t=%u value=0x%04x\n",
back.id, back.t, back.value);
/* the same data from a big-endian MCU: 7 bytes, no padding */
unsigned char be[7] = { r.id, 0x00, 0x0f, 0x42, 0x40, 0x11, 0x22 };
show("MCU sends (7 bytes):", be, sizeof be);
uint32_t wrong, right;
memcpy(&wrong, be + 1, 4); /* host order -- a lie */
right = ((uint32_t)be[1] << 24) | ((uint32_t)be[2] << 16)
| ((uint32_t)be[3] << 8) | (uint32_t)be[4];
printf("t as host uint32: %u t big endian: %u ratio %.0f\n",
wrong, right, (double)wrong / right);
return 0;
}
$ gcc -O2 -Wall -Wextra -o wirebug wirebug.c && ./wirebug
we send (12 bytes): 07 aa aa aa 40 42 0f 00 22 11 aa aa
peer reads (12 bytes): 07 aa aa aa 40 42 0f 00 22 11 aa aa
receiver decodes: id=7 t=1000000 value=0x1122
MCU sends (7 bytes): 07 00 0f 42 40 11 22
t as host uint32: 1078071040 t big endian: 1000000 ratio 1078
仔细读这份输出,因为它一口气装了三个 bug。
填充。 sizeof(struct reading) 是 12,不是 7——offsetof(t) 是 4,
offsetof(value) 是 8,编译器把空隙补上了。id 和 t 之间那三个 aa 就是填
充字节,我在这里强制填成 0xAA 好让它们显形;真实代码里它们是栈上恰好躺着的
任何东西。你刚刚把未初始化的栈内存发给了一个对端——这也是无意中泄露数据的典型
途径。
字节序。 t 发出去是 40 42 0f 00。看看 Modbus 那一类 MCU 对同一个值发的
是什么:00 0f 42 40。把那四个字节当成主机 uint32_t 来读,得到的是
1078071040 而不是 1000000——差了 1078 倍。这个方向是对称的:MCU 把我的
40 42 0f 00 读成 1078071040 也一样。
长度。 同一个结构体,一边 12 字节,一边 7 字节。一个说”12 字节”的协议和一 个只发 7 字节的对端,根本没走到就该为数字吵架的那一步。
最残酷的地方是前三行:经过一次真实的 socketpair() 往返,结果完全正确。两端都
在 x86 上,同一个结构体定义,同一个编译器——这个 bug 在有一端搬走之前是看不见
的。
检测字节序,以及字节交换
大多数时候你不需要检测它,编译器本来就知道。__BYTE_ORDER__ 在 GCC 和 Clang
上编译期就可用:
#if __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
/* big endian */
#endif
用 union 写的运行时版本是合法的、很快的,而且在 -O2 下会被整个折叠掉。我编译
出的二进制上 objdump 显示 host_is_big_endian() 被内联并化简成一个立即数
0;这个函数在二进制里根本不存在。而上面 htonl(1) 那个技巧不需要任何头文件
就能给你答案。
你真正需要检测它的场合只有一个:读一个自带字节序的文件或帧。惯用做法是读一
个已知的魔数,看它是顺着排的还是反着排的。如果你读到四个字节 a1 b2 c3 d4,
说明源是大端;如果读到 d4 c3 b2 a1,说明源是小端。这一个测试就决定了你怎么
读剩下的全部内容——pcap 和一切自描述二进制格式干的就是这件事。
要做显式的字节交换,用 __builtin_bswap32 / __builtin_bswap16(不够还有
__builtin_bswap64)。我查了一下 gcc 16 对非常量输入实际生成的代码——一条指
令,没有循环:
$ gcc -O2 -std=c11 -o swap swap.c && ./swap
bswap32(0x11223344) = 0x44332211
bswap16(0x1234) = 0x3412
$ objdump -d swap --no-show-raw-insn | sed -n '/<sw>:/,/^$/p'
1190: mov %edi,%eax
1192: bswap %eax
1194: ret
Python 中等价的写法是一次切片反转:x.to_bytes(4, "big")[::-1]。
它在嵌入式里从哪儿咬人
传感器寄存器。 很多 ADC 和 IMU 芯片按 MSB first 输出多样本。一个 3 字节的寄
存器读到 7f 12 34:
raw = bytes([0x7F, 0x12, 0x34])
raw.hex(" "), int.from_bytes(raw, "big"), int.from_bytes(raw, "little")
'7f 12 34' 8327732 3412735
按数据手册,正确答案是 8327732。小端的读数是 3412735,差了大
约 2.4 倍。符号更糟:同样三个字节按有符号大端读是 -60876,按无符号小端读却是
3412735。当一个本该是负数的值读出来又大又正时,先怀疑字节序。
Modbus。 Modbus 帧里每个字段都是大端,没有例外,也没有任何标志位说明这件 事。寄存器 1000、258 和 48879 在线上长这样:
regs = [1000, 258, 0xBEEF]
wire = b"".join(struct.pack(">H", r) for r in regs)
for r in regs:
onwire = struct.pack(">H", r)
print("reg 0x%04x: big-endian read %6d | little-endian read %6d"
% (r, struct.unpack(">H", onwire)[0], struct.unpack("<H", onwire)[0]))
reg 0x03e8: big-endian read 1000 | little-endian read 59395
reg 0x0102: big-endian read 258 | little-endian read 513
reg 0xbeef: big-endian read 48879 | little-endian read 61374
如果你正在逆向一台没文档的设备,同样的逻辑是你的朋友:我写的 串口协议逆向实录——抓字节、对含义提假 设、验证假设——正是摸清一台未知设备用哪种字节序的方法。两种都试一遍,看哪一种 在物理输入变化时给出合理变化的数值。
文件格式。 pcap 嗅自己的魔数(上面已经演示过)。PNG 是大端,BMP 是小端, WAV 的块是小端,PostScript 和 JPEG 是大端。格式自己会告诉你,你只要去读规格说 明书里那一节。
规矩:把线上格式写下来,在边界上转换
这个结构体 bug 的教训不是”字节序很难”。而是:结构体不是序列化格式。结构体描述 的是你的编译器的布局决定——填充、对齐、字段顺序、字节序。这些没有一项属于你 的协议,除非你把它写下来。
那就写下来,然后把编解码只写一次。下面是同一个 struct reading 的正确做法——
一个明确的九字节帧,每个字段的偏移、宽度和字节序都写在注释里,并用
static_assert 锁住布局,这样编译器一旦挪动字段就无法悄悄通过:
struct reading { /* 1 + 4 + 2 = 7 bytes of fields */
uint8_t id;
uint32_t t; /* milliseconds */
uint16_t value;
};
static_assert(offsetof(struct reading, id) == 0, "id at 0");
static_assert(offsetof(struct reading, t) == 4, "t at 4");
static_assert(offsetof(struct reading, value) == 8, "value at 8");
static void put_u16(unsigned char *b, uint16_t v) { b[0] = v >> 8; b[1] = v; }
static void put_u32(unsigned char *b, uint32_t v) {
b[0] = v >> 24; b[1] = v >> 16; b[2] = v >> 8; b[3] = v;
}
static void put_reading(unsigned char *b, struct reading r) {
b[0] = r.id;
put_u32(b + 1, r.t); /* offset 1, 4 bytes, big endian */
put_u16(b + 5, r.value); /* offset 5, 2 bytes, big endian */
b[7] = 0; /* reserved */
b[8] = 0;
}
$ gcc -O2 -std=c11 -o codec codec.c && ./codec
sizeof(struct reading) = 12, sizeof(frame) = 9
frame on the wire: 07 00 0f 42 40 11 22 00 00
decoded back: id=7 t=1000000 value=0x1122
这个帧永远是九字节,在任何主机上都是九字节,里面既没有填充,也没有主机字节序。 代价是每个字段大约五行代码,这是嵌入式工作里最便宜的保险。
我用下面这些代替 memcpy 结构体:
- 把线上格式明确写下来——每个字段的字节偏移、宽度和字节序。“偏移 4 处一个 u16 大端”是规格说明;“把结构体发过去”不是。
- 在边界上一个字段一个字段地序列化——
put_u32(buf + 4, v),或者struct.pack(">Iih", …),或者一个带 schema 的编解码器。只有一处做转换,其他 地方全用宿主机类型。 - 永远不要把裸结构体送出进程、机器或架构边界。 不通过 socket、不写进文 件、不进共享内存。
- 测字节,不测值——断言这一帧是
00 0f 42 40,这是一条关于协议的陈述;assert(htons(x) == …)只是一条关于你笔记本的陈述。 - 自己预留字节,别让编译器替你插。 给结构体加
__attribute__((packed))看 起来很诱人,但那是用填充换非对齐访问,而某些 ARM 和 MIPS 内核遇到非对齐访问 会直接陷入异常。一个显式的b[7] = 0不花任何代价,而且在规格说明里看得见。
字节序不会消失。它在你能摸到的每一条总线上,而字节确实是反着出来的——在大半个 硬件世界里都是如此。