第21章 结构体与联合体的字节序问题

字节序,说白了就是多字节数据在内存里怎么排排坐的问题。大端还是小端?这玩意儿在嵌入式开发里可是个坑,我踩过不止一次。今天咱们就把这个事儿彻底聊透。

21.1 什么是大端与小端

先看个最简单的例子。假设有个32位整数 0x12345678,它在内存里占4个字节。大端和小端的存储方式完全相反:

字节序 低地址 → 高地址 特点
大端(Big-Endian) 0x12 | 0x34 | 0x56 | 0x78 高位字节在低地址,符合人类阅读习惯
小端(Little-Endian) 0x78 | 0x56 | 0x34 | 0x12 低位字节在低地址,CPU处理更高效

我个人习惯把大端想象成「从左往右读」,小端就是「从右往左读」。你想想看,我们写数字 1234 时,左边是千位、右边是个位——这就是大端思维。但x86架构偏偏反着来,它用小端。

核心要点:大端 = 高位在前,小端 = 低位在前。没有谁对谁错,只是不同架构的选择。

21.2 为什么嵌入式开发要关注字节序

我在项目中遇到过这么一件事:两块板子通过SPI通信,A板是ARM Cortex-M(小端),B板是PowerPC(大端)。A板发了一个结构体过去,B板解析出来全是乱码。嗯,这就是字节序不一致导致的经典问题。

具体来说,字节序问题主要出现在以下场景:

  • 网络通信:TCP/IP协议栈规定使用大端(网络字节序),而x86/ARM默认是小端
  • 文件存储:跨平台读取二进制文件时,字节序不匹配会读错数据
  • 硬件寄存器:某些外设寄存器要求特定字节序,写错了设备不工作
  • 联合体访问:用联合体拆分多字节数据时,结果依赖字节序

警告:千万不要假设所有平台字节序相同。我曾经见过一个产品,开发时在x86上跑得好好的,换到MIPS平台就崩了——就是因为代码里硬编码了字节序。

21.3 如何检测当前平台的字节序

检测字节序的方法有很多,我推荐两种最实用的。

方法一:联合体检测法

这是最经典的方法,利用联合体共享内存的特性:

#include <stdio.h>

int check_endian(void) {
    union {
        unsigned int value;
        unsigned char bytes[4];
    } test;

    test.value = 0x01020304;

    // 如果低地址存的是0x01,就是大端
    // 如果低地址存的是0x04,就是小端
    return (test.bytes[0] == 0x01) ? 0 : 1;
}

int main() {
    if (check_endian()) {
        printf("小端模式\n");
    } else {
        printf("大端模式\n");
    }
    return 0;
}

为什么能这么检测?联合体里 valuebytes 共用同一块内存。你写 value,读 bytes[0] 就能看到最低地址的那个字节。说白了,就是利用联合体「同一块内存不同解读」的特性。

方法二:指针强制转换法

int check_endian_ptr(void) {
    unsigned int x = 0x01020304;
    unsigned char *p = (unsigned char *)&x;
    return (p[0] == 0x01) ? 0 : 1;
}

我个人更偏爱联合体法,代码更清晰,可读性更好。指针法虽然也行,但容易让新手困惑。

小技巧:很多编译器提供了预定义宏,比如 __BYTE_ORDER__(GCC)、__LITTLE_ENDIAN__(某些ARM编译器)。编译时就能判断,省去运行时检测的开销。

21.4 字节序转换实战

检测出字节序不一致怎么办?转呗。网络编程里常用的几个函数:

#include <arpa/inet.h>  // Linux
// 或 #include <winsock2.h>  // Windows

uint32_t htonl(uint32_t hostlong);  // 主机字节序 → 网络字节序(大端)
uint32_t ntohl(uint32_t netlong);   // 网络字节序 → 主机字节序
uint16_t htons(uint16_t hostshort);
uint16_t ntohs(uint16_t netshort);

但有时候我们需要自己实现转换,比如在裸机环境下没有标准库。我写过一个通用的字节序转换函数:

// 通用32位字节序翻转
uint32_t swap_endian32(uint32_t value) {
    return ((value & 0xFF000000) >> 24) |
           ((value & 0x00FF0000) >>  8) |
           ((value & 0x0000FF00) <<  8) |
           ((value & 0x000000FF) << 24);
}

// 通用16位字节序翻转
uint16_t swap_endian16(uint16_t value) {
    return (value >> 8) | (value << 8);
}

