【C++】 探秘网络通信:大小端序转换与结构体对齐底层逻辑


🔥大奇个人主页 :https://blog.csdn.net/m0_75192474?type=blog

本文所属专栏:https://blog.csdn.net/m0_75192474/category_13131150.html

最近在做雷达与IMU的无线数据转发,但在数据解析过程中出现了数据混乱,让我很是头疼,因为之前是用Python做的,由于Python代码较为简单,所以对当时的大小端问题,有疏忽;这次换做了C++的Boost库来做UDP通信,发现了这个问题。

在网络通信当中,要传输的多字节数例如
16位,32位数据,通常需要涉及到数据的大小端转换问题,也与发送端和接收端的大小端存储模式相关,为了避免在数据接收端出现数据错乱的情况,我们通常要使用大小端转换来解决

发送端:ESP32S3 小端模式
接收端:电脑 x86 小端模式

uint16位数据发送和接收过程(无大小端转换)

例如:uint16_t temp = 0x1234 ,他有两个8位的字节组成,

  • 高字节:0x12
  • 低字节:0x34

发送端:由于ESP32S3的小端特性,低字节放低地址,先存低字节

内存排列(从左到右 → 地址由低到高):👉 内存:0x34 0x12 网络线路传输顺序:先发 0x34,再发 0x12

接收端:由于电脑 x86架构也是小端模式,收到顺序存入内存 收到第一个字节0x34放到低内存地址;收到第二个字节0x12放进高内存地址 拼接规则:低地址当低字节,高地址当高字节读到的值 = 0x1234

⭐ 此时好像没什么问题,但是他必须要求发送和接收端都是小端模式存储,如果你更换接收设备,同时这个设备他不是小端模式存储,那么数据就会错乱,直接乱码。

⚡ 因此历史规范:**互联网规定「网络字节序强制大端」**所有标准字段:IP、端口、协议长度字段,都统一按照大端模式存储

uint16位数据发送和接收过程(有大小端转换)

要发送的数据uint16_t temp = 0x1234目标 :接收端同样收到 0x1234

  • 发送前转成大端模式 :使用 htons()转换

    uint16_t send_net = htons(val); 转换后逻辑值还是0x1234,但**内存字节顺序翻转,**原来是0x3412,现在是0x1234;在网络中,先发0x12,再发0x34.

  • 接收后转成小端模式 :接收到的数据在内存中存储为0x3412

转换小端模式,使用uint16_t recv_host = ntohs(recv) 此时 recv_host存储的值就是0x1234

