humid1ch blogs

本篇文章

手机用户建议
PC模式 或 横屏
阅读


C语言 2022 年 2 月 15 日

[C语言] 详解结构体,详细分析结构体对齐

结构体是一些值的集合,这些值成为成员变量。结构的每个成员可以是不同类型的变量。结构体属于自定义类型。

结构体

1. 结构体概念

​ 结构体是一些值的集合,这些值成为成员变量。结构的每个成员可以是不同类型的变量。结构体属于自定义类型。

1.1 结构体声明

1.1.1 标准声明

struct User {
    char Name[20];
    char Sex[10];
};

此结构体的类型就是 struct User User 是结构体标签(tag)

1.1.2 特殊声明(不完全声明)

struct {
	char Name[20];
    char Sex[10];
    int age;
}User1;

struct {
	char Name[20];
    char Sex[10];
    int age;
}*p;

这种声明方法省略了结构体标签tag), 直接定义的 User1p 均被称为匿名结构体变量。无法在 main 函数中再次定义此种结构体变量 并且,虽然表面上 User1*p 结构体类型 好像一样。

但是如果 p = &User1; 进行编译,将会报出警告。警告内容是: 指针 到 指针 的类型不兼容。说明 两个变量的结构体类型并不相同,编译器认为,两个成员一样的匿名结构体类型是两种不同的类型

1.2 结构体的自引用

以 链表节点 为例:
struct Node {
    int data;
    struct Node* next;
}
// 自引用 结构体类型的指针
但是
typedef struct {
    int data;
    Node* next;
}Node;
// 不行
// 结构体内部使用 Node* 时, typedef 还未生效

1.3 结构体变量的定义和初始化

示例:
// 结构体声明
struct Contact {
    char Addr[30];
    char Tele[12];
};
// 嵌套结构体的结构体声明
struct User {
    char Name[20];
    char Sex[10];
    int age;
    struct Contact C;
};

int main() {
    struct Contact C1 = {"CSDN", "2xxxxxxxxxx" };//结构体变量的定义和初始化
    
    struct Contact C2 = { }; // 结构体变量所有位初始化为0, 与{0}同作用
    struct Contact C3 = { .Tele = "2xxxxxxxxxx" };//结构体变量的定义 部分初始化
    
	struct User U1 = { "July3", "Male", 19, { "CSDN", "1xxxxxxxxxx" } };	//嵌套结构体的结构体变量的定义和初始化
    
	return 0;
}
使用{}对结构体变量初始化, 可以这样理解:
  1. 未指定初始化值的成员, 均被初始化为0
  2. 如果指定了, 就初始化为指定值

1.4 结构体内存对齐 *

要弄明白什么是结构体内存对齐,我们先来看一下结构体大小
下面这段结构体的大小是多少?
struct S1 {
    char c1;
    int i;
    char c2;
};
我们用 sizeof 求出此结构体类型的大小是:12 字节
但是 int类型大小是 4 字节,char 类型的大小是 1 字节。这个结构体大小不应该是 6 字节吗? 为什么是 12 字节呢?
这就是由于内存对齐的原因:

在正式开始讲解什么是内存对齐之前,我们先来了解一个概念:结构体成员的偏移量

什么是结构体成员的偏移量?

一个结构体的每个成员地址相对于其结构体类型的首地址的偏移量,就是结构体成员的偏移量。

怎么计算结构体成员的偏移量?

C语言中,计算结构体成员的偏移量有一个函数:offsetof

offsetof

size_t offsetof( structName, memberName);

offsetof函数的两个参数是:结构体类型名,计算偏移量的成员名

所在头文件是 stddef.h

我们先计算一下上边这段结构体类型,各成员的偏移量(%zu是输出 size_t 类型的数据的指定格式)

第一个,c1的偏移量是 0;

其次,i 的偏移量是 4;

最后,c2的偏移量是 8.

我们做图明确出来:

可以非常明显的看出,结构体成员c1i 之间,有三个字节的空间是空的

不过,这也才占用了 9字节的空间,但是我们计算的 结构体的大小 是 12,所以我们判断,在 c2 之后还有三个字节的空间 被这个结构体所占用。

所以,此结构体的内存空间占用情况,可能是这样的:

那么,为什么呢?为什么会有 开辟了,但是没有用到 的空间呢?一个结构体类型的大小,到底如何计算呢?

1.4.1 结构体内存对齐规则

  1. 结构体的第一个成员变量存放在结构体变量 开始位置的 0 偏移处

  2. 从第二个成员变量开始,要对齐到 某个对齐数 的整数倍的地址处

    对齐数:编译器的默认对齐数 与 该成员自身大小 的较小值

    • VS编译器 中 对齐数默认为 8
    • 如果编译器默认没有对齐数,对齐数就是成员自身大小
  3. 结构体总大小必须是 最大对齐数的整数倍

    最大对齐数:结构体所有成员中,对齐数最大的成员的对齐数

  4. 如果是结构体嵌套了结构体的情况,被嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍

了解了结构体内存对齐规则,就来详细了解一下结构体到底是如何内存对齐的

1.4.2 结构体内存对齐详解

1.4.2.1 示例1:
我们以上面举过例子的结构体为例:
struct S1 {
    char c1;
    int i;
    char c2;
};
先看一下总大小:
然后我们具体来计算一下:
除第一个成员外的结构体成员成员自身大小成员自身大小(类型大小)编译器默认对齐数实际对齐数(取较小值)
i(int)484
c2(char)181

根据规则:

  1. c1 存放在结构体变量 开始地址的 0 偏移处

  2. i 的对齐数是 4,所以存放在偏移量是 4的整数倍 处

    至少是4

  3. c2 的对齐数是 1,所以存放在偏移量是 1的整数倍 处

  4. 结构体总大小必须为 最大对齐数的整数倍,在此结构体中即为 4 的整数倍。

    c2所在空间已经是 第 9 个字节,所以此结构体总大小 最小为 12

    所以,结构体大小为 12 字节

1.4.2.2 示例2:
再来看下边这个例子:
struct S2 {
    char c1;
    char c2;
    int i;
};
我们将,上一个结构体成员中的,ic2换一换位置结果又是什么呢?
我们发现只是换了一下位置,结构体大小就减少了 4 个字节
这次又是怎么对齐和计算的呢?
除第一个成员外的结构体成员成员自身大小成员自身大小(类型大小)编译器默认对齐数实际对齐数(取较小值)
c2(char)181
i(int)484
  1. c1 存放在结构体变量 开始地址的 0 偏移处

  2. c2 的对齐数是 1,所以存放在偏移量是 1的整数倍 处,c2 下面就可以

  3. i 的对齐数是 4,所以存放在偏移量是 4的整数倍 处, 至少是4

  4. 结构体总大小必须为 最大对齐数的整数倍,在此结构体中即为 4 的整数倍

    i存放完,结构体占8个字节,正好是 4的倍数,所以不用再占用其他空间

此结构体总大小为:8字节

1.4.2.3 示例3:
再来一个例子:
struct S3 {
	double n;
    char c1;
    int i;
};
我们先自己计算:
除第一个成员外的结构体成员成员自身大小(类型大小)编译器默认对齐数实际对齐数(取较小值)
c1(char)181
i(int)484
  1. n 存放在结构体变量 开始地址的 0 偏移处

  2. c2 的对齐数是 1,所以存放在偏移量是 1的整数倍 处

  3. i 的对齐数是 4,所以存放在偏移量是 4的整数倍处, 至少是12

  4. 结构体总大小必须为 最大对齐数的整数倍,在此结构体中即为 8 的整数倍。

    i存放完,结构体占16个字节,正好是 8的倍数,所以不用再占用其他空间

    此结构体总大小为:16字节

我们来验证一下:
确实跟我们计算的一样,这个结构体大小为 16 字节
1.4.2.4 示例4(嵌套结构体的结构体)
struct S3 {
	double n;
    char c1;
    int i;
};

struct S4 {
    int n;
    struct S3 s;
    char c1;
};
那么嵌套有结构体的结构体S4,它的成员怎么去对齐,它的大小怎么计算呢?
按照内存对齐的规则:

如果是结构体嵌套了结构体的情况,被嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍

被嵌套的结构体的对齐数,就是它的 对齐数最大的成员 的对齐数
所以,我们先来计算:
除第一个成员外的结构体成员成员自身大小(类型大小)对齐数最大的成员的对齐数编译器默认对齐数实际对齐数
s(struct S3)16(double)888
c1(char)181
  1. n (大小为4)存放在结构体变量 开始地址的 0 偏移处

  2. s (大小为16)的对齐数是 8,所以存放在偏移量是 8的整数倍 处

  3. c1(大小为 1)的对齐数是 1,所以存放在偏移量是 1的倍数 处

  4. 结构体总大小必须为 最大对齐数的整数倍,在此结构体中即为 8 的整数倍。

    c1 存放完,已经占用 25 字节,所以此结构体总大小 最小为 32

    此结构体总大小为:32 字节

举过 4 个例子之后,结构体的内存对齐应该就已经解释清楚了
不过,我们再来总结一下规则:
  1. 结构体的第一个成员,就存放在结构体偏移量为 0 的地址处

  2. 第二个及以后的成员,需要在 对齐数的整数倍 的地址处存放

  3. 结构体的总大小,必须是其成员的最大对齐数的整数倍

  4. 如果是结构体嵌套了结构体的情况,被嵌套的结构体的对齐数,就是 它内部成员的最大对齐数

例如:

struct S3 {
    double n;
    char c1;
    int i;
};

struct S4 {
    int n;
    struct S3 s;
    char c1;
};

在 结构体 struct S4声明中,嵌套了结构体struct S3

