C语言笔记(第n版):结构、联合与位字段

一、结构体(Struct)

(一)概念

        A struct is a type consisting of a sequence of members whose storage is allocated in an ordered sequence。

        结构是由成员序列组成的类型,其存储按有序序列分配。

        结构体是一种用户自定义的数据类型,它可以将不同类型的数据组合在一起,形成一个逻辑上相关的整体。这在很大程度上,解决了数组只能存储同一类型的缺点。

(二)定义、声明

        结构体定义与声明的一般性语法如下:

        definition \Rightarrow \boldsymbol{\textbf{ struct name }} \left \{ \textbf{struct-declaration-list} \right \};

declaration \Rightarrow \textbf{struct name};

        如:        

struct Student;    // 声明

struct Student {   // 定义
    int id;
    char name[50];
    float score;
};

        上述代码定义了一个名为 Student的结构体类型,其中包含了 id(整数类型)、name(字符数组)和 score(浮点数类型)三个成员。

        \textbf{declaration}(声明,在这里是前向声明)并不是必要的,这仅仅是告诉编译器,有一个名为 Student的结构体类型,这时候此结构体类型将是不完整类型(incomplete type),因为编译器不知道此结构体类型的具体细节,最基本的,所占字节数。

(三)初始化与访问

        和之前学过的枚举类型一样,定义结构体变量的一般性语法如下:

\boldsymbol{\textbf{ struct name }} \left \{ \textbf{struct-declaration-list} \right \} identifier;

\textbf{ struct name identifier};

        如:

struct Student stu1;

        倘若,结构体类型没有定义,则在一些IDE就会有错误提醒:

        如上结构体变量的定义可以有以下两种方式:

  • 在定义结构体类型的同时声明变量
  • 先定义结构体类型,再声明变量

        这两种方式存在一定的差异,我们慢慢展开说明

1、类型定义时创建变量

        这里先创建一个Time的结构体辅助说明

struct Time {
    short hour;
    short minute;
    short second;
    short year;
    short month;
    short day;
}time;

        我在定义的同时定义了一个time的变量,现在,其实这个变量已经完成了初始化。为了验证,我们需要先学会怎么访问其成员,语法如下

        \textbf{expression . member-name }

        其中(\mathbf{.}),称之为成员访问运算符(Member access operator) ,\textbf{expression}是结构体或者联合体类型的(所以,后面的联合体,你也会了)。

        验证如下:        

        所有的成员全部被初始化为0,这是因为在C中,所有未被显式初始化的结构体成员都会被空初始化(\textbf{ empty-initialized } )

        在某些情况下,如果一个对象未被明确初始化,它将被空初始化,即:

  • 指针被初始化为其类型的空指针值;
  • 整型对象被初始化为无符号的零;
  • 浮点型对象被初始化为正零;
  • 数组的所有元素、结构体的所有成员以及联合体的第一个成员都被递归地空初始化,并且所有填充位都被初始化为零

(在空指针值和浮点零具有全零位表示的平台上,这种静态对象的初始化形式通常通过在程序映像的.bss 节中为其分配来实现)

         这种初始化的显式语法是=\left \{ \right \},所以

struct Time {
    short hour;
    short minute;
    short second;
    short year;
    short month;
    short day;
}time = {};

        是一样的。那怎么初始化其它值呢,也很简单,在\left \{ \right \}里面加值即可,这就像数组一样

         除此,和数组一样,我们可以使用designator,语法和在数组中略有不同,现在要使用成员运算符指定,而不是数组下标,如

        未显式指定的值,从designator指定的元素之后声明的下一个元素开始顺序初始化,这点请结合上面例子。

        初始化后,我们仍然可以修改结构体成员,类似

time.year = 2024;

         举一反三。在类型定义时也可创建多个变量

struct Time {
    short hour;
    short minute;
    short second;
    short year;
    short month;
    short day;
}time = {.year= 2022, 10, 31, .hour= 23, 59, 59}, time2 = {23, 59, 59, 2022, 10, 31}, time3;

        它们的初始化同上,不再赘述。值得注意的是,诸如下面的定义

struct {

    int a,
    int b
}c;

        是无法再在定义后声明更多的变量了。 

2、类型定义后创建变量

        类型定义后创建,其实和前面的差不了多少

        除了上面的一般方法外,我们还可以使用复合字面量