大小端转换的4个函数原型(字节顺序翻转

包含头文件 #include <arpa/inet.h>

  • 主机 → 网络(发送前用)
c 复制代码
uint16_t htons(uint16_t hostshort);  // 16位:端口号
uint32_t htonl(uint32_t hostlong);   // 32位:IP/数值
  • 网络 → 主机(接收后用)
c 复制代码
uint16_t ntohs(uint16_t netshort);   // 16位
uint32_t ntohl(uint32_t netlong);    // 32位
  • 16 位转换:htons /ntohs 内部实现

因为翻转两次 = 还原,所以收发共用一套逻辑

c 复制代码
// 内部真实实现(简化版)
uint16_t htons(uint16_t x)
{
// 把 2 个字节反过来!
return ((x & 0xFF) << 8) | ((x >> 8) & 0xFF);
}

输入:0x1234

  1. x & 0xFF → 取低字节 0x34
  2. << 8 → 左移 8 位 → 0x3400
  3. x >> 8 → 右移 8 位 → 0x12
  4. 两者相或 → 0x3400 | 0x0012 = 0x3412

结果:0x1234 → 变成 → 0x3412,字节完全翻转!

  • 32 位转换:htonl /ntohl 内部实现
c 复制代码
uint32_t htonl(uint32_t x)
{
return ((x & 0xFF) << 24) |
(((x >> 8) & 0xFF) << 16) |
(((x >> 16) & 0xFF) << 8) |
((x >> 24) & 0xFF);
}

把 4 个字节 ABCD → DCBA

有符号int16_t数据处理

  • 不管 int16_t(有符号)还是 uint16_t(无符号):内存里只是两个二进制字节,符号位本身就藏在高位字节里;
  • 大小端转换只做一件事:翻转两个字节顺序,不碰里面的 bit、不修改正负;
  • 发送:强转uint16_thtons翻转字节 → 发送
  • 接收:收网络大端数据 → ntohs翻回本机顺序 → 强转回int16_t自动恢复正负

问题表述:在UDP套接字传输过程中,我在解析ESP32S3发来的雷达数据时,发现雷达数据解析失败,进一步排查是雷达的距离数据和强度数据出现了错位,距离数据时16位,强度数据是8位,后续查找资料后发现是结构体字节对齐问题。

什么是结构体对齐

CPU 不是按 1 字节随便读内存 ,而是按「固定块」读取(4 字节、8 字节一块)编译器为了让 CPU 读得更快、硬件不报错,会自动在结构体成员之间填充空白字节 ,这就是结构体内存对齐

举例说明结构体字节对齐

  • uint16_t(2 字节,16 位)
  • uint8_t (1 字节,8 位)
c 复制代码
// 没有任何对齐指令:默认自然对齐
struct MsgDefault
{
	uint8_t  flag;   // 1字节  8位
	uint16_t value;  // 2字节 16位
};

规定:

  • 8 位(1 字节):随便放哪里都可以(0、1、2、3...)
  • 16 位(2 字节)必须从偶数位置开始放(0、2、4...)
  • 32 位(4 字节):必须从 4 的倍数开始放

下面一步步来排一下内存分布

  • 首先放flag变量,由于它是1个字节,那么他无所谓放在那里,我们把它放在 偏移0的位置,那么下一个位置就是偏移1

  • 接下来放 value变量,他是16位的,占两个字节,又由于16位数据必须放在偏移位置位偶数的地方,而此时偏移位置为1,是奇数 ;此时编译器自动帮你塞一个空字节(填充)占住偏移 1, 0: flag;1: 填充字节(空的,没用)

  • 现在下一个位置变成了 偏移 2(偶数),可以放 value

    最终的内存分布
    0: flag
    1: 填充
    2: value 第1字节
    3: value 第2字节

总大小 = 1 + 1 + 2 = 4 字节(原来是1+2=3字节)现在编译器自动添加了一个字节,那么在读取的时候就会出现呢内存错位。

解决结构体字节对齐问题(强制紧凑对齐)

🔥使用__attribute__((packed))关键字 直接告诉编译器:这个结构体,取消所有自动填充,全部紧凑排列。

c 复制代码
typedef struct {
    uint8_t  a;
    uint16_t b;
} __attribute__((packed)) test_t;

强制覆盖对齐规则

  • 不让 16 位变量必须从偶数开始
  • 不让 32 位变量必须从 4 的倍数开始
  • 所有成员紧贴着放,没有任何空隙
c 复制代码
a(1字节) + b(2字节) = 总3字节
无填充、无空位

🔥使用#pragma pack(push, 1) + #pragma pack(pop) 它告诉编译器:从现在开始,所有结构体按 1 字节对齐,不许填充

作用

  • push:保存当前对齐方式
  • 1:强制改成 1 字节对齐
  • pop:恢复原来的对齐方式
c 复制代码
#pragma pack(push, 1)  // 临时改成1字节对齐

struct Test {
uint8_t  a;
uint16_t b;
};

#pragma pack(pop)   // 恢复默认

🔥_Alignas(1) (C11 标准关键字)

_Alignas(N) = 强制按 N 字节对齐

c 复制代码
struct Test {
uint8_t  a;
uint16_t b;
} _Alignas(1);

本项目实际情况

传输雷达距离强度数据到电脑

cpp 复制代码
    // 雷达单个点的结构体
    typedef struct {
        uint16_t distance;  // 距离,单位mm
        uint8_t intensity;  // 信号强度
    }  ld14p_point_t;

发送端ESP32S3

c 复制代码
 for (int i = 0; i < LD14P_POINT_PER_PACK; i++) //每包12个数据点
        {
            ros_lidar.lidar.points[i].distance = hton16(temp_lidar.points[i].distance);//距离数组16位
            ros_lidar.lidar.points[i].intensity = temp_lidar.points[i].intensity;//强度数据8位
            printf("距离:0x%x,强度:0x%x \\r\\n", temp_lidar.points[i].distance , temp_lidar.points[i].intensity);
        }

接收端电脑x86

cpp 复制代码
   for(int i=0;i< LD14P_POINT_PER_PACK;i++){ //此处注意接口提字节对齐问题,距离数据位16位,强度数据位8位,不用对齐会错位(已踩坑2026.3.27 21:07)
              lidar_rawData.points[i].distance = (recv_buf_[3*i+7] << 8) | recv_buf_[3*i+8];
              lidar_rawData.points[i].intensity = recv_buf_[3*i+9];
              printf("距离:0x%x,强度:0x%x \\r\\n", lidar_rawData.points[i].distance , lidar_rawData.points[i].intensity);
              }

结果出现了字节错位,原因是距离为2个字节,强度为1个字节

修改发送与接收的雷达单点结构体加上__attribute__((packed))

cpp 复制代码
 // 雷达单个点的结构体
    typedef struct {
        uint16_t distance;  // 距离,单位mm
        uint8_t intensity;  // 信号强度
    } __attribute__((packed)) ld14p_point_t;

结果正确

转换成实际距离M

方式 语法风格 适用平台 特点
__attribute__((packed)) GCC 风格 Linux / 嵌入式 最常用、最简单
#pragma pack(1) 预处理指令 全平台(Windows+Linux) 最兼容
_Alignas(1) C11 标准 现代编译器 标准官方

注意事项 :如果你定义的结构体中含有子结构体,那么在使用__attribute__((packed))_Alignas时,要对每一个结构体加上,否则无法实现紧凑对齐

c 复制代码
// 子结构体:加 packed
struct Sub {
uint8_t  a;
uint16_t b;
} **attribute**((packed));

// 父结构体:也加 packed
struct Main {
uint8_t  x;
struct Sub sub;
uint32_t y;
} **attribute**((packed));

在使用__attribute__((packed))_Alignas时,要对每一个结构体加上,否则无法实现紧凑对齐

c 复制代码
// 子结构体:加 packed
struct Sub {
uint8_t  a;
uint16_t b;
} **attribute**((packed));

// 父结构体:也加 packed
struct Main {
uint8_t  x;
struct Sub sub;
uint32_t y;
} **attribute**((packed));

在 C++(以及 C 语言)中,memsetmemcpy 是有关底层内存操作。它们直接操作内存字节,效率极高,如果发送和接收端定义的结构体完全一致,且紧凑对齐,可以使用 memcpy 来拷贝数据。

结构体在内存里就是一段连续的二进制数据, memcpy 就是拷贝连续二进制,两边结构一致 → 拷贝后完美还原

memcpy (内存拷贝)

  • 功能:从源地址拷贝指定数量的字节到目标地址。
  • 函数原型void *memcpy(void *destination, const void *source, size_t num);

参数解释:

  1. destination: 目标内存地址。
  2. source: 源内存地址。
  3. num : 拷贝的字节数

典型场景:打包 UDP 数据包

如果你想把结构体转为字节流发给 UDP:

c 复制代码
uint8_t buffer[128];
memcpy(buffer, &speedtoros, sizeof(speedtoros));
// 现在 buffer 里存的就是 speedtoros 的原始二进制数据

⚠️ 致命误区:

  1. 内存重叠 :如果源地址和目标地址有重叠(比如把数组的后半部分拷到前半部分),memcpy 的行为是未定义的。此时必须使用 memmove
  2. 长度溢出 :如果 num 超过了目标缓冲区的实际大小,会发生缓冲区溢出,这是嵌入式开发中最隐蔽的 Bug。

memset (内存设置)

  • 功能 :将一段内存区域的每个字节都设置为同一个值。
  • 函数原型void *memset(void *ptr, int value, size_t num);
  • 参数解释:
  1. ptr: 指向要填充的内存块的指针。
  2. value : 要设置的值(虽然是 int,但内部会转为 unsigned char,即只取低 8 位)。
  3. num : 要设置的字节数 (常用 sizeof 获取)。

**典型场景:**结构体清零

c 复制代码
SensorData_t myData;
memset(&myData, 0, sizeof(myData)); // 将所有成员初始化为 0

⚠️ 致命误区:

  • 不能用于非 0/ -1 的整数数组填充: 如果你写 memset(arr, 1, sizeof(arr)),由于它是按字节 填充,每个 int 变成 0x01010101,结果是 16843009 而不是 1
  • 不能用于带虚函数或 STL 容器的类:std::string 或带有虚函数的类使用 memset 会破坏内部指针(如虚函数表指针 vptr),导致程序直接崩溃。

memset 的误区:为什么填充 1 会失败?

memset 是按 字节 (Byte) 填充的,而不是按 数字 (Number) 填充。

假设有一个 int 数组(在 ESP32 中,一个 int 占 4 个字节)如果执行 memset(arr, 1, sizeof(arr));

  • 理想情况:把每个 int 变成 1
  • **实际发生:**它把 int 内部的 4个字节 全部变成了 01
  • **结果:**这个 int 在内存里变成了二进制的 00000001 00000001 00000001 00000001

转换成十进制,这个数是 16,843,009 ,而不是 1

结论memset 只适合初始化为 0 (00000000) 或 -1 (补码是 11111111)。

memcpy 的误区:什么是内存重叠

memcpy 的底层实现通常是极其追求速度的,它假设源地址和目标地址是完全分开的。

假设有一个数组 char a[] = "abcdefg";,你想把从 a[2] 开始的内容往后挪一位,挪到 a[3]源地址是 &a[2] 目标地址是 &a[3]

  • 问题 :目标地址 a[3] 本身就是源数据的一部分。
  • 后果 :当 memcpy 正在拷贝第一个字节时,它可能会覆盖掉后面还没来得及拷贝的原始数据。最后导致数据乱码或全变成重复的字符。

解决方法:

  • memcpy :仅用于两块完全独立的缓冲区(比如从传感器结构体拷贝到发送缓冲区)。
  • memmove :如果需要在同一个数组 内移动数据,使用 memmove。它会多做一个判断,如果是重叠的,它会从后往前拷贝,确保数据安全。
相关推荐
南境十里·墨染春水2 小时前
C++ 笔记 运算符重载(面象对象)
开发语言·c++·笔记
Yupureki2 小时前
《Linux系统编程》18.线程概念与控制
java·linux·服务器·c语言·jvm·c++
CylMK2 小时前
题解:UVA1218 完美的服务 Perfect Service
数据结构·c++·算法·深度优先·图论
墨^O^2 小时前
并发控制策略与分布式数据重排:锁机制、Redis 分片与 Spark Shuffle 简析
java·开发语言·c++·学习·spark
凤年徐2 小时前
封装红黑树实现 mymap 和 myset
网络·c++·算法
zhangren024683 小时前
Laravel6.x核心特性全解析
开发语言·c++·php
William_wL_3 小时前
【C++】vector的实现
c++
feng_you_ying_li3 小时前
封装map和set所需第二步:红黑树
c++
郝学胜-神的一滴3 小时前
图形学基础:OpenGL、图形引擎与IG的核心认知及核心模式解析
开发语言·c++·qt·程序人生·图形渲染