4、字节序:大端序与小端序、网络字节序、如何判断系统字节序、字节序转换

字节序这东西,说白了就是「多字节数据在内存里怎么排排坐」。我早年刚接触嵌入式时,被它坑过不止一次。两台设备明明协议对好了,数据就是解析不对——后来发现,一个用大端,一个用小端,鸡同鸭讲。

这一章咱们就把字节序彻底掰扯清楚。

4.1 大端序与小端序:两种世界观

假设有一个 32 位整数 0x12345678,它在内存里占 4 个字节。这 4 个字节怎么放?

  • 大端序(Big-Endian):高位字节存低地址。也就是 0x12 在最前面,像我们平时写数字的顺序。
  • 小端序(Little-Endian):低位字节存低地址。0x78 在最前面,反着来。

举个例子,地址从 0x1000 开始:

地址 大端序 小端序
0x1000 0x12 0x78
0x1001 0x34 0x56
0x1002 0x56 0x34
0x1003 0x78 0x12

你想想看,大端序更符合人类的阅读习惯,小端序则更贴近 CPU 的运算逻辑——从低地址开始取数,直接就能做加法。

核心记忆点:大端 = 高位在前,小端 = 低位在前。别搞反了。

4.2 网络字节序:为什么必须统一

网络传输是跨平台的。你一台 x86 小端机器,跟一台 PowerPC 大端机器通信,如果不统一字节序,数据就是乱的。

所以 TCP/IP 协议栈规定:网络字节序统一使用大端序。所有多字节数据在发送前,必须转成大端;接收后再转回本机字节序。

我个人习惯在写网络程序时,凡是遇到 uint16_t 或 uint32_t,一律用 htons / htonl 包装一下。哪怕本机就是大端,也写上——这是给读代码的人一个明确信号:这里涉及网络传输。

小技巧:用 Wireshark 抓包时,看到的十六进制数据就是网络字节序。如果你发现抓到的 0x1234 在内存里变成了 0x3412,说明本机是小端,需要转换。

4.3 如何判断系统字节序

写跨平台代码时,经常需要知道当前系统是大端还是小端。我见过有人写死宏定义,结果换了个架构就崩了。

最可靠的方法:运行时检测。

#include <stdio.h>
#include <stdint.h>

int is_little_endian() {
    uint16_t test = 0x0001;
    uint8_t *p = (uint8_t *)&test;
    return p[0] == 0x01;  // 低地址存的是低位字节
}

int main() {
    if (is_little_endian())
        printf("小端序\n");
    else
        printf("大端序\n");
    return 0;
}

原理很简单:0x0001 的低位字节是 0x01,高位是 0x00。如果低地址存的是 0x01,那就是小端。

我曾经在移植一个旧项目时,发现对方用 #ifdef __BIG_ENDIAN__ 来判断,结果在 ARM 上死活不对。后来改成运行时检测,问题就解决了。

注意:不要依赖编译器预定义宏来判断字节序。不同编译器、不同版本、不同平台,宏的名字和定义方式都不一样。运行时检测最安全。

4.4 字节序转换:手动还是用库

标准库提供了现成的转换函数,POSIX 和 Windows 都有:

函数 作用 平台
htons() Host to Network Short (16位) POSIX / Winsock
htonl() Host to Network Long (32位) POSIX / Winsock
ntohs() Network to Host Short POSIX / Winsock
ntohl() Network to Host Long POSIX / Winsock

如果本机是大端,这些函数就是空操作;如果是小端,就做字节交换。你不需要关心内部实现,直接用就行。

但有些场景——比如解析二进制文件格式——你可能需要手动转换。这时候我建议写一个通用宏:

#include <stdint.h>

// 交换16位字节序
#define SWAP16(x) ((uint16_t)( \
    ((x) >> 8) | \
    ((x) << 8)   \
))

// 交换32位字节序
#define SWAP32(x) ((uint32_t)( \
    ((x) >> 24) | \
    ((x) >> 8)  & 0x0000FF00 | \
    ((x) << 8)  & 0x00FF0000 | \
    ((x) << 24)   \
))

嗯,这里要注意:宏的参数要加括号,防止运算符优先级问题。我见过有人写 SWAP16(a | b) 结果展开成一团乱麻。

4.5 字节序与二进制文件格式

很多二进制文件格式会在文件头里声明字节序。比如:

  • ELF 文件:e_ident[EI_DATA] 字段,1 表示小端,2 表示大端。
  • BMP 图片:文件头固定小端,但 BI_BITFIELDS 压缩格式可能混用。
  • TIFF 图片:前两个字节是 0x4949(小端)或 0x4D4D(大端)。

解析这类文件时,第一步就是读字节序标志,然后根据它决定后续字段怎么读。我当年写 BMP 解析器时,默认按小端读,结果遇到一个用大端保存的 BMP 文件,直接崩了。后来加了个字节序检测,才搞定。

避坑指南:解析任何二进制格式,先读字节序标志。不要假设文件一定是小端或大端。

4.6 一张图看懂字节序

下面这张 SVG 图,把大端、小端、网络字节序、转换关系串起来了:

字节序核心知识体系 字节序核心知识体系 大端序 (Big-Endian) 高位字节存低地址 小端序 (Little-Endian) 低位字节存低地址 网络字节序 (Network Byte Order) 统一使用大端序 直接发送 需转换 判断系统字节序 运行时检测法 字节序转换 htons / SWAP16 等 核心原则 解析二进制文件时,先读字节序标志

4.7 实战:解析一个跨平台二进制文件

假设你要解析一个自定义的二进制文件,文件头结构如下:

typedef struct {
    uint16_t magic;      // 魔数,固定 0xABCD
    uint8_t  endian;     // 0x01 小端,0x02 大端
    uint32_t data_len;   // 数据长度
    uint32_t checksum;   // 校验和
} FileHeader;

解析步骤:

  1. 先读 magic,验证文件格式。
  2. 读 endian,确定字节序。
  3. 根据字节序,决定后续字段是否需要转换。

代码示例:

#include <stdio.h>
#include <stdint.h>
#include <arpa/inet.h>  // 提供 ntohl / ntohs

int parse_header(FILE *fp, FileHeader *hdr) {
    // 先按小端读入
    fread(&hdr->magic, 2, 1, fp);
    fread(&hdr->endian, 1, 1, fp);
    fread(&hdr->data_len, 4, 1, fp);
    fread(&hdr->checksum, 4, 1, fp);

    // 判断字节序
    if (hdr->endian == 0x02) {
        // 文件是大端,本机是小端时需要转换
        hdr->magic = ntohs(hdr->magic);
        hdr->data_len = ntohl(hdr->data_len);
        hdr->checksum = ntohl(hdr->checksum);
    }
    // 如果文件是小端,且本机也是小端,什么都不用做

    return 0;
}

嗯,这里有个细节:magic 是 2 字节,用 ntohs;data_len 和 checksum 是 4 字节,用 ntohl。别搞混了。

个人经验:我习惯在解析函数里统一用网络字节序函数,而不是自己写 if-else 做字节交换。这样代码更清晰,也更容易移植。

4.8 小结

字节序是二进制世界里绕不开的话题。大端和小端只是两种不同的排列方式,没有谁对谁错。关键是:读写双方必须约定一致。

网络字节序强制使用大端,解决了跨平台通信的问题。判断系统字节序用运行时检测最可靠。转换函数用标准库的 htons / htonl 系列,或者自己写宏。

解析二进制文件时,永远先读字节序标志。这是我在多次踩坑后,最想告诉你的话。