struct Time time4 = (struct Time){
        .hour = 12, 
        .minute = 30, 
        .second = 30, 
        .year = 2022, 
        .month = 10, 
        .day = 10
};

         这看起来有些鸡肋,不过或许在有些时候它是个不错的选择。

(四)内存布局

        结构体的成员在内存中是按照它们声明的顺序依次存储的。每个成员都根据其类型占据一定的内存空间,并且通常会根据系统的对齐(Alignment)规则进行内存对齐(通常是以4字节),以提高访问效率,所以:

        The size of a struct is at least as large as the sum of the sizes of its members.

        结构体的大小至少与其成员大小之和一样大。

         例如,对于上述 Student结构体,如果int占用 4 个字节,char数组name占用 50 个字节,score 占用 4 个字节,那么 stu1在内存中的布局可能是这样的(假设从地址 0x1000 开始存储):

0x1000101 (id 的值)
0x1004 - 0x1031"Alice" (name 的值)
0x1032 - 0x103595.5 (score 的值)

         示例

        一个char*只需一字节即可,不过事实上的内存不是这么分配的,这不免浪费了空间,所以C语言提供有位字段。

(五)结构体的数组“尾巴”

         If a struct defines at least one named member, it is allowed to additionally declare its last member with incomplete array

        如果结构定义了至少一个具名成员,则允许最后一个成员(不是第一个)为不完整类型的数组。

        所谓具名,就是有名字,比如下面结构体成员就没有名字

struct unamed{

    struct {}
}

         所谓不完整的数组,就是没有指定大小的数组,例如

struct Room
{
    int number;
    char *size;
    char* client[];
};

        client可称之为变长数组成员。可变长数组成员,不能在初始化时初始化,这是因为初始化时会忽略此成员。

struct Room room1 = {1, "small", {"client1", "client2"}};

         使用sizeof时也会忽略此成员         

        只能单独赋值,但注意这是一个数组名(指针常量),不要整体赋值。

struct Room room1 = {1, "small"};
room1.client[0] = "John";
printf("Room %d is %s and has %s as a client\n", room1.number, room1.size, room1.client[0]);

       虽然上面执行成功了,但是却是未定义行为,因为,明显貌似我们没提供空间给可变数组成员,当我们继续增长,发现,这依然有效,不过,这就对了吗?

         让我们看看,内存地址是怎么样的

        可以看到,client在 size成员后面排列,我们应该可以预见一个可能的后果,如下面一个示例,它污染了我们的数据,所以对于这种结构体我们要使用堆来分配空间。

         Structures with flexible array members (or unions who have a recursive-possibly structure member with flexible array member) cannot appear as array elements or as members of other structures.

        具有可变数组成员的结构(或者具有可能递归的、带有可变数组成员的结构成员的联合)不能作为数组元素或其他结构的成员出现。

(六)结构体数组

        结构体数组,其实想想也知道怎么操作,这并没有难度。不过这确实由于复合了多种数据结构,其操作也将更加复杂一点。

1、定义结构体数组

        结构体数组的定义方式与定义其他类型的数组类似,只是在数组元素类型上指定为结构体类型。

struct Student {  
    int id;  
    char name[50];  
    float score;  
};  
  
// 定义结构体数组  
struct Student students[10];

        在这个例子中,students是一个结构体数组,包含10个Student类型的元素。每个元素都是一个Student结构体,具有idnamescore三个成员。

2、初始化结构体数组

        结构体数组的初始化可以在定义时直接进行,也可以逐个元素地进行。

  • 在定义时初始化:       
struct Student students[3] = {  
    {1, "Alice", 95.5},  
    {2, "Bob", 88.0},  
    {3, "Charlie", 92.0}  
};

         这里,students数组被初始化为包含三个Student结构体元素的数组,每个元素都被赋予了初始值。

  • 逐个元素初始化

      如果数组在定义时没有初始化,可以在后续的代码中逐个为数组元素赋值。   

struct Student students[10];  
students[0].id = 1;  
strcpy(students[0].name, "Alice");  
students[0].score = 95.5;  
  
// 以此类推,为其他元素赋值

       

3、访问结构体数组的元素

        访问结构体数组元素的方式与访问其他类型数组的元素类似,只是需要使用.操作符来访问结构体成员。

