目录
文章目录
前文列表
《程序编译流程与 GCC 编译器》
《C 语言编程 — 基本语法》
《C 语言编程 — 基本数据类型》
《C 语言编程 — 变量与常量》
《C 语言编程 — 运算符》
《C 语言编程 — 逻辑控制语句》
《C 语言编程 — 函数》
《C 语言编程 — 高级数据类型 — 指针》
《C 语言编程 — 高级数据类型 — 数组》
《C 语言编程 — 高级数据类型 — 字符串》
《C 语言编程 — 高级数据类型 — 枚举》
结构体
我们知道数组(Array)是一组具有相同数据类型的数据集合。而在实际的编程中,我们往往还需要一组具有不同数据类型的数据集合,例如:学生信息的登记表,其中包含类型为字符串的 “姓名”,为整数的 “学号” 以及 “年龄”,和为小数的 “”成绩。数组类型显然无法满足这一需求,此时可以使用结构体(Struct)来存放一组不同类型的数据。
C 语言中,像 int、float、char 等是由 C 语言本身提供的数据类型,不能再进行分拆,我们称之为基本数据类型。而结构体则是一种复杂数据类型(构造数据类型),它由程序员自己定义,由一系列具有相同或不同数据类型的变量组成。我们可以使用结构体表示更加复杂的数据类型。
注意:我们应该将结构体定义在所有需要用到它的函数的上方,枚举类型和基本数据类型的使用方法没有任何区别。
定义结构体
使用 struct 关键字声明一个结构体数据类型:
struct 结构体名 {
结构体所包含的变量成员
};
使用 struct 关键字声明并定义一个结构体数据类型:
struct tag {
member
member
member
...
} variable-list ;
- tag 是结构体的标识(名字)。
- member 是几结构体的成员,为标准的变量定义语句,比如:
int i
。 - variable-list 结构体变量,可以一次性指定一个或多个结构体变量。
e.g. 定义一个 book 结构体变量:
struct Books {
char title[50];
char author[50];
char subject[100];
int book_id;
} book;
注:声明 只在编译阶段有意义,使得编译器知道这样的一个 “名称”,让代码得以完成编译;而 定义 是为了创建一个实际的 “对象”,即分配具体的内存空间,在程序运行的期间有意义。我们经常会顺口溜的说:声明类型、定义变量,从而避免概念上的混淆。
可见,结构体就像一个 “模板”,定义出来的变量都具有相同的性质,可以使用结构体来实现 C++ 中的类和实例的继承机制。
结构体的定义具有多种方式,比较灵活。通常的,tag、member、variable-list 这 3 部分至少要出现 2 个:
- 直接定义:在声明结构体类型的同时定义结构体变量,如果后面不再需要定义其他新的结构体变量,那么我们可以在定义时不给出结构体类型名称。这种方式书写简单,但是因为没有结构体类型名称,所以后面就没法用该结构体类型定义新的变量。
struct {
int a;
char b;
double c;
} s1;
- 间接定义:先声明定义结构体类型,再另外的定义结构体变量。
// 声明
struct SIMPLE
{
int a;
char b;
double c;
};
// 定义
struct SIMPLE t1, t2[20], *t3;
注意,在上述的两个示例中,第一个和第二声明被编译器当作两个完全不同的类型,即使他们的成员列表是一样的。
- 别名(无名)定义:我们可以同时使用关键字 struct 和 typedef 来声明一个结构体类型,并未其赋予一个 “别名”,后续使用该 “别名” 定义结构体变量时,就可以不重复的书写 struct 关键字了。
typedef struct
{
int a;
char b;
double c;
} Simple2;
// 现在可以用 Simple2 作为类型,声明新的结构体变量
Simple2 u1, u2[20], *u3;
NOTE:在实际的编程中,我们经常会使用 *_s 后缀来命名一个结构体类型,而是用 *_t 后缀来命名一个结构体类型的别名(也称之为自定义一个私有的数据类型)。e.g.
typedef struct student_t {
char *name;
int num;
int age;
char group;
float score;
} student_s
student_s stu1;
sizeof(student_s);
结构体的成员可以是其他结构体,也可以包含指向自己结构体类型的指针,这种指针的应用通常是为了实现一些更高级的数据结构如链表和树等。
struct COMPLEX {
char string[100];
struct SIMPLE a;
};
struct NODE {
char string[100];
struct NODE *next_node;
};
如果两个结构体互相包含,则需要对其中一个结构体进行声明,同时还要注意语句的顺序,否则会出现编译错误:
struct B;
struct A{
struct B *partner;
//other members;
};
struct B {
struct A *partner;
//other members;
};
初始化结构体变量
下述是初始化结构体变量的一种写法(整体赋值,仅限于定义结构体变量的时候):
#include <stdio.h>
/**
* 声明 Books 结构体类型;
* 定义 book 结构体变量;
* 初始化 book 结构体变量;
*/
struct Books {
char title[50];
char author[50];
char subject[100];
int id;
} book = {"is book", "fanguiju", "C", 123};
int main() {
printf("Book's title: %s
author: %s
subject: %s
id: %d
", book.title, book.author, book.subject, book.id);
return 0;
}
访问结构体成员
下述是初始化结构体变量的一种写法(分别赋值,在使用过程中只能对成员逐一赋值),并使用成员访问运算符 .
对结构体的成员进行访问:
#include <stdio.h>
#include <string.h>
/* 声明 Books 结构体类型 */
struct Books {
char title[50];
char author[50];
char subject[100];
int id;
};
int main() {
/* 定义 book 结构体变量 */
struct Books book1;
/* 初始化 book 结构体变量 */
strcpy(book1.title, "C Programming");
strcpy(book1.author, "Nuha Ali");
strcpy(book1.subject, "C Programming Tutorial");
book1.id = 123;
/* 访问结构体成员 */
printf("Book's title: %s
author: %s
subject: %s
id: %d
", book1.title, book1.author, book1.subject, book1.id);
return 0;
}
结构体的内存分布
理论上讲,结构体的各个成员在内存空间中是连续存储的,和数组非常类似,比如上面的结构体变量 stu1 的内存分布如下图所示,共占用 4 + 4 + 4 + 1 + 4 = 17 个字节。
但是在编译器的具体实现中,各个成员之间可能会存在缝隙,对于 stu1 的成员变量 group 和 score 之间就可能存在 3 个字节的空白填充。这样算来,stu1 其实占用了 17 + 3 = 20 个字节。
将结构体作为实参传入函数
#include <stdio.h>
#include <string.h>
struct Books {
char title[50];
char author[50];
char subject[100];
int id;
};
void printBook(struct Books book) {
printf("Book's title: %s
author: %s
subject: %s
id: %d
", book.title, book.author, book.subject, book.id);
}
int main() {
struct Books book1;
strcpy(book1.title, "C Programming");
strcpy(book1.author, "Nuha Ali");
strcpy(book1.subject, "C Programming Tutorial");
book1.id = 123;
printBook(book1);
return 0;
}
指向结构体变量的指针
定义基类为结构体 Books 的指针类型变量:
struct Books *struct_pointer;
现在,就可以在上述定义的指针变量中存储结构体变量的内存地址了:
struct_pointer= &book1;
在使用指向该结构体变量的指针访问结构体成员时,必须使用 ->
运算符,如下所示:
struct_pointer->title;
因为结构体指针变量 struct_pointer 本质是一个内存地址,跟结构体变量不同,不可以直接使用成员访问运算符 .
,而是使用 ->
运算符。
#include <stdio.h>
#include <string.h>
struct Books {
char title[50];
char author[50];
char subject[100];
int id;
};
void printBook(struct Books *book) {
printf("Book's title: %s
author: %s
subject: %s
id: %d
", book->title, book->author, book->subject, book->id);
}
int main() {
struct Books book1;
strcpy(book1.title, "C Programming");
strcpy(book1.author, "Nuha Ali");
strcpy(book1.subject, "C Programming Tutorial");
book1.id = 123;
printBook(&book1);
return 0;
}
位域
在某些场景中,需要存储的数据值并不需要占用一个完整的字节(Byte),而只需占几个或一个二进制位(Bit)。例如:存放一个开关量,只有 0 和 1 两种状态,使用到 1 位二进位即可。
为了节省存储空间,并使处理简便,C 语言又提供了一种数据结构 —— 位域。
所谓 “位域” 就是把一个字节中的二进位划分为几个不同的区域,并说明每个区域的位数以及标识(域名),允许在程序中按域名进行操作。这样就可以把几个不同的变量用同一个字节总的不同二进制位域来表示。
定义位域
位域的定义与结构体定义类型,本质是一种特殊的结构体:
struct 位域结构体名 {
类型说明符 [位域名]: 位域长度(Bit)
...
};
e.g.
struct bs {
int a:8;
int b:2;
int c:6;
} data;
上例位域结构体变量 data 占用了 2 个字段(16 位)。
- 一个位域存储在同一个字节(单元)中,当一个字节所剩的空间不够存放另一位域时,则会从下一单元起存放该位域。也可以有意使某位域从下一单元开始:
struct bs {
unsigned a:4;
unsigned :4; /* 空域,填 0 表示不使用 */
unsigned b:4; /* 刻意从下一单元开始存放 */
unsigned c:4
}
-
由于位域不允许跨字节,因此位域的长度不能大于一个字节的长度(8 Bit)。如果最大长度大于计算机的整数字长,一些编译器可能会允许域的内存重叠,另外一些编译器可能会把大于一个域的部分存储在下一个字中。视乎于编译器的实现,这也是 C 语言的特点之一。
-
位域可以是无名位域,这时它只用来作填充或调整位置。无名的位域是不能使用的。如上例子中的空域。
使用位域结构体的成员
#include <stdio.h>
int main() {
struct BS {
unsigned a:1;
unsigned b:3;
unsigned c:4;
};
struct BS bit;
struct BS *pbit;
bit.a = 1; /* 给位域赋整型值 1,数值没有超过位域 a 的 1bit */
bit.b = 7; /* 给位域赋整型值 7,数值没有超过位域 b 的 3bit */
bit.c = 15; /* 给位域赋整型值 15,数值没有超过位域 c 的 4bit */
printf("%d, %d, %d
", bit.a, bit.b, bit.c);
pbit = &bit; /* 把位域结构体变量 bit 的地址赋给位域结构体指针变量 pbit */
pbit->a = 0; /* 结构体变量访问结构体成员 */
pbit->b &= 3; /* 与赋值运算 */
pbit->c |= 1; /* 或赋值运算 */
printf("%d, %d, %d
", pbit->a, pbit->b, pbit->c);
return 0;
}
运行:
$ ./main
1, 7, 15
0, 3, 15
数组强制类型转换为结构体
先看一个例子:
#include <stdio.h>
int main(void) {
unsigned char arr[] = "0123456789abcdefghijk";
struct A {
int a;
char b;
char c;
char d;
int e;
} p, *pp;
struct B {
int a;
char b;
int c;
};
p.a = 1;
p.b = '2';
p.c = '3';
p.d = '4';
p.e = 5;
pp = &p;
printf("pp->a: %d
pp->b: %c
pp->c: %c
pp->d: %c
pp->e: %d
", pp->a, pp->b, pp->c, pp->d, pp->e);
printf("**********
");
pp = (struct A *)arr;
printf("pp->a: %d
pp->b: %c
pp->c: %c
pp->d: %c
pp->e: %d
", pp->a, pp->b, pp->c, pp->d, pp->e);
return 0;
}
运行结果:
pp->a: 1
pp->b: 2
pp->c: 3
pp->d: 4
pp->e: 5
**********
pp->a: 858927408
pp->b: 4
pp->c: 5
pp->d: 6
pp->e: 1650538808
上述是一个将数组类型变量强制类型转换为 struct A 的例子,结合结构体内存分布的内容我们可以看出:结构体数据类型转换的本质就是对结构体内存空间的填充。通过这种方式,可以把某一起始地址的数据类型与结构体成员相对应。
结构体的内存对齐
计算机的内存空间都是按照字节划分的。从理论上讲,似乎可以从任意地址开始访问任何类型的变量。但实际上,计算机系统对于基本数据类型在内存中的存放位置是有限制的,举个例子:一个变量占用 n 个字节,则该变量的起始地址必须能够被 n 整除(起始地址 % n = 0)。基本数据类型在内存中都是按照这种规则排列的,而不是一个紧接着一个排列的。这就是内存对齐。
对于结构体而言,结构体变量存储的起始地址则必须能够被其成员中的数据类型所占空间值最大的那个整除(结构体成员之间默认 32 位对齐)。所以,通常结构体中成员变量声明的顺序是按照成员类型大小 从小到大 的顺序进行,有时候这样可以减少中间的填充空间。
内存对齐作为一种强制的要求,有两点原因:
- 第一简化了处理器与内存之间传输系统的设计。
- 第二可以提升读取数据的速度。
不同的硬件平台对内存空间的处理也有着很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取,例如:有些 CPU 架构在访问一个没有进行对齐的变量时会发生错误,那么在这种架构下编程必须保证内存对齐。相对的,其他平台可能没有这种情况,但常见的是,如果不按照适合其平台的要求对数据存放进行对齐,确实会带来存取效率上的损失。例如:有些平台每次读都是从偶地址开始,如果一个 int 型(假设为 32 位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这 32bit;相反,如果存放在了奇地址开始的地方,就需要 2 个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该 32bit 数据。显然在读取效率上下降很多。
结构体变量内存对齐的原则:
- 结构体成员按数据类型自身的对齐值对齐:第一个结构体成员放在 offset(偏移量)为 0 的地方,以后每个数据成员存储的起始位置要从该成员大小的整数倍开始。
- 结构体中成员为其他结构体,则结构体成员要按自身结构体内部的最大对齐值(成员中的数据类型所占空间值最大的那个)进行对齐:比如 struct A 中包含 struct B 类型的成员,B 中有 char、int、double 元素,那么 B 应该从 sizeof(double) 的整数倍开始存储。
- 结构体的自身对齐值是其成员中自身对齐值最大的那个值:即:结构体的总大小必须是其内部最大成员的整数倍,不足的要补齐。
下面通过几个例子,加深对这几个原则的理解(32 位系统):
EXAMLE1: sizeof(A)=8 字节,因为两个成员变量 int 都是 4 字节;sizeof(B)=8 字节,因为原则 1.,如果将 b1 和 b2 的位置互换,则准守原则 3.。
struct A {
int a1; // 4B
int a2; // 4B
};
struct B {
char b1; // 1B
int b2; // 4B
};
EXAMLE2:sizeof©=8 字节,而拥有相同成员定义的 D 则为 sizeof(D)=12 字节。C 和 D 的区别在于两者定义的成员的顺序不一样,这导致了两者占用内存的大小也不一样。因为 C 按原则 1. 进行对齐后,刚好满足原则 3.;而 D 中将 char 放在最后,按原则 1. 进行对齐后,并不满足原则 3.,还需要按照原则 3. 进行补齐,所以占用了额外的空间。
struct C {
short c1; // 2B
char c2; // 1B
long c3; // 4B
};
struct D {
short d1; // 2B
long d2; // 4B
char d3; // 1B
};
EXAMPLE3: sizeof(E)=16 字节,因为原则 2. 要求成员结构体 e2,需要按照 C 的对齐值 4 对齐,所以内存从 offset(偏移值)为 4 的地方开始存储。将 e2 和 e3 互换位置,可以得到 sizeof(E)=12 字节。
struct E {
char e1; // 1B
struct C e2; // 8B
short e3; // 2B
pragma pack(value) 宏指令对其
宏定义 pragma pack(value) 的 value 就是指定的对齐值,通常 value 的值取 2 的较小次方。
- 如果 value 的值小于变量类型的对齐值,则按照 value 的值进行对齐。
- 如果 value 的值大于变量类型的对齐值,则按照原来的对齐值进行对齐。
简而言之,使用该宏的时候,按照 value 值和原来对齐值之间较小的值进行对齐。
再举 3 个例子:
- 成员变量对齐使用
int a[] = {'abcd', 4444};
typedef struct _GPIO_t {
char in;
char out;
char type;
char value;
int data;
} GPIO_t;
GPIO_t *GPIOA = (GPIO_t *)&a;
printf("%c
", GPIOA->in);
printf("%c
", GPIOA->out);
printf("%c
", GPIOA->type);
printf("%c
", GPIOA->value);
printf("%d
", GPIOA->data);
注:数据存储格式分为大小端存储,所以结构引用输出顺序可能不太对应。
- 成员变量没有对齐使用
int a[] = {1234, 5678, 'abcd', 4444};
typedef struct _GPIO_t {
int in;
int out;
char type;
char value;
int data; // 会自动四字节对齐因此直接指向 a[3]
} GPIO_t;
GPIO_t *GPIOA = (GPIO_t *)&a;
printf("%d
", GPIOA->in);
printf("%d
", GPIOA->out);
printf("%c
", GPIOA->type);
printf("%c
", GPIOA->value);
printf("%d
", GPIOA->data);
注:因为自动对齐缘故,其中有些数据会自动丢掉。
- 成员变量不使用自动给对齐
int a[] = {1234, 5678, 'abcd', 4444};
#pragma pack(1) // 强制设置 1 字节对齐
typedef struct _GPIO_t {
int in;
int out;
char type;
char value;
int data; // 会自动四字节对齐因此直接指向 a[3]
} GPIO_t;
GPIO_t *GPIOA = (GPIO_t *)&a;
printf("%d
", GPIOA->in);
printf("%d
", GPIOA->out);
printf("%c
", GPIOA->type);
printf("%c
", GPIOA->value);
printf("%d
", GPIOA->data);
注:最后一个成员由于对齐错误出现乱码。
结构体之间的强制类型转换
要理解结构体之间的强制类型转换,需要明白以下几点原理:
- 结构体变量是如何分布内存的。
- 结构体变量的内存首地址。
- 结构体成员在结构体内存中的偏移地址。
实际上在上述的内容中,我们已经提到了这 3 点内容。
先看一个例子:
#include <stdio.h>
struct A {
int x;
char y;
} a, *pa;
struct B {
char x;
int y;
} b, *pb;
int main(void) {
a.x = 1;
a.y = 'A';
pa = &a;
printf("pa->x: %d, pa->y: %c
", pa->x, pa->y);
b.x = 'A';
b.y = 1;
pb = &b;
printf("pb->x: %c, pb->y: %d
", pb->x, pb->y);
struct B z;
z.x = ((struct B *)pa)->x;
printf("z.x: %c, z.y: %d
", z.x, z.y);
}
输出结果:
pa->x: 1, pa->y: A
pb->x: A, pb->y: 1
z.x: , z.y: 32766
上述例子为结构体之间的强制类型转换,根据结构体内存分布的内容,并且我们暂不考虑内存对齐的话,我们知道:
- a 的内存分布为:前 4B,后 1B
- b 的内存分配为:前 1B,后 4B
当我们执行强制类型转换时,本质是就是 C 语言会对结构体变量 a 的空间,按照 struct B 的布局进行解释:也就是说,将 a 的第一个字节看成 struct B 的第一个成员,且按 ASCII 码处理数据,而将后面的 4B 看成 struct B 的第二个成员,并按补码格式解释数据。
需要注意的是,C 语言中的结构体强制类型转换本质是对指针进行转换,所以转换的对象必须为一个指针类型:
struct str1 a;
struct str2 b;
a = (struct str1)b; // this is wrong
a = ((struct str1)&b); // this is correct
通过数组强制类型转换为结构体以及结构体之间互相转换的内容,我们可以总结到:C 语言中结构体变量之间直接的赋值和转换本质是将右值的内存数据直接覆盖到左值所占用内存空间中,然后再根据 C 语言对这块内存的理解(类型定义)表达出来。
示例
struct in_addr {
unsigned long a_addr;
}
struct sockaddr_in {
unsigned short sin_family; // 地址类型(2B)
unsigned short int sin_port; // 端口号(2B)
struct in_addr sin_addr; // IP 地址(4B)
unsigned char sin_zero[8]; // 填充空间(8B)
}
struct sockaddr {
unsigned short sa_family; // 地址类型(2B)
char sa_data[14]; // 协议地址(14B)
}
在实际的网络编程中,通常会先初始化 sockaddr_in,再将它强制转化成 sockaddr 来使用。这两个结构体,长度都为 16 字节,sockaddr_in.sin_family 的数据存入 sockaddr.sa_family,剩下的 14 个字节存入 sockaddr.sa_data,这样在各种操作中可以方便的处理端口号和 IP 地址。
相关阅读: