【C++】 探秘网络通信:大小端序转换与结构体对齐底层逻辑
大奇个人主页 https://blog.csdn.net/m0_75192474?typeblog⚡本文所属专栏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的小端特性低字节在前高字节在后所以0x1234在内存中存储的为前34 后12。内存排列0x340x12网络线路传输顺序默认以内存中存储的顺序先发0x34再发0x12接收端由于电脑 x86架构也是小端模式收到第一个字节0x34放到低内存地址收到第二个字节0x12放进高内存地址即在内存中先收到的放在前面后收到的放在后面所以内存中排列仍为 前34 后12。然后读取数据内存中低字节在前高字节在后0x1234⭐ 此时好像没什么问题但是他必须要求发送和接收端都是小端模式存储如果你更换接收设备同时这个设备他不是小端模式存储那么数据就会错乱直接乱码。就比如接收端是大端存储那么大端是低字节在后高字节在前则这种方式读到的数据就是0x3412了直接反了。⚡ 因此历史规范互联网规定网络字节序强制大端所有标准字段IP、端口、协议长度字段都统一按照大端模式存储。uint16位数据发送和接收过程有大小端转换要发送的数据uint16_t temp 0x1234目标接收端同样收到0x1234发送前转成大端模式使用htons()转换uint16_t send_net htons(val);转换后逻辑值还是0x1234但内存字节顺序翻转原来是排列方式是前34 后12现在前12 后34在网络中先发0x12,再发0x34.接收后转成小端模式收到第一个字节0x12放到低内存地址收到第二个字节0x34放进高内存地址即在内存中先收到的放在前面后收到的放在后面所以内存中排列为 前12 后34。然后再转为小端模式使用uint16_t recv_host ntohs(recv)内存中的顺序就位 前34 后12。再根据小端低字节在前高字节在后原则,此时recv_host存储的值就是0x1234总结小端模式存储低字节在前高字节在后大端模式存储低字节在后高字节在前这里的前后指的是内存中排列的顺序网络中字节发送的顺序按照内存中的顺序发送内存中谁在前面先发谁在后面后发接收到的按照在内存中先收到的放在前面后收到的放在后面高低字节属于数据本身前后位置属于内存地址。两者不要混淆大小端转换的4个函数原型字节顺序翻转包含头文件#include arpa/inet.h主机 → 网络发送前用uint16_thtons(uint16_thostshort);// 16位端口号uint32_thtonl(uint32_thostlong);// 32位IP/数值网络 → 主机接收后用uint16_tntohs(uint16_tnetshort);// 16位uint32_tntohl(uint32_tnetlong);// 32位16 位转换htons /ntohs内部实现因为翻转两次 还原所以收发共用一套逻辑// 内部真实实现简化版uint16_thtons(uint16_tx){// 把 2 个字节反过来return((x0xFF)8)|((x8)0xFF);}输入0x1234x 0xFF→ 取低字节0x34 8→ 左移 8 位 →0x3400x 8→ 右移 8 位 →0x12两者相或 →0x3400 | 0x0012 0x3412结果0x1234→ 变成 →0x3412字节完全翻转32 位转换htonl /ntohl内部实现uint32_thtonl(uint32_tx){return((x0xFF)24)|(((x8)0xFF)16)|(((x16)0xFF)8)|((x24)0xFF);}把 4 个字节ABCD → DCBA有符号int16_t数据处理不管int16_t有符号还是uint16_t无符号内存里只是两个二进制字节符号位本身就藏在高位字节里大小端转换只做一件事翻转两个字节顺序不碰里面的 bit、不修改正负发送强转uint16_t→htons翻转字节 → 发送接收收网络大端数据 →ntohs翻回本机顺序 → 强转回int16_t自动恢复正负问题表述在UDP套接字传输过程中我在解析ESP32S3发来的雷达数据时发现雷达数据解析失败进一步排查是雷达的距离数据和强度数据出现了错位距离数据时16位强度数据是8位后续查找资料后发现是结构体字节对齐问题。什么是结构体对齐CPU不是按 1 字节随便读内存而是按「固定块」读取4 字节、8 字节一块编译器为了让 CPU 读得更快、硬件不报错会自动在结构体成员之间填充空白字节这就是结构体内存对齐举例说明结构体字节对齐uint16_t2 字节16 位uint8_t1 字节8 位// 没有任何对齐指令默认自然对齐structMsgDefault{uint8_tflag;// 1字节 8位uint16_tvalue;// 2字节 16位};规定8 位1 字节随便放哪里都可以0、1、2、3…16 位2 字节必须从偶数位置开始放0、2、4…32 位4 字节必须从 4 的倍数开始放下面一步步来排一下内存分布首先放flag变量由于它是1个字节那么他无所谓放在那里我们把它放在偏移0的位置那么下一个位置就是偏移1接下来放value变量他是16位的占两个字节又由于16位数据必须放在偏移位置位偶数的地方而此时偏移位置为1是奇数此时编译器自动帮你塞一个空字节填充占住偏移 10: flag1: 填充字节空的没用现在下一个位置变成了偏移 2偶数可以放value了最终的内存分布 0: flag 1: 填充 2: value 第1字节 3: value 第2字节总大小 1 1 2 4 字节原来是123字节现在编译器自动添加了一个字节那么在读取的时候就会出现呢内存错位。解决结构体字节对齐问题强制紧凑对齐使用__attribute__((packed))关键字直接告诉编译器这个结构体取消所有自动填充全部紧凑排列。typedefstruct{uint8_ta;uint16_tb;}__attribute__((packed))test_t;它强制覆盖对齐规则不让 16 位变量必须从偶数开始不让 32 位变量必须从 4 的倍数开始所有成员紧贴着放没有任何空隙a(1字节)b(2字节)总3字节 无填充、无空位使用#pragma pack(push, 1)#pragma pack(pop)它告诉编译器从现在开始所有结构体按 1 字节对齐不许填充作用push保存当前对齐方式1强制改成1 字节对齐pop恢复原来的对齐方式#pragmapack(push,1)// 临时改成1字节对齐structTest{uint8_ta;uint16_tb;};#pragmapack(pop)// 恢复默认_Alignas(1)C11 标准关键字_Alignas(N)强制按 N 字节对齐structTest{uint8_ta;uint16_tb;}_Alignas(1);本项目实际情况传输雷达距离和强度数据到电脑// 雷达单个点的结构体typedefstruct{uint16_tdistance;// 距离单位mmuint8_tintensity;// 信号强度}ld14p_point_t;发送端ESP32S3for(inti0;iLD14P_POINT_PER_PACK;i)//每包12个数据点{ros_lidar.lidar.points[i].distancehton16(temp_lidar.points[i].distance);//距离数组16位ros_lidar.lidar.points[i].intensitytemp_lidar.points[i].intensity;//强度数据8位printf(距离0x%x,强度0x%x \\r\\n,temp_lidar.points[i].distance,temp_lidar.points[i].intensity);}接收端电脑x86for(inti0;iLD14P_POINT_PER_PACK;i){//此处注意接口提字节对齐问题距离数据位16位强度数据位8位不用对齐会错位(已踩坑2026.3.27 21:07)lidar_rawData.points[i].distance(recv_buf_[3*i7]8)|recv_buf_[3*i8];lidar_rawData.points[i].intensityrecv_buf_[3*i9];printf(距离0x%x,强度0x%x \\r\\n,lidar_rawData.points[i].distance,lidar_rawData.points[i].intensity);}结果出现了字节错位原因是距离为2个字节强度为1个字节修改发送与接收的雷达单点结构体加上__attribute__((packed))// 雷达单个点的结构体typedefstruct{uint16_tdistance;// 距离单位mmuint8_tintensity;// 信号强度}__attribute__((packed))ld14p_point_t;结果正确转换成实际距离M方式语法风格适用平台特点__attribute__((packed))GCC 风格Linux / 嵌入式最常用、最简单#pragma pack(1)预处理指令全平台WindowsLinux最兼容_Alignas(1)C11 标准现代编译器标准官方注意事项如果你定义的结构体中含有子结构体那么在使用__attribute__((packed))和_Alignas时要对每一个结构体加上否则无法实现紧凑对齐// 子结构体加 packedstructSub{uint8_ta;uint16_tb;}**attribute**((packed));// 父结构体也加 packedstructMain{uint8_tx;structSubsub;uint32_ty;}**attribute**((packed));在使用__attribute__((packed))和_Alignas时要对每一个结构体加上否则无法实现紧凑对齐// 子结构体加 packedstructSub{uint8_ta;uint16_tb;}**attribute**((packed));// 父结构体也加 packedstructMain{uint8_tx;structSubsub;uint32_ty;}**attribute**((packed));在 C以及 C 语言中memset和memcpy是有关底层内存操作。它们直接操作内存字节效率极高如果发送和接收端定义的结构体完全一致且紧凑对齐可以使用memcpy来拷贝数据。结构体在内存里就是一段连续的二进制数据memcpy就是拷贝连续二进制两边结构一致 → 拷贝后完美还原memcpy (内存拷贝)功能从源地址拷贝指定数量的字节到目标地址。函数原型void *memcpy(void *destination, const void *source, size_t num);参数解释destination: 目标内存地址。source: 源内存地址。num: 拷贝的字节数。典型场景打包 UDP 数据包如果你想把结构体转为字节流发给 UDPuint8_tbuffer[128];memcpy(buffer,speedtoros,sizeof(speedtoros));// 现在 buffer 里存的就是 speedtoros 的原始二进制数据⚠️ 致命误区内存重叠如果源地址和目标地址有重叠比如把数组的后半部分拷到前半部分memcpy的行为是未定义的。此时必须使用memmove。长度溢出如果num超过了目标缓冲区的实际大小会发生缓冲区溢出这是嵌入式开发中最隐蔽的 Bug。memset (内存设置)功能将一段内存区域的每个字节都设置为同一个值。函数原型void *memset(void *ptr, int value, size_t num);参数解释ptr: 指向要填充的内存块的指针。value: 要设置的值虽然是int但内部会转为unsigned char即只取低 8 位。num: 要设置的字节数常用sizeof获取。典型场景结构体清零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。它会多做一个判断如果是重叠的它会从后往前拷贝确保数据安全。