printf("ID: %d, Name: %s, Score: %.2f\n", students[0].id, students[0].name, students[0].score);

        这个例子打印了students数组中第一个元素的idnamescore成员的值。

(七)结构体与函数

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

        当需要将多个相关数据项作为参数传递给函数时,可以使用结构体。这样,可以一次性传递整个数据集合,而不是单独传递每个成员。        

void printStudentInfo(struct Student student) {  
    printf("ID: %d, Name: %s, Score: %.2f\n", student.id, student.name, student.score);  
}

2、通过结构体返回多个值

        在C语言中,函数通常只能返回一个值。但是,通过返回结构体,可以一次性返回多个值。

struct Result {  
    int success;  
    int value;  
};  

struct Result computeSomething() {  
    struct Result result;  
    result.success = 1; // 假设操作成功  
    result.value = 42; // 计算结果  
    return result;  
}

3、在函数内部操作结构体

        函数可以接收结构体作为参数,并在函数内部修改其成员的值。注意,如果结构体是通过值传递的(如上面的printStudentInfo示例),则对结构体的修改不会反映到原始数据上,除非使用指针或返回结构体(但这通常用于新的值或结果,而不是在原地修改)。

        例如:

        这没有修改原值 

(八)结构体与指针

1、指向结构体的指针

        指向结构体的指针的声明与指向其他类型(如int、float等)的指针类似,只是在类型部分使用结构体类型。假设有一个名为Student的结构体,你可以这样声明一个指向Student结构体的指针:        

struct Student *ptr;

        这里,ptr是一个指针,它可以存储一个Student类型变量的地址。

        在C语言中,指针的定义和初始化通常是分开的,但也可以在声明时立即初始化。以下是一些例子:

// 定义结构体变量并初始化  
struct Student student1 = {1, "Alice", 95.5};  
  
// 声明并初始化指向结构体的指针  
struct Student *ptr = &student1;  
  
// 或者分步进行  
struct Student *ptr2;  
ptr2 = &student1;

        在上面的例子中,ptrptr2都指向了同一个Student类型的变量student1

2、使用指向结构体的指针

        通过指针访问结构体成员需要使用操作符(\rightarrow,member access through pointer),可称之为箭头操作符。其一般语法如下:

 

        其中expression为指向结构体或联合体的指针类型的表达式,\textbf{member-name}为成员名。

printf("ID: %d, Name: %s, Score: %.2f\n", ptr->id, ptr->name, ptr->score);

        在这个例子中,ptr->idptr->nameptr->score分别访问了ptr所指向的Student结构体的idnamescore成员。

        注意,使用复合字面量创建指向结构体的指针时,可能要借助数组,如

3、结构体名字和指针

        你可以发现上面指针替代的结构体名的"前导"作用,这就像指针“替代”数组名一样。那结构体名字相当于一个指针变量吗?

        让我们做一个地址测试,

        结构体的名字所代表的对象的值不是地址,看样子是第一个成员的值,是吗?继续测试

        确实如此,那地址就很明白了 

        那指向结构体的指针呢?

         可以发现指向结构的指针指向其第一个成员。那么我有一个大胆的想法

        这确实是可行的

         a pointer to the first member of a struct can be cast to a pointer to the enclosing struct。

        一个指向一个结构体第一个成员的指针可以强制转换为指向包含其作为成员的结构体的指针。

\left ( \textbf{the-pointer-type-to-sturct } \right )\left ( \textbf{ \&the-first-member-of-struct} \right ) \\ \Rightarrow \textbf{ the pointer to struct} 

二、联合体(Union)

        联合体,也叫做共用体,和其特点贴切,是一种和结构体用法一样的用户自定义数据类型,但是它允许在相同的内存位置存储不同的数据类型,也就是说,在任何给定的时间,共用体只能存储它的一个成员的值;你不能同时存储所有成员的值。

(一)内存分布

        以下程序为避免内存对齐,没有使用char等类型

        可以发现,其大小刚好能够容纳其最大的成员

        The union is only as big as necessary to hold its largest member (additional unnamed trailing padding may also be added). The other members are allocated in the same bytes as part of that largest member.

        联合体仅与容纳其最大成员所需的大小一样大(也可以添加额外的未命名尾随填充)。其他成员共用最大成员的相同的字节空间。           

