3、字节序(Endianness)处理:大端与小端,如何编写字节序无关的代码

字节序这个问题,说白了就是「多字节数据在内存里怎么排队」。大端(Big-Endian)把高位字节排前面,小端(Little-Endian)把低位字节排前面。听起来简单,但我在实际项目中见过太多因为字节序没处理好导致的诡异 bug——数据明明发过去了,对方解析出来全是乱码。

为什么会这样?因为你的代码跑在某个平台上,数据却要跟另一个平台交互。比如你的 MCU 是小端,但通信协议规定用大端。或者你存了个文件,换台机器读就全错了。

嗯,这里要注意:字节序问题只出现在多字节数据类型上,比如 uint16_tuint32_tfloat 等。单字节的 uint8_tchar 不受影响。

3.1 先搞清楚你的平台是什么字节序

我个人习惯,在项目一开始就确定目标平台的字节序。怎么判断?写个小函数就行:

#include <stdint.h>

int is_little_endian(void) {
    uint16_t test = 0x0102;
    uint8_t *p = (uint8_t *)&test;
    return (p[0] == 0x02);
}

这个函数返回 1 表示小端,0 表示大端。原理很简单:把 0x0102 存到内存里,然后看第一个字节是什么。如果是 0x02,说明低位在前,那就是小端。

我的经验: 我在一个项目里遇到过,同事直接假设平台是小端,结果换了个 ARM Cortex-M 的芯片,通信协议就全乱了。后来我强制要求每个模块在初始化时调用一次字节序检测,发现问题立刻报错。

3.2 编写字节序无关的代码——核心原则

说白了就一句话:不要假设内存布局。你写的代码应该能在任何字节序的平台上正确运行。

具体怎么做?我总结了三条原则:

  • 原则一: 跨平台传输数据时,统一使用网络字节序(大端)。发送前转换,接收后还原。
  • 原则二: 读写多字节数据时,用移位操作代替指针强制转换。
  • 原则三: 结构体不要直接 memcpy 或通过指针强转来读写,除非你明确知道对齐和字节序。

3.3 手动构造和解析多字节数据

这是最可靠的方法。你想想看,用移位操作来组装或拆分数据,完全不受字节序影响:

// 将 uint32_t 按大端序写入缓冲区
void write_uint32_be(uint8_t *buf, uint32_t val) {
    buf[0] = (uint8_t)(val >> 24);
    buf[1] = (uint8_t)(val >> 16);
    buf[2] = (uint8_t)(val >> 8);
    buf[3] = (uint8_t)(val);
}

// 从缓冲区读取大端序的 uint32_t
uint32_t read_uint32_be(const uint8_t *buf) {
    return ((uint32_t)buf[0] << 24) |
           ((uint32_t)buf[1] << 16) |
           ((uint32_t)buf[2] << 8)  |
           ((uint32_t)buf[3]);
}

这段代码不管跑在大端还是小端平台上,结果都一样。我在做传感器数据采集时就用这个套路,把传感器发来的大端数据转成平台本地字节序,再处理。

关键点: 移位操作是字节序无关的。因为 <<>> 操作的是数值,不是内存地址。你永远不用担心移位会受字节序影响。

3.4 使用标准库函数(如果可用)

如果你的平台有标准库,可以用现成的函数:

函数 作用 头文件
htonl() 32位主机字节序转网络字节序 <arpa/inet.h>
htons() 16位主机字节序转网络字节序 <arpa/inet.h>
ntohl() 32位网络字节序转主机字节序 <arpa/inet.h>
ntohs() 16位网络字节序转主机字节序 <arpa/inet.h>

但要注意,这些函数在裸机环境下不一定有。我做过一个 FreeRTOS 项目,没有网络库,就只能自己手写转换函数。所以我的建议是:自己封装一套字节序转换接口,这样不管有没有标准库,代码都能移植。

3.5 结构体与字节序——一个常见的坑

我曾经接手过一个项目,代码里直接这么写:

typedef struct {
    uint16_t id;
    uint32_t value;
    uint8_t  status;
} __attribute__((packed)) SensorData;

// 从串口接收数据
SensorData data;
uart_read_bytes((uint8_t *)&data, sizeof(SensorData));

这段代码在 x86 上跑得好好的,换到 ARM 上就全乱了。为什么?因为结构体成员的内存布局受字节序影响。你想想看,iduint16_t,在小端平台上内存里是低位在前,但发送方可能是大端,直接 memcpy 进来就全反了。

警告: 永远不要用 memcpy 或指针强转来读写结构体中的多字节字段,除非你百分之百确定双方字节序一致。否则,请逐字段手动转换。

正确的做法是:

typedef struct {
    uint16_t id;
    uint32_t value;
    uint8_t  status;
} SensorData;

// 从缓冲区解析数据(假设缓冲区是大端序)
SensorData parse_sensor_data(const uint8_t *buf) {
    SensorData data;
    data.id     = read_uint16_be(buf);       // 自己实现的函数
    data.value  = read_uint32_be(buf + 2);
    data.status = buf[6];                     // 单字节不用转
    return data;
}

3.6 浮点数的字节序处理

浮点数也要注意。我记得有一次调试一个温度传感器,数据总是差一点点,查了半天才发现是 float 的字节序没处理。浮点数的内存表示跟整型一样受字节序影响。

处理方式:先把 float 的地址强转成 uint32_t *,然后按整型的方式转换字节序:

// 将 float 按大端序写入缓冲区
void write_float_be(uint8_t *buf, float val) {
    uint32_t tmp;
    memcpy(&tmp, &val, sizeof(tmp));  // 把 float 的位模式复制到 uint32_t
    write_uint32_be(buf, tmp);         // 按大端序写入
}

// 从缓冲区读取大端序的 float
float read_float_be(const uint8_t *buf) {
    uint32_t tmp = read_uint32_be(buf);
    float result;
    memcpy(&result, &tmp, sizeof(result));
    return result;
}
小技巧: 如果你用的编译器支持 union 类型双关,也可以用 union 来转换,更简洁。但要注意,有些编译器对 union 的严格别名规则有警告,用 memcpy 是最安全的。

3.7 总结一下我的经验

字节序处理其实不难,难的是你忘了处理它。我给自己定了个规矩:所有跨平台或跨设备的数据交互,一律显式转换字节序。哪怕当前平台是大端,我也写转换代码——因为代码以后可能会移植到小端平台。

最后送你一句话:不要相信内存布局,要相信移位操作。这是我在嵌入式领域摸爬滚打多年,最深刻的体会之一。