3.结构体内存对齐与位操作

3 阅读5分钟

一. 结构体内存对齐

1.基础知识

概念含义例子(32位系统)
CPU字长CPU一次访存能读多少字节(硬件固定)4字节(32位)

2.三原则

  1. 首地址对齐:结构体首地址是最大成员大小的整数倍(编译器默认帮你做)

    解释:这个指的是内存从0开始,比如结构体最大成员是int,那么这个结构体的地址就从0,4这样的4的倍数开始存放。确保不是跨格子(如:3,4,5,6)这样cpu运行不连贯。

  2. 成员偏移对齐:每个成员偏移量是该成员大小的整数倍,不够前面填充

    解释:比如下面例子,char直接放第一个,0是1的倍数,不用填充,偏移量是0。int是4,而1,2,3都不是4的倍数,所以直接作为填充到前面,int这个放到4,所以偏移为4,填充3,而int本身占据4字节。char,依旧1位就行,直接紧挨着这个int末尾,从偏移8开始。

  3. 总大小对齐:结构体总大小是最大成员大小的整数倍,不够末尾填充

    解释:还是下面的例子紧跟着上面的解释,在末尾填充4字节确保结构体总大小是最大成员大小int的整数倍。即为12。

3.手算示例(32位系统)

struct Example {
    char a;    // 偏移0,占1字节
    int b;     // 偏移4(填充3字节),占4字节(偏移4-7)
    char c;    // 偏移8,占1字节
};             // 总大小12(末尾填充3字节,保证是4的倍数)

答案:12 字节。调换顺序  int b; char a; char c; 只需 8 字节

4.面试精炼回答

"对齐是为了CPU访问效率——CPU按字长读取,不对齐的数据需要多次访存。三个原则:首地址对齐最大成员、成员偏移对齐自身大小、总大小对齐最大成员。合理安排成员顺序能节省内存,把大的放前面。"

二. #pragma pack 与 packed 属性

1.demo

// 默认对齐(12字节)
struct Example {
    char a;    // 偏移0
    int b;     // 偏移4(填充3)
    char c;    // 偏移8
};             // 总大小12

// pack(1) 强制1字节对齐(三原则都是变成1)
#pragma pack(1)
struct PackedExample {
    char a;    // 偏移0
    int b;     // 偏移1(无填充)
    char c;    // 偏移5
};             // 总大小6
#pragma pack()    //恢复默认对齐

// 或 GCC 风格
struct __attribute__((packed)) Header {
    uint8_t  type;
    uint32_t timestamp;
    uint16_t length;
};

2.应用场景

场景为什么必须用
网络协议头TCP/UDP报文格式固定,填充会破坏协议解析
文件格式BMP/ELF等文件头有固定字节偏移,填充导致读取错位
硬件寄存器映射寄存器地址连续,填充会覆盖相邻寄存器
跨平台通信不同平台默认对齐不同,packed保证布局一致

3.代价

  • 非对齐访问:比如int在奇数地址,某些CPU(ARM Cortex-M0)直接硬件异常
  • 性能下降:X86允许非对齐但需多次访存+拼接,慢2~3倍
  • 原子性丧失:非对齐的读写不是原子操作,多任务下隐患

4.面试题

#pragma pack / __attribute__((packed)) 和 DMA、外设寄存器映射、跨平台协议结构体一起用时注意什么?

  • ① packed 结构体不能用取地址再做指针运算(可能非对齐);(安全做法:用memcpy逐字节拷贝到对齐的局部变量。)
  • ② DMA 要求物理连续且对齐;(正确做法:单独分配对齐缓冲区,用memcpy搬运)
  • ③ 跨平台时不同编译器对齐规则不同,务必显式指定。

三. 位操作四件套

// 设置第 N 位为 1
#define SET_BIT(REG, N)   ((REG) |= (1U << (N)))

// 清除第 N 位为 0
#define CLR_BIT(REG, N)   ((REG) &= ~(1U << (N)))

// 翻转第 N 位(0→1,1→0)
#define TOGGLE_BIT(REG, N) ((REG) ^= (1U << (N)))

// 读取第 N 位的值(返回0或1)
#define CHECK_BIT(REG, N) (((REG) >> (N)) & 0x1)

1.嵌入式开发为什么必须用 1U

1U 是无符号整数,无符号数没有符号位,左移 31 位是明确定义的:0x80000000(一个合法的无符号数)。

对有符号整数进行移位,如果结果超出了该类型的表示范围,或者改变了符号位,结果是未定义的。也就是说:

  • 编译器可能给你 -2147483648
  • 也可能崩溃
  • 也可能给你一个莫名其妙的数
  • 甚至可能优化掉整段代码

不同编译器、不同优化等级下,结果都不可预测

2.面试题

2.1判断 2 的幂(不用if)
bool is_power_of_two(int n) {
    return n > 0 && (n & (n - 1)) == 0;
}

原理:2 的幂二进制只有一位是 1,减 1 后该位变 0 且低位全变 1,按位与结果为 0。例如 8(1000) 和 7(0111) 按位与 = 0。

2.2判断大小端
#include <stdio.h>

int main(void) {
    int num = 1;
    if (*(char *)&num == 1)
        printf("小端\n");   // x86、ARM
    else
        printf("大端\n");   // 网络字节序
    return 0;
}

基础知识:大端 => 内存里的字节顺序和人类读写数字的顺序一样,高位在前。小端刚好反过来。

原理1 的十六进制是 0x00000001。小端低字节存低地址 01,大端低字节存高地址 00