那么,变量s的对齐数,就是结构体struct S3内部的成员的最大对齐数(为 8),变量s的大小,就是结构体struct S3的大小(为 16

1.4.3 为什么存在内存对齐

  1. 硬件平台原因(移植的问题)

    不是所有的硬件平台都能访问 任意地址上 的 任意数据 ;

    某些硬件平台只能在 某些地址处 取 某些特定类型的数据(经过对齐的特定类型的数据),否则抛出硬件异常。

  2. 性能原因

    数据结构(尤其是栈)应该尽可能地在自然边界上对齐。 原因在于,若访问未对齐的内存,处理器需要作两次内存访问;而对 对齐的内存访问仅需要一次访问

    举个例子:

    struct S {
       char c;
       int i;
    }
    
    int main() {
       struct S s1;
       return 0;
    }

    上边的代码,创建了一个结构体变量,我们来假设两种情况

    1. 假设 不对齐内存

      内存不对其
      内存不对其
    2. 假设 对齐内存

      内存对齐
      内存对齐

    假设,我们是 32 位环境,一次可以读取 4 字节

    那么对于第一种情况:

    我们要访问完整的 i 的数据,就需要访问两次

    因为,第一次只访问到了 i的 前三个字节,第二次访问了 i 的最后一个字节

    对于第二种情况

    我们要访问完整的 i 的数据,只需要一次访问

    跳过前 4 个字节,直接访问 i 的数据,效率要更高一些

    所以,内存对齐,有一定的性能的提升

    所以,结构体的内存对齐,是一种用空间来换取时间的做法。

1.4.4 结构体声明(设计)的优化

了解了结构体内存对齐,知道了结构体变量内部的成员,在内存中可能并不是紧密排列的,是需要对齐的。是一种拿空间换取时间的做法。
那么有没有一种做法,可以在对其的同时尽量的节省空间呢?
答案是:有!
我们 举过的例子,示例 2示例 3 仅仅是将结构体内部的成员换了一个位置,就节省了 4 个字节的空间。
struct S1 {
    char c1;
    int i;
    char c2;
};
// ↓↓↓
struct S2 {
    char c1;
    char c2;
    int i;
};
看过上边的对比,我们发现 c1c2 对空间的占用都很小,都是 1 字节。两个占用内存空间小的变量挤在一起,就能减少空间的浪费。
所以呢,如果 既要满足对齐,还要尽量节省空间,就

让占用空间小的成员尽量集中在一起

1.4.5 默认对齐数的修改

上边对齐规则中,我们提到 VS编译器中,结构体的 默认对齐数 是8。而在 Linux 平台下的 gcc编译器 是没有默认对齐数的。但是,我们可以通过一个预处理指令 来设置本项目中的默认对齐数。
#pragma pack (n)
这句预处理指令是设置默认对齐数用的,n 就是 要设置的默认对齐数的值
#pragma pack(1)		//设置默认对齐数为1
#include <stdio.h>

struct S1 {
 char c1;
 int i;
 char c2;
}

int main() {
 printf("%zu", sizeof(struct S1));

 return 0;
}

此时,struct S1的总大小变成了 6 字节,而我们没有改变的时候是 12 字节

注意!!默认对齐数千万不要乱改,一般改为 2 的次方。

1.5 结构体传参

如果我们想要写一个比如,修改结构体变量、输出结构体变量等功能的函数,就会遇到和其他函数一样的问题——传参。
函数调用结构体变量时,怎么传参给函数呢?
  1. 结构体变量直接传参

    struct S {
        int data[1000];
        int num;
    };
    struct S s1 = { {2,0,0,2}, 2022 };
    
    void print1(struct S s) {
        printf("%d\n", s.num);
    }
    
    int main() {
        print1(s1);
        return 0;
    }
  2. 结构体变量地址传参

    struct S {
        int data[1000];
        int num;
    };
    struct S s1 = { {2,0,0,2}, 2022 };
    
    void print2(struct S* ptrs) {
        printf("%d\n", ptrs->num);
    }
    
    int main() {
        print2(&s1);
        return 0;
    }
以上两种结构体的传参方式,大家思考一下,哪一种传参方式好一些,为什么?
先来分析一下,结构体传参,传值和传地址,有什么不同:
如果传值:

给函数传入结构体变量的值,函数需要将结构体变量的值完全拷贝一份,再存储到形参。可以修改形参的值,但不能直接访问原结构体变量。

如果传地址:

给函数传入结构体变量的地址,函数需要将地址拷贝一份,存储至形参。并且可以通过地址,来直接访问原结构体变量。

所以这样来看,应该是 传结构体地址的方式好一些。具体原因:

函数传参时,参数是需要压栈的,会有时间和空间上的系统开销。

如果传递一个结构体对象的时候,结构体过大,函数压栈的系统开销就会比较大,会导致项目性能的下降。

版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)

作者: 哈米d1ch 发表日期:2022 年 2 月 15 日