(二)联合体与指针

        不同于结构体,联合体的成员共用一块地址,也即,

        一、如果说一个指向结构体的指针可以转换为指向其第一个成员的指针,那么一个指向联合体的指针可以转换为指向其任意成员的指针。        

        二、如果说一个指向结构体第一个成员的指针可以转换为指向,包含此成员的结构体的,指针类型,那么指向一个联合体任意成员的指针皆可转换为指向,包含此成员的联合体的,指针类型。

        A pointer to a union can be cast to a pointer to each of its members (if a union has bit-field members, the pointer to a union can be cast to the pointer to the bit-field's underlying type). Likewise, a pointer to any member of a union can be cast to a pointer to the enclosing union.

        指向联合的指针可以转换为指向其每个成员的指针(如果联合具有位字段成员,则指向联合的指针可以转换为指向位字段底层类型的指针)。同样,指向联合的任何成员的指针都可以转换为指向联合的指针。         

#include <stdio.h>  
  
union sex
{
    int male;
    int female;
};
  
int main() {  

    union sex sex;
    sex.female = 1;
    union sex *p2sex = &sex;
    printf("The address of sex=%p, male=%p, female=%p\n", &sex, &sex.male, &sex.female);
    printf("The address of p2sex=%p, male=%p, female=%p\n", p2sex, &p2sex->male, &p2sex->female);
    printf("The value of sex=%d, male=%d, female=%d\n", sex, sex.male, sex.female);
    printf("The value of p2sex=%d, male=%d, female=%d\n", *p2sex, p2sex->male, p2sex->female);
    printf("The value of male=%d, female=%d\n", 
                        ( (union sex *) &(sex.male))->male,
                        ( (union sex *) &(sex.female))->female );

  
    return 0;  
}

         

        综合以上可知 

        联合体名和结构体名一样是一个“类指针”,在结构体中,它相当于第一个元素,在联合体中,它相当于全部成员,但并不是,注意以下示例,使用联合体名获取的成员显然知道数据的正确范围,而而联合体却固定取四个字节的数据范围,而如果将联合体的两个成员换成int,它们将输出同样的结果,所以进一步说,联合体应该是“域”为联合体大小的“类指针”。

        

(三)联合体的使用实例

        共用体通常用于节省内存或者当你需要让同一个变量以不同的方式解释其存储的字节时。一个用例如下,这是一个Windows套接字API中的一个结构体,用于储存IPv4地址。        

struct in_addr {
  union {
    struct {
      u_char s_b1;
      u_char s_b2;
      u_char s_b3;
      u_char s_b4;
    } S_un_b;
    struct {
      u_short s_w1;
      u_short s_w2;
    } S_un_w;
    u_long S_addr;
  } S_un;
};

        在此结构中,包含了一个名为S_un的联合体(union)。这个联合体的设计是为了以不同的方式表示和访问一个IPv4地址。IPv4地址通常是一个32位的数值,可以通过不同的方式来分割和表示这个数值,以便于不同的处理需求。

  1. 字节级访问(S_un_b
    • 通过S_un_b结构体,我们可以将IPv4地址视为四个单独的字节(u_char类型,即无符号字符类型,通常是8位)。这四个字节分别命名为s_b1s_b2s_b3s_b4,对应于IPv4地址中的四个十进制数(当用点分十进制表示时)。这种方式允许程序员直接操作地址的每一个字节。
  2. 半字节级访问(S_un_w
    • 通过S_un_w结构体,我们可以将IPv4地址视为两个16位的短整型(u_short类型,即无符号短整型,通常是16位)。这两个短整型分别命名为s_w1s_w2。这种方式在某些情况下可能更便于处理,比如当需要将IPv4地址的高16位和低16位分开处理时。
  3. 32位整数访问(S_addr
    • S_addr是一个u_long类型的成员,它允许我们直接以32位无符号长整型的形式访问和操作整个IPv4地址。这是最直接和常用的方式,因为IPv4地址本质上就是一个32位的数值。

关键点

  • 由于联合体(union)的特性,S_un在同一时间只能存储其成员之一的值。但是,由于这个联合体被嵌入到struct in_addr中,我们可以通过不同的成员来访问和操作相同的内存区域,只是以不同的方式解释这些内存中的位。
  • 在实际使用中,我们通常会根据需要选择最适合的方式来访问和操作IPv4地址。例如,当我们需要按字节处理地址时,我们会使用S_un_b;当我们需要按32位整数处理地址时,我们会使用S_addr
  • 这种设计提供了一种灵活的方式来处理IPv4地址,使得程序员可以根据不同的需求和场景选择最合适的访问方式。        

三、位字段(Bit Field)

(一)对齐(Alignment)机制

1、对齐的定义与作用

        在计算机中,对齐(Alignment)是指数据在内存中的存储方式,即数据存放的起始地址必须是某个数(通常是2的幂次方)的倍数。这种机制主要是为了优化内存访问的效率,减少CPU访问内存的次数,从而提高程序的执行速度。由于计算机硬件的限制,特别是CPU的缓存和内存总线的设计,对齐的数据访问通常比未对齐的数据访问更快。

2、对齐的原因

  1. 硬件要求:某些硬件平台(如某些CPU架构)要求特定类型的数据只能从特定的内存地址开始存取,否则可能导致访问错误或性能下降。
  2. 性能优化:未对齐的数据访问可能导致CPU需要执行额外的内存访问操作来读取完整的数据,从而降低访问效率。

(二)C语言中的对齐

        在C语言中,对齐是由编译器自动处理的。编译器会根据目标平台的硬件特性和编译选项来决定变量的对齐方式。但是,C语言也提供了手动控制对齐的机制,以满足特定的需求。

  1. 默认对齐方式
    • 编译器通常会根据变量的类型来决定其默认的对齐方式。例如,在32位系统中,int类型变量通常按4字节对齐,而double类型变量则按8字节对齐。
    • 对于结构体和联合体,它们的对齐方式通常是它们各个成员中对齐要求最严格的那个。
  2. 手动控制对齐
    • 使用编译器特定的扩展属性或编译选项来手动控制对齐。例如,在GCC中,可以使用__attribute__((aligned(n)))来指定变量或结构体的对齐方式。
    • 使用#pragma pack(n)来设置结构体成员的对齐方式。这个指令会告诉编译器在随后的结构体定义中,按照n字节对齐的方式来分配内存。

(三)位字段(Bit-field)

一、定义与声明

        位字段(Bit-field)是C语言中一种特殊的数据类型,允许在结构体或联合体中定义其成员占据的位数,而不是通常的字节数。这使得位字段成为在内存受限或需要高效位操作的场合下非常有用的工具。

位字段的声明语法如下:

struct {  
    type [member_name] : width;  
    // ...  
};
  • type:指定位字段的基础数据类型,

        位域(Bit-fields)在以下情况中(直至 C99 为三种,自 C99 至 C23 为四种)只能具有以下类型:

无符号整型(unsigned int)用于无符号位域(例如 unsigned int b:3; 其取值范围为 0 至 7);
有符号整型(signed int)用于有符号位域(例如 signed int b:3; 其取值范围为 -4 至 3)
整型(int)用于实现定义了符号性的位域(请注意,这与关键字 int 在其他任何地方的含义不同,在其他地方它意味着“有符号整型”)。例如,int b:3; 可能的取值范围为 0 至 7 或者 -4 至 3。
_Bool 型用于单一位的位域(例如 bool x:1;),其取值范围为 0 至 1,并且与其之间的隐式转换遵循布尔转换规则。(自 C99 起) 精确位整数类型(例如 _BitInt(5):4; 取值范围为 -8 至 7,而 unsigned _BitInt(5):4; 取值范围为 0 至 15)

        

  • member_name:位字段的名称,是可选的。特殊的未命名的宽度为零的位域会打破填充:它指明下一个位域从下一个分配单元的起始位置开始。
  • width:指定位字段的位数,必须是一个常量整数表达式。
  • 相邻的位域成员可以被打包以共享和跨越各个字节。

二、访问与使用

        位字段的访问和使用与结构体中的其他成员类似,可以使用点运算符(.)或箭头运算符(->)来访问。但是,由于位字段的特殊性质,它们在内存中的布局和访问方式可能与普通结构体成员有所不同。

(四)位字段与指针

        因为位域不一定从字节的起始位置开始,所以不能获取位域的地址。指向位域的指针是不可能存在的。位域不能与 sizeof 和 _Alignas(自 C11 起)一起使用。

        如,对于

union test{
    char a;
    int b:3;
    int c:4;
};

一、位字段与指针的交互

1. 指针访问位字段

  • 理论上,可以使用指针来访问和修改位字段的值,但实际操作中需要注意位字段的特殊性。由于位字段并不是直接以独立的内存单元(如字节)存储,而是作为结构体或联合体的一部分按照特定的位数进行分配,因此直接通过指针访问位字段可能并不直观。
  • 通常,我们会通过指向包含位字段的结构体或联合体的指针,然后使用点运算符(.)或箭头运算符(->)来访问位字段。例如,如果有一个结构体struct S { unsigned int bitField : 1; }和一个指向该结构体的指针struct S *ptr,则可以通过ptr->bitField来访问位字段。

2. 修改位字段

  • 通过指针访问到位字段后,可以像访问普通变量一样修改其值。但是,由于位字段的位数限制,赋值时需要确保值在允许的范围内,否则可能导致数据溢出或未定义行为。

二、位字段对指向其的指针的影响

1. 内存布局

  • 位字段的引入会影响结构体或联合体的内存布局。编译器会根据位字段的定义和平台的对齐要求来安排内存。这可能导致结构体或联合体的大小与其中成员的大小之和不同,因为编译器可能会插入填充字节以确保对齐。
  • 因此,指向包含位字段的结构体或联合体的指针所指向的内存区域的大小和布局可能会受到位字段定义的影响。

2. 访问效率

  • 由于位字段的特殊内存布局,通过指针访问位字段时可能需要额外的位操作或内存访问指令。这可能会影响程序的执行效率,尤其是在对位字段进行频繁读写操作时。

3. 跨平台问题

  • 位字段的具体实现和布局可能会因编译器和计算机体系结构的不同而有所差异。因此,在跨平台编程时,需要特别注意位字段的使用,以确保程序的正确性和可移植性。

四、对齐问题

        Every complete object type has a property called alignment requirement, which is an integer value of type size_t representing the number of bytes between successive addresses at which objects of this type can be allocated. The valid alignment values are non-negative integral powers of two.

        每一个完整的 对象类型有一个名为 对齐要求的属性,它是一个整数类型size_t 的值 表示可以分配这种类型的对象的连续地址之间的字节数。有效的对齐值是2的非负整数幂。

        在这里,对特别是结构体与联合体的内存对齐问题,做一个探讨。笔者认为一个结构体(联合体)的最终大小涉及到三个因素,分别是编译器本身的对齐机制与指定的对齐方案,成员的不同数据类型的影响,成员的声明前后(其实也算是数据类型不同)的影响。

(一)正常情况下

  1. 基本变量类型:1 个字节的变量(如 char 类型)可放在任意地址;2 个字节的变量(如 short 类型)放在 2 的整数倍地址上;4 个字节的变量(如 float、int 类型)放在 4 的整数倍地址上;8 个字节的变量(如 long long、double 类型)放在 8 的整数倍地址上。
  2. 结构体中间:各结构体的起始地址按照各个类型变量默认规则进行摆放,但除了 char 类型变量(一般遵循 2 的倍数地址开始存储)。
  3. 结构体最后:视结构体中最大类型是哪一个,如果是像 int 类型(4 个字节),并且结构体的结尾地址不满足 4 的倍数的话,向离最近的 4 的倍数地址补齐;如果是像 double 类型(8 个字节),并且结构体的结尾地址不满足 8 的倍数的话,向离最近的 8 的倍数地址补齐,以此类推。
  4. 结构体嵌套:子结构体的成员变量起始地址要视子结构体中最大变量类型决定。例如,父结构体中含有子结构体,子结构体中有 char、int、double 等元素,那么子结构体应该从 8 的整数倍开始存储。
  5. 含数组成员:数组的对齐方式和连续写多个相同类型的变量是一样的。例如,char a[5],它还是按一个字节对齐。
  6. 含联合体(union)成员:取联合体中最大类型的整数倍地址开始存储。
struct a{
 
    char a;  //  | a | | | |  char类型占用1个字节,所以a从第1个字节开始
    int b;   //  | b       |  int类型占用4个字节,前面4个字节已经占用1个字节,不足4个字节,所以b从第5个字节开始
    char c;  //  | c | | d |   char类型占用1个字节,上面已经占用8个字节,所以c从第9个字节开始
    short d; //  short类型占用2个字节,前面的两字节c占用了一个,所以d从第11个字节开始
}instance;   // 总共12个字节

         

struct MyStruct {
    char a;      //  |a| | | |  char类型占用1个字节,所以a从第1个字节开始
    char b;      //  |a|b| | |  char类型占用1个字节,上面已经占用1个字节,所以b从第2个字节开始
    short c;     //  |a|b|c  |   short类型占用2个字节,上面已经占用2个字节,所以c从第3个字节开始
    char d;      //  |a|b|c  |d| |  char类型占用1个字节,上面已经占用4个字节,所以d从第5个字节开始
    // 结构体的最大数据类型是short,整个结构体不足2的倍数,所以需要补齐,所以整个结构体占用6个字节
};

(二)不允许对齐情况

        在C语言中,__attribute__((packed))__attribute__((aligned(n)))是两个GCC编译器特有的属性,它们允许开发者对结构体或联合体的内存布局进行精细控制。

  • __attribute__((packed)):这个属性用于告诉编译器在编译过程中取消成员之间的默认对齐填充,使得结构体或联合体的成员紧密排列在一起,按照它们实际占用的字节数进行布局。这通常用于需要精确控制内存布局的场景,如网络协议解析、硬件寄存器映射等。然而,使用__attribute__((packed))可能会导致内存访问效率下降,因为非对齐的内存访问在某些硬件上可能较慢。

值得注意的是,此属性可能只取消后补的填充字节。

        例如对于上面的例子,如果使用此属性,其结果是5,但是如果是下面这样,其结果还是5,此属性并没有更改变量的存放属性

struct MyStruct {
    char a; 
    short c;
    char d;    
}__attribute__((packed));
  • __attribute__((aligned(n))):这个属性用于指定结构体或联合体在内存中的对齐方式,其中n是对齐的字节数。默认情况下,编译器会根据目标平台的硬件特性选择最合适的对齐方式。但在某些情况下,开发者可能需要显式地指定对齐方式以满足特定的性能要求或硬件要求。如果n大于结构体或联合体紧密排列后的大小,编译器可能会在结构体或联合体的末尾添加填充字节以满足对齐要求。

        当这两个属性同时用于同一个结构体或联合体时,__attribute__((packed))会首先生效,确保成员之间的紧密排列。随后,__attribute__((aligned(n)))会考虑指定的对齐要求。如果指定的对齐字节数n大于紧密排列后的结构体或联合体的大小,那么编译器会在末尾添加必要的填充字节以满足对齐要求。如果n小于或等于紧密排列后的大小,则对齐方式可能仍然是紧密排列,或者根据编译器和平台的特定行为而定。     

(三)数据对齐的编译器处理

        转载自【保持同步 | Microsoft Learn】        

        编译器尝试以防止数据未对齐的方式分配数据。

        对于简单的数据类型,编译器将分配是数据类型的大小(以字节为单位)的倍数的地址。 例如,编译器将地址分配给类型为 long 且是 4 的倍数的变量,并将地址的最底部 2 位设置为零。

编译器还以自然对齐结构的每一个元素的方式填充结构。 来看看下面代码示例中的结构 struct x_

struct x_
{
   char a;     // 1 byte
   int b;      // 4 bytes
   short c;    // 2 bytes
   char d;     // 1 byte
} bar[3];

编译器填充此结构以自然强制实施对齐方式。

下面的代码示例展示了编译器如何将填充的结构置于内存中:

// Shows the actual memory layout
struct x_
{
   char a;            // 1 byte
   char _pad0[3];     // padding to put 'b' on 4-byte boundary
   int b;            // 4 bytes
   short c;          // 2 bytes
   char d;           // 1 byte
   char _pad1[1];    // padding to make sizeof(x_) multiple of 4
} bar[3];

两个声明都将 sizeof(struct x_) 作为 12 个字节返回。

第二个声明包括两个填充元素:

  1. char _pad0[3],对齐 4 字节边界上的 int b 成员。
  2. char _pad1[1] 用于在 4 字节边界上对齐结构 struct _x bar[3]; 的数组元素。

填充以允许自然访问的方式对齐 bar[3] 的元素。

下面的代码示例展示了 bar[3] 的数组布局:

adr offset   element
------   -------
0x0000   char a;         // bar[0]
0x0001   char pad0[3];
0x0004   int b;
0x0008   short c;
0x000a   char d;
0x000b   char _pad1[1];

0x000c   char a;         // bar[1]
0x000d   char _pad0[3];
0x0010   int b;
0x0014   short c;
0x0016   char d;
0x0017   char _pad1[1];

0x0018   char a;         // bar[2]
0x0019   char _pad0[3];
0x001c   int b;
0x0020   short c;
0x0022   char d;
0x0023   char _pad1[1];

五、Typedef声明 

        在C语言中,typedef声明提供了一种定义标识符作为类型别名的方法,以便替换可能复杂的类型名称。就比如前面声明一个结构体变量就比声明普通的变量要麻烦一点。typedef关键字在声明中用作类似于存储类别指定符的位置,但它并不影响变量的存储或链接特性。

(一)基本语法

typedef type alias;

        其中 type 是已存在的类型,而 alias 则是新创建的类型别名。

#include <stdio.h>

// 1. 为基本数据类型定义别名
typedef int myInt;

// 2. 为数组类型定义别名
typedef int myIntArray[5];

// 3. 为指针类型定义别名
typedef int* myIntPointer;

// 4. 为结构体类型定义别名
struct Student {
    char name[50];
    int age;
};
typedef struct Student myStudent;

int main() {
    // 使用定义的别名
    myInt num = 10;
    printf("num = %d\n", num);

    myIntArray arr = {1, 2, 3, 4, 5};
    for (int i = 0; i < 5; i++) {
        printf("arr[%d] = %d\n", i, arr[i]);
    }

    myIntPointer ptr = &num;
    printf("*ptr = %d\n", *ptr);

    myStudent stu1 = {"Alice", 20};
    printf("Name: %s, Age: %d\n", stu1.name, stu1.age);

    return 0;
}

解释

  • 如果声明使用typedef作为存储类别指定符,则声明中的每个声明符都会定义一个标识符作为指定类型的别名。
  • 由于一个声明只能有一个存储类别指定符,因此typedef声明不能同时是staticextern
  • typedef声明不引入新的类型,它只是为现有类型建立了一个同义词,因此typedef名称与其所别名的类型是兼容的。
  • typedef名称与普通的标识符(如枚举器、变量和函数)共享命名空间。

(二)VLA的typedef

从C99开始,可以在块作用域内为可变长度数组(VLA)定义typedef

void copyt(int n) {

    typedef int B[n]; // B是VLA,其大小为n,当函数被调用时,n就确定了
    n += 1;
    B a; // a的大小为在n+=1之前的n
    int b[n]; // a和b的大小不同
    for (int i = 1; i < n; i++){
        a[i-1] = b[i];
    }
        
}

(三)不完整类型的typedef

typedef名称可以是一个不完整的类型,之后可以被完善:

typedef int A[]; // A是int[]
A a = {1, 2}, b = {3,4,5}; // a的类型是int[2],b的类型是int[3]

(三)结构体标签和typedef

typedef声明经常用于将结构体标签空间中的名字注入到普通命名空间中:

typedef struct tnode tnode; // tnode在普通命名空间中,是tag name space中的tnode的别名
struct tnode {
    int count;
    tnode *left, *right; // 同 struct tnode *left, *right;
};
// 现在tnode也是一个完整的类型
tnode s, *sp; // 等同于 struct tnode s, *sp;

甚至可以完全避免使用结构体标签空间:

typedef struct {
    double hi, lo;
} range;
range z, *zp;

(四)复杂类型的简化

typedef名称常用来简化复杂类型的声明:

// 没有typedef的情况
int (*(*callbacks[5])(void))[3];
// 使用typedef
typedef int arr_t[3]; // arr_t是包含3个int的数组
typedef arr_t* (*fp)(void); // 指向返回arr_t*的函数的指针
fp callbacks[5];

(五)系统依赖性的类型

库经常会通过typedef暴露系统依赖或配置依赖的类型,以便为用户提供一致的接口:

#if defined(_LP64)
typedef int wchar_t;
#else
typedef long wchar_t;
#endif

致读者:

        复合类型不仅仅是原子类型的简单组合,就比如整体内存大小不是简单成员内存大小相加。本文对于复合类型的探讨仅仅是作为一个初级编程人员的一些简单理解。关于复合类型还存在许多值得去思考的地方....

  • 26
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值