这段代码的原理很简单:把每个字节单独提取出来,重新排列。你想想看,大端转小端,不就是把字节顺序反过来嘛。

21.5 结构体字节序的陷阱

结构体里的成员如果包含多字节类型(int、short、float等),直接memcpy发送或存储就会出问题。看这个例子:

typedef struct {
    uint16_t id;      // 2字节
    uint32_t value;   // 4字节
    uint8_t  flag;    // 1字节
} SensorData;

// 发送端(小端)直接发送
SensorData tx_data = {0x1234, 0x56789ABC, 0x01};
send((uint8_t *)&tx_data, sizeof(tx_data));

// 接收端(大端)直接解析——数据全错!
SensorData *rx_data = (SensorData *)recv_buffer;
printf("id = 0x%04X\n", rx_data->id);   // 实际是0x3412
printf("value = 0x%08X\n", rx_data->value); // 完全不对

正确的做法是:每个多字节成员单独转换。我建议封装成序列化/反序列化函数:

// 序列化:将结构体转为网络字节序的字节流
void serialize_sensor(SensorData *s, uint8_t *buf) {
    uint16_t id_net = htons(s->id);
    uint32_t val_net = htonl(s->value);
    
    memcpy(buf, &id_net, 2);
    memcpy(buf + 2, &val_net, 4);
    buf[6] = s->flag;  // 单字节不用转
}

// 反序列化:从网络字节序的字节流恢复结构体
void deserialize_sensor(SensorData *s, uint8_t *buf) {
    uint16_t id_net;
    uint32_t val_net;
    
    memcpy(&id_net, buf, 2);
    memcpy(&val_net, buf + 2, 4);
    
    s->id = ntohs(id_net);
    s->value = ntohl(val_net);
    s->flag = buf[6];
}

避坑指南:我曾经在项目里直接用 memcpy 把结构体塞进网络包,结果不同架构的板子通信全乱套。后来老老实实写序列化函数,虽然代码多了几行,但再也没出过字节序问题。

21.6 联合体与字节序的微妙关系

联合体经常被用来拆分多字节数据,但结果依赖字节序。看这个例子:

typedef union {
    uint32_t full;
    struct {
        uint8_t b0;
        uint8_t b1;
        uint8_t b2;
        uint8_t b3;
    } bytes;
} Reg32;

Reg32 reg;
reg.full = 0x12345678;

// 小端模式下:bytes.b0 = 0x78, bytes.b3 = 0x12
// 大端模式下:bytes.b0 = 0x12, bytes.b3 = 0x78
printf("b0 = 0x%02X\n", reg.bytes.b0);

如果你写跨平台代码,千万别依赖这种联合体拆分方式。我建议用移位操作代替:

uint8_t get_byte0(uint32_t val) { return (uint8_t)(val & 0xFF); }
uint8_t get_byte1(uint32_t val) { return (uint8_t)((val >> 8) & 0xFF); }
uint8_t get_byte2(uint32_t val) { return (uint8_t)((val >> 16) & 0xFF); }
uint8_t get_byte3(uint32_t val) { return (uint8_t)((val >> 24) & 0xFF); }

移位操作与字节序无关,无论大端小端,get_byte0 永远取到最低8位。这才是真正可移植的写法。

21.7 知识体系总览

下面这张图总结了本章的核心内容,帮你理清思路:

结构体与联合体字节序知识体系 字节序概念 大端:高位在低地址 小端:低位在低地址 检测字节序 联合体检测法 指针强制转换法 编译器预定义宏 字节序转换 htonl/ntohl标准函数 手动移位翻转 结构体序列化 核心原则 跨平台通信时,每个多字节成员单独转换,不要直接memcpy结构体

21.8 写在最后

字节序这个问题,说大不大,说小不小。但一旦踩坑,排查起来特别痛苦——数据看起来都对,就是解析出来不对。我个人的经验是:所有跨平台的数据交换,一律显式转换字节序,不要依赖任何隐式行为。

记住一句话:写代码时多花五分钟做字节序转换,能省下未来五小时的调试时间。这笔账,怎么算都划算。

终极建议:在项目初期就定义一个统一的「线缆格式」(wire format),规定所有多字节字段使用网络字节序。然后封装好序列化/反序列化函数,团队所有人都用这套接口。这样不管底层是什么架构,上层代码都不用操心字节序问题。


公众号:蓝海资料掘金营,微信deep3321