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

结构体 Link to 结构体

1. 结构体概念 Link to 1. 结构体概念

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

1.1 结构体声明 Link to 1.1 结构体声明

1.1.1 标准声明 Link to 1.1.1 标准声明

C
1
2
3
4
struct User {
    char Name[20];
    char Sex[10];
};

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

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

C
1
2
3
4
5
6
7
8
9
10
11
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 结构体的自引用 Link to 1.2 结构体的自引用

以 链表节点 为例:

C
1
2
3
4
5
struct Node {
    int data;
    struct Node* next;
}
// 自引用 结构体类型的指针

但是

C
1
2
3
4
5
6
typedef struct {
    int data;
    Node* next;
}Node;
// 不行
// 结构体内部使用 Node* 时, typedef 还未生效

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

示例:

C
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 结构体声明
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 结构体内存对齐 * Link to 1.4 结构体内存对齐 *

要弄明白什么是结构体内存对齐, 我们先来看一下结构体大小

下面这段结构体的大小是多少?

C
1
2
3
4
5
struct S1 {
    char c1;
    int i;
    char c2;
};

我们用 sizeof 求出此结构体类型的大小是: 12 字节

|small

但是 int类型大小是 4 字节, char 类型的大小是 1 字节。这个结构体大小不应该是 6 字节吗? 为什么是 12 字节呢?

这就是由于内存对齐的原因:


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

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

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

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

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

offsetof

C
1
size_t offsetof( structName, memberName);

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

所在头文件是 stddef.h

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

 |large

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

其次, i 的偏移量是 4;

最后, c2的偏移量是 8.

我们做图明确出来:

 |large

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

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

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

 |large

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

1.4.1 结构体内存对齐规则 Link to 1.4.1 结构体内存对齐规则

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

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

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

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

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

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

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

1.4.2 结构体内存对齐详解 Link to 1.4.2 结构体内存对齐详解

1.4.2.1 示例1: Link to 1.4.2.1 示例1:

我们以上面举过例子的结构体为例:

C
1
2
3
4
5
struct S1 {
    char c1;
    int i;
    char c2;
};

先看一下总大小:

|medium

然后我们具体来计算一下:

除第一个成员外的结构体成员成员自身大小成员自身大小(类型大小)编译器默认对齐数实际对齐数(取较小值)
i(int)484
c2(char)181

根据规则:

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

     |medium

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

    至少是4

     |medium

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

     |medium

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

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

     |huge

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

1.4.2.2 示例2: Link to 1.4.2.2 示例2:

再来看下边这个例子:

C
1
2
3
4
5
struct S2 {
    char c1;
    char c2;
    int i;
};

我们将, 上一个结构体成员中的, ic2换一换位置结果又是什么呢?

 |medium

我们发现只是换了一下位置, 结构体大小就减少了 4 个字节

这次又是怎么对齐和计算的呢?

除第一个成员外的结构体成员成员自身大小成员自身大小(类型大小)编译器默认对齐数实际对齐数(取较小值)
c2(char)181
i(int)484
  1. c1 存放在结构体变量 开始地址的 0 偏移处

     |medium

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

     |medium

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

     |medium

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

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

     |inline

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

1.4.2.3 示例3: Link to 1.4.2.3 示例3:

再来一个例子:

C
1
2
3
4
5
struct S3 {
	double n;
    char c1;
    int i;
};

我们先自己计算:

除第一个成员外的结构体成员成员自身大小(类型大小)编译器默认对齐数实际对齐数(取较小值)
c1(char)181
i(int)484
  1. n 存放在结构体变量 开始地址的 0 偏移处

     |medium

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

     |medium

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

     |medium

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

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

     |inline

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

我们来验证一下:

|medium

确实跟我们计算的一样, 这个结构体大小为 16 字节

1.4.2.4 示例4(嵌套结构体的结构体) Link to 1.4.2.4 示例4(嵌套结构体的结构体)
C
1
2
3
4
5
6
7
8
9
10
11
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 偏移处

     |medium

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

     |medium

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

     |medium

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

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

     |huge

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

举过 4 个例子之后, 结构体的内存对齐应该就已经解释清楚了

不过, 我们再来总结一下规则:

  1. 结构体的第一个成员, 就存放在结构体偏移量为 0 的地址处

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

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

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

例如:

C
1
2
3
4
5
6
7
8
9
10
11
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 为什么存在内存对齐 Link to 1.4.3 为什么存在内存对齐

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

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

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

  2. 性能原因

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

    举个例子:

    C
    1
    2
    3
    4
    5
    6
    7
    8
    9
    struct S {
       char c;
       int i;
    }
    
    int main() {
       struct S s1;
       return 0;
    }
    

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

    1. 假设 不对齐内存

      内存不对其 |wide

    2. 假设 对齐内存

      内存对齐 |wide

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

    那么对于第一种情况:

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

     |wide

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

    对于第二种情况:

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

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

     |wide

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

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

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

了解了结构体内存对齐, 知道了结构体变量内部的成员, 在内存中可能并不是紧密排列的, 是需要对齐的。是一种拿空间换取时间的做法。

那么有没有一种做法, 可以在对其的同时尽量的节省空间呢?

答案是: 有!

我们 举过的例子, 示例 2示例 3 仅仅是将结构体内部的成员换了一个位置, 就节省了 4 个字节的空间。

C
1
2
3
4
5
6
7
8
9
10
11
struct S1 {
    char c1;
    int i;
    char c2;
};
// ↓↓↓
struct S2 {
    char c1;
    char c2;
    int i;
};

看过上边的对比, 我们发现 c1c2 对空间的占用都很小, 都是 1 字节。两个占用内存空间小的变量挤在一起, 就能减少空间的浪费。

所以呢, 如果 既要满足对齐, 还要尽量节省空间, 就

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

1.4.5 默认对齐数的修改 Link to 1.4.5 默认对齐数的修改

上边对齐规则中, 我们提到 VS编译器中, 结构体的 默认对齐数 是8。而在 Linux 平台下的 gcc编译器 是没有默认对齐数的。但是, 我们可以通过一个预处理指令 来设置本项目中的默认对齐数。

C
1
#pragma pack (n)

这句预处理指令是设置默认对齐数用的, n 就是 要设置的默认对齐数的值

|tiny

C
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma pack(1)		//设置默认对齐数为1
#include <stdio.h>

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

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

 return 0;
}

 |medium

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

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

1.5 结构体传参 Link to 1.5 结构体传参

如果我们想要写一个比如, 修改结构体变量、输出结构体变量等功能的函数, 就会遇到和其他函数一样的问题——传参。

函数调用结构体变量时, 怎么传参给函数呢?

  1. 结构体变量直接传参

    C
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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. 结构体变量地址传参

    C
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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;
    }
    

以上两种结构体的传参方式, 大家思考一下, 哪一种传参方式好一些, 为什么?

先来分析一下, 结构体传参, 传值和传地址, 有什么不同:

如果传值:

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

如果传地址:

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

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

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

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

Thanks for reading!

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

Tue Feb 15 2022
4697 字 · 22 分钟

© 哈米d1ch | CC BY-SA 4.0