• C语言——自定义数据类型(结构体内存对齐)


    C语言中不只有内置类型诸如 int 、float、char 等类型,还有自定义数据类型,本文主要探讨结构体(struct)、联合体(union)、枚举(enum)三种自定义数据类型。

    在我之前的文章《C语言——数据类型-CSDN博客》中对这三个类型有过简要的介绍。

    一、结构体

    对于结构体,也是一组数据的集合,但与数据不同这里的数据的集合是不同类型的数据。

    我的文章《C语言——结构体-CSDN博客》有对结构体具体的介绍,本文对其进行补充。

    1、结构体类型的定义

    1)格式:

    使用 struct 关键字可以对结构体进行定义:

    1. struct tag {
    2. member-list
    3. member-list
    4. member-list
    5. ...
    6. } variable-list ;
    7. struct 结构体标签
    8. {
    9. 变量定义;
    10. 变量定义;
    11. 变量定义;
    12. ...
    13. } 结构变量列表;

    tag 是结构体标签。
    member-list 是标准的变量定义,比如 int i; 或者 float f;,或者其他有效的变量定义。
    variable-list 结构变量,定义在结构的末尾,最后一个分号之前,您可以指定一个或多个结构变量。
    在一般情况下,tag、member-list、variable-list 这 3 部分至少要出现 2 个

    2)例子:

    使用 struct 关键字来定义一个结构体。结构体定义了一个新的数据类型,但并不分配内存空间。内存分配发生在创建结构体变量时。以下是一个结构体的定义示例:

    1. struct Student {
    2. char name[20];
    3. int age;
    4. };

    2、结构体变量的声明

    1)一般声明:

    定义结构体后,可以像使用其他任何数据类型一样声明同时初始化结构体变量:

    1. struct Student Bob = { "Bob",18 };
    2. struct Student Alice = { "Alice",19 };

    也可以在结构体类型定义的同时声明和初始化结构体变量:

    1. struct Student {
    2. char name[20];
    3. int age;
    4. }Bob = { "Bob",18 }, Alice = { "Alice",19 };

    2)特殊声明:

    匿名结构体提供了一种在C语言中定义没有名称的结构体的方法。这种结构体类型常用于嵌入其他结构体中,以便无需通过中间结构体成员即可直接访问其字段。它们也可以用来创建临时的复合数据结构,无需为这些一次性使用的结构命名。

    匿名结构体类型,只能被使用一次,需要在匿名结构体定义的同时进行结构体变量的定义和初始化。

    1. struct
    2. {
    3. char name[20];
    4. int age;
    5. }student1;

    这样就可以定义一个匿名结构体类型然后定义一个匿名结构体类型变量。

    但是由于匿名结构体没有名字,后续就无法直接引用结构体类型来创建其他实例了。

    3、结构体的自引用

    1)定义:

    结构体的自引用是一种在结构体定义中包含指向其自身类型的指针的技术。这在C语言中通常用来实现链表其他复杂的数据结构。自引用结构体的关键是在结构体定义内部使用指针,因为实际的结构体尺寸必须在编译时是已知的,而指针的尺寸总是已知的。

    这里自引用不能使用自身类型的结构体,而是使用自身结构体类型的指针的原因是因为直接包含同类型的结构体会造成无限大小的定义。

    2)例子:

    以下是一个典型的自引用结构体定义的例子,它定义了一个链表的节点类型:

    1. struct Node
    2. {
    3. int value; // 数据部分
    4. struct Node* next; // 指向下一个节点的指针
    5. };

    在这个例子中,Node 结构体类型包含了一个 int 类型的成员 value 和一个指向 Node 类型的指针 next。尽管 Node 在定义 next 成员的时候尚未完全定义,但是由于 next 是一个指针,所以编译器知道如何处理它。这允许结构体在逻辑上引用它自己,形成了一个可以持续链接下去的节点链。

    4、使用 typedef 定义结构体类型

    1)格式:

    使用 typedef 定义结构体类型可以在定义结构体同时对结构体进行命名。

    1. typedef struct 标签
    2. {
    3. 变量列表;
    4. 变量列表;
    5. 变量列表;
    6. ...
    7. }别名;

    使用typedef为结构体创建一个别名时,需要在结构体定义的末尾添加别名。

    2)例子

    1. typedef struct Stu
    2. {
    3. int age;
    4. char name[20];
    5. }Student;

    这里的别名和标签是可以不同的。

    所以这里就可以有两种创建结构变量的方法:

    1)使用别名Student(由typedef创建)直接声明和初始化结构体变量。

    Student student1 = { 18,"Bob" };

    2)使用完整的结构体声明(包括struct关键字加结构体标签)来声明和初始化结构体变量。即使已经有了类型别名,这种写法仍然有效,因为它明确地引用了原始的结构体定义。

    struct Stu student2 = { 19,"Alice" };

    在C语言中,typedef关键字允许我们为类型创建一个新的名称,但这并不会抹去原有类型的名称。这就是为什么即使定义了Student作为struct Student的别名后,我们仍然可以使用struct Student来定义变量。这在我们向代码库中添加新的类型别名,同时仍然需要保持对旧代码的兼容性时非常有用。

    在实际编程实践中,推荐选择一种声明方式并在代码中保持一致,以提高代码的可读性和可维护性。由于我们已经使用typedef定义了类型别名Student,通常会更倾向于使用这个别名而不是带有struct关键字的版本,除非特定情况下需要区分。

    5、结构体类型的标签(tag)和别名

    在上面一节的讨论中,我们发现了这两种结构体的标志。

    1)标签(tag)

    对于直接用结构体关键字 (struct) 定义的结构体类型,只存在结构体标签,不存在结构体类型别名。

    1. struct Stu
    2. {
    3. int age;
    4. char name[20];
    5. };

    就像这里,Stu 就是这个结构体类型的标签,当我们要引用这个结构体类型来创建此结构体类型的变量时,要使用以下形式:

    struct 标签 变量名 = { 初始化内容 }:

    例如:

    struct Stu student2 = { 19,"Alice" };

    而不能只使用标签不使用 struct 关键字。

    2)别名

    对于使用 typedef 定义的结构体类型,会既存在结构体标签,又存在结构体类型别名。

    1. typedef struct 标签
    2. {
    3. 变量列表;
    4. 变量列表;
    5. 变量列表;
    6. ...
    7. }别名;

    例如:

    1. typedef struct Stu
    2. {
    3. int age;
    4. char name[20];
    5. }Student;

    结构体变量的创建方式有两种:

    1. #include
    2. typedef struct Stu
    3. {
    4. int age;
    5. char name[20];
    6. }Student;
    7. int main()
    8. {
    9. struct Stu student2 = { 19,"Alice" };//使用struct关键字和标签声明和初始化变量
    10. Student student1 = { 18,"Bob" };//使用别名声明和初始化变量
    11. return 0;
    12. }

    6、匿名结构体

    匿名结构体提供了一种在C语言中定义没有名称的结构体的方法。这种结构体类型常用于嵌入其他结构体中,以便无需通过中间结构体成员即可直接访问其字段。它们也可以用来创建临时的复合数据结构,无需为这些一次性使用的结构命名。我们来更详细地探讨匿名结构体的特点、用法和局限性。

    1)特点

    1. 无名称:正如“匿名”所暗示的,这些结构体没有名称。因此,无法直接引用结构体类型来创建其他实例。只能在匿名结构体定义时创建其他实例。
    2. 直接访问成员:当匿名结构体嵌入到另一个结构体中时,可以直接通过外部结构体访问匿名结构体的成员,无需借助额外的结构体名或成员名。
    3. 代码简洁:在某些场景下,它们可以简化代码的复杂性,避免定义多余的结构体类型名。

    2)基本用法

    1)匿名结构体嵌入示例
    1. #include
    2. #include
    3. struct Person
    4. {
    5. char name[50];
    6. int age;
    7. struct {
    8. char street[100];
    9. int zipcode;
    10. } address; // 匿名结构体作为成员
    11. };
    12. int main()
    13. {
    14. struct Person person;
    15. strcpy(person.name, "John Doe");
    16. person.age = 30;
    17. strcpy(person.address.street, "123 Main St");
    18. person.address.zipcode = 12345;
    19. return 0;
    20. }

    在这个例子中,Person 结构体包含了一个匿名结构体作为 address 成员。这允许直接通过 person.address.streetperson.address.zipcode 来访问地址信息。这样就无需再特地定义一个结构体类型来创建 address 结构体变量了。

    2)完全匿名嵌套结构体
    1. #include
    2. struct Rectangle {
    3. int width, height;
    4. struct {
    5. int x, y;
    6. }; // 完全匿名结构体,没有成员名
    7. } rect;
    8. int main()
    9. {
    10. rect.width = 10;
    11. rect.height = 20;
    12. rect.x = 5; // 直接访问匿名结构体中的成员
    13. rect.y = 15;
    14. return 0;
    15. }

    这里,Rectangle 结构体直接嵌入了一个完全匿名的结构体,用于存放坐标。这样可以简化对其成员 xy 的访问。

    3)使用 typedef 对匿名结构体重命名

    1)一般情况

    通过使用typedef关键字,可以为匿名结构体指定一个新的名称,从而创建一个可以在代码中重复使用的类型。这样做之后,该结构体就不再是匿名的。

    例如:

    1. #include
    2. typedef struct {
    3. int x, y;
    4. } Point;
    5. int main()
    6. {
    7. Point p1, p2;
    8. p1.x = 10;
    9. p1.y = 20;
    10. return 0;
    11. }

    在这个例子中,我们定义了一个没有名称的结构体,并立即使用typedef给它起了一个名字Point。之后,我们就可以像使用普通结构体一样使用Point类型来声明变量p1p2

    这里对变量的声明只能是:

    	Point p2;

    不能是:

    	struct Point p2;

    这样会报错,因为这里的匿名结构体没有标签,只有别名。所以只能用别名进行结构体变量的创建。

    2)特殊情况

    下面这种情况是不对的:

    1. typedef struct
    2. {
    3. int value;
    4. Node* next;
    5. }Node;

    因为使用 typedef 的基础是我们已经定义了这个匿名结构体,但是这时匿名结构体没有名字,所以结构体的第二个元素 Node* 这个类型是未被定义的, Node 是需要匿名结构体完成定义后再用 typedef 命名得到的结构体类型,然而在匿名结构体类型定义之前是不存在的,所以这个匿名结构体就不能被定义。

    4)匿名结构体的特点

    两个匿名结构体即使变量列表一摸一样,它们两个也不是一样的结构体类型。在C语言中,即使两个匿名结构体的成员列表完全相同,它们也被视为不同的类型。这是因为在C语言中,结构体的类型是根据它们的声明来区分的,而不仅仅是它们的结构(成员列表)。

    例子:

    1. struct {
    2. int age;
    3. char name[20];
    4. } student1,*p;
    5. struct {
    6. int age;
    7. char name[20];
    8. } student2;

    如果进行这样的操作:

    p = &student2;

    就会报错,因为类型不兼容。

    7、typedef 定义结构体类型的例子

    1)例子

    1. #include
    2. typedef struct Stu
    3. {
    4. int age;
    5. char name[20];
    6. }* pStu;
    7. int main()
    8. {
    9. struct Stu student1 = { 18,"Bob" };
    10. pStu pstudent1 = &student1;
    11. return 0;
    12. }

    这里的 typedef 给 Stu 结构体的指针类型重命名为了 pStu ,然后我们就可以使用 pStu 来创建这个结构体的指针变量。

    8、结构体的成员

    1)成员种类

    在C语言中,结构体的成员可以是几乎任何类型,包括:

    1. 基本数据类型:如 int, float, double, char 等。

    2. 数组类型:可以是基本数据类型的数组,例如 int numbers[10];,也可以是结构体类型的数组。

    3. 指针类型:包括指向基本数据类型、数组、其他结构体或者函数的指针。

    4. 结构体类型

      • 同类型的结构体指针(因为直接包含同类型的结构体会造成无限大小的定义)。
      • 其他类型的结构体或结构体数组,常用于嵌套结构体。
    5. 联合体(union)类型:可以包含联合体,它是一种特殊的数据结构,允许在相同的内存位置存储不同的数据类型。

    6. 枚举类型(enum):可以包含枚举类型成员,用于表示成员变量只能取有限个命名的整数值。

    7. 函数指针类型:可以包含指向函数的指针,这允许结构体“拥有”可以调用的函数。

    2)小细节

    如果两个结构体互相包含,则需要对其中一个结构体进行不完整声明,如下所示:

    1. struct B;//对结构体B进行不完整声明
    2. struct A//结构体A中包含指向结构体B的指针
    3. {
    4. struct B* partner;
    5. //other members;
    6. };
    7. //结构体B中包含指向结构体A的指针,在A声明完后,B也随之进行声明
    8. struct B
    9. {
    10. struct A* partner;
    11. //other members;
    12. };

    3)字段

    结构体的字段(Fields)就是结构体的成员(Members),也就是结构体中的每个变量。

    9、结构体成员的访问

    1). 点操作符(成员访问操作符)

    格式:

    结构体变量.成员

    实例:

    1. #include
    2. struct Student
    3. {
    4. char name[20];
    5. double height;
    6. int age;
    7. };
    8. int main()
    9. {
    10. struct Student a = {"xiaoa",1.80,19};
    11. printf("%s %lf %d",a.name,a.height,a.age);
    12. return 0;
    13. }

     运行结果:

    也可以使用指针,但 . 操作符的优先级大于 * 操作符,要加()

    1. #include
    2. struct Student
    3. {
    4. char name[20];
    5. double height;
    6. int age;
    7. };
    8. int main()
    9. {
    10. struct Student a = {"xiaoa",1.80,19};
    11. struct Student* pa = &a;
    12. printf("%s %lf %d",(*pa).name,(*pa).height,(*pa).age);
    13. return 0;
    14. }

     运行结果:

    2)->箭头操作符(成员访问操作符)

    格式:

    结构体变量的指针->成员

    实例:

    1. #include
    2. struct Student
    3. {
    4. char name[20];
    5. double height;
    6. int age;
    7. };
    8. int main()
    9. {
    10. struct Student a = {"xiaoa",1.80,19};
    11. struct Student* pa = &a;
    12. printf("%s %lf %d\n",pa->name,pa->height,pa->age);
    13. return 0;
    14. }

     运行结果:

    二、结构体内存对齐

    1、内存对齐的规则

    1)第一个成员在与结构体变量偏移量(offset)为0的地址处。

    2)其他成员变量要对其到某个数字(对其数)的整数倍的地址处。如果必要,编译器会在成员之间插入填充字节(padding)来确保这一点。

    对齐数 = 该成员大小。(如果成员是结构体,则对齐数是此结构体的最大对齐数)

    • 但是VS中有一个默认的对齐数是8,这时对齐数 = 编译器的默认对齐数与该成员大小的较小值。其他编译器一般没有。

    3)结构体总大小为最大对齐数(每个成员都有一个对齐数)的整数倍。编译器会添加额外的填充字节来确保整个结构体的大小满足对齐要求。

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

    2、内存对齐实例

    1)例子

    1. struct Example {
    2. char a; // 占用1字节
    3. int b; // 占用4字节
    4. short c; // 占用2字节
    5. };

    下面为示例图(图中一个格子代表1字节):

    根据内存对齐规则分析。

    • a 占用1字节,后面跟着3字节的填充,以确保 b 所在位置相对于结构体首地址的偏移量恰好是4的倍数,因为 b 的对齐数是4;
    • 通过3个填充字节, b 完成了对齐。
    • c 占用2字节,这时偏移量恰好是2的倍数,c 完成对齐,后面跟着2个字节的填充,以确保整个结构体的大小是4(最大对齐数)的倍数。

    最终这个结构体的大小是12字节。

    1. #include
    2. struct Example {
    3. char a;
    4. int b;
    5. short c;
    6. };
    7. int main()
    8. {
    9. printf("%zu\n", sizeof(struct Example));
    10. return 0;
    11. }

    运行结果:

    2)小细节

    在实际分析中,为什么只需要确保偏移量是对齐数的整数倍,而不是确保地址是对齐数的整数倍呢?

    确保变量所在偏移量是对齐数的整数倍,而不是要求其绝对地址必须是对齐数的整数倍,是为了提高数据结构的灵活性和兼容性,特别是在嵌入式系统或跨平台开发中非常重要。

    1. 起始地址的灵活性: 数据结构(如结构体)的起始地址并不总是确定的或能够事先知道的,特别是在动态分配内存时。动态内存分配可能会返回任何合法的内存地址,不一定是某个数据类型对齐要求的整数倍。因此,只要内部成员相对于结构体起始地址的偏移量符合对齐要求,无论起始地址是什么,都能保证成员访问的有效性效率

    2. 提高内存访问效率: 多数硬件平台对内存的访问都有特定的对齐要求。如果一个数据类型的起始地址符合其对齐要求,那么处理器可以更高效地执行读写操作。从结构体起始地址开始,只要成员变量相对于起始地址的偏移量满足其对齐要求,即可实现高效访问,而不必担心结构体本身的绝对起始地址。

    3. 允许结构体嵌套: 在复杂的数据结构中,结构体往往会嵌套其他结构体。如果每个结构体或变量的地址都必须是对齐数的整数倍,会极大增加内存浪费,因为需要在嵌套的结构体之间添加大量填充字节。通过只要求偏移量满足对齐要求,可以最小化这种内存浪费,同时保持高效的内存访问。

    4. 跨平台兼容性: 不同的平台可能有不同的内存对齐要求。通过确保相对偏移量而非绝对地址符合对齐要求,可以更容易地将代码移植到不同的硬件平台上,而无需对每个平台进行大幅修改或优化。

    总的来说,通过确保偏移量符合对齐要求,而不是依赖于结构体的绝对起始地址,可以提供更大的灵活性和效率,减少内存浪费,并简化跨平台开发的复杂性。此外,这种方法也允许编译器和链接器在不牺牲性能的前提下,更自由地管理内存布局。

    3、通过 offsetof 宏来验证上面的结论

    offsetof 宏定义在 C 语言的标准库头文件 中,用于获取一个结构体成员相对于结构体开始位置的字节偏移量。这个宏通常用于底层编程和数据结构对齐。

    offsetof 宏通常是这样定义的:

    #define offsetof(type, member) ((size_t) &(((type *)0)->member))

    这里的 type 是一个结构体类型,member 是该结构体中的一个成员的名字。

    这个宏的工作原理是通过将一个假设的结构体的指针设置为 0(NULL地址),然后取其成员的地址。这个地址实际上就是成员离结构体起始地址的偏移量,因为整个结构体是从 0 地址开始布局的。由于仅仅是求地址,并没有实际的解引用操作,所以这种方式是合法的,不会造成运行时错误。

    我们使用上面的例子来试验一下:

    1. #include
    2. #include
    3. struct Example {
    4. char a;
    5. int b;
    6. short c;
    7. };
    8. int main()
    9. {
    10. printf("%zu\n", offsetof(Example, a));
    11. printf("%zu\n", offsetof(Example, b));
    12. printf("%zu\n", offsetof(Example, c));
    13. return 0;
    14. }

    运行结果:

    可以发现这与我们分析的结果是相同的。

    4、优化内存对齐

    程序员有时为了减少内存的浪费,可能会重新排列结构体的成员顺序,以减少填充字节的数量。例如,将上面的结构体调整为:

    1. struct ExampleOptimized {
    2. int b;
    3. short c;
    4. char a;
    5. };

    优化过的结构体的内存对齐为(图中一个格子代表1字节):

    这种布局方法结构体只占8字节,只有一个填充字节。

    1. #include
    2. #include
    3. struct ExampleOptimized {
    4. int b;
    5. short c;
    6. char a;
    7. };
    8. int main()
    9. {
    10. printf("%zu\n", sizeof(ExampleOptimized));
    11. printf("--\n");
    12. printf("%zu\n", offsetof(ExampleOptimized, b));
    13. printf("%zu\n", offsetof(ExampleOptimized, c));
    14. printf("%zu\n", offsetof(ExampleOptimized, a));
    15. return 0;
    16. }

    运行结果:

    可以发现与我们分析的相同。

    5、结构体嵌套结构体的情况

    1. struct Example {
    2. int i;
    3. struct Example1 {
    4. double a;
    5. char b;
    6. int c;
    7. }group;
    8. double j;
    9. };

    下面是分析图(图中一个格子代表1字节):

    通过我们的分析,可以发现这个结构体大小为32字节。

    1. #include
    2. #include
    3. struct Example {
    4. int i;
    5. struct Example1 {
    6. double a;
    7. char b;
    8. int c;
    9. }group;
    10. double j;
    11. };
    12. int main()
    13. {
    14. printf("%zu\n", sizeof(Example));
    15. printf("--\n");
    16. printf("%zu\n", offsetof(Example, i));
    17. printf("%zu\n", offsetof(Example, group.a));
    18. printf("%zu\n", offsetof(Example, group.b));
    19. printf("%zu\n", offsetof(Example, group.c));
    20. printf("%zu\n", offsetof(Example, j));
    21. return 0;
    22. }

    运行结果:

    可以发现与我们分析的结果一样。

    6、为什么需要内存对齐?

    内存对齐是基于硬件访问内存的需求而产生的。大多数计算机硬件访问内存时,如果数据的地址是某个大小(通常是4或8字节)的倍数,那么访问速度会更快。未对齐的内存访问可能导致多次内存访问或者在某些硬件上引发异常。

    1)平台原因(移植原因):

    并不是所有硬件平台都可以访问任意地址上的任意数据的,某些硬件凭他只能在某些地址处取某些特定类型的数据,否则抛出异常。

    2)性能原因:

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

    例如:

    1. struct Example {
    2. int i;
    3. double j;
    4. };

    对于64位硬件平台,处理器一次可以读取64位。

    如果我们只需要访问变量 j 而不需要访问变量 i ,则内存对齐的方式会有很好的性能,同时内存对齐的方式对变量 i 的访问也没有影响。

    3)总的来说:

    内存对齐是一种用空间换时间的操作。

    4)怎么既满足对齐又节省空间呢?

    尽量将内存占用小的元素放到一起。

    7、修改默认对齐数

    1)介绍:

    #pragma pack(n)是一个常用于C和C++编程中,通过编译器指令改变结构体、联合和类成员的默认对齐方式的方法。n在这里指的是对齐的字节数。

    在这里我们可以用 #pragma pack(n) 这个指令来改变结构体的对齐方法。

    使用#pragma pack可以指定一个新的对齐值,这个值会影响紧随其后定义的结构体或联合的成员对齐方式。这通常用于减小数据结构的大小,或者为了与特定硬件平台的要求或二进制兼容性而进行匹配。

    2)示例:

    下面是一个示例,展示了如何使用#pragma pack

    1. #pragma pack(push, n) // 保存当前对齐状态,并设置新的对齐值为n
    2. struct MyPackedStruct {
    3. char a; // 占用1字节
    4. int b; // 通常占用4字节,但因为#pragma pack可能不对齐
    5. };// 结构体的总大小将受到#pragma pack指定的n值的影响
    6. #pragma pack(pop) // 恢复之前保存的对齐状态

    在上面的代码中,#pragma pack(push, n)保存当前的对齐设置,并设置新的对齐值n。结构体MyPackedStruct中的成员将按照n字节对齐。最后,#pragma pack(pop)指令用于恢复之前保存的对齐设置。

    3)实例:

    1. #pragma pack(push,4)
    2. struct Example {
    3. int i;
    4. double j;
    5. };
    6. #pragma pack(pop)

    这个例子在上面的默认对齐数为8时,大小是16字节。

    1. #include
    2. #pragma pack(push,4)
    3. struct Example {
    4. int i;//大小为4,默认对齐数为4,较小值为对齐数,为4
    5. double j;//大小为8,默认对齐数为4,较小值为对齐数,为4
    6. };
    7. #pragma pack(pop)
    8. int main()
    9. {
    10. printf("%zu\n", sizeof(Example));
    11. return 0;
    12. }

    运行结果:

    在这里我们发现大小是12字节,原因就是对齐数变成了4,double 类型变量 j 对齐在 int 类型变量 i 后面,中间没有填充字节,最终结构体大小也是对齐数4的整数倍,所以最终结构体的大小是12字节。

    4)注意:

    对对齐值的改变可能会导致性能下降,因为处理器可能需要进行额外的内存访问来处理未对齐的数据。此外,在不同的平台上,对齐要求可能不同,不正确的对齐可能导致程序崩溃或数据损坏。因此,使用#pragma pack时应当谨慎,并且确保理解其对你的程序可能造成的影响。

    三、结构体其他知识

    1、结构体作为函数参数传参

    在C语言中,结构体可以作为函数参数传递。这通常可以通过以下两种方式完成:

    1)传值(By Value):

    这种方式会将整个结构体的内容复制到函数的形参中。虽然使用起来简单直观,但是如果结构体内容较大时,这种方式可能会导致较大的性能开销,因为它涉及到内存中数据的复制操作。

    1. #include
    2. struct Example {
    3. int data[3];
    4. char ch;
    5. };
    6. void Print(struct Example s)
    7. {
    8. int i = 0;
    9. for (i = 0; i < 3; i++)
    10. {
    11. printf("%d ", s.data[i]);
    12. }
    13. printf("%c", s.ch);
    14. }
    15. int main()
    16. {
    17. struct Example s = { {1,2,3},'a' };
    18. Print(s);
    19. return 0;
    20. }

    运行结果:

    2)传址(By Reference):

    由于C语言本身不支持引用,所以"传址"实际是通过传递结构体的指针来实现的。这种方式不会复制整个结构体,只会复制指针的值,因此性能开销较小。此外,由于传递的是原始结构体的地址,函数内部可以通过指针修改原始结构体的内容。

    1. #include
    2. struct Point {
    3. int x;
    4. int y;
    5. };
    6. void movePoint(struct Point* p, int dx, int dy)
    7. {
    8. p->x += dx;
    9. p->y += dy;
    10. // 注意:这里修改的是指向的原始结构体,调用者会看到这些改变
    11. }
    12. int main() {
    13. struct Point p1 = { 10, 20 };
    14. movePoint(&p1, 5, 5); // 传递p1的地址,函数内部可以修改p1的值
    15. // p1的值这里被改变了
    16. printf("%d %d\n", p1.x, p1.y);
    17. return 0;
    18. }

    运行结果:

    在实际编程中,建议通过传递结构体的指针来避免不必要的性能开销,特别是对于较大的结构体。同时,通过指针传递也可以允许函数修改原始结构体的内容。然而,如果你不希望函数内部修改原始数据,或者结构体非常小,那么传值也是一个可行的选择。

    2、位段

    1、什么是位段

    结构体位段(Bit fields)是在C语言中定义结构体成员时,允许你指定每个成员占据多少位的一种特性。位段主要用于程序与硬件设备的底层数据交互,或者是当你需要打包数据以节省空间时。使用位段,可以非常精确地控制内存的布局。

    2、位段的声明

    1)位段在结构体中的声明看起来和一般的成员声明类似,但是会在成员名后面加上冒号和一个数字,这个数字指定了该成员要使用的位数。

    这个数字,即位段(bit-fields)的大小是有限制的,它不能超出其类型所能表示的最大位数。

    1. struct S {
    2. int d : 33;
    3. };

    这段代码就是错的,位段大小设为33,而整型(int)大小为4字节,即32位,超出了类型大小。

    2)位段成员不能是某些类型,例如指针或者浮点数,通常它们是整数类型(比如 int, unsigned int, signed int,char)。

    1. struct BitField {
    2. unsigned int is_enabled: 1; // 使用1位
    3. unsigned int is_visible: 1; // 使用1位
    4. unsigned int state: 3; // 使用3位
    5. unsigned int priority: 4; // 使用4位
    6. };

    3、位段的存储

    位段成员的存储是连续的,但是它们通常会被打包到底层硬件的字边界内。这意味着位段可能会跨越几个字节,但通常不会跨越处理器的字边界。这也会导致一些对齐和填充的问题。

    4、位段的内存对齐和内存分配

    1)内存对齐

    对于大多数硬件平台,位段的布局和对齐依赖于底层硬件和编译器的实现。例如,有些编译器可能会在位段成员之间插入填充位,以确保按照特定的对齐方式存储。

    2)内存分配
    i.介绍

    位段在空间中是按需以4字节(int)或1字节(char)的方式来开辟空间的。

    1. #include
    2. struct S {
    3. int a : 1;
    4. int b : 3;
    5. int c : 10;
    6. //首先开辟4字节空间,变量a、b和c使用了4字节中的14bit,还剩18bit,d需要30bit,所以剩余的空间不够了,再开辟4字节
    7. int d : 30;
    8. };
    9. int main()
    10. {
    11. printf("%zu\n", sizeof(S));
    12. //最终大小为8
    13. return 0;
    14. }

    运行结果:

    ii.例子
    1. #include
    2. struct S {
    3. char a : 3;//首先开辟1字节空间,这里用了3bit,还剩5bit
    4. char b : 4;//只需4bit,上面剩下5bit,够用,还剩1bit
    5. char c : 5;//需要5bit,上面还剩1bit,不够用,再开辟1字节空间,这里不使用上面剩下的1bit,直接使用这里新开辟的1字节,上面的剩下的1bit被丢弃,这里使用了5bit,还剩3bit
    6. char d : 4;//需要4bit,上面剩下3bit,不够用,然后3bit被丢弃,再开辟1字节空间,直接使用新开辟的1字节,使用4bit,还剩4字节
    7. };
    8. int main()
    9. {
    10. printf("%zu\n", sizeof(struct S));
    11. return 0;
    12. }

    运行结果:

    可以发现与我们分析的一样。

    1. #include
    2. struct S {
    3. char a : 3;//首先开辟1字节空间,这里用了3bit,还剩5bit
    4. char b : 4;//只需4bit,上面剩下5bit,够用,还剩1bit
    5. char c : 5;//需要5bit,上面还剩1bit,不够用,再开辟1字节空间,这里不使用上面剩下的1bit,直接使用这里新开辟的1字节,上面的剩下的1bit被丢弃,这里使用了5bit,还剩3bit
    6. char d : 4;//需要4bit,上面剩下3bit,不够用,然后3bit被丢弃,再开辟1字节空间,直接使用新开辟的1字节,使用4bit,还剩4字节
    7. };
    8. int main()
    9. {
    10. struct S s = { 0 };//将全部的bit位都初始化为0
    11. s.a = 10;
    12. s.b = 12;
    13. s.c = 3;
    14. s.d = 4;
    15. printf("%zu\n", sizeof(struct S));
    16. return 0;
    17. }

    具体分析图(由于位段在内存中的具体分布由很多因素决定,这里的环境是VS2022,所以这里是从低地址到高地址存储的,每一个位段在一个字节中的使用是从后往前的。这里的一个格子代表1bit):

    我们可以通过监视功能来查看:

    可以看到与我们分析的是一样的。

    我们再从内存中看一下(这里的一个格子代表1bit):

    可以发现与我们上面分析的在内存中存储的情况是一样的。

    5、位段的访问

    位段的成员可以像普通的结构体成员一样访问,但是背后的机制不同。编译器会生成额外的指令来访问这些特定的位,可能会有一些额外的性能开销。

    6、可移植性

    1)介绍

    由于不同的编译器可能会以不同的方式实现位段,所以使用位段可能会导致可移植性问题。一个编译器可能会以一种方式打包位段,而另一个编译器可能会有不同的打包方式。因此,在需要跨平台或跨编译器工作时,位段的使用可能会导致不确定的行为。

    2)跨平台的一些问题

    1、int 位段被当成有符号还是无符号数是不确定的。
    2、位段中最大位的的数目是不确定的,(16位的机器最大是16,32位的机器最大是32,如果这里的位段是27bit大小,在16位机器会出现问题)。
    3、位段中的成员在内存中从右向左分配还是从左向右分配标准尚未定义。
    4、当一个结构体包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。

    7、使用场景

    1)介绍

    尽管位段提供了很好的空间优化能力,但它们在现代应用程序编程中使用较少。然而,在需要和硬件密切合作的嵌入式系统编程中,位段却非常有用。例如,在定义硬件寄存器的模型或者在处理特定的通信协议时,位段可以帮助程序员以非常精确的方式控制数据的布局。

    在使用位段的时候,需要特别注意编译器的文档,以理解编译器对位段的具体实现方式,以及它可能带来的对齐和端序等问题。

    2)用途

    下图是网络数据包的头部和数据部分具体情况:

    可以看到这里的每一个数据都是精确到 bit 的,所以这里是使用位段的。

    以下是一个IPv4头部的位段表示(假设没有选项字段),在实际应用中,每个字段都有它的特定意义:

    1. // IPv4头部结构
    2. struct IPv4Header {
    3. uint8_t version : 4; // 版本,占用4位
    4. uint8_t headerLength : 4; // 头部长度(首部长度),占用4位
    5. uint8_t typeOfService : 8; // 服务类型,占用8位
    6. uint16_t totalLength : 16; // 总长度,占用16位
    7. uint16_t identification : 16; // 标识符,占用16位
    8. uint16_t flags : 3; // 标志位,占用3位
    9. uint16_t fragmentOffset : 13; // 片偏移,占用13位
    10. uint8_t timeToLive : 8; // 生存时间(TTL),占用8位
    11. uint8_t protocol : 8; // 协议,占用8位
    12. uint16_t checksum : 16; // 头部校验和,占用16位
    13. uint32_t sourceAddress : 32; // 源IP地址,占用32位
    14. uint32_t destAddress : 32; // 目的IP地址,占用32位
    15. // 选项字段和填充位在这里省略,因为它们是可选的,且长度可变
    16. };

    四、枚举

    1、介绍

    C语言中的枚举(enumeration)是一种用户定义的数据类型,它允许程序员为整数值定义一组命名的常量。这样可以通过名字来代替数字,使得程序更加易读和便于理解。枚举类型通过关键字 enum 来定义。

    2、枚举类型的声明和定义

    1. enum 枚举名 {
    2. 枚举元素1,
    3. 枚举元素2,
    4. ...
    5. 枚举元素N
    6. };

    3、一些特性

    每个枚举元素实际上是一个整数常量。当你定义一个枚举类型时,如果不显式指定数值,默认情况下,第一个枚举元素的值为0,后续元素的值依次递增1。你也可以为枚举元素显式指定整数值:

    1. enum color {
    2. RED, // 0
    3. GREEN, // 1
    4. BLUE // 2
    5. };
    6. enum color {
    7. RED = 1, // 1
    8. GREEN, // 2
    9. BLUE // 3
    10. };
    11. enum color {
    12. RED = 5, // 5
    13. GREEN = 10, // 10
    14. BLUE = 15 // 15
    15. };

    可以使用 printf 函数打印枚举常量的值:

    1. #include
    2. enum Color {
    3. RED,
    4. GREEN,
    5. BULE
    6. };
    7. int main()
    8. {
    9. printf("%d\n", RED);
    10. printf("%d\n", GREEN);
    11. printf("%d\n", BULE);
    12. return 0;
    13. }

    运行结果:

    4、使用方法

    枚举类型通常用于代替数值,以使代码更具可读性。例如,代替使用数字表示颜色代码,可以使用上面定义的枚举类型 color,这样代码就会更加直观。

    在C语言中,枚举类型的实质是整数,因此它们可以存储在整数类型的变量中,并且可以在表达式中像整数常量一样使用。但是,使用枚举类型可以提升代码的表意性和可维护性。

    下面是如何在C语言中使用枚举的一个示例:

    1. #include
    2. enum color {
    3. RED, // 0
    4. GREEN, // 1
    5. BLUE // 2
    6. };
    7. int main()
    8. {
    9. enum color favorite_color;//枚举变量声明
    10. favorite_color = BLUE;//枚举变量初始化
    11. if (favorite_color == RED)
    12. {
    13. printf("Your favorite color is Red.\n");
    14. }
    15. else if (favorite_color == GREEN)
    16. {
    17. printf("Your favorite color is Green.\n");
    18. }
    19. else if (favorite_color == BLUE)
    20. {
    21. printf("Your favorite color is Blue.\n");
    22. }
    23. else
    24. {
    25. printf("Unknown color.\n");
    26. }
    27. return 0;
    28. }

    在这个示例中,枚举 color 被用来声明变量 favorite_color,并且我们用 BLUE 来初始化它。在 if 条件语句中,我们检查 favorite_color 的值,并打印出相应的颜色名称。

    运行结果:

    5、枚举优点

    1)提高代码可读性:

    枚举允许程序员使用描述性名称代替数值,使得代码更加易懂。这种命名方式有助于理解代码中变量的意图和用途。

    2)易于维护:

    如果你需要改变某个枚举常量的值,你只需要在枚举的定义中更改它,而不需要在代码中搜索和替换所有的硬编码值,这极大地方便了代码的维护。

    3)类型安全:

    枚举增加了类型安全,因为你不能将一个枚举的值直接赋给另一个不同的枚举类型的变量(尽管在C语言中,枚举值可以自由地转换为整数,但这种转换在C++中更受限制)。

    4)易于调试:

    当你在调试程序时,看到枚举的名字通常比看到一个裸露的整数值更有帮助,因为名字更有意义。

    对于#define这样的预处理指令,例如:

    1. #include
    2. #define MAX 100
    3. int main()
    4. {
    5. int num = MAX;
    6. return 0;
    7. }

    在预处理时,MAX会被直接替换为100,在调试时,你只能看到100,而不能看到MAX。对于枚举类型,则与之不同,在调试时我们依旧可以看到枚举常量的名字。

    五、联合(共用体)

    1、介绍

    在C语言中,联合体(Union)是一种特殊的数据类型,它允许在同一内存位置存储不同的数据类型。联合体为包含的所有成员分配一个共享的内存空间,这个空间足够大,以存储联合体中最大的成员。因此,联合体的大小等于其最大成员的大小。不同于结构体(struct),在任何给定时间点,联合体只能存储其中的一个成员的值,修改联合体的任何成员都会改变其余成员的值。

    2、声明和定义联合体

    联合体使用关键字 union 来定义。下面是定义联合体的一般语法:

    1. union UnionName {
    2. type1 member1;
    3. type2 member2;
    4. type3 member3;
    5. ...
    6. };

    这里,UnionName 是联合体的名称,type1type2type3 等是联合体成员的数据类型,member1member2member3 等是对应的成员名称。

    3、一些特点

    1)共享内存:

    联合体的所有成员共享同一块内存,所以对一个成员的更新会影响到其他成员。

    1. #include
    2. union Example {
    3. int a;
    4. char b;
    5. };
    6. int main()
    7. {
    8. union Example u;
    9. printf("%p\n", &u);
    10. printf("%p\n", &u.a);
    11. printf("%p\n", &u.b);
    12. return 0;
    13. }

    运行结果:

    可以看到每个成员的地址都是相同的,所以它们的内存实际上时公用的或者说是重叠的。

    示例图(这里一个格子代表一个字节):

    在对一个联合体成员的操作时,其他成员也会有影响:

    1. #include
    2. union Example {
    3. int a;
    4. char b;
    5. };
    6. int main()
    7. {
    8. union Example u;
    9. u.a = 0x11223344;
    10. printf("%x\n", u.a);
    11. printf("%x\n", u.b);
    12. u.b = 0x55;
    13. printf("%x\n", u.a);
    14. printf("%x\n", u.b);
    15. return 0;
    16. }

    运行结果:

    可以发现在对一个成员进行改动时,另一个成员也发生了改变。同时我们还可以发现这里是小端字节序。

    2)节省空间

    由于共享内存,联合体可以比结构体更节省空间,特别是当联合体包含了不同类型的大型数据时。

    3)大小

    联合体的大小至少等于其最大成员的大小。

    4、应用场景

    联合体通常用于以下场景:

    • 数据的不同表示:当一个数据单元可能以多种格式出现时,可以使用联合体来处理不同的数据格式。例如,一个变量可以存储整型、浮点型或字符型数据。
    • 资源节省:在需要在多种数据类型间切换但不需要同时使用它们的场合,联合体可以有效节省内存资源。

    5、联合体的大小

    联合体也存在内存对齐:

    1、联合体大小至少是最大成员的大小
    2、当最大成员大小不是最大对齐数的整数倍时,就要补齐到最大对齐数的整数倍。

    1. #include
    2. union Example {
    3. char arr[5];
    4. int i;
    5. };
    6. int main()
    7. {
    8. union Example u;
    9. printf("%zu\n", sizeof(u));
    10. return 0;
    11. }

    运行结果:

    示例图(这里一个格子代表一个字节):

  • 相关阅读:
    Dubbo(一)——Dubbo及注册中心原理
    NLP - 数据预处理 - 文本按句子进行切分
    【假设检验】MATLAB实现K-S检验
    计算机网络第四节 数据链路层
    3天3定制大屏,反向PUA
    桥接模式(结构型)
    C#__使用流读取和写入数据的简单用法
    Flink源码阅读笔记——StreamGraph、JobGraph、ExecutionGraph
    Golang RabbitMQ实现的延时队列
    JAVA多线程信号量Semaphore
  • 原文地址:https://blog.csdn.net/stewie6/article/